From d9d4f0a905cdb6624d6c17bc97d36ff7c3807ec7 Mon Sep 17 00:00:00 2001 From: "Sergey \"Shnatsel\" Davidoff" Date: Thu, 5 Mar 2026 13:10:07 +0000 Subject: [PATCH 01/17] Add safe Rust Zstd decoding option --- Cargo.toml | 4 ++++ src/decoder/image.rs | 2 ++ tests/decode_images.rs | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d1531f7..f4f5223 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ fax34 = { package = "fax", version = "0.2.6", optional = true } flate2 = { version = "1.0.20", optional = true } weezl = { version = "0.1.10", optional = true } zstd = { version = "0.13", optional = true } +ruzstd = { version = "0.8.2", optional = true } zune-jpeg = { version = "0.5.1", optional = true } image-webp = {version = "0.2.4", optional = true } @@ -45,6 +46,9 @@ jpeg = ["dep:zune-jpeg"] lzw = ["dep:weezl"] webp = ["dep:image-webp"] zstd = ["dep:zstd"] +# Fully safe Rust but much slower Zstd decoding. +# When both zstd and safe-rust-zstd are enabled, zstd takes precedence. +safe-rust-zstd = ["dep:ruzstd"] [[bench]] name = "lzw" diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 2a4366f..fb8fcb4 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -659,6 +659,8 @@ impl Image { )), #[cfg(feature = "zstd")] CompressionMethod::ZSTD => Box::new(zstd::Decoder::new(reader)?), + #[cfg(all(not(feature = "zstd"), feature = "safe-rust-zstd"))] + CompressionMethod::ZSTD => Box::new(ruzstd::decoding::StreamingDecoder::new(reader).unwrap()), // TODO: Error handling CompressionMethod::PackBits => Box::new(PackBitsReader::new(reader, compressed_length)), #[cfg(feature = "deflate")] CompressionMethod::Deflate | CompressionMethod::OldDeflate => { diff --git a/tests/decode_images.rs b/tests/decode_images.rs index 74f9f9f..08314af 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -569,7 +569,7 @@ fn test_predictor_3_gray_f32() { } #[test] -#[cfg(feature = "zstd")] +#[cfg(any(feature = "zstd", feature = "safe-rust-zstd"))] fn test_zstd_compression() { // gdal_translate -co COMPRESS=ZSTD -co ZSTD_LEVEL=20 int16.tif int16_zstd.tif test_image_sum_i16("int16_zstd.tif", ColorType::Gray(16), 354396); From 61dbcc13922e948472fc2fca558acd9aa257bc53 Mon Sep 17 00:00:00 2001 From: "Sergey \"Shnatsel\" Davidoff" Date: Thu, 5 Mar 2026 13:15:33 +0000 Subject: [PATCH 02/17] Add error handling --- src/decoder/image.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index fb8fcb4..eb513fc 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -660,7 +660,10 @@ impl Image { #[cfg(feature = "zstd")] CompressionMethod::ZSTD => Box::new(zstd::Decoder::new(reader)?), #[cfg(all(not(feature = "zstd"), feature = "safe-rust-zstd"))] - CompressionMethod::ZSTD => Box::new(ruzstd::decoding::StreamingDecoder::new(reader).unwrap()), // TODO: Error handling + CompressionMethod::ZSTD => Box::new( + ruzstd::decoding::StreamingDecoder::new(reader) + .map_err(|e| std::io::Error::other(e))?, + ), CompressionMethod::PackBits => Box::new(PackBitsReader::new(reader, compressed_length)), #[cfg(feature = "deflate")] CompressionMethod::Deflate | CompressionMethod::OldDeflate => { From 19caae4f534f1ebae71ad34a50fd9da4161f1399 Mon Sep 17 00:00:00 2001 From: "Sergey \"Shnatsel\" Davidoff" Date: Fri, 6 Mar 2026 09:36:09 +0000 Subject: [PATCH 03/17] Rename safe-rust-zstd to zstd-safe-rust --- Cargo.toml | 4 ++-- src/decoder/image.rs | 2 +- tests/decode_images.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f4f5223..2896ebb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,8 +47,8 @@ lzw = ["dep:weezl"] webp = ["dep:image-webp"] zstd = ["dep:zstd"] # Fully safe Rust but much slower Zstd decoding. -# When both zstd and safe-rust-zstd are enabled, zstd takes precedence. -safe-rust-zstd = ["dep:ruzstd"] +# When both zstd and zstd-safe-rust are enabled, zstd takes precedence. +zstd-safe-rust = ["dep:ruzstd"] [[bench]] name = "lzw" diff --git a/src/decoder/image.rs b/src/decoder/image.rs index eb513fc..6d0dc24 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -659,7 +659,7 @@ impl Image { )), #[cfg(feature = "zstd")] CompressionMethod::ZSTD => Box::new(zstd::Decoder::new(reader)?), - #[cfg(all(not(feature = "zstd"), feature = "safe-rust-zstd"))] + #[cfg(all(not(feature = "zstd"), feature = "zstd-safe-rust"))] CompressionMethod::ZSTD => Box::new( ruzstd::decoding::StreamingDecoder::new(reader) .map_err(|e| std::io::Error::other(e))?, diff --git a/tests/decode_images.rs b/tests/decode_images.rs index 08314af..6e3deab 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -569,7 +569,7 @@ fn test_predictor_3_gray_f32() { } #[test] -#[cfg(any(feature = "zstd", feature = "safe-rust-zstd"))] +#[cfg(any(feature = "zstd", feature = "zstd-safe-rust"))] fn test_zstd_compression() { // gdal_translate -co COMPRESS=ZSTD -co ZSTD_LEVEL=20 int16.tif int16_zstd.tif test_image_sum_i16("int16_zstd.tif", ColorType::Gray(16), 354396); From 0fdf1e05021983061f3a1029adfd1ace147c7dc6 Mon Sep 17 00:00:00 2001 From: "A. Molzer" <5550310+197g@users.noreply.github.com> Date: Fri, 15 May 2026 19:10:16 +0200 Subject: [PATCH 04/17] Upgrade weezl to 0.2 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 0df78ad..a7d4275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ quick-error = "2.0.1" # Rename to avoid confusion with the feature name. fax34 = { package = "fax", version = "0.2.6", optional = true } flate2 = { version = "1.0.20", optional = true } -weezl = { version = "0.1.10", optional = true } +weezl = { version = "0.2.1", optional = true } zstd = { version = "0.13", optional = true } zune-jpeg = { version = "0.5.1", optional = true } image-webp = {version = "0.2.4", optional = true } From 5947d3b72e3f4c857f4e22748762489487fb2045 Mon Sep 17 00:00:00 2001 From: "A. Molzer" <5550310+197g@users.noreply.github.com> Date: Sat, 16 May 2026 00:46:02 +0200 Subject: [PATCH 05/17] Make newer clippy happy --- src/decoder/logluv.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/decoder/logluv.rs b/src/decoder/logluv.rs index 7744736..ff3e742 100644 --- a/src/decoder/logluv.rs +++ b/src/decoder/logluv.rs @@ -92,11 +92,7 @@ fn packbits_decode( let mut i = 0; let mut code = [0u8; 1]; - loop { - let Some(pixels) = buf.get_mut(i..) else { - break; - }; - + while let Some(pixels) = buf.get_mut(i..) { if pixels.is_empty() { break; } From a1426ca8022aa36cde60eda4e6accea26129578a Mon Sep 17 00:00:00 2001 From: "A. Molzer" <5550310+197g@users.noreply.github.com> Date: Sat, 16 May 2026 02:28:36 +0200 Subject: [PATCH 06/17] Fix benches/lzw.rs directory iteration --- benches/lzw.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benches/lzw.rs b/benches/lzw.rs index 0c9c73d..0cd5741 100644 --- a/benches/lzw.rs +++ b/benches/lzw.rs @@ -9,9 +9,9 @@ use tiff::decoder::Decoder; fn read_image(image: &[u8]) { let image = std::io::Cursor::new(image); let decoder = Decoder::open(black_box(image)); - let mut reader = decoder.unwrap(); while reader.more_images() { + reader.next_directory().unwrap(); reader.read_image().unwrap(); } } From f11a1c539310de29f5df166858ba640010e28b11 Mon Sep 17 00:00:00 2001 From: Dustin Thomson <154049-dustin.thomson@users.noreply.gitlab.com> Date: Thu, 28 May 2026 08:48:12 -0600 Subject: [PATCH 07/17] Add PhotometricInterpretation unknown tag support Provides a mechanism to support private or extension photometric interpretations (such as CFA) --- src/decoder/image.rs | 14 ++++++++------ src/tags.rs | 5 ++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 1b51b51..88b63f0 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -660,12 +660,14 @@ impl Image { ), )), PhotometricInterpretation::RGBPalette => Ok(ColorType::Palette(self.bits_per_sample)), - PhotometricInterpretation::TransparencyMask => Err(TiffError::UnsupportedError( - TiffUnsupportedError::InterpretationWithBits( - self.photometric_interpretation, - vec![self.bits_per_sample; self.samples as usize], - ), - )), + PhotometricInterpretation::TransparencyMask | PhotometricInterpretation::Unknown(_) => { + Err(TiffError::UnsupportedError( + TiffUnsupportedError::InterpretationWithBits( + self.photometric_interpretation, + vec![self.bits_per_sample; self.samples as usize], + ), + )) + } } } diff --git a/src/tags.rs b/src/tags.rs index 8a3f481..c0eff16 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -295,7 +295,10 @@ pub enum CompressionMethod(u16) unknown( } tags! { -pub enum PhotometricInterpretation(u16) { +pub enum PhotometricInterpretation(u16) unknown( + /// A private or extension tag + unknown +) { WhiteIsZero = 0, BlackIsZero = 1, RGB = 2, From 279851a9a9f39a373b0b7cc5de2098535aca9462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=90=AF?= Date: Mon, 22 Jun 2026 10:58:42 +0800 Subject: [PATCH 08/17] fix: correct extra bits rgb test assertion based on write extra bits image logic --- .gitignore | 1 + tests/decode_images.rs | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 4ad8ac2..4257da3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ **/*.rs.bk Cargo.lock tests/benches/corpus/ +.DS_Store diff --git a/tests/decode_images.rs b/tests/decode_images.rs index a68380a..af4d0d0 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -16,7 +16,8 @@ macro_rules! test_image_sum { fn $name(file: &str, expected_type: ColorType, expected_sum: $sum_ty) { let path = PathBuf::from(TEST_IMAGE_DIR).join(file); let img_file = File::open(path).expect("Cannot find test image!"); - let mut decoder = Decoder::open(img_file).expect("Cannot create decoder"); + let mut decoder = + Decoder::open_with_options(img_file, true).expect("Cannot create decoder"); decoder.next_image().expect("Cannot read image IFD"); assert_eq!(decoder.colortype().unwrap(), expected_type); @@ -975,7 +976,7 @@ fn extra_bits_gray() { #[test] fn extra_bits_rgb() { - test_image_sum_u8("extra_bits_rgb_8b.tiff", ColorType::RGB(8), 0); + test_image_sum_u8("extra_bits_rgb_8b.tiff", ColorType::RGBA(8), 64); } #[test] From 0309349e1bed8c7cff39247416bbca8d8047c64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=90=AF?= Date: Mon, 22 Jun 2026 10:59:36 +0800 Subject: [PATCH 09/17] fix: add lenient mode for tiff image decoder --- src/decoder/image.rs | 18 ++++++++++++------ src/decoder/mod.rs | 32 ++++++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 88b63f0..f37b2b1 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -97,6 +97,7 @@ pub(crate) struct Image { /// run on samples directly and thus skip the outer byte order entirely, except potentially /// they have interior byte order treatment. pub decompression_to_host_endian: bool, + pub lenient: bool, } /// Describes how to read a tile-aligned portion of the image. @@ -155,9 +156,10 @@ pub(crate) struct ReadoutLayout { } impl Image { - pub fn from_reader( + pub fn from_reader_with_options( decoder: &mut ValueReader, ifd: &Directory, + lenient: bool, ) -> TiffResult { let mut tag_reader = TagReader { decoder, ifd }; @@ -500,6 +502,7 @@ impl Image { compression_method, CompressionMethod::SgiLog | CompressionMethod::SgiLog24 ), + lenient, }) } @@ -515,10 +518,13 @@ impl Image { } pub(crate) fn colortype(&self) -> TiffResult { - let is_alpha_extra_samples = matches!( - self.extra_samples.as_slice(), - [ExtraSamples::AssociatedAlpha, ..] | [ExtraSamples::UnassociatedAlpha, ..] - ); + let is_alpha_extra_samples = match self.extra_samples.as_slice().get(0) { + Some(ExtraSamples::AssociatedAlpha) | Some(ExtraSamples::UnassociatedAlpha) => true, + // In lenient mode, keep unspecified alpha interpretation logic compatible with libtiff + // See Issue #341 + Some(ExtraSamples::Unspecified) if self.lenient && self.samples > 3 => true, + _ => false, + }; match self.photometric_interpretation { PhotometricInterpretation::RGB => match self.photometric_samples { @@ -1192,7 +1198,7 @@ impl Image { super::fix_endianness_and_predict( out_row, color_type.bit_depth(), - samples, + data_samples, ByteOrder::native(), predictor, scratch, diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index cfc19ef..7a3a9b9 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -227,6 +227,7 @@ pub struct TiffHeader { pub byte_order: ByteOrder, pub variant: TiffVariant, pub first_ifd: IfdPointer, + lenient: bool, } /// The representation of a TIFF decoder @@ -257,6 +258,8 @@ pub struct Decoder { /// Reusable scratch buffer for the floating-point predictor. Stored here so that the /// allocation persists across strips/tiles and images, avoiding per-row heap allocation. scratch: Vec, + /// Leniency flag that allows libtiff-like TIFF color interpretation behavior + lenient: bool, } /// All the information needed to read and interpret byte slices from the underlying file, i.e. to @@ -674,7 +677,11 @@ fn fix_endianness(buf: &mut [u8], byte_order: ByteOrder, bit_depth: u8) { } impl TiffHeader { - pub fn parse(mut r: R) -> TiffResult { + pub fn parse(r: R) -> TiffResult { + Self::parse_with_options(r, false) + } + + pub fn parse_with_options(mut r: R, lenient: bool) -> TiffResult { let mut endianess = Vec::with_capacity(2); (&mut r).take(2).read_to_end(&mut endianess)?; let byte_order = match &*endianess { @@ -727,6 +734,7 @@ impl TiffHeader { TiffVariant::Standard }, first_ifd: next_ifd, + lenient, }) } @@ -750,6 +758,7 @@ impl TiffHeader { seen_ifds: cycles::IfdCycles::new(), image: None, scratch: Vec::new(), + lenient: self.lenient, } } } @@ -792,6 +801,11 @@ impl Decoder { Ok(header.open(r)) } + pub fn open_with_options(mut r: R, lenient: bool) -> TiffResult> { + let header = TiffHeader::parse_with_options(&mut r, lenient)?; + Ok(header.open(r)) + } + /// Create a decoder and attempt to parse the first image directory. /// /// To open a file without parsing any directory yet (for instance to retain a [`Decoder`] on @@ -846,7 +860,8 @@ impl Decoder { )); } - let image = Image::from_reader(&mut self.value_reader, &self.directory)?; + let image = + Image::from_reader_with_options(&mut self.value_reader, &self.directory, self.lenient)?; Ok(( self.image.insert(image), &mut self.value_reader, @@ -982,6 +997,7 @@ impl Decoder { current_ifd_pointer: self.current_ifd_pointer, image: core::mem::take(&mut self.image), scratch: core::mem::take(&mut self.scratch), + lenient: self.lenient, } } @@ -1030,7 +1046,11 @@ impl Decoder { /// error is returned. pub fn seek_to_image(&mut self, ifd_index: usize) -> TiffResult<()> { let (offset, directory) = self.find_nth_ifd(ifd_index)?; - self.image = Some(Image::from_reader(&mut self.value_reader, &directory)?); + self.image = Some(Image::from_reader_with_options( + &mut self.value_reader, + &directory, + self.lenient, + )?); self.current_ifd_pointer = Some(offset); self.directory = directory; Ok(()) @@ -1123,7 +1143,11 @@ impl Decoder { pub fn next_image(&mut self) -> TiffResult<()> { let next_ifd = self.directory.next(); let (offset, directory) = self.read_chained_ifd(self.current_ifd_pointer, next_ifd)?; - self.image = Some(Image::from_reader(&mut self.value_reader, &directory)?); + self.image = Some(Image::from_reader_with_options( + &mut self.value_reader, + &directory, + self.lenient, + )?); self.current_ifd_pointer = Some(offset); self.directory = directory; Ok(()) From a658c09f0971135131c977e72265801ebc9c5ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=90=AF?= Date: Mon, 22 Jun 2026 12:02:54 +0800 Subject: [PATCH 10/17] feat: add lenient decoder example --- examples/lenient_decode.rs | 233 +++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 examples/lenient_decode.rs diff --git a/examples/lenient_decode.rs b/examples/lenient_decode.rs new file mode 100644 index 0000000..0c673ef --- /dev/null +++ b/examples/lenient_decode.rs @@ -0,0 +1,233 @@ +use tiff::decoder::{BufferLayoutPreference, Decoder, DecodingSampleBuffer, DecodingSampleSlice}; + +fn main() -> Result<(), Box> { + let Some(image) = std::env::args_os().nth(1) else { + eprintln!("Usage: decode FILE"); + return Ok(()); + }; + + let file = std::fs::File::open(image)?; + let io = std::io::BufReader::new(file); + let mut reader = Decoder::open_with_options(io, true)?; + + let mut data = DecodingSampleBuffer::I8(vec![]); + + for i in 0u32.. { + if !reader.more_images() { + break; + } + + reader.next_directory()?; + + let colortype = reader.colortype()?; + let dimensions = reader.dimensions()?; + let layout = reader.read_image_to_buffer(&mut data)?; + + debug_planes(i, &mut data, &layout, &colortype, dimensions); + } + + Ok(()) +} + +fn debug_planes( + index: u32, + data: &mut DecodingSampleBuffer, + layout: &BufferLayoutPreference, + colortype: &tiff::ColorType, + (width, height): (u32, u32), +) { + match data { + DecodingSampleBuffer::F16(v) => { + let samples = v + .iter() + .map(|&f| (f64::from(f) * f64::from(u16::MAX)) as u16) + .collect::>(); + *data = DecodingSampleBuffer::U16(samples); + } + DecodingSampleBuffer::F32(v) => { + let samples = v + .iter() + .map(|&f| (f64::from(f) * f64::from(u16::MAX)) as u16) + .collect::>(); + *data = DecodingSampleBuffer::U16(samples); + } + DecodingSampleBuffer::F64(v) => { + let samples = v + .iter() + .map(|&f| (f * f64::from(u16::MAX)) as u16) + .collect::>(); + *data = DecodingSampleBuffer::U16(samples); + } + _ => {} + } + + let (depth, mut tupltype) = match colortype { + // Note: we will expand, so this is in fact not BLACKANDWHITE + tiff::ColorType::Gray(_) => (1, "GRAYSCALE"), + tiff::ColorType::RGB(_) => (3, "RGB"), + tiff::ColorType::RGBA(_) => (4, "RGB_ALPHA"), + tiff::ColorType::Palette(_) => (1, "PALETTE"), + tiff::ColorType::CMYK(_) => (4, "CMYK"), + tiff::ColorType::Multiband { num_samples: 2, .. } => (2, "GRAYSCALE_ALPHA"), + _ => { + eprintln!("Unsupported color type for PAM output: {:?}", colortype); + return; + } + }; + + // Number of samples in a line and number of sample lines in the image, depending on whether + // they are planar or not. This determines the layout of the image buffer when expanding bit + // packed samples to a full byte (we support power-of-two sample sizes, positive and negative, + // but only those). + let (swidth, sheight) = if layout.planes > 1 { + ( + u64::from(width) * u64::from(colortype.num_samples()), + u64::from(height), + ) + } else { + ( + u64::from(width), + u64::from(height) * u64::from(colortype.num_samples()), + ) + }; + + let mut fallback_buffer; + let maxval = match data.as_buffer(0) { + // `U8` is also our representation of bit-packed color. PAM uses the smallest number of + // *bytes* needed to represent a sample but we must also adhere to maxval. We could explode + // them here (e.g. represent 1-bit as `{0, 1}`) but we'd also set `maxval` accordingly and + // this requires tupltype to be `BLACKANDWHITE` so introducing a bunch of complexity. If + // you want to do that in your real code, go ahead. + DecodingSampleSlice::U8(buf) => match colortype.bit_depth() { + 8 => u64::from(u8::MAX), + 4 => { + fallback_buffer = vec![0; (swidth * sheight) as usize]; + expand_4bit(buf, swidth, &mut fallback_buffer); + *data = DecodingSampleBuffer::U8(fallback_buffer); + u64::from(u8::MAX) + } + 2 => { + fallback_buffer = vec![0; (swidth * sheight) as usize]; + expand_2bit(buf, swidth, &mut fallback_buffer); + *data = DecodingSampleBuffer::U8(fallback_buffer); + u64::from(u8::MAX) + } + 1 => { + fallback_buffer = vec![0; (swidth * sheight) as usize]; + expand_1bit(buf, swidth, &mut fallback_buffer); + *data = DecodingSampleBuffer::U8(fallback_buffer); + u64::from(u8::MAX) + } + _ => { + eprintln!( + "Unsupported bit depth for U8 buffer: {}", + colortype.bit_depth() + ); + return; + } + }, + DecodingSampleSlice::U16(_) => u64::from(u16::MAX), + DecodingSampleSlice::U32(_) => u64::from(u32::MAX), + DecodingSampleSlice::U64(_) => u64::MAX, + // Eh, this is a good effort I guess. + DecodingSampleSlice::I8(items) => { + items.iter_mut().for_each(|v| *v = 0.max(*v)); + u64::from(i8::MAX as u8) + } + DecodingSampleSlice::I16(items) => { + items.iter_mut().for_each(|v| *v = 0.max(*v)); + u64::from(i16::MAX as u16) + } + DecodingSampleSlice::I32(items) => { + items.iter_mut().for_each(|v| *v = 0.max(*v)); + u64::from(i32::MAX as u32) + } + DecodingSampleSlice::I64(items) => { + items.iter_mut().for_each(|v| *v = 0.max(*v)); + i64::MAX as u64 + } + // Not supported by PAM. + DecodingSampleSlice::F16(_) | DecodingSampleSlice::F32(_) | DecodingSampleSlice::F64(_) => { + return + } + }; + + let byte_len = if layout.planes > 1 { + tupltype = "GRAYSCALE"; + layout.plane_stride.unwrap().get() + } else { + data.as_buffer(0).byte_len() + }; + + // Write out this image's planes to PAM files. + let header = format!("P7\nWIDTH {width}\nHEIGHT {height}\nDEPTH {depth}\nMAXVAL {maxval}\nTUPLTYPE {tupltype}\nENDHDR\n"); + + for (plane, plane_data) in data + .as_buffer(0) + .as_bytes_mut() + .chunks_exact(byte_len) + .enumerate() + { + let filename = format!("image_{index:03}_plane_{plane:03}.pam"); + let mut file = std::fs::File::create(filename).expect("Failed to create output file"); + + use std::io::Write; + file.write_all(header.as_bytes()) + .expect("Failed to write PAM header"); + file.write_all(plane_data) + .expect("Failed to write PAM data"); + } +} + +/// Put exactly the ith `SCALE`-bit chunk of `val` into the least significant bits. +#[inline(always)] +fn bits_extract(val: u8, i: u8) -> u8 { + // Shift left to discard high bits, then right to bring desired bits down. This way also + // conveniently avoids all cases of overflow. + (val << (i * SCALE)) >> (8 - SCALE) +} + +fn expand_1bit(from: &[u8], samples_per_padded: u64, into: &mut [u8]) { + let stride = samples_per_padded.div_ceil(8); + + let from_row = from.chunks_exact(stride as usize); + let into_row = into.chunks_exact_mut(samples_per_padded as usize); + + for (from_row, into_row) in from_row.zip(into_row) { + for (into, &from) in into_row.chunks_mut(8).zip(from_row) { + let data = + core::array::from_fn::(|i| 0xff * bits_extract::<1>(from, i as u8)); + into.copy_from_slice(&data[..into.len()]); + } + } +} + +fn expand_2bit(from: &[u8], samples_per_padded: u64, into: &mut [u8]) { + let stride = samples_per_padded.div_ceil(4); + + let from_row = from.chunks_exact(stride as usize); + let into_row = into.chunks_exact_mut(samples_per_padded as usize); + + for (from_row, into_row) in from_row.zip(into_row) { + for (into, &from) in into_row.chunks_mut(4).zip(from_row) { + let data = + core::array::from_fn::(|i| 0xaa * bits_extract::<2>(from, i as u8)); + into.copy_from_slice(&data[..into.len()]); + } + } +} + +fn expand_4bit(from: &[u8], samples_per_padded: u64, into: &mut [u8]) { + let stride = samples_per_padded.div_ceil(2); + + let from_row = from.chunks_exact(stride as usize); + let into_row = into.chunks_exact_mut(samples_per_padded as usize); + + for (from_row, into_row) in from_row.zip(into_row) { + for (into, &from) in into_row.chunks_mut(2).zip(from_row) { + let data = + core::array::from_fn::(|i| 0x88 * bits_extract::<4>(from, i as u8)); + into.copy_from_slice(&data[..into.len()]); + } + } +} From c7a11763816b41402d3c4df2453c670a44065cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=90=AF?= Date: Mon, 22 Jun 2026 14:13:53 +0800 Subject: [PATCH 11/17] fix: eliminate clippy errors --- src/decoder/image.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index f37b2b1..fe5a40a 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -518,7 +518,7 @@ impl Image { } pub(crate) fn colortype(&self) -> TiffResult { - let is_alpha_extra_samples = match self.extra_samples.as_slice().get(0) { + let is_alpha_extra_samples = match self.extra_samples.as_slice().first() { Some(ExtraSamples::AssociatedAlpha) | Some(ExtraSamples::UnassociatedAlpha) => true, // In lenient mode, keep unspecified alpha interpretation logic compatible with libtiff // See Issue #341 From c0043c1a8413c7d523bf68399508ad5b964e8f30 Mon Sep 17 00:00:00 2001 From: "Sergey \"Shnatsel\" Davidoff" Date: Sat, 27 Jun 2026 12:47:51 +0100 Subject: [PATCH 12/17] Use zrip-decode crate instead of ruzstd --- Cargo.toml | 6 +++--- src/decoder/image.rs | 5 +---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2896ebb..a8537be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ fax34 = { package = "fax", version = "0.2.6", optional = true } flate2 = { version = "1.0.20", optional = true } weezl = { version = "0.1.10", optional = true } zstd = { version = "0.13", optional = true } -ruzstd = { version = "0.8.2", optional = true } +zrip-decode = { version = "0.7.0", features = ["paranoid"], optional = true } zune-jpeg = { version = "0.5.1", optional = true } image-webp = {version = "0.2.4", optional = true } @@ -46,9 +46,9 @@ jpeg = ["dep:zune-jpeg"] lzw = ["dep:weezl"] webp = ["dep:image-webp"] zstd = ["dep:zstd"] -# Fully safe Rust but much slower Zstd decoding. +# Fully safe Rust but somewhat slower Zstd decoding. # When both zstd and zstd-safe-rust are enabled, zstd takes precedence. -zstd-safe-rust = ["dep:ruzstd"] +zstd-safe-rust = ["dep:zrip-decode"] [[bench]] name = "lzw" diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 6d0dc24..3a107b7 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -660,10 +660,7 @@ impl Image { #[cfg(feature = "zstd")] CompressionMethod::ZSTD => Box::new(zstd::Decoder::new(reader)?), #[cfg(all(not(feature = "zstd"), feature = "zstd-safe-rust"))] - CompressionMethod::ZSTD => Box::new( - ruzstd::decoding::StreamingDecoder::new(reader) - .map_err(|e| std::io::Error::other(e))?, - ), + CompressionMethod::ZSTD => Box::new(zrip_decode::streaming::FrameDecoder::new(reader)), CompressionMethod::PackBits => Box::new(PackBitsReader::new(reader, compressed_length)), #[cfg(feature = "deflate")] CompressionMethod::Deflate | CompressionMethod::OldDeflate => { From 1c6001ea5d52dda5261225ccb720e9b16532c0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=90=AF?= Date: Fri, 3 Jul 2026 17:19:48 +0800 Subject: [PATCH 13/17] fix: resolve comments --- .gitignore | 1 - examples/decode.rs | 6 +- examples/lenient_decode.rs | 233 ------------------------------------- src/decoder/mod.rs | 24 ++-- tests/decode_images.rs | 5 +- 5 files changed, 18 insertions(+), 251 deletions(-) delete mode 100644 examples/lenient_decode.rs diff --git a/.gitignore b/.gitignore index 4257da3..4ad8ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ **/*.rs.bk Cargo.lock tests/benches/corpus/ -.DS_Store diff --git a/examples/decode.rs b/examples/decode.rs index 9594e40..6b52533 100644 --- a/examples/decode.rs +++ b/examples/decode.rs @@ -2,13 +2,15 @@ use tiff::decoder::{BufferLayoutPreference, Decoder, DecodingSampleBuffer, Decod fn main() -> Result<(), Box> { let Some(image) = std::env::args_os().nth(1) else { - eprintln!("Usage: decode FILE"); + eprintln!("Usage: decode FILE [--lenient]"); return Ok(()); }; + let lenient = std::env::args().nth(2).as_deref() == Some("--lenient"); + let file = std::fs::File::open(image)?; let io = std::io::BufReader::new(file); - let mut reader = Decoder::open(io)?; + let mut reader = Decoder::open(io)?.with_lenient(lenient); let mut data = DecodingSampleBuffer::I8(vec![]); diff --git a/examples/lenient_decode.rs b/examples/lenient_decode.rs deleted file mode 100644 index 0c673ef..0000000 --- a/examples/lenient_decode.rs +++ /dev/null @@ -1,233 +0,0 @@ -use tiff::decoder::{BufferLayoutPreference, Decoder, DecodingSampleBuffer, DecodingSampleSlice}; - -fn main() -> Result<(), Box> { - let Some(image) = std::env::args_os().nth(1) else { - eprintln!("Usage: decode FILE"); - return Ok(()); - }; - - let file = std::fs::File::open(image)?; - let io = std::io::BufReader::new(file); - let mut reader = Decoder::open_with_options(io, true)?; - - let mut data = DecodingSampleBuffer::I8(vec![]); - - for i in 0u32.. { - if !reader.more_images() { - break; - } - - reader.next_directory()?; - - let colortype = reader.colortype()?; - let dimensions = reader.dimensions()?; - let layout = reader.read_image_to_buffer(&mut data)?; - - debug_planes(i, &mut data, &layout, &colortype, dimensions); - } - - Ok(()) -} - -fn debug_planes( - index: u32, - data: &mut DecodingSampleBuffer, - layout: &BufferLayoutPreference, - colortype: &tiff::ColorType, - (width, height): (u32, u32), -) { - match data { - DecodingSampleBuffer::F16(v) => { - let samples = v - .iter() - .map(|&f| (f64::from(f) * f64::from(u16::MAX)) as u16) - .collect::>(); - *data = DecodingSampleBuffer::U16(samples); - } - DecodingSampleBuffer::F32(v) => { - let samples = v - .iter() - .map(|&f| (f64::from(f) * f64::from(u16::MAX)) as u16) - .collect::>(); - *data = DecodingSampleBuffer::U16(samples); - } - DecodingSampleBuffer::F64(v) => { - let samples = v - .iter() - .map(|&f| (f * f64::from(u16::MAX)) as u16) - .collect::>(); - *data = DecodingSampleBuffer::U16(samples); - } - _ => {} - } - - let (depth, mut tupltype) = match colortype { - // Note: we will expand, so this is in fact not BLACKANDWHITE - tiff::ColorType::Gray(_) => (1, "GRAYSCALE"), - tiff::ColorType::RGB(_) => (3, "RGB"), - tiff::ColorType::RGBA(_) => (4, "RGB_ALPHA"), - tiff::ColorType::Palette(_) => (1, "PALETTE"), - tiff::ColorType::CMYK(_) => (4, "CMYK"), - tiff::ColorType::Multiband { num_samples: 2, .. } => (2, "GRAYSCALE_ALPHA"), - _ => { - eprintln!("Unsupported color type for PAM output: {:?}", colortype); - return; - } - }; - - // Number of samples in a line and number of sample lines in the image, depending on whether - // they are planar or not. This determines the layout of the image buffer when expanding bit - // packed samples to a full byte (we support power-of-two sample sizes, positive and negative, - // but only those). - let (swidth, sheight) = if layout.planes > 1 { - ( - u64::from(width) * u64::from(colortype.num_samples()), - u64::from(height), - ) - } else { - ( - u64::from(width), - u64::from(height) * u64::from(colortype.num_samples()), - ) - }; - - let mut fallback_buffer; - let maxval = match data.as_buffer(0) { - // `U8` is also our representation of bit-packed color. PAM uses the smallest number of - // *bytes* needed to represent a sample but we must also adhere to maxval. We could explode - // them here (e.g. represent 1-bit as `{0, 1}`) but we'd also set `maxval` accordingly and - // this requires tupltype to be `BLACKANDWHITE` so introducing a bunch of complexity. If - // you want to do that in your real code, go ahead. - DecodingSampleSlice::U8(buf) => match colortype.bit_depth() { - 8 => u64::from(u8::MAX), - 4 => { - fallback_buffer = vec![0; (swidth * sheight) as usize]; - expand_4bit(buf, swidth, &mut fallback_buffer); - *data = DecodingSampleBuffer::U8(fallback_buffer); - u64::from(u8::MAX) - } - 2 => { - fallback_buffer = vec![0; (swidth * sheight) as usize]; - expand_2bit(buf, swidth, &mut fallback_buffer); - *data = DecodingSampleBuffer::U8(fallback_buffer); - u64::from(u8::MAX) - } - 1 => { - fallback_buffer = vec![0; (swidth * sheight) as usize]; - expand_1bit(buf, swidth, &mut fallback_buffer); - *data = DecodingSampleBuffer::U8(fallback_buffer); - u64::from(u8::MAX) - } - _ => { - eprintln!( - "Unsupported bit depth for U8 buffer: {}", - colortype.bit_depth() - ); - return; - } - }, - DecodingSampleSlice::U16(_) => u64::from(u16::MAX), - DecodingSampleSlice::U32(_) => u64::from(u32::MAX), - DecodingSampleSlice::U64(_) => u64::MAX, - // Eh, this is a good effort I guess. - DecodingSampleSlice::I8(items) => { - items.iter_mut().for_each(|v| *v = 0.max(*v)); - u64::from(i8::MAX as u8) - } - DecodingSampleSlice::I16(items) => { - items.iter_mut().for_each(|v| *v = 0.max(*v)); - u64::from(i16::MAX as u16) - } - DecodingSampleSlice::I32(items) => { - items.iter_mut().for_each(|v| *v = 0.max(*v)); - u64::from(i32::MAX as u32) - } - DecodingSampleSlice::I64(items) => { - items.iter_mut().for_each(|v| *v = 0.max(*v)); - i64::MAX as u64 - } - // Not supported by PAM. - DecodingSampleSlice::F16(_) | DecodingSampleSlice::F32(_) | DecodingSampleSlice::F64(_) => { - return - } - }; - - let byte_len = if layout.planes > 1 { - tupltype = "GRAYSCALE"; - layout.plane_stride.unwrap().get() - } else { - data.as_buffer(0).byte_len() - }; - - // Write out this image's planes to PAM files. - let header = format!("P7\nWIDTH {width}\nHEIGHT {height}\nDEPTH {depth}\nMAXVAL {maxval}\nTUPLTYPE {tupltype}\nENDHDR\n"); - - for (plane, plane_data) in data - .as_buffer(0) - .as_bytes_mut() - .chunks_exact(byte_len) - .enumerate() - { - let filename = format!("image_{index:03}_plane_{plane:03}.pam"); - let mut file = std::fs::File::create(filename).expect("Failed to create output file"); - - use std::io::Write; - file.write_all(header.as_bytes()) - .expect("Failed to write PAM header"); - file.write_all(plane_data) - .expect("Failed to write PAM data"); - } -} - -/// Put exactly the ith `SCALE`-bit chunk of `val` into the least significant bits. -#[inline(always)] -fn bits_extract(val: u8, i: u8) -> u8 { - // Shift left to discard high bits, then right to bring desired bits down. This way also - // conveniently avoids all cases of overflow. - (val << (i * SCALE)) >> (8 - SCALE) -} - -fn expand_1bit(from: &[u8], samples_per_padded: u64, into: &mut [u8]) { - let stride = samples_per_padded.div_ceil(8); - - let from_row = from.chunks_exact(stride as usize); - let into_row = into.chunks_exact_mut(samples_per_padded as usize); - - for (from_row, into_row) in from_row.zip(into_row) { - for (into, &from) in into_row.chunks_mut(8).zip(from_row) { - let data = - core::array::from_fn::(|i| 0xff * bits_extract::<1>(from, i as u8)); - into.copy_from_slice(&data[..into.len()]); - } - } -} - -fn expand_2bit(from: &[u8], samples_per_padded: u64, into: &mut [u8]) { - let stride = samples_per_padded.div_ceil(4); - - let from_row = from.chunks_exact(stride as usize); - let into_row = into.chunks_exact_mut(samples_per_padded as usize); - - for (from_row, into_row) in from_row.zip(into_row) { - for (into, &from) in into_row.chunks_mut(4).zip(from_row) { - let data = - core::array::from_fn::(|i| 0xaa * bits_extract::<2>(from, i as u8)); - into.copy_from_slice(&data[..into.len()]); - } - } -} - -fn expand_4bit(from: &[u8], samples_per_padded: u64, into: &mut [u8]) { - let stride = samples_per_padded.div_ceil(2); - - let from_row = from.chunks_exact(stride as usize); - let into_row = into.chunks_exact_mut(samples_per_padded as usize); - - for (from_row, into_row) in from_row.zip(into_row) { - for (into, &from) in into_row.chunks_mut(2).zip(from_row) { - let data = - core::array::from_fn::(|i| 0x88 * bits_extract::<4>(from, i as u8)); - into.copy_from_slice(&data[..into.len()]); - } - } -} diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index 7a3a9b9..b42bda7 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -227,7 +227,6 @@ pub struct TiffHeader { pub byte_order: ByteOrder, pub variant: TiffVariant, pub first_ifd: IfdPointer, - lenient: bool, } /// The representation of a TIFF decoder @@ -677,11 +676,7 @@ fn fix_endianness(buf: &mut [u8], byte_order: ByteOrder, bit_depth: u8) { } impl TiffHeader { - pub fn parse(r: R) -> TiffResult { - Self::parse_with_options(r, false) - } - - pub fn parse_with_options(mut r: R, lenient: bool) -> TiffResult { + pub fn parse(mut r: R) -> TiffResult { let mut endianess = Vec::with_capacity(2); (&mut r).take(2).read_to_end(&mut endianess)?; let byte_order = match &*endianess { @@ -734,7 +729,6 @@ impl TiffHeader { TiffVariant::Standard }, first_ifd: next_ifd, - lenient, }) } @@ -758,7 +752,7 @@ impl TiffHeader { seen_ifds: cycles::IfdCycles::new(), image: None, scratch: Vec::new(), - lenient: self.lenient, + lenient: false, } } } @@ -801,11 +795,6 @@ impl Decoder { Ok(header.open(r)) } - pub fn open_with_options(mut r: R, lenient: bool) -> TiffResult> { - let header = TiffHeader::parse_with_options(&mut r, lenient)?; - Ok(header.open(r)) - } - /// Create a decoder and attempt to parse the first image directory. /// /// To open a file without parsing any directory yet (for instance to retain a [`Decoder`] on @@ -846,6 +835,15 @@ impl Decoder { self } + /// Use a lenient decoder. + /// + /// Configuring this make the parser respect libtiff's behavior to maintain compatibility with + /// some older softwares (e.g. some old Adobe suite products). + pub fn with_lenient(mut self, lenient: bool) -> Decoder { + self.lenient = lenient; + self + } + // FIXME: this tuple would be a good candidate for a proxy, like IfdDecoder. That has been // requested in some form or another in a number of issues. #[expect(clippy::unnecessary_unwrap)] // Lifetimes would overlap due to return. diff --git a/tests/decode_images.rs b/tests/decode_images.rs index af4d0d0..d2cafd3 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -16,8 +16,9 @@ macro_rules! test_image_sum { fn $name(file: &str, expected_type: ColorType, expected_sum: $sum_ty) { let path = PathBuf::from(TEST_IMAGE_DIR).join(file); let img_file = File::open(path).expect("Cannot find test image!"); - let mut decoder = - Decoder::open_with_options(img_file, true).expect("Cannot create decoder"); + let mut decoder = Decoder::open(img_file) + .expect("Cannot create decoder") + .with_lenient(true); decoder.next_image().expect("Cannot read image IFD"); assert_eq!(decoder.colortype().unwrap(), expected_type); From 4a9e1d152be3a910ae7ab9283b27239d4f75d397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=90=AF?= Date: Fri, 3 Jul 2026 18:49:16 +0800 Subject: [PATCH 14/17] fix: resolve comments --- src/decoder/image.rs | 2 +- src/decoder/mod.rs | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index fe5a40a..b90e25c 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -156,7 +156,7 @@ pub(crate) struct ReadoutLayout { } impl Image { - pub fn from_reader_with_options( + pub fn from_reader( decoder: &mut ValueReader, ifd: &Directory, lenient: bool, diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index b42bda7..f2f39af 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -858,8 +858,7 @@ impl Decoder { )); } - let image = - Image::from_reader_with_options(&mut self.value_reader, &self.directory, self.lenient)?; + let image = Image::from_reader(&mut self.value_reader, &self.directory, self.lenient)?; Ok(( self.image.insert(image), &mut self.value_reader, @@ -1044,7 +1043,7 @@ impl Decoder { /// error is returned. pub fn seek_to_image(&mut self, ifd_index: usize) -> TiffResult<()> { let (offset, directory) = self.find_nth_ifd(ifd_index)?; - self.image = Some(Image::from_reader_with_options( + self.image = Some(Image::from_reader( &mut self.value_reader, &directory, self.lenient, @@ -1141,7 +1140,7 @@ impl Decoder { pub fn next_image(&mut self) -> TiffResult<()> { let next_ifd = self.directory.next(); let (offset, directory) = self.read_chained_ifd(self.current_ifd_pointer, next_ifd)?; - self.image = Some(Image::from_reader_with_options( + self.image = Some(Image::from_reader( &mut self.value_reader, &directory, self.lenient, From 11133cb1e320a6bfe801b6544dc4f7220cc8dd01 Mon Sep 17 00:00:00 2001 From: "Sergey \"Shnatsel\" Davidoff" Date: Mon, 6 Jul 2026 15:15:04 +0100 Subject: [PATCH 15/17] upgrade zrip to 0.8.x series --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a8537be..7f512d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ fax34 = { package = "fax", version = "0.2.6", optional = true } flate2 = { version = "1.0.20", optional = true } weezl = { version = "0.1.10", optional = true } zstd = { version = "0.13", optional = true } -zrip-decode = { version = "0.7.0", features = ["paranoid"], optional = true } +zrip-decode = { version = "0.8.2", features = ["paranoid"], optional = true } zune-jpeg = { version = "0.5.1", optional = true } image-webp = {version = "0.2.4", optional = true } From 6276defac6629e74f52e4eea9024ae6b1833b9b7 Mon Sep 17 00:00:00 2001 From: florian Date: Thu, 16 Jul 2026 16:29:05 +0200 Subject: [PATCH 16/17] Fix clippy 1.97 useless_borrows_in_formatting lint Rust 1.97's new clippy lint flags the redundant `&` on `self.offset` in Entry's Debug impl. CI runs clippy with -D warnings in every job, so this single pre-existing lint currently fails all PR pipelines. Co-Authored-By: Claude Fable 5 --- src/decoder/ifd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/decoder/ifd.rs b/src/decoder/ifd.rs index e062ab4..f2bd19d 100644 --- a/src/decoder/ifd.rs +++ b/src/decoder/ifd.rs @@ -363,7 +363,7 @@ impl ::std::fmt::Debug for Entry { fn fmt(&self, fmt: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> { fmt.write_str(&format!( "Entry {{ type_: {:?}, count: {:?}, offset: {:?} }}", - self.type_, self.count, &self.offset + self.type_, self.count, self.offset )) } } From 35c14f1e3cad75ecd4b47126bb17e7de6cb51139 Mon Sep 17 00:00:00 2001 From: Lilith River Date: Tue, 30 Jun 2026 19:53:17 -0600 Subject: [PATCH 17/17] decoder: reconstruct extended-depth samples modulo declared bit depth Promoted extended-depth grayscale (9-15 bits -> u16, 17-31 bits -> u32) can carry bits above the declared depth after the horizontal predictor, which reconstructs prev+delta in the promoted storage width rather than modulo 2^BitsPerSample. This underflowed `max - v` in invert_colors (the image-rs/image-tiff#396 panic in debug, wrong pixel in release) and left junk in the high bits of the non-inverted output (#396 review, @197g: "clamp the channel values in the non-inverted color case"). An n-bit predictor delta is stored in exactly n bits, i.e. modulo 2^n, so reconstruction is defined modulo 2^n as well. Since 2^n divides the u16/u32 storage modulus, masking each promoted sample with (1< 2406 bytes ...epth-invert-overflow-9f2f479685f84884.tiff | Bin 0 -> 2775 bytes tests/predict.rs | 164 ++++++++++++++++++ 5 files changed, 254 insertions(+) create mode 100644 tests/fuzz_images/oor_panic/graydepth-invert-overflow-5a0aad5024cc4ff3.tiff create mode 100644 tests/fuzz_images/oor_panic/graydepth-invert-overflow-9f2f479685f84884.tiff diff --git a/src/decoder/image.rs b/src/decoder/image.rs index b90e25c..3cfaa48 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -1204,6 +1204,14 @@ impl Image { scratch, ); + // An n-bit predictor delta is stored modulo 2^n, but the + // reconstruction above accumulated it in the promoted u16/u32 + // storage width. Reduce back modulo the declared depth so both + // the inverted and non-inverted paths see exact, in-range + // values (2^n divides the storage modulus, so one mask after + // the row is exact). + super::mask_samples_to_bit_depth(out_row, tiff_bps, out_bps, self.sample_format); + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(out_row, color_type, self.sample_format)?; } @@ -1224,6 +1232,16 @@ impl Image { ); } + // No-op in this branch: promoted layouts (declared depth < storage + // width) are handled exclusively by the unpacking branch above. + // Called in every integer branch so they cannot diverge. + super::mask_samples_to_bit_depth( + tile, + layout.tiff_bits_per_sample, + layout.output_bits_per_sample, + self.sample_format, + ); + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(tile, color_type, self.sample_format)?; } @@ -1250,6 +1268,8 @@ impl Image { } _ => unreachable!(), } + // No masking here: the floating point predictor requires + // IEEEFP samples, which are never depth-promoted. if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(row, color_type, self.sample_format)?; } @@ -1279,6 +1299,16 @@ impl Image { scratch, ); + // No-op in this branch: promoted layouts only occur in the + // unpacking branch. Called so the integer branches cannot + // diverge. + super::mask_samples_to_bit_depth( + row, + layout.tiff_bits_per_sample, + layout.output_bits_per_sample, + self.sample_format, + ); + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(row, color_type, self.sample_format)?; } @@ -1321,6 +1351,16 @@ impl Image { scratch, ); + // No-op in this branch: promoted layouts only occur in the + // unpacking branch. Called so the integer branches cannot + // diverge. + super::mask_samples_to_bit_depth( + row, + layout.tiff_bits_per_sample, + layout.output_bits_per_sample, + self.sample_format, + ); + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(row, color_type, self.sample_format)?; } diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index f2f39af..b242a33 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -568,6 +568,56 @@ fn fix_endianness_and_predict( } } +/// Reduce promoted extended-depth samples (9–15 bits → `u16`, 17–31 bits → +/// `u32`) modulo their declared bit depth, i.e. mask with `(1 << n) - 1`. +/// +/// An n-bit horizontal-predicted sample stores its delta in exactly n bits, +/// i.e. modulo `2^n`, so reconstruction is also defined modulo `2^n`. The +/// predictor accumulates `prev + delta` in the promoted storage width +/// (`u16`/`u32`, see `rev_hpredict_nsamp`); since `2^n` divides the storage +/// modulus, masking once after the predictor yields exactly the mod-`2^n` +/// value for every sample — including legitimate wrap-arounds (a 9-bit +/// `511, 0` pair is stored as delta `1`, and `511 + 1 = 512` masks back to +/// `0`). Masking also bounds any out-of-range sample in malformed input, so +/// all downstream consumers (`invert_colors`' `max - v` and the non-inverted +/// output path) see in-range values. For samples already in range it is a +/// no-op. +/// +/// This runs in every integer decode branch of `expand_chunk` so the +/// branches cannot diverge. Outside the unpacking branch it reduces to the +/// two guards below and touches nothing: promoted layouts (`declared < +/// storage`) exist only there — standard depths have `declared == storage`, +/// and sub-byte depths keep byte-granular storage. +#[inline] +fn mask_samples_to_bit_depth( + buf: &mut [u8], + declared_bits: u8, + storage_bits: u8, + sample_format: SampleFormat, +) { + // Only unsigned samples are promoted with zero-extension; signed + // extended depths would need sign extension instead and are not + // supported by the unpacking path. + if sample_format != SampleFormat::Uint { + return; + } + match storage_bits { + 16 if declared_bits < 16 => { + let mask = (1u16 << declared_bits) - 1; + for chunk in buf.as_chunks_mut::<2>().0 { + *chunk = (u16::from_ne_bytes(*chunk) & mask).to_ne_bytes(); + } + } + 32 if declared_bits < 32 => { + let mask = (1u32 << declared_bits) - 1; + for chunk in buf.as_chunks_mut::<4>().0 { + *chunk = (u32::from_ne_bytes(*chunk) & mask).to_ne_bytes(); + } + } + _ => {} + } +} + fn invert_colors( buf: &mut [u8], color_type: ColorType, diff --git a/tests/fuzz_images/oor_panic/graydepth-invert-overflow-5a0aad5024cc4ff3.tiff b/tests/fuzz_images/oor_panic/graydepth-invert-overflow-5a0aad5024cc4ff3.tiff new file mode 100644 index 0000000000000000000000000000000000000000..21c7a2c29bd3362bcfe801cd8d4893330f76bcd0 GIT binary patch literal 2406 zcmc&$J!lkB5dP*iZq}$V7m`hJA}6Bgc|r_Wh=wGh!XmMWRtgGM+6WdFf*7!|6csE* zun;>z6jN$vsTVCJVj~Koq9Nkm_|1E}cbChLK@o?$+qdt{o1bs~E=ejl0i&7M`tpZr zA0&nVBG7e^Np#xbuy$hqPuoYTq`H0!Bt@A!sq#m4mQ1`kijPQ=O365jf3KAg>oUEz@&?%uV<1au)!^YY;Q4(&tRYZ#l=m5^2=X(?iiqh%NYDw}+hYsdy z258?`@|YTlWq`s%zHYnj5O^_@ko9D&wCO4bbZ*2J;aU`n^=739mw!Hb-z2_;@8^yr*9Pg)Q6X|BH zQbk$9vS}J4OPVBh-rL)I^&N>%(mT4b<$#0*-w)75MkBkAZsmibrCXHGZflcHAghGy`uf_&R$#EC-(`FJM{-e9s@ z6sXykLQLBXla;oVc|xK_OKGhq$c3YIhw$ur0xuJ;dHK$jNVsH=nUXUGNZSO;`w5S% zpR|Q`C~I8p{Ui^Mg$M{Nw5C_Fv7+@4KU-$Q5|fYpW`Ln~W7G}bVU%*9;vM~xWl)!& z$=>d~6*+b0Xj9$Hw43P#ie2dA3U%>cz-C@n-9S%DbGs~o`sMoznu5>NRggrsFN3bv z+*X5)G#G2-0dpbp?iqGIl2)pIbC@e7dx literal 0 HcmV?d00001 diff --git a/tests/fuzz_images/oor_panic/graydepth-invert-overflow-9f2f479685f84884.tiff b/tests/fuzz_images/oor_panic/graydepth-invert-overflow-9f2f479685f84884.tiff new file mode 100644 index 0000000000000000000000000000000000000000..86824ecd45bf84aee7f0ac705a9edab1487d436b GIT binary patch literal 2775 zcmeHJziSjx5dP-wvbO{;KhC%TV-Y1_h=FL4bVm}wV(lbaX^~E`38WA##FPP>fQ6+b z1pfrVAXr#wYod)Hu}Pi~{p$`5w;?R)cP=FOXL-izZg+<;;GodT)@ zkTL`ifz}{PsM=sdtGFoEk3=1x%aUN2J&E$A`jtW)p1}^{cx=cdH>`!%Plbxn7?PUE zGqxZhB8f}_A?;X;k4V6Op-nvE$R_d0We^4&jn1KhN$;rBw)aeAPtY+2Q7{R7l#&On0_|HgXZ@kEMgC=Lau+yZDDP|(M6`uvk4_~0 zQcV}K8OLvR>QXbBgPB8FGzaNwKoOCHfHy^E(}24OZI9N?X$u+5u!q`1(&APmvjGi{ zG3?wrh;0a^LWHUxESjdm{I*`sOats$z}_8GYZA;0m*S@z-&AaM^pu)azt~thkMG~x zn|bQ^+n6tiS4V;ltJ#kiV|PlDc1Sfar?;HX9dnvF;GAzsdfFLpN&0(bOh4waeN)t`=(<^h$mYhht!#WK>!k)1C2^gPr#nCdD1e)7*y z+^5cx45*hq-YAMyjFy7I`q0RDXFJp6AUg;BFw605^v~dKhPVZutbR|iyNbizH~OMt z=4RPQtYPa)#!;7nx&*uqumtq*Q##Kva^L8$?CoJc#hYh)7j9-3h}wwRs$zXTodCTq zJol&UZ;g{3YlL1F^b3wT9(bEQs&{`0S}u4>*ar3("tiled-jpeg-ycbcr.tif", ColorType::YCbCr(8)); } + +// --------------------------------------------------------------------------- +// Extended bit depths (9-15 promoted to u16, 17-31 promoted to u32). +// +// A horizontal-predicted n-bit sample stores its delta in exactly n bits, +// i.e. modulo 2^n, so reconstruction must also be modulo 2^n. The decoder +// accumulates in the promoted storage width and reduces back to n bits; +// these tests pin that reduction down with exact pixel values, including +// deltas that wrap modulo 2^n. The encoder does not support extended +// depths, so the TIFFs are hand-built. +// --------------------------------------------------------------------------- + +/// Hand-build a minimal little-endian, single-strip, uncompressed grayscale +/// TIFF with `Predictor::Horizontal` and the given (possibly non-standard) +/// bit depth. `pixels` are the intended sample values; they are converted to +/// horizontal differences modulo `2^bits` and bit-packed MSB-first with each +/// row padded to a byte boundary — the only representation an n-bit field +/// can hold. +fn build_gray_hpredict_tiff( + width: u32, + height: u32, + bits: u8, + photometric: u16, + pixels: &[u32], +) -> Vec { + let max = ((1u64 << bits) - 1) as u32; + let row_samples = width as usize; + assert_eq!(pixels.len(), row_samples * height as usize); + assert!(pixels.iter().all(|&v| v <= max)); + + // Per-row horizontal differencing modulo 2^bits. + let mut deltas = Vec::with_capacity(pixels.len()); + for row in pixels.chunks(row_samples) { + for (i, &v) in row.iter().enumerate() { + deltas.push(if i == 0 { + v + } else { + v.wrapping_sub(row[i - 1]) & max + }); + } + } + + // Bit-pack MSB-first, each row padded to a byte boundary. + let row_bytes = (row_samples * bits as usize).div_ceil(8); + let mut data = vec![0u8; row_bytes * height as usize]; + for (r, row) in deltas.chunks(row_samples).enumerate() { + for (i, &d) in row.iter().enumerate() { + let start = r * row_bytes * 8 + i * bits as usize; + for (off, b) in (start..).zip((0..bits).rev()) { + if (d >> b) & 1 == 1 { + data[off / 8] |= 0x80 >> (off % 8); + } + } + } + } + + // Classic little-endian TIFF: header, strip data, then a single IFD. + let mut out = Vec::new(); + out.extend_from_slice(b"II"); + out.extend_from_slice(&42u16.to_le_bytes()); + let data_offset = 8u32; + let ifd_offset = data_offset + data.len() as u32; + out.extend_from_slice(&ifd_offset.to_le_bytes()); + out.extend_from_slice(&data); + + const SHORT: u16 = 3; + const LONG: u16 = 4; + let entries: &[(u16, u16, u32)] = &[ + (256, LONG, width), // ImageWidth + (257, LONG, height), // ImageLength + (258, SHORT, u32::from(bits)), // BitsPerSample + (259, SHORT, 1), // Compression: none + (262, SHORT, u32::from(photometric)), // PhotometricInterpretation + (273, LONG, data_offset), // StripOffsets + (277, SHORT, 1), // SamplesPerPixel + (278, LONG, height), // RowsPerStrip + (279, LONG, data.len() as u32), // StripByteCounts + (317, SHORT, 2), // Predictor: horizontal + (339, SHORT, 1), // SampleFormat: unsigned integer + ]; + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for &(tag, ty, value) in entries { + out.extend_from_slice(&tag.to_le_bytes()); + out.extend_from_slice(&ty.to_le_bytes()); + out.extend_from_slice(&1u32.to_le_bytes()); + // A count-1 SHORT payload occupies the low bytes of the value field. + out.extend_from_slice(&value.to_le_bytes()); + } + out.extend_from_slice(&0u32.to_le_bytes()); // no next IFD + out +} + +fn decode_gray_u16(tiff: Vec, bits: u8) -> Vec { + let mut decoder = Decoder::open(Cursor::new(tiff)).expect("Cannot create decoder"); + decoder.next_image().unwrap(); + assert_eq!(decoder.colortype().unwrap(), ColorType::Gray(bits)); + match decoder.read_image().unwrap() { + DecodingSampleBuffer::U16(res) => res, + _ => panic!("Wrong data type"), + } +} + +fn decode_gray_u32(tiff: Vec, bits: u8) -> Vec { + let mut decoder = Decoder::open(Cursor::new(tiff)).expect("Cannot create decoder"); + decoder.next_image().unwrap(); + assert_eq!(decoder.colortype().unwrap(), ColorType::Gray(bits)); + match decoder.read_image().unwrap() { + DecodingSampleBuffer::U32(res) => res, + _ => panic!("Wrong data type"), + } +} + +#[test] +fn test_gray12_hpredict_wraparound_exact() { + // Pixel row [4095, 0, 1] encodes as deltas [4095, 1, 1] (mod 2^12). + // u16 accumulation yields [4095, 4096, 4097]; reduction modulo 2^12 + // must restore [4095, 0, 1]. Saturating instead would corrupt the two + // wrapped samples to 4095. + let tiff = build_gray_hpredict_tiff(3, 1, 12, 1, &[4095, 0, 1]); + assert_eq!(decode_gray_u16(tiff, 12), vec![4095, 0, 1]); +} + +#[test] +fn test_gray12_hpredict_wraparound_white_is_zero_exact() { + // Same stored samples with PhotometricInterpretation = WhiteIsZero: + // after the mod-2^12 reduction the decoder inverts as (2^12 - 1) - v. + let tiff = build_gray_hpredict_tiff(3, 1, 12, 0, &[4095, 0, 1]); + assert_eq!(decode_gray_u16(tiff, 12), vec![0, 4095, 4094]); +} + +#[test] +fn test_gray20_hpredict_wraparound_exact() { + // The same wrap-around shape on the u32 storage path (17-31 bits). + let tiff = build_gray_hpredict_tiff(3, 1, 20, 1, &[0xF_FFFF, 0, 1]); + assert_eq!(decode_gray_u32(tiff, 20), vec![0xF_FFFF, 0, 1]); +} + +#[test] +fn test_extended_depth_hpredict_random_roundtrip() { + // Deterministic pseudo-random pixels across a spread of extended depths; + // encode-then-decode must be the identity for every sample, including + // every mod-2^n wrap-around the differencing produces. Three rows verify + // that prediction restarts per row. + let mut state = 0x243F_6A88_85A3_08D3u64; + let mut next = move || { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (state >> 33) as u32 + }; + for &bits in &[9u8, 11, 12, 15] { + let max = (1u32 << bits) - 1; + let pixels: Vec = (0..16 * 3).map(|_| next() & max).collect(); + let tiff = build_gray_hpredict_tiff(16, 3, bits, 1, &pixels); + let expected: Vec = pixels.iter().map(|&v| v as u16).collect(); + assert_eq!(decode_gray_u16(tiff, bits), expected, "bits={bits}"); + } + for &bits in &[17u8, 20, 25, 31] { + let max = ((1u64 << bits) - 1) as u32; + let pixels: Vec = (0..16 * 3).map(|_| next() & max).collect(); + let tiff = build_gray_hpredict_tiff(16, 3, bits, 1, &pixels); + assert_eq!(decode_gray_u32(tiff, bits), pixels, "bits={bits}"); + } +}