From dfd019140390a0e3b8fead1a35ed5b0d151fbeb6 Mon Sep 17 00:00:00 2001 From: aarshivv <89091351+aarshivv@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:02:15 +0530 Subject: [PATCH 1/4] support palette expansion, CIE L*a*b*, transparency mask, and lzw end marker tolerance Add expand_palette_to_rgb() for converting palette indices to RGB using the color map (1/2/4/8/16-bit indices, row-aligned sub-byte). Accept CIE L*a*b* (photometric=8) as Lab(8) for 8-bit 3-channel images. Re-bias a/b from signed to unsigned during expand_chunk so callers handle CIELab and ICCLab the same way. Treat TransparencyMask as grayscale instead of erroring. Handle missing LZW end-of-information code gracefully when output was already produced. --- src/decoder/image.rs | 52 +++++++++++-------- src/decoder/mod.rs | 117 +++++++++++++++++++++++++++++++++++++++++- src/decoder/stream.rs | 6 +++ 3 files changed, 153 insertions(+), 22 deletions(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 1b51b51..c54ff06 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -591,20 +591,19 @@ impl Image { ), )), }, - // Unsupported due to inherently heterogeneous sample types. This is represented as: - // ``` - // struct TiffLab /* Interpretation 8* { - // pub L: u8, // SampleFormat::Uint - // pub a: i8, // SampleFormat::Int - // pub b: i8, // SampleFormat::Int - // } - // ``` - PhotometricInterpretation::CIELab => Err(TiffError::UnsupportedError( - TiffUnsupportedError::InterpretationWithBits( - PhotometricInterpretation::CIELab, - vec![self.bits_per_sample; self.samples as usize], - ), - )), + // CIE L*a*b* uses signed a/b channels whereas ICC L*a*b* uses unsigned + // with a +128 bias. For 8-bit, the byte-level data is the same size — we + // re-bias a/b from signed to unsigned during expand_chunk so that callers + // can treat CIELab identically to ICCLab (both return Lab(8)). + PhotometricInterpretation::CIELab => match self.photometric_samples { + 3 if matches!(self.bits_per_sample, 8) => Ok(ColorType::Lab(self.bits_per_sample)), + _ => Err(TiffError::UnsupportedError( + TiffUnsupportedError::InterpretationWithBits( + PhotometricInterpretation::CIELab, + vec![self.bits_per_sample; self.samples as usize], + ), + )), + }, PhotometricInterpretation::CIELogL => match self.samples { 1 if matches!(self.bits_per_sample, 32) && matches!(self.sample_format, SampleFormat::IEEEFP) => @@ -660,12 +659,10 @@ 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], - ), - )), + // TransparencyMask is a single-channel mask, treat as grayscale. + PhotometricInterpretation::TransparencyMask => { + Ok(ColorType::Gray(self.bits_per_sample)) + } } } @@ -1199,6 +1196,9 @@ impl Image { if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(out_row, color_type, self.sample_format)?; } + if photometric_interpretation == PhotometricInterpretation::CIELab { + super::cielab_to_icclab(out_row); + } } } else if is_output_chunk_rows && is_all_bits { // Here we can read directly into the output buffer itself. @@ -1219,6 +1219,9 @@ impl Image { if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(tile, color_type, self.sample_format)?; } + if photometric_interpretation == PhotometricInterpretation::CIELab { + super::cielab_to_icclab(tile); + } } else if chunk_row_bytes > data_row_bytes && self.predictor == Predictor::FloatingPoint { // The floating point predictor shuffles the padding bytes into the encoded output, so // this case is handled specially when needed. @@ -1245,6 +1248,9 @@ impl Image { if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(row, color_type, self.sample_format)?; } + if photometric_interpretation == PhotometricInterpretation::CIELab { + super::cielab_to_icclab(row); + } } } else if is_all_bits { // We read row-by-row but each row fits in its output buffer. @@ -1274,6 +1280,9 @@ impl Image { if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(row, color_type, self.sample_format)?; } + if photometric_interpretation == PhotometricInterpretation::CIELab { + super::cielab_to_icclab(row); + } } } else { // The encoded data potentially takes up more space than the output data so we must be @@ -1316,6 +1325,9 @@ impl Image { if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(row, color_type, self.sample_format)?; } + if photometric_interpretation == PhotometricInterpretation::CIELab { + super::cielab_to_icclab(row); + } } } diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index cfc19ef..9ee2e41 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -3,8 +3,8 @@ use std::io::{self, Read, Seek}; use std::num::NonZeroUsize; use crate::tags::{ - ByteOrder, IfdPointer, PlanarConfiguration, Predictor, SampleFormat, Tag, TiffVariant, Type, - ValueBuffer, + ByteOrder, IfdPointer, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat, + Tag, TiffVariant, Type, ValueBuffer, }; use crate::{ ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError, @@ -659,6 +659,97 @@ fn invert_colors( Ok(()) } +/// Convert CIE L*a*b* (photometric=8) byte encoding to ICC L*a*b* (photometric=9). +/// +/// CIELab stores a/b as signed int8 (-128..127). ICCLab stores them as uint8 with +/// a +128 bias (0..255, where 128 = neutral). The L channel is identical in both. +/// This re-biases a/b so that downstream code can treat CIELab the same as ICCLab. +fn cielab_to_icclab(buf: &mut [u8]) { + for pixel in buf.chunks_exact_mut(3) { + // L stays as-is; a and b get +128 (wrapping handles the two's complement) + pixel[1] = pixel[1].wrapping_add(128); + pixel[2] = pixel[2].wrapping_add(128); + } +} + +/// Expand palette indices in `buf` to RGB using the color map. +/// +/// `buf` contains packed palette indices at `bits_per_sample` depth. The color map +/// has layout `[R0..Rn, G0..Gn, B0..Bn]` with 16-bit entries. Each index is looked +/// up and replaced with an 8-bit RGB triplet (high byte of the 16-bit color map value). +/// +/// Returns a new `Vec` with 3 bytes (R,G,B) per pixel. Sub-byte indices (1, 2, 4-bit) +/// are unpacked from the row-aligned packed representation. +fn expand_palette( + buf: &[u8], + color_map: &[u16], + width: u32, + height: u32, + bits_per_sample: u8, +) -> Vec { + let num_colors = 1usize << bits_per_sample; + let pixels = (width as usize) * (height as usize); + let mut rgb = Vec::with_capacity(pixels * 3); + + if bits_per_sample == 8 { + for &idx in buf.iter().take(pixels) { + let i = idx as usize; + if i < num_colors { + rgb.push((color_map[i] >> 8) as u8); + rgb.push((color_map[num_colors + i] >> 8) as u8); + rgb.push((color_map[2 * num_colors + i] >> 8) as u8); + } else { + rgb.extend_from_slice(&[0, 0, 0]); + } + } + } else if bits_per_sample == 16 { + for chunk in buf.chunks(2).take(pixels) { + if chunk.len() < 2 { + break; + } + let i = u16::from_ne_bytes([chunk[0], chunk[1]]) as usize; + if i < num_colors { + rgb.push((color_map[i] >> 8) as u8); + rgb.push((color_map[num_colors + i] >> 8) as u8); + rgb.push((color_map[2 * num_colors + i] >> 8) as u8); + } else { + rgb.extend_from_slice(&[0, 0, 0]); + } + } + } else { + // Sub-byte (1, 2, 4-bit): rows are byte-aligned with padding bits at end + let samples_per_byte = 8 / bits_per_sample as usize; + let bytes_per_row = ((width as usize) * bits_per_sample as usize).div_ceil(8); + + for row in 0..height as usize { + let row_start = row * bytes_per_row; + let row_end = (row_start + bytes_per_row).min(buf.len()); + let mut col = 0u32; + + for &byte in &buf[row_start..row_end] { + for bit_idx in (0..samples_per_byte).rev() { + if col >= width { + break; + } + let shift = bit_idx as u8 * bits_per_sample; + let mask = ((1u16 << bits_per_sample) - 1) as u8; + let i = ((byte >> shift) & mask) as usize; + if i < num_colors { + rgb.push((color_map[i] >> 8) as u8); + rgb.push((color_map[num_colors + i] >> 8) as u8); + rgb.push((color_map[2 * num_colors + i] >> 8) as u8); + } else { + rgb.extend_from_slice(&[0, 0, 0]); + } + col += 1; + } + } + } + } + + rgb +} + /// Fix endianness. If `byte_order` matches the host, then conversion is a no-op. fn fix_endianness(buf: &mut [u8], byte_order: ByteOrder, bit_depth: u8) { let host = ByteOrder::native(); @@ -1385,6 +1476,28 @@ impl Decoder { self.image.as_ref().and_then(|img| img.color_map.as_deref()) } + /// Expand a palette-indexed image buffer to 8-bit RGB. + /// + /// Takes the raw decoded buffer (palette indices at `bits_per_sample` depth) and + /// returns a new buffer with 3 bytes per pixel (R, G, B), using the image's color map. + /// Sub-byte indices (1, 2, 4-bit) are unpacked from the row-aligned representation. + /// + /// Returns `None` if the current image is not a palette image or has no color map. + pub fn expand_palette_to_rgb(&self, indices: &[u8]) -> Option> { + let image = self.image.as_ref()?; + let color_map = image.color_map.as_ref()?; + if image.photometric_interpretation != PhotometricInterpretation::RGBPalette { + return None; + } + Some(expand_palette( + indices, + color_map, + image.width, + image.height, + image.bits_per_sample, + )) + } + /// Read a tag-entry map from a known offset. /// /// A TIFF [`Directory`], aka. image file directory aka. IFD, refers to a map from diff --git a/src/decoder/stream.rs b/src/decoder/stream.rs index 01dc70a..8820af4 100644 --- a/src/decoder/stream.rs +++ b/src/decoder/stream.rs @@ -170,6 +170,12 @@ impl Read for LZWReader { } } Ok(weezl::LzwStatus::NoProgress) => { + // Some encoders omit the LZW end-of-information code. If we + // already produced output this call, treat it as a clean EOF + // rather than an error — the decoded data up to this point is valid. + if result.consumed_out > 0 { + return Ok(result.consumed_out); + } return Err(io::Error::new( io::ErrorKind::UnexpectedEof, "no lzw end code found", From 30e04f3381ad2a455ba93f324b895ec442eef1ba Mon Sep 17 00:00:00 2001 From: aarshivv <89091351+aarshivv@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:48:40 +0530 Subject: [PATCH 2/4] chore: drop palette and LZW, add tests for CIELab and TransparencyMask --- src/decoder/image.rs | 17 ++++-- src/decoder/mod.rs | 115 +++------------------------------------- src/decoder/stream.rs | 6 --- tests/decode_images.rs | 117 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 137 insertions(+), 118 deletions(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index c54ff06..ccc2b0f 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -591,10 +591,17 @@ impl Image { ), )), }, - // CIE L*a*b* uses signed a/b channels whereas ICC L*a*b* uses unsigned - // with a +128 bias. For 8-bit, the byte-level data is the same size — we - // re-bias a/b from signed to unsigned during expand_chunk so that callers - // can treat CIELab identically to ICCLab (both return Lab(8)). + // CIE L*a*b* (TIFF6 Section 23, pp 110-115) encodes a/b as signed int8 + // (-128..127) while ICC L*a*b* uses unsigned with +128 bias. For 8-bit we + // re-bias a/b during expand_chunk so callers see the same encoding as ICCLab. + // + // libtiff takes the same approach — accepts only 8-bit CIELab and converts + // without checking SampleFormat: + // https://libtiff.gitlab.io/libtiff/functions/TIFFcolor.html + // https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_getimage.c + // + // 16-bit CIELab would need L=uint16 + a/b=int16, which SampleFormat can't + // express (it applies uniformly). libtiff doesn't support it either. PhotometricInterpretation::CIELab => match self.photometric_samples { 3 if matches!(self.bits_per_sample, 8) => Ok(ColorType::Lab(self.bits_per_sample)), _ => Err(TiffError::UnsupportedError( @@ -1033,6 +1040,7 @@ impl Image { | ColorType::YCbCr(n) | ColorType::Gray(n) | ColorType::Palette(n) + | ColorType::Lab(n) | ColorType::Multiband { bit_depth: n, num_samples: _, @@ -1044,6 +1052,7 @@ impl Image { | ColorType::YCbCr(n) | ColorType::Gray(n) | ColorType::Palette(n) + | ColorType::Lab(n) | ColorType::Multiband { bit_depth: n, num_samples: _, diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index 9ee2e41..bd318ba 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -3,8 +3,8 @@ use std::io::{self, Read, Seek}; use std::num::NonZeroUsize; use crate::tags::{ - ByteOrder, IfdPointer, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat, - Tag, TiffVariant, Type, ValueBuffer, + ByteOrder, IfdPointer, PlanarConfiguration, Predictor, SampleFormat, Tag, TiffVariant, Type, + ValueBuffer, }; use crate::{ ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError, @@ -659,97 +659,20 @@ fn invert_colors( Ok(()) } -/// Convert CIE L*a*b* (photometric=8) byte encoding to ICC L*a*b* (photometric=9). +/// Re-bias CIE L*a*b* a/b channels from signed to unsigned encoding. /// -/// CIELab stores a/b as signed int8 (-128..127). ICCLab stores them as uint8 with -/// a +128 bias (0..255, where 128 = neutral). The L channel is identical in both. -/// This re-biases a/b so that downstream code can treat CIELab the same as ICCLab. +/// CIELab (photometric=8) stores a/b as int8 where 0 is neutral. +/// ICCLab (photometric=9) stores them as uint8 where 128 is neutral. +/// Adding 128 (wrapping) converts between the two representations. +/// See libtiff's TIFFCIELabToXYZ for reference: +/// https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_color.c fn cielab_to_icclab(buf: &mut [u8]) { for pixel in buf.chunks_exact_mut(3) { - // L stays as-is; a and b get +128 (wrapping handles the two's complement) pixel[1] = pixel[1].wrapping_add(128); pixel[2] = pixel[2].wrapping_add(128); } } -/// Expand palette indices in `buf` to RGB using the color map. -/// -/// `buf` contains packed palette indices at `bits_per_sample` depth. The color map -/// has layout `[R0..Rn, G0..Gn, B0..Bn]` with 16-bit entries. Each index is looked -/// up and replaced with an 8-bit RGB triplet (high byte of the 16-bit color map value). -/// -/// Returns a new `Vec` with 3 bytes (R,G,B) per pixel. Sub-byte indices (1, 2, 4-bit) -/// are unpacked from the row-aligned packed representation. -fn expand_palette( - buf: &[u8], - color_map: &[u16], - width: u32, - height: u32, - bits_per_sample: u8, -) -> Vec { - let num_colors = 1usize << bits_per_sample; - let pixels = (width as usize) * (height as usize); - let mut rgb = Vec::with_capacity(pixels * 3); - - if bits_per_sample == 8 { - for &idx in buf.iter().take(pixels) { - let i = idx as usize; - if i < num_colors { - rgb.push((color_map[i] >> 8) as u8); - rgb.push((color_map[num_colors + i] >> 8) as u8); - rgb.push((color_map[2 * num_colors + i] >> 8) as u8); - } else { - rgb.extend_from_slice(&[0, 0, 0]); - } - } - } else if bits_per_sample == 16 { - for chunk in buf.chunks(2).take(pixels) { - if chunk.len() < 2 { - break; - } - let i = u16::from_ne_bytes([chunk[0], chunk[1]]) as usize; - if i < num_colors { - rgb.push((color_map[i] >> 8) as u8); - rgb.push((color_map[num_colors + i] >> 8) as u8); - rgb.push((color_map[2 * num_colors + i] >> 8) as u8); - } else { - rgb.extend_from_slice(&[0, 0, 0]); - } - } - } else { - // Sub-byte (1, 2, 4-bit): rows are byte-aligned with padding bits at end - let samples_per_byte = 8 / bits_per_sample as usize; - let bytes_per_row = ((width as usize) * bits_per_sample as usize).div_ceil(8); - - for row in 0..height as usize { - let row_start = row * bytes_per_row; - let row_end = (row_start + bytes_per_row).min(buf.len()); - let mut col = 0u32; - - for &byte in &buf[row_start..row_end] { - for bit_idx in (0..samples_per_byte).rev() { - if col >= width { - break; - } - let shift = bit_idx as u8 * bits_per_sample; - let mask = ((1u16 << bits_per_sample) - 1) as u8; - let i = ((byte >> shift) & mask) as usize; - if i < num_colors { - rgb.push((color_map[i] >> 8) as u8); - rgb.push((color_map[num_colors + i] >> 8) as u8); - rgb.push((color_map[2 * num_colors + i] >> 8) as u8); - } else { - rgb.extend_from_slice(&[0, 0, 0]); - } - col += 1; - } - } - } - } - - rgb -} - /// Fix endianness. If `byte_order` matches the host, then conversion is a no-op. fn fix_endianness(buf: &mut [u8], byte_order: ByteOrder, bit_depth: u8) { let host = ByteOrder::native(); @@ -1476,28 +1399,6 @@ impl Decoder { self.image.as_ref().and_then(|img| img.color_map.as_deref()) } - /// Expand a palette-indexed image buffer to 8-bit RGB. - /// - /// Takes the raw decoded buffer (palette indices at `bits_per_sample` depth) and - /// returns a new buffer with 3 bytes per pixel (R, G, B), using the image's color map. - /// Sub-byte indices (1, 2, 4-bit) are unpacked from the row-aligned representation. - /// - /// Returns `None` if the current image is not a palette image or has no color map. - pub fn expand_palette_to_rgb(&self, indices: &[u8]) -> Option> { - let image = self.image.as_ref()?; - let color_map = image.color_map.as_ref()?; - if image.photometric_interpretation != PhotometricInterpretation::RGBPalette { - return None; - } - Some(expand_palette( - indices, - color_map, - image.width, - image.height, - image.bits_per_sample, - )) - } - /// Read a tag-entry map from a known offset. /// /// A TIFF [`Directory`], aka. image file directory aka. IFD, refers to a map from diff --git a/src/decoder/stream.rs b/src/decoder/stream.rs index 8820af4..01dc70a 100644 --- a/src/decoder/stream.rs +++ b/src/decoder/stream.rs @@ -170,12 +170,6 @@ impl Read for LZWReader { } } Ok(weezl::LzwStatus::NoProgress) => { - // Some encoders omit the LZW end-of-information code. If we - // already produced output this call, treat it as a clean EOF - // rather than an error — the decoded data up to this point is valid. - if result.consumed_out > 0 { - return Ok(result.consumed_out); - } return Err(io::Error::new( io::ErrorKind::UnexpectedEof, "no lzw end code found", diff --git a/tests/decode_images.rs b/tests/decode_images.rs index a68380a..1e1a77a 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -5,7 +5,7 @@ use tiff::tags::{ByteOrder, Type}; use tiff::ColorType; use std::fs::File; -use std::io::{Read, Seek}; +use std::io::{Cursor, Read, Seek}; use std::path::PathBuf; const TEST_IMAGE_DIR: &str = "./tests/images/"; @@ -1057,3 +1057,118 @@ fn test_gray_u12_hpredict() { // Delta-encoded 12-bit values: [100, 200, 300, 400, 1000, 2000, 3000, 4000] test_image_sum_u16("hpredict-1c-12b.tiff", ColorType::Gray(12), 11000); } + +// ---- TransparencyMask ---- + +/// Build a minimal uncompressed TIFF in memory with the given photometric interpretation. +/// Returns a 2x2 image with the provided pixel data as a single strip. +fn build_raw_tiff( + photometric: u16, + bits_per_sample: u16, + samples_per_pixel: u16, + pixel_data: &[u8], +) -> Vec { + let width: u16 = 2; + let height: u16 = 2; + let num_tags: u16 = 8; + let ifd_offset: u32 = 8; + let ifd_size = 2 + (num_tags as u32) * 12 + 4; + let strip_offset = 8 + ifd_size; + + let mut buf = Vec::new(); + buf.extend_from_slice(b"II"); + buf.extend_from_slice(&42u16.to_le_bytes()); + buf.extend_from_slice(&ifd_offset.to_le_bytes()); + buf.extend_from_slice(&num_tags.to_le_bytes()); + + // IFD entries (must be ascending tag order) + for &(tag, typ, value) in &[ + (256u16, 3u16, width as u32), // ImageWidth + (257, 3, height as u32), // ImageLength + (258, 3, bits_per_sample as u32), // BitsPerSample + (259, 3, 1), // Compression = None + (262, 3, photometric as u32), // PhotometricInterpretation + (273, 4, strip_offset), // StripOffsets + (277, 3, samples_per_pixel as u32), // SamplesPerPixel + (279, 4, pixel_data.len() as u32), // StripByteCounts + ] { + buf.extend_from_slice(&tag.to_le_bytes()); + buf.extend_from_slice(&typ.to_le_bytes()); + buf.extend_from_slice(&1u32.to_le_bytes()); + buf.extend_from_slice(&value.to_le_bytes()); + } + + buf.extend_from_slice(&0u32.to_le_bytes()); // next IFD = 0 + buf.extend_from_slice(pixel_data); + buf +} + +#[test] +fn test_transparency_mask() { + // 2x2 mask: values 0, 255, 128, 64 + let data = build_raw_tiff(4, 8, 1, &[0, 255, 128, 64]); + let mut decoder = Decoder::new(Cursor::new(&data)).unwrap(); + + assert_eq!(decoder.colortype().unwrap(), ColorType::Gray(8)); + + let result = decoder.read_image().unwrap(); + match result { + DecodingSampleBuffer::U8(pixels) => { + // TransparencyMask is decoded as grayscale + assert_eq!(pixels.len(), 4); + assert_eq!(pixels[0], 0); + assert_eq!(pixels[1], 255); + assert_eq!(pixels[2], 128); + assert_eq!(pixels[3], 64); + } + _ => panic!("Expected U8 buffer"), + } +} + +// ---- CIE L*a*b* ---- + +#[test] +fn test_cielab_decode() { + // 2x2 CIELab image: 3 bytes per pixel (L, a, b) + // L=128, a=0 (signed neutral), b=0 (signed neutral) + // After re-biasing: a=128 (unsigned neutral), b=128 (unsigned neutral) + let pixels: Vec = vec![ + 128, 0, 0, // pixel 0: L=128, a=0, b=0 (neutral gray) + 255, 50, 50, // pixel 1: L=255, a=+50, b=+50 + 100, 206, 206, // pixel 2: L=100, a=-50 (as i8), b=-50 (as i8) + 0, 128, 128, // pixel 3: L=0, a=-128 (as i8), b=-128 (as i8) + ]; + + let data = build_raw_tiff(8, 8, 3, &pixels); + let mut decoder = Decoder::new(Cursor::new(&data)).unwrap(); + + assert_eq!(decoder.colortype().unwrap(), ColorType::Lab(8)); + + let result = decoder.read_image().unwrap(); + match result { + DecodingSampleBuffer::U8(decoded) => { + assert_eq!(decoded.len(), 12); // 4 pixels * 3 channels + + // Pixel 0: L=128, a=0+128=128, b=0+128=128 + assert_eq!(decoded[0], 128); + assert_eq!(decoded[1], 128); + assert_eq!(decoded[2], 128); + + // Pixel 1: L=255, a=50+128=178, b=50+128=178 + assert_eq!(decoded[3], 255); + assert_eq!(decoded[4], 178); + assert_eq!(decoded[5], 178); + + // Pixel 2: L=100, a=206+128=78 (wrapping), b=206+128=78 + assert_eq!(decoded[6], 100); + assert_eq!(decoded[7], 78); + assert_eq!(decoded[8], 78); + + // Pixel 3: L=0, a=128+128=0 (wrapping), b=128+128=0 + assert_eq!(decoded[9], 0); + assert_eq!(decoded[10], 0); + assert_eq!(decoded[11], 0); + } + _ => panic!("Expected U8 buffer"), + } +} From 9924ea2721ae078e0b602fc476610612cf2505fc Mon Sep 17 00:00:00 2001 From: aarshivv <89091351+aarshivv@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:56:42 +0530 Subject: [PATCH 3/4] chore: clean clippy deprecated warn in test case --- tests/decode_images.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/decode_images.rs b/tests/decode_images.rs index 1e1a77a..1e2fe84 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -1107,7 +1107,8 @@ fn build_raw_tiff( fn test_transparency_mask() { // 2x2 mask: values 0, 255, 128, 64 let data = build_raw_tiff(4, 8, 1, &[0, 255, 128, 64]); - let mut decoder = Decoder::new(Cursor::new(&data)).unwrap(); + let mut decoder = Decoder::open(Cursor::new(&data)).unwrap(); + decoder.next_image().unwrap(); assert_eq!(decoder.colortype().unwrap(), ColorType::Gray(8)); @@ -1140,7 +1141,8 @@ fn test_cielab_decode() { ]; let data = build_raw_tiff(8, 8, 3, &pixels); - let mut decoder = Decoder::new(Cursor::new(&data)).unwrap(); + let mut decoder = Decoder::open(Cursor::new(&data)).unwrap(); + decoder.next_image().unwrap(); assert_eq!(decoder.colortype().unwrap(), ColorType::Lab(8)); From 8f83f0fa82c315d31e2706f9d0cc97c95d3d3491 Mon Sep 17 00:00:00 2001 From: aarshivv <89091351+aarshivv@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:39:19 +0530 Subject: [PATCH 4/4] chore: address review feedback on CIELab/TransparencyMask PR --- src/decoder/image.rs | 77 +++++++++++++++++++++--------------------- src/decoder/mod.rs | 30 +++++++++++++--- tests/decode_images.rs | 34 +++++++++++-------- 3 files changed, 83 insertions(+), 58 deletions(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index ccc2b0f..5aa98c7 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -592,16 +592,15 @@ impl Image { )), }, // CIE L*a*b* (TIFF6 Section 23, pp 110-115) encodes a/b as signed int8 - // (-128..127) while ICC L*a*b* uses unsigned with +128 bias. For 8-bit we - // re-bias a/b during expand_chunk so callers see the same encoding as ICCLab. + // (-128..=127) while ICC L*a*b* (photometric=9) uses unsigned samples with + // a +128 bias. For 8-bit we re-bias a/b in post-processing (see + // `cielab_to_icclab`) so callers see the same encoding as ICCLab. // - // libtiff takes the same approach — accepts only 8-bit CIELab and converts - // without checking SampleFormat: - // https://libtiff.gitlab.io/libtiff/functions/TIFFcolor.html - // https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_getimage.c - // - // 16-bit CIELab would need L=uint16 + a/b=int16, which SampleFormat can't - // express (it applies uniformly). libtiff doesn't support it either. + // Only 8-bit is accepted here. 16-bit CIELab is a valid format that libtiff + // does decode (`putcontig8bitCIELab16`, selected by `initCIELabConversion` + // in tif_getimage.c line 2216 @ 04884181e8903915038be553bfab404302d98ea0), + // but it uses L=uint16 with a/b=int16 — a mixed layout that `SampleFormat`, + // which applies uniformly to every sample, cannot express. Left as follow-up. PhotometricInterpretation::CIELab => match self.photometric_samples { 3 if matches!(self.bits_per_sample, 8) => Ok(ColorType::Lab(self.bits_per_sample)), _ => Err(TiffError::UnsupportedError( @@ -1202,12 +1201,12 @@ impl Image { scratch, ); - if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { - super::invert_colors(out_row, color_type, self.sample_format)?; - } - if photometric_interpretation == PhotometricInterpretation::CIELab { - super::cielab_to_icclab(out_row); - } + super::post_process_row( + out_row, + photometric_interpretation, + color_type, + self.sample_format, + )?; } } else if is_output_chunk_rows && is_all_bits { // Here we can read directly into the output buffer itself. @@ -1225,12 +1224,12 @@ impl Image { ); } - if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { - super::invert_colors(tile, color_type, self.sample_format)?; - } - if photometric_interpretation == PhotometricInterpretation::CIELab { - super::cielab_to_icclab(tile); - } + super::post_process_row( + tile, + photometric_interpretation, + color_type, + self.sample_format, + )?; } else if chunk_row_bytes > data_row_bytes && self.predictor == Predictor::FloatingPoint { // The floating point predictor shuffles the padding bytes into the encoded output, so // this case is handled specially when needed. @@ -1254,12 +1253,12 @@ impl Image { } _ => unreachable!(), } - if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { - super::invert_colors(row, color_type, self.sample_format)?; - } - if photometric_interpretation == PhotometricInterpretation::CIELab { - super::cielab_to_icclab(row); - } + super::post_process_row( + row, + photometric_interpretation, + color_type, + self.sample_format, + )?; } } else if is_all_bits { // We read row-by-row but each row fits in its output buffer. @@ -1286,12 +1285,12 @@ impl Image { scratch, ); - if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { - super::invert_colors(row, color_type, self.sample_format)?; - } - if photometric_interpretation == PhotometricInterpretation::CIELab { - super::cielab_to_icclab(row); - } + super::post_process_row( + row, + photometric_interpretation, + color_type, + self.sample_format, + )?; } } else { // The encoded data potentially takes up more space than the output data so we must be @@ -1331,12 +1330,12 @@ impl Image { scratch, ); - if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { - super::invert_colors(row, color_type, self.sample_format)?; - } - if photometric_interpretation == PhotometricInterpretation::CIELab { - super::cielab_to_icclab(row); - } + super::post_process_row( + row, + photometric_interpretation, + color_type, + self.sample_format, + )?; } } diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index bd318ba..9ab8d3f 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -3,8 +3,8 @@ use std::io::{self, Read, Seek}; use std::num::NonZeroUsize; use crate::tags::{ - ByteOrder, IfdPointer, PlanarConfiguration, Predictor, SampleFormat, Tag, TiffVariant, Type, - ValueBuffer, + ByteOrder, IfdPointer, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat, + Tag, TiffVariant, Type, ValueBuffer, }; use crate::{ ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError, @@ -667,12 +667,32 @@ fn invert_colors( /// See libtiff's TIFFCIELabToXYZ for reference: /// https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_color.c fn cielab_to_icclab(buf: &mut [u8]) { - for pixel in buf.chunks_exact_mut(3) { - pixel[1] = pixel[1].wrapping_add(128); - pixel[2] = pixel[2].wrapping_add(128); + for [_, a, b] in buf.as_chunks_mut::<3>().0 { + *a = a.wrapping_add(128); + *b = b.wrapping_add(128); } } +/// Apply photometric-interpretation-specific post-processing to a decoded row. +/// +/// Some interpretations need a fixup once the raw samples are in the output +/// buffer: `WhiteIsZero` inverts intensities, and `CIELab` re-biases the signed +/// a/b channels into the unsigned ICCLab encoding. These are mutually exclusive, +/// so at most one applies to any given row. +fn post_process_row( + buf: &mut [u8], + photometric_interpretation: PhotometricInterpretation, + color_type: ColorType, + sample_format: SampleFormat, +) -> TiffResult<()> { + match photometric_interpretation { + PhotometricInterpretation::WhiteIsZero => invert_colors(buf, color_type, sample_format)?, + PhotometricInterpretation::CIELab => cielab_to_icclab(buf), + _ => {} + } + Ok(()) +} + /// Fix endianness. If `byte_order` matches the host, then conversion is a no-op. fn fix_endianness(buf: &mut [u8], byte_order: ByteOrder, bit_depth: u8) { let host = ByteOrder::native(); diff --git a/tests/decode_images.rs b/tests/decode_images.rs index 1e2fe84..7810359 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -1,7 +1,7 @@ extern crate tiff; use tiff::decoder::{ifd, Decoder, DecodingSampleBuffer}; -use tiff::tags::{ByteOrder, Type}; +use tiff::tags::{ByteOrder, PhotometricInterpretation, Type}; use tiff::ColorType; use std::fs::File; @@ -1063,7 +1063,7 @@ fn test_gray_u12_hpredict() { /// Build a minimal uncompressed TIFF in memory with the given photometric interpretation. /// Returns a 2x2 image with the provided pixel data as a single strip. fn build_raw_tiff( - photometric: u16, + photometric: PhotometricInterpretation, bits_per_sample: u16, samples_per_pixel: u16, pixel_data: &[u8], @@ -1081,19 +1081,20 @@ fn build_raw_tiff( buf.extend_from_slice(&ifd_offset.to_le_bytes()); buf.extend_from_slice(&num_tags.to_le_bytes()); - // IFD entries (must be ascending tag order) + // IFD entries (must be ascending tag order). Tag IDs stay raw for the + // ascending-order property; the field type uses the `Type` enum. for &(tag, typ, value) in &[ - (256u16, 3u16, width as u32), // ImageWidth - (257, 3, height as u32), // ImageLength - (258, 3, bits_per_sample as u32), // BitsPerSample - (259, 3, 1), // Compression = None - (262, 3, photometric as u32), // PhotometricInterpretation - (273, 4, strip_offset), // StripOffsets - (277, 3, samples_per_pixel as u32), // SamplesPerPixel - (279, 4, pixel_data.len() as u32), // StripByteCounts + (256u16, Type::SHORT, width as u32), // ImageWidth + (257, Type::SHORT, height as u32), // ImageLength + (258, Type::SHORT, bits_per_sample as u32), // BitsPerSample + (259, Type::SHORT, 1), // Compression = None + (262, Type::SHORT, photometric.to_u16() as u32), // PhotometricInterpretation + (273, Type::LONG, strip_offset), // StripOffsets + (277, Type::SHORT, samples_per_pixel as u32), // SamplesPerPixel + (279, Type::LONG, pixel_data.len() as u32), // StripByteCounts ] { buf.extend_from_slice(&tag.to_le_bytes()); - buf.extend_from_slice(&typ.to_le_bytes()); + buf.extend_from_slice(&typ.to_u16().to_le_bytes()); buf.extend_from_slice(&1u32.to_le_bytes()); buf.extend_from_slice(&value.to_le_bytes()); } @@ -1106,7 +1107,12 @@ fn build_raw_tiff( #[test] fn test_transparency_mask() { // 2x2 mask: values 0, 255, 128, 64 - let data = build_raw_tiff(4, 8, 1, &[0, 255, 128, 64]); + let data = build_raw_tiff( + PhotometricInterpretation::TransparencyMask, + 8, + 1, + &[0, 255, 128, 64], + ); let mut decoder = Decoder::open(Cursor::new(&data)).unwrap(); decoder.next_image().unwrap(); @@ -1140,7 +1146,7 @@ fn test_cielab_decode() { 0, 128, 128, // pixel 3: L=0, a=-128 (as i8), b=-128 (as i8) ]; - let data = build_raw_tiff(8, 8, 3, &pixels); + let data = build_raw_tiff(PhotometricInterpretation::CIELab, 8, 3, &pixels); let mut decoder = Decoder::open(Cursor::new(&data)).unwrap(); decoder.next_image().unwrap();