From 3b11e2f5774175a19751c03bca76477818af5324 Mon Sep 17 00:00:00 2001 From: Lilith River Date: Mon, 9 Mar 2026 21:03:30 -0600 Subject: [PATCH] feat: support palette (ColorMap) image decoding Read the ColorMap tag (320) for RGBPalette images and return raw palette indices as the decoded data with ColorType::Palette(n). Changes: - Read and validate ColorMap tag during IFD parsing (3 * 2^bps entries) - Return ColorType::Palette from colortype() for RGBPalette images - Accept Palette in expand_chunk validation (standard + sub-byte depths) - Expose color_map() method on Decoder for palette lookup - Add tests for 1-bit, 4-bit, 8-bit palette images with checksums - Add test verifying ColorMap accessibility and structure The decoder returns raw palette indices; consumers apply the lookup using the color map returned by Decoder::color_map(). --- src/decoder/image.rs | 47 +++++++++++++++++++++++++++++++++++------- src/decoder/mod.rs | 10 +++++++++ tests/decode_images.rs | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index e9e0917..5383302 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -85,6 +85,9 @@ pub(crate) struct Image { pub chunk_offsets: Vec, pub chunk_bytes: Vec, pub chroma_subsampling: (u16, u16), + /// The color map (palette) for `PhotometricInterpretation::RGBPalette`. + /// Contains `3 * 2^BitsPerSample` u16 entries: R values, then G values, then B values. + pub color_map: Option>, /// Will decompression yield host-endian samples? /// /// Standard compression algorithms run on pure bytes, taking the sample slice as it would be @@ -166,6 +169,7 @@ impl Image { chunk_offsets: Vec::new(), chunk_bytes: Vec::new(), chroma_subsampling: (2, 2), + color_map: None, decompression_to_host_endian: false, } } @@ -458,6 +462,31 @@ impl Image { bits_per_sample = 32; } + // Read ColorMap for palette images. The tag contains 3 * 2^BitsPerSample u16 entries. + let color_map = if photometric_interpretation == PhotometricInterpretation::RGBPalette { + match tag_reader.find_tag(Tag::ColorMap)? { + Some(v) => { + let map = v.into_u16_vec()?; + let expected_len = 3usize + .checked_mul(1usize << bits_per_sample) + .ok_or(TiffError::LimitsExceeded)?; + if map.len() != expected_len { + return Err(TiffError::FormatError( + TiffFormatError::InconsistentSizesEncountered, + )); + } + Some(map) + } + None => { + return Err(TiffError::FormatError( + TiffFormatError::RequiredTagNotFound(Tag::ColorMap), + )); + } + } + } else { + None + }; + Ok(Image { width, height, @@ -477,6 +506,7 @@ impl Image { chunk_offsets, chunk_bytes, chroma_subsampling, + color_map, decompression_to_host_endian: matches!( compression_method, CompressionMethod::SgiLog | CompressionMethod::SgiLog24 @@ -629,14 +659,13 @@ impl Image { vec![self.bits_per_sample; self.samples as usize], ), )), - PhotometricInterpretation::RGBPalette | PhotometricInterpretation::TransparencyMask => { - Err(TiffError::UnsupportedError( - TiffUnsupportedError::InterpretationWithBits( - self.photometric_interpretation, - vec![self.bits_per_sample; self.samples as usize], - ), - )) - } + 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], + ), + )), } } @@ -979,11 +1008,13 @@ impl Image { | ColorType::CMYKA(n) | ColorType::YCbCr(n) | ColorType::Gray(n) + | ColorType::Palette(n) | ColorType::Multiband { bit_depth: n, num_samples: _, } if n == 8 || n == 16 || n == 32 || n == 64 => {} ColorType::Gray(n) + | ColorType::Palette(n) | ColorType::Multiband { bit_depth: n, num_samples: _, diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index 3626bef..c4ecf93 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -941,6 +941,16 @@ impl Decoder { self.image.colortype() } + /// Returns the color map (palette) for the current image, if it uses + /// `PhotometricInterpretation::RGBPalette`. + /// + /// The returned slice contains `3 * 2^BitsPerSample` entries as u16 values, + /// laid out as: all red values, then all green values, then all blue values. + /// Each value is in the range `0..=65535` regardless of the actual bit depth. + pub fn color_map(&self) -> Option<&[u16]> { + self.image.color_map.as_deref() + } + /// The offset of the directory representing the current image. pub fn ifd_pointer(&mut self) -> Option { self.current_ifd_pointer diff --git a/tests/decode_images.rs b/tests/decode_images.rs index b4dd38d..3575773 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -73,6 +73,47 @@ fn test_cmyk_f32() { test_image_sum_f32("cmyk-3c-32b-float.tiff", ColorType::CMYK(32), 496.0405); } +#[test] +fn test_palette_u1() { + test_image_sum_u8("palette-1c-1b.tiff", ColorType::Palette(1), 379313); +} + +#[test] +fn test_palette_u4() { + test_image_sum_u8("palette-1c-4b.tiff", ColorType::Palette(4), 1480490); +} + +#[test] +fn test_palette_u8() { + test_image_sum_u8("palette-1c-8b.tiff", ColorType::Palette(8), 2876948); +} + +#[test] +fn test_palette_color_map() { + let path = PathBuf::from(TEST_IMAGE_DIR).join("palette-1c-8b.tiff"); + let img_file = File::open(path).expect("Cannot find test image!"); + let mut decoder = Decoder::open(img_file).expect("Cannot create decoder"); + decoder.next_image().expect("Cannot read image IFD"); + + assert_eq!(decoder.colortype().unwrap(), ColorType::Palette(8)); + + let color_map = decoder + .color_map() + .expect("Palette image should have a color map"); + // 8-bit palette: 3 * 256 = 768 entries + assert_eq!(color_map.len(), 768); + + // Each section (R, G, B) has 256 entries + let r = &color_map[..256]; + let g = &color_map[256..512]; + let b = &color_map[512..768]; + + // Verify all values are valid u16 (non-zero palette, at least some variation) + assert!(r.iter().any(|&v| v > 0)); + assert!(g.iter().any(|&v| v > 0)); + assert!(b.iter().any(|&v| v > 0)); +} + #[test] fn test_gray_u8() { test_image_sum_u8("minisblack-1c-8b.tiff", ColorType::Gray(8), 2840893);