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",