diff --git a/CHANGES.md b/CHANGES.md index f816018..f305a02 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,15 @@ - `Directory` now implements `FromIterator<(Tag, Value)>`. +Changes: +- The decoder now interprets the `ExtraSamples` tag. The sample count must now + more strict match the expected value with alpha channels only allowing for + explicitly denoted unassociated or associated alpha. This effects the + indicated color type when decoding images with additional samples indicated + as unspecified relation. Previously, these may have been interpreted as + alpha by the total sample count (e.g. RgbA if 4 samples under a photometric + interpretation of RGB). + # Version 0.10.3 New features: diff --git a/src/decoder/image.rs b/src/decoder/image.rs index f13a28b..05e82e1 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -4,7 +4,8 @@ use super::tag_reader::TagReader; use super::ChunkType; use super::{predict_f16, predict_f32, predict_f64, ValueReader}; use crate::tags::{ - CompressionMethod, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat, Tag, + CompressionMethod, ExtraSamples, PhotometricInterpretation, PlanarConfiguration, Predictor, + SampleFormat, Tag, }; use crate::{ ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError, @@ -69,6 +70,11 @@ pub(crate) struct Image { pub height: u32, pub bits_per_sample: u8, pub samples: u16, + /// The `ExtraSamples`, defaulting to empty if not given. + pub extra_samples: Vec, + /// Number of samples that belong to the photometric interpretation, samples except + /// `ExtraSamples` (338, 0x0152) tag. + pub photometric_samples: u16, pub sample_format: SampleFormat, pub photometric_interpretation: PhotometricInterpretation, pub compression_method: CompressionMethod, @@ -134,10 +140,30 @@ impl Image { .map(Value::into_u16) .transpose()? .unwrap_or(1); + if samples == 0 { return Err(TiffFormatError::SamplesPerPixelIsZero.into()); } + let extra_samples = match tag_reader.find_tag(Tag::ExtraSamples)? { + Some(n) => n.into_u16_vec()?, + None => vec![], + }; + + let extra_samples = extra_samples + .into_iter() + .map(|x| ExtraSamples::from_u16(x).unwrap_or(ExtraSamples::Unspecified)) + .collect::>(); + + let photometric_samples = match usize::from(samples).checked_sub(extra_samples.len()) { + None => { + return Err(TiffError::FormatError( + TiffFormatError::InconsistentSizesEncountered, + )); + } + Some(n) => n as u16, + }; + let sample_format = match tag_reader.find_tag_uint_vec(Tag::SampleFormat)? { Some(vals) => { let sample_format: Vec<_> = vals @@ -297,6 +323,8 @@ impl Image { height, bits_per_sample: bits_per_sample[0], samples, + extra_samples, + photometric_samples, sample_format, photometric_interpretation, compression_method, @@ -312,15 +340,19 @@ impl Image { } pub(crate) fn colortype(&self) -> TiffResult { + let is_alpha_extra_samples = matches!( + self.extra_samples.as_slice(), + [ExtraSamples::AssociatedAlpha] | [ExtraSamples::UnassociatedAlpha] + ); + match self.photometric_interpretation { - PhotometricInterpretation::RGB => match self.samples { - 3 => Ok(ColorType::RGB(self.bits_per_sample)), + PhotometricInterpretation::RGB => match self.photometric_samples { + 3 => Ok(if is_alpha_extra_samples { + ColorType::RGBA(self.bits_per_sample) + } else { + ColorType::RGB(self.bits_per_sample) + }), 4 => Ok(ColorType::RGBA(self.bits_per_sample)), - // FIXME: We should _ignore_ other components. In particular: - // > Beware of extra components. Some TIFF files may have more components per pixel - // than you think. A Baseline TIFF reader must skip over them gracefully,using the - // values of the SamplesPerPixel and BitsPerSample fields. - // > -- TIFF 6.0 Specification, Section 7, Additional Baseline requirements. _ => Err(TiffError::UnsupportedError( TiffUnsupportedError::InterpretationWithBits( self.photometric_interpretation, @@ -328,8 +360,12 @@ impl Image { ), )), }, - PhotometricInterpretation::CMYK => match self.samples { - 4 => Ok(ColorType::CMYK(self.bits_per_sample)), + PhotometricInterpretation::CMYK => match self.photometric_samples { + 4 => Ok(if is_alpha_extra_samples { + ColorType::CMYKA(self.bits_per_sample) + } else { + ColorType::CMYK(self.bits_per_sample) + }), 5 => Ok(ColorType::CMYKA(self.bits_per_sample)), _ => Err(TiffError::UnsupportedError( TiffUnsupportedError::InterpretationWithBits( @@ -338,7 +374,7 @@ impl Image { ), )), }, - PhotometricInterpretation::YCbCr => match self.samples { + PhotometricInterpretation::YCbCr => match self.photometric_samples { 3 => Ok(ColorType::YCbCr(self.bits_per_sample)), _ => Err(TiffError::UnsupportedError( TiffUnsupportedError::InterpretationWithBits( @@ -351,6 +387,9 @@ impl Image { // later called when that interpretation is read. That function does not support // Multiband as a color type and will error. It's unclear how to resolve that exactly. PhotometricInterpretation::BlackIsZero | PhotometricInterpretation::WhiteIsZero => { + // Note: compatibility with previous implementation requires us to return extra + // samples as `Multiband`. For gray images however the better choice would be + // returning a `Gray` color, i.e. matching on `photometric_samples` instead. match self.samples { 1 => Ok(ColorType::Gray(self.bits_per_sample)), _ => Ok(ColorType::Multiband { @@ -371,12 +410,28 @@ impl Image { } } + /// Get the multiband color describing this with its extra samples. + pub(crate) fn color_multiband_with_extras(&self) -> ColorType { + ColorType::Multiband { + bit_depth: self.bits_per_sample, + num_samples: self.samples, + } + } + + /// Describe this with an accurate color or a multiband. + pub(crate) fn color_or_fallback(&self) -> ColorType { + self.colortype() + .unwrap_or_else(|_| self.color_multiband_with_extras()) + } + pub(crate) fn minimum_row_stride(&self, dims: (u32, u32)) -> Option { let (width, height) = dims; + let color = self.color_or_fallback(); + let row_stride = u64::from(width) - .saturating_mul(self.samples_per_pixel() as u64) - .saturating_mul(self.bits_per_sample as u64) + .saturating_mul(u64::from(self.samples_per_out_texel(color))) + .saturating_mul(u64::from(self.bits_per_sample)) .div_ceil(8); // Note: row stride should be smaller than the len if we have an actual buffer. If there @@ -490,6 +545,13 @@ impl Image { } } + pub(crate) fn samples_per_out_texel(&self, color: ColorType) -> u16 { + match self.planar_config { + PlanarConfiguration::Chunky => color.num_samples(), + PlanarConfiguration::Planar => 1, + } + } + /// Number of strips per pixel. pub(crate) fn strips_per_pixel(&self) -> usize { match self.planar_config { @@ -643,6 +705,7 @@ impl Image { .ok_or(TiffError::FormatError( TiffFormatError::InconsistentSizesEncountered, ))?; + if *compressed_bytes > limits.intermediate_buffer_size as u64 { return Err(TiffError::LimitsExceeded); } @@ -650,8 +713,16 @@ impl Image { let compression_method = self.compression_method; let photometric_interpretation = self.photometric_interpretation; let predictor = self.predictor; - let samples = self.samples_per_pixel(); + let samples = self.samples_per_pixel(); + let data_samples = self.samples_per_out_texel(color_type); + + // We have two dimensions: the 2d rectangle of encoded data and the 2d rectangle this + // takes up in the output. Each has an associated count of bits per pixel. The first + // dimension, i.e. a ''row'', is the number of pixels that are encoded with bit packing + // while the second is the byte-padded array of each so encoded slices. + // + // During decoding we map the relevant bits from one to the other. let chunk_dims = self.chunk_dimensions()?; let data_dims = self.chunk_data_dimensions(chunk_index)?; @@ -661,7 +732,7 @@ impl Image { let chunk_row_bytes: usize = chunk_row_bits.div_ceil(8).try_into()?; let data_row_bits = (u64::from(data_dims.0) * u64::from(self.bits_per_sample)) - .checked_mul(samples as u64) + .checked_mul(data_samples as u64) .ok_or(TiffError::LimitsExceeded)?; let data_row_bytes: usize = data_row_bits.div_ceil(8).try_into()?; @@ -677,7 +748,11 @@ impl Image { chunk_dims, )?; - if output_row_stride == chunk_row_bytes { + let is_all_bits = samples == usize::from(data_samples); + let is_output_chunk_rows = output_row_stride == chunk_row_bytes; + + if is_output_chunk_rows && is_all_bits { + // Here we can read directly into the output buffer itself. let tile = &mut buf[..chunk_row_bytes * data_dims.1 as usize]; reader.read_exact(tile)?; @@ -690,6 +765,7 @@ impl Image { predictor, ); } + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(tile, color_type, self.sample_format)?; } @@ -711,7 +787,8 @@ impl Image { super::invert_colors(row, color_type, self.sample_format)?; } } - } else { + } else if is_all_bits { + // We read row-by-row but each row fits in its output buffer. for row in buf.chunks_mut(output_row_stride).take(data_dims.1 as usize) { let row = &mut row[..data_row_bytes]; reader.read_exact(row)?; @@ -729,6 +806,48 @@ impl Image { byte_order, predictor, ); + + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { + super::invert_colors(row, color_type, self.sample_format)?; + } + } + } else { + // The encoded data potentially takes up more space than the output data so we must be + // prepared to discard some of it. That decision is bit-by-bit. + let bits_per_pixel = u32::from(self.bits_per_sample) * u32::from(self.samples); + // Assumes the photometric samples are always the start.. This is slightly problematic. + // To expand spport we should instead have different methods of transforming the read + // buffer data, not only the `compact_photometric_bytes` method below and then choose + // from the right one with supplied parameters. Then we can also bit-for-bit copy with + // a selection for better performance. + let photometric_bit_end = u32::from(self.bits_per_sample) * data_samples as u32; + + debug_assert!(bits_per_pixel >= photometric_bit_end); + + if bits_per_pixel % 8 != 0 || photometric_bit_end % 8 != 0 { + return Err(TiffError::UnsupportedError( + TiffUnsupportedError::InterpretationWithBits( + self.photometric_interpretation, + vec![self.bits_per_sample; self.samples as usize], + ), + )); + } + + let photo_range = photometric_bit_end / 8..bits_per_pixel / 8; + let mut encoded = vec![0u8; chunk_row_bytes]; + for row in buf.chunks_mut(output_row_stride).take(data_dims.1 as usize) { + reader.read_exact(&mut encoded)?; + + Self::compact_photometric_bytes(&mut encoded, row, &photo_range); + + super::fix_endianness_and_predict( + row, + color_type.bit_depth(), + samples, + byte_order, + predictor, + ); + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(row, color_type, self.sample_format)?; } @@ -737,4 +856,18 @@ impl Image { Ok(()) } + + /// Turn a contiguous buffer of a whole number of raw sample arrays into a whole number of + /// photometric sample arrays by removing the extra samples in-between. + fn compact_photometric_bytes( + raw: &mut [u8], + row: &mut [u8], + photo_range: &std::ops::Range, + ) { + raw.chunks_exact_mut(photo_range.end as usize) + .zip(row.chunks_exact_mut(photo_range.start as usize)) + .for_each(|(src, dst)| { + dst.copy_from_slice(&src[..photo_range.start as usize]); + }); + } } diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index ffa3c32..7b42575 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -667,6 +667,8 @@ impl Decoder { height: 0, bits_per_sample: 1, samples: 1, + extra_samples: vec![], + photometric_samples: 1, sample_format: SampleFormat::Uint, photometric_interpretation: PhotometricInterpretation::BlackIsZero, compression_method: CompressionMethod::None, @@ -981,8 +983,10 @@ impl Decoder { chunk_index: u32, output_width: usize, ) -> TiffResult<()> { + let color = self.image.color_or_fallback(); + let output_row_stride = (output_width as u64) - .saturating_mul(self.image.samples_per_pixel() as u64) + .saturating_mul(self.image.samples_per_out_texel(color) as u64) .saturating_mul(self.image.bits_per_sample as u64) .div_ceil(8); @@ -1032,9 +1036,12 @@ impl Decoder { .map_err(|_| TiffError::LimitsExceeded)? }; + let color = self.image.color_or_fallback(); + let samples = self.image.samples_per_out_texel(color); + let buffer_size = row_samples .checked_mul(height) - .and_then(|x| x.checked_mul(self.image.samples_per_pixel())) + .and_then(|x| x.checked_mul(samples.into())) .ok_or(TiffError::LimitsExceeded)?; Ok(match self.image().sample_format { @@ -1232,13 +1239,16 @@ impl Decoder { chunk_dimensions.0.min(width), chunk_dimensions.1.min(height), ); + if chunk_dimensions.0 == 0 || chunk_dimensions.1 == 0 { return Err(TiffError::FormatError( TiffFormatError::InconsistentSizesEncountered, )); } - let samples = self.image().samples_per_pixel(); + let color = self.image().colortype()?; + let samples = self.image().samples_per_out_texel(color); + if samples == 0 { return Err(TiffError::FormatError( TiffFormatError::InconsistentSizesEncountered, diff --git a/src/encoder/mod.rs b/src/encoder/mod.rs index ffea51b..7ba03cf 100644 --- a/src/encoder/mod.rs +++ b/src/encoder/mod.rs @@ -11,7 +11,7 @@ use std::{ use crate::{ decoder::ifd::Entry, error::{TiffResult, UsageError}, - tags::{CompressionMethod, IfdPointer, ResolutionUnit, SampleFormat, Tag, Type}, + tags::{CompressionMethod, ExtraSamples, IfdPointer, ResolutionUnit, SampleFormat, Tag, Type}, Directory, TiffError, TiffFormatError, }; @@ -506,6 +506,7 @@ pub struct ImageEncoder<'a, W: 'a + Write + Seek, C: ColorType, K: TiffKind> { row_samples: u64, width: u32, height: u32, + extra_samples: Vec, rows_per_strip: u64, strip_offsets: Vec, strip_byte_count: Vec, @@ -583,6 +584,7 @@ impl<'a, W: 'a + Write + Seek, T: ColorType, K: TiffKind> ImageEncoder<'a, W, T, strip_idx: 0, row_samples, rows_per_strip, + extra_samples: vec![], width, height, strip_offsets: Vec::new(), @@ -594,6 +596,27 @@ impl<'a, W: 'a + Write + Seek, T: ColorType, K: TiffKind> ImageEncoder<'a, W, T, }) } + pub fn extra_samples(&mut self, extra: &[ExtraSamples]) -> Result<(), TiffError> { + if self.strip_idx != 0 { + return Err(TiffError::UsageError( + UsageError::ReconfiguredAfterImageWrite, + )); + } + + let samples = self.extra_samples.len() + extra.len() + ::BITS_PER_SAMPLE.len(); + let row_samples = u64::from(self.width) * u64::try_from(samples)?; + + self.extra_samples + .extend(extra.iter().map(ExtraSamples::to_u16)); + + self.encoder + .write_tag(Tag::ExtraSamples, &self.extra_samples[..])?; + + self.row_samples = row_samples; + + Ok(()) + } + /// Number of samples the next strip should have. pub fn next_strip_sample_count(&self) -> u64 { if self.strip_idx >= self.strip_count { diff --git a/src/error.rs b/src/error.rs index 4fe7d3a..e8977e2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -190,6 +190,9 @@ quick_error! { ZeroIfdPointer { display("the offset 0 can not point to a valid IFD") } + ReconfiguredAfterImageWrite { + display("attempted to reconfigure the encoder after image writing has started") + } } } diff --git a/src/lib.rs b/src/lib.rs index 0e0c5bc..39b9ddb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,4 +61,18 @@ impl ColorType { | ColorType::Multiband { bit_depth: b, .. } => b, } } + + fn num_samples(&self) -> u16 { + match *self { + ColorType::Gray(_) => 1, + ColorType::RGB(_) => 3, + ColorType::Palette(_) => 1, + ColorType::GrayA(_) => 2, + ColorType::RGBA(_) => 4, + ColorType::CMYK(_) => 4, + ColorType::CMYKA(_) => 5, + ColorType::YCbCr(_) => 3, + ColorType::Multiband { num_samples, .. } => num_samples, + } + } } diff --git a/src/tags.rs b/src/tags.rs index 2cc1344..7e4aa31 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -311,6 +311,17 @@ pub enum SampleFormat(u16) unknown( } } +tags! { +pub enum ExtraSamples(u16) { + /// There is no specified association between the sample and the image. + Unspecified = 0, + /// The sample is associated alpha, i.e. pre-multiplied color. + AssociatedAlpha = 1, + /// The sample is unassociated alpha such as a mask. There might be more than one such sample. + UnassociatedAlpha = 2, +} +} + /// Byte order of the TIFF file. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ByteOrder { diff --git a/tests/decode_images.rs b/tests/decode_images.rs index f6d7aa6..e1e55f5 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -684,3 +684,21 @@ fn test_fax4() { fn test_fax4_white_is_min() { test_image_sum_u8("imagemagick_group4.tiff", ColorType::Gray(1), 3742820); } + +#[test] +fn extra_bits_image() { + use tiff::{encoder, tags::ExtraSamples}; + + let path = PathBuf::from(TEST_IMAGE_DIR).join("extra_bits_gray_4b.tiff"); + let img_file = File::create(path).expect("Cannot find test image!"); + + let mut encoder = encoder::TiffEncoder::new(img_file).expect("Cannot create encoder"); + let mut img = encoder + .new_image::(8, 8) + .unwrap(); + + img.extra_samples(&[ExtraSamples::Unspecified]).unwrap(); + + let data: [u8; 128] = core::array::from_fn(|i| i as u8 % 2); + img.write_data(&data).expect("correct amount of data to write"); +} diff --git a/tests/encode_images.rs b/tests/encode_images.rs index ea83b25..d16bf69 100644 --- a/tests/encode_images.rs +++ b/tests/encode_images.rs @@ -235,6 +235,7 @@ const TEST_IMAGE_DIR: &str = "./tests/images/"; macro_rules! test_roundtrip { ($name:ident, $buffer:ident, $buffer_ty:ty) => { + #[track_caller] fn $name>( file: &str, expected_type: ColorType, diff --git a/tests/images/cmyk-4c-8b.tiff b/tests/images/cmyk-4c-8b.tiff index 4391843..4c4cb95 100644 Binary files a/tests/images/cmyk-4c-8b.tiff and b/tests/images/cmyk-4c-8b.tiff differ