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.
This commit is contained in:
1 parent
cf9eee64ad
commit
dfd0191403
3 files changed
+153
-22
No files matched your search
+32
-20
@@ -591,20 +591,19 @@ impl Image {
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
},
|
},
|
||||||
// Unsupported due to inherently heterogeneous sample types. This is represented as:
|
// 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
|
||||||
// struct TiffLab /* Interpretation 8* {
|
// re-bias a/b from signed to unsigned during expand_chunk so that callers
|
||||||
// pub L: u8, // SampleFormat::Uint
|
// can treat CIELab identically to ICCLab (both return Lab(8)).
|
||||||
// pub a: i8, // SampleFormat::Int
|
PhotometricInterpretation::CIELab => match self.photometric_samples {
|
||||||
// pub b: i8, // SampleFormat::Int
|
3 if matches!(self.bits_per_sample, 8) => Ok(ColorType::Lab(self.bits_per_sample)),
|
||||||
// }
|
_ => Err(TiffError::UnsupportedError(
|
||||||
// ```
|
TiffUnsupportedError::InterpretationWithBits(
|
||||||
PhotometricInterpretation::CIELab => Err(TiffError::UnsupportedError(
|
PhotometricInterpretation::CIELab,
|
||||||
TiffUnsupportedError::InterpretationWithBits(
|
vec![self.bits_per_sample; self.samples as usize],
|
||||||
PhotometricInterpretation::CIELab,
|
),
|
||||||
vec![self.bits_per_sample; self.samples as usize],
|
)),
|
||||||
),
|
},
|
||||||
)),
|
|
||||||
PhotometricInterpretation::CIELogL => match self.samples {
|
PhotometricInterpretation::CIELogL => match self.samples {
|
||||||
1 if matches!(self.bits_per_sample, 32)
|
1 if matches!(self.bits_per_sample, 32)
|
||||||
&& matches!(self.sample_format, SampleFormat::IEEEFP) =>
|
&& matches!(self.sample_format, SampleFormat::IEEEFP) =>
|
||||||
@@ -660,12 +659,10 @@ impl Image {
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
PhotometricInterpretation::RGBPalette => Ok(ColorType::Palette(self.bits_per_sample)),
|
PhotometricInterpretation::RGBPalette => Ok(ColorType::Palette(self.bits_per_sample)),
|
||||||
PhotometricInterpretation::TransparencyMask => Err(TiffError::UnsupportedError(
|
// TransparencyMask is a single-channel mask, treat as grayscale.
|
||||||
TiffUnsupportedError::InterpretationWithBits(
|
PhotometricInterpretation::TransparencyMask => {
|
||||||
self.photometric_interpretation,
|
Ok(ColorType::Gray(self.bits_per_sample))
|
||||||
vec![self.bits_per_sample; self.samples as usize],
|
}
|
||||||
),
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1199,6 +1196,9 @@ impl Image {
|
|||||||
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
|
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
|
||||||
super::invert_colors(out_row, color_type, self.sample_format)?;
|
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 {
|
} else if is_output_chunk_rows && is_all_bits {
|
||||||
// Here we can read directly into the output buffer itself.
|
// Here we can read directly into the output buffer itself.
|
||||||
@@ -1219,6 +1219,9 @@ impl Image {
|
|||||||
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
|
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
|
||||||
super::invert_colors(tile, color_type, self.sample_format)?;
|
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 {
|
} 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
|
// The floating point predictor shuffles the padding bytes into the encoded output, so
|
||||||
// this case is handled specially when needed.
|
// this case is handled specially when needed.
|
||||||
@@ -1245,6 +1248,9 @@ impl Image {
|
|||||||
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
|
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
|
||||||
super::invert_colors(row, color_type, self.sample_format)?;
|
super::invert_colors(row, color_type, self.sample_format)?;
|
||||||
}
|
}
|
||||||
|
if photometric_interpretation == PhotometricInterpretation::CIELab {
|
||||||
|
super::cielab_to_icclab(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if is_all_bits {
|
} else if is_all_bits {
|
||||||
// We read row-by-row but each row fits in its output buffer.
|
// We read row-by-row but each row fits in its output buffer.
|
||||||
@@ -1274,6 +1280,9 @@ impl Image {
|
|||||||
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
|
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
|
||||||
super::invert_colors(row, color_type, self.sample_format)?;
|
super::invert_colors(row, color_type, self.sample_format)?;
|
||||||
}
|
}
|
||||||
|
if photometric_interpretation == PhotometricInterpretation::CIELab {
|
||||||
|
super::cielab_to_icclab(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// The encoded data potentially takes up more space than the output data so we must be
|
// 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 {
|
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
|
||||||
super::invert_colors(row, color_type, self.sample_format)?;
|
super::invert_colors(row, color_type, self.sample_format)?;
|
||||||
}
|
}
|
||||||
|
if photometric_interpretation == PhotometricInterpretation::CIELab {
|
||||||
|
super::cielab_to_icclab(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+115
-2
@@ -3,8 +3,8 @@ use std::io::{self, Read, Seek};
|
|||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
|
|
||||||
use crate::tags::{
|
use crate::tags::{
|
||||||
ByteOrder, IfdPointer, PlanarConfiguration, Predictor, SampleFormat, Tag, TiffVariant, Type,
|
ByteOrder, IfdPointer, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat,
|
||||||
ValueBuffer,
|
Tag, TiffVariant, Type, ValueBuffer,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError,
|
ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError,
|
||||||
@@ -659,6 +659,97 @@ fn invert_colors(
|
|||||||
Ok(())
|
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<u8>` 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<u8> {
|
||||||
|
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.
|
/// 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) {
|
fn fix_endianness(buf: &mut [u8], byte_order: ByteOrder, bit_depth: u8) {
|
||||||
let host = ByteOrder::native();
|
let host = ByteOrder::native();
|
||||||
@@ -1385,6 +1476,28 @@ impl<R: Read + Seek> Decoder<R> {
|
|||||||
self.image.as_ref().and_then(|img| img.color_map.as_deref())
|
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<Vec<u8>> {
|
||||||
|
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.
|
/// Read a tag-entry map from a known offset.
|
||||||
///
|
///
|
||||||
/// A TIFF [`Directory`], aka. image file directory aka. IFD, refers to a map from
|
/// A TIFF [`Directory`], aka. image file directory aka. IFD, refers to a map from
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ impl<R: Read> Read for LZWReader<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(weezl::LzwStatus::NoProgress) => {
|
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(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::UnexpectedEof,
|
io::ErrorKind::UnexpectedEof,
|
||||||
"no lzw end code found",
|
"no lzw end code found",
|
||||||
|
|||||||
Reference in new issue
Block a user