Compare commits

...
Author SHA1 Message Date
A. Molzer cad95f255e Retain LZW decoder across tiles 2026-08-10 22:15:59 +02:00
A. Molzer 988a5a0c07 Make it an extension trait of Read 2026-08-10 21:09:32 +02:00
A. Molzer 5942111bb5 Add ReadSamples trait for decompression reading 2026-08-10 21:08:58 +02:00
Aurelia Molzer e27f6fe00d Merge pull request #401 from jacovdbergh/fix-tiled-border-extra-samples
Fix: clip border tile rows when discarding extra samples
2026-08-10 18:46:22 +00:00
Aurelia Molzer 46b19cdacf Merge pull request #407 from aarshivv/feat/old-style-lzw
feat: decode old-style (pre-TIFF-6.0) LZW streams
2026-07-25 08:11:01 +00:00
aarshivv c27fd94e1d feat: decode old-style (pre-TIFF-6.0) LZW streams 2026-07-25 12:45:40 +05:30
Aurelia Molzer f28f5fc830 Merge pull request #406 from aarshivv/feat/cielab-16bit
feat: support 16-bit CIELab decoding
2026-07-24 10:55:17 +00:00
aarshivv 3436a2222b feat: support 16-bit CIELab decoding 2026-07-24 13:40:56 +05:30
Aurelia Molzer 31919c7bfc Merge pull request #386 from aarshivv/palette-cielab-transparency-lzw
support palette expansion, CIE L*a*b*, transparency mask, and lzw end…
2026-07-24 06:14:30 +00:00
aarshivv d0c9198016 Merge remote-tracking branch 'origin/main' into palette-cielab-transparency-lzw 2026-07-23 00:52:37 +05:30
aarshivv 8f83f0fa82 chore: address review feedback on CIELab/TransparencyMask PR 2026-07-23 00:39:19 +05:30
Sergey "Shnatsel" Davidoff f3f9ff1244 Merge pull request #398 from Shnatsel/safe-rust-zstd-2
Add safe Rust zstd decoding backend
2026-07-20 00:33:05 +01:00
Aurelia Molzer a7dda6f4b1 Merge pull request #399 from lilith/fix/clamp-extended-depth
decoder: reconstruct extended-depth samples modulo declared bit depth (supersedes #396)
2026-07-19 22:18:33 +00:00
Lilith River 35c14f1e3c decoder: reconstruct extended-depth samples modulo declared bit depth
Promoted extended-depth grayscale (9-15 bits -> u16, 17-31 bits -> u32)
can carry bits above the declared depth after the horizontal predictor,
which reconstructs prev+delta in the promoted storage width rather than
modulo 2^BitsPerSample. This underflowed `max - v` in invert_colors (the
image-rs/image-tiff#396 panic in debug, wrong pixel in release) and left
junk in the high bits of the non-inverted output (#396 review, @197g:
"clamp the channel values in the non-inverted color case").

An n-bit predictor delta is stored in exactly n bits, i.e. modulo 2^n,
so reconstruction is defined modulo 2^n as well. Since 2^n divides the
u16/u32 storage modulus, masking each promoted sample with (1<<n)-1 once
after the predictor yields exactly the mod-2^n value -- including
legitimate wrap-arounds -- and also bounds any out-of-range sample from
malformed input. invert_colors' `max - v` is then always in range,
superseding the targeted saturating_sub from #396.

mask_samples_to_bit_depth runs in every integer decode branch of
expand_chunk; outside the extended-depth unpacking branch it is a
statically provable no-op (promoted layouts, declared < storage, occur
only there -- sub-byte depths keep byte-granular storage and reject the
horizontal predictor, standard depths have declared == storage, and the
float predictor requires IEEEFP samples that are never promoted), so the
branches cannot diverge.

Adds exact-pixel regression tests on hand-built TIFFs: 12/20-bit
wrap-around (plain and WhiteIsZero) and a 9-31-bit encode/decode
identity sweep, alongside the two fuzzer-minimized panic seeds. A
saturating clamp corrupts the wrap-around cases -- 12-bit [4095, 0, 1]
is stored as deltas [4095, 1, 1] and masks back to [4095, 0, 1], but
saturating produced [4095, 4095, 4095] -- so the tests lock the modular
semantics.
2026-07-18 16:04:42 -06:00
Aurelia Molzer a9cf5e17b9 Merge pull request #404 from kleinicke/fix-clippy-useless-borrow
Fix clippy 1.97 useless_borrows_in_formatting lint failing CI
2026-07-16 15:59:04 +00:00
florianandClaude Fable 5 6276defac6 Fix clippy 1.97 useless_borrows_in_formatting lint
Rust 1.97's new clippy lint flags the redundant `&` on `self.offset`
in Entry's Debug impl. CI runs clippy with -D warnings in every job,
so this single pre-existing lint currently fails all PR pipelines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:29:05 +02:00
jacovdbergh@gmail.com dee0c2fb11 fix: clip border tile rows when discarding extra samples
Tiled images with a width that is not a multiple of the tile width
wrote the border tiles' horizontal padding into the next output row.
Fixes #400.
2026-07-08 08:16:27 +02:00
Sergey "Shnatsel" Davidoff 11133cb1e3 upgrade zrip to 0.8.x series 2026-07-06 15:15:04 +01:00
Aurelia Molzer 8ad05b85db Merge pull request #397 from soyosaki/lenient
Fix: add a lenient mode for image decoder
2026-07-03 14:36:35 +00:00
陈启 4a9e1d152b fix: resolve comments 2026-07-03 18:49:16 +08:00
陈启 1c6001ea5d fix: resolve comments 2026-07-03 17:19:48 +08:00
Sergey "Shnatsel" Davidoff c0043c1a84 Use zrip-decode crate instead of ruzstd 2026-06-27 12:47:51 +01:00
陈启 c7a1176381 fix: eliminate clippy errors 2026-06-22 14:13:53 +08:00
陈启 a658c09f09 feat: add lenient decoder example 2026-06-22 12:02:54 +08:00
陈启 0309349e1b fix: add lenient mode for tiff image decoder 2026-06-22 10:59:36 +08:00
陈启 279851a9a9 fix: correct extra bits rgb test assertion based on write extra bits
image logic
2026-06-22 10:58:42 +08:00
Aurelia Molzer 22b4c39254 Merge pull request #394 from powturns/main
Add PhotometricInterpretation unknown tag support
2026-06-02 17:50:20 +00:00
Dustin Thomson f11a1c5393 Add PhotometricInterpretation unknown tag support
Provides a mechanism to support private or extension
photometric interpretations (such as CFA)
2026-05-28 08:48:12 -06:00
Aurelia Molzer c4285aea4a Merge pull request #393 from image-rs/bench-lzw
Fix benches/lzw.rs directory iteration
2026-05-16 15:59:17 +00:00
aarshivv 9924ea2721 chore: clean clippy deprecated warn in test case 2026-04-11 20:56:42 +05:30
aarshivv 30e04f3381 chore: drop palette and LZW, add tests for CIELab and TransparencyMask 2026-04-11 20:48:40 +05:30
aarshivv dfd0191403 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.
2026-04-10 23:02:15 +05:30
Sergey "Shnatsel" Davidoff 19caae4f53 Rename safe-rust-zstd to zstd-safe-rust 2026-03-06 09:36:09 +00:00
Sergey "Shnatsel" Davidoff 61dbcc1392 Add error handling 2026-03-05 13:15:33 +00:00
Sergey "Shnatsel" Davidoff d9d4f0a905 Add safe Rust Zstd decoding option 2026-03-05 13:10:07 +00:00
15 changed files with 1019 additions and 67 deletions

No files matched your search

+7
View File
@@ -1,5 +1,12 @@
# Version 0.12.0 (unreleased)
Fixes:
- Decoding a tiled image whose width is not a multiple of the tile width no
longer corrupts the columns below the right border tiles when extra samples
are discarded (e.g. RGB with an unspecified extra sample). The horizontal
tile padding was written past the end of the output row, spilling into the
leftmost columns of the following row.
Additions:
- Added the `TiffHeader` type. It can be used to create a `Decoder` in a state
before the first IFD and allows opening files at an arbitrary first IFD
+4
View File
@@ -25,6 +25,7 @@ fax34 = { package = "fax", version = "0.2.6", optional = true }
flate2 = { version = "1.0.20", optional = true }
weezl = { version = "0.2.1", optional = true }
zstd = { version = "0.13", optional = true }
zrip-decode = { version = "0.8.2", features = ["paranoid"], optional = true }
zune-jpeg = { version = "0.5.1", optional = true }
image-webp = {version = "0.2.4", optional = true }
@@ -45,6 +46,9 @@ jpeg = ["dep:zune-jpeg"]
lzw = ["dep:weezl"]
webp = ["dep:image-webp"]
zstd = ["dep:zstd"]
# Fully safe Rust but somewhat slower Zstd decoding.
# When both zstd and zstd-safe-rust are enabled, zstd takes precedence.
zstd-safe-rust = ["dep:zrip-decode"]
[[bench]]
name = "lzw"
+4 -2
View File
@@ -2,13 +2,15 @@ use tiff::decoder::{BufferLayoutPreference, Decoder, DecodingSampleBuffer, Decod
fn main() -> Result<(), Box<dyn std::error::Error>> {
let Some(image) = std::env::args_os().nth(1) else {
eprintln!("Usage: decode FILE");
eprintln!("Usage: decode FILE [--lenient]");
return Ok(());
};
let lenient = std::env::args().nth(2).as_deref() == Some("--lenient");
let file = std::fs::File::open(image)?;
let io = std::io::BufReader::new(file);
let mut reader = Decoder::open(io)?;
let mut reader = Decoder::open(io)?.with_lenient(lenient);
let mut data = DecodingSampleBuffer::I8(vec![]);
+1 -1
View File
@@ -363,7 +363,7 @@ impl ::std::fmt::Debug for Entry {
fn fmt(&self, fmt: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> {
fmt.write_str(&format!(
"Entry {{ type_: {:?}, count: {:?}, offset: {:?} }}",
self.type_, self.count, &self.offset
self.type_, self.count, self.offset
))
}
}
+127 -37
View File
@@ -1,8 +1,9 @@
use super::ifd::Value;
use super::stream::PackBitsReader;
use super::stream::{PackBitsReader, ReadSamples};
use super::tag_reader::TagReader;
use super::ChunkType;
use super::{predict_f16, predict_f32, predict_f64, ValueReader};
use crate::decoder::stream::CompressionEngines;
use crate::tags::{
ByteOrder, CompressionMethod, ExtraSamples, PhotometricInterpretation, PlanarConfiguration,
Predictor, SampleFormat, Tag,
@@ -11,7 +12,7 @@ use crate::{
ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError,
};
use std::io::{self, Cursor, Read, Seek};
use std::io::{Cursor, Read, Seek};
use std::sync::Arc;
#[derive(Debug)]
@@ -97,6 +98,7 @@ pub(crate) struct Image {
/// run on samples directly and thus skip the outer byte order entirely, except potentially
/// they have interior byte order treatment.
pub decompression_to_host_endian: bool,
pub lenient: bool,
}
/// Describes how to read a tile-aligned portion of the image.
@@ -158,6 +160,7 @@ impl Image {
pub fn from_reader<R: Read + Seek>(
decoder: &mut ValueReader<R>,
ifd: &Directory,
lenient: bool,
) -> TiffResult<Image> {
let mut tag_reader = TagReader { decoder, ifd };
@@ -500,6 +503,7 @@ impl Image {
compression_method,
CompressionMethod::SgiLog | CompressionMethod::SgiLog24
),
lenient,
})
}
@@ -515,10 +519,13 @@ impl Image {
}
pub(crate) fn colortype(&self) -> TiffResult<ColorType> {
let is_alpha_extra_samples = matches!(
self.extra_samples.as_slice(),
[ExtraSamples::AssociatedAlpha, ..] | [ExtraSamples::UnassociatedAlpha, ..]
);
let is_alpha_extra_samples = match self.extra_samples.as_slice().first() {
Some(ExtraSamples::AssociatedAlpha) | Some(ExtraSamples::UnassociatedAlpha) => true,
// In lenient mode, keep unspecified alpha interpretation logic compatible with libtiff
// See Issue #341
Some(ExtraSamples::Unspecified) if self.lenient && self.samples > 3 => true,
_ => false,
};
match self.photometric_interpretation {
PhotometricInterpretation::RGB => match self.photometric_samples {
@@ -591,20 +598,30 @@ 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(
// CIE L*a*b* (TIFF6 Section 23, pp 110-115) encodes a/b as signed integers
// (int8 at 8-bit, int16 at 16-bit) with 0 neutral, while ICC L*a*b*
// (photometric=9) uses unsigned samples with the neutral at the middle of
// the range. `SampleFormat` applies uniformly to all samples, so it can't
// describe the L=unsigned + a/b=signed mix; instead we take a/b as signed
// from the photometric interpretation (requiring `SampleFormat::Uint`, like
// the ICCLab arm) and re-bias them to the unsigned ICCLab encoding in
// post-processing (see `cielab_to_icclab`). libtiff does the same, decoding
// both depths (`putcontig8bitCIELab8` / `putcontig8bitCIELab16`, selected by
// `initCIELabConversion` in tif_getimage.c line 2216
// @ 04884181e8903915038be553bfab404302d98ea0).
PhotometricInterpretation::CIELab => match self.photometric_samples {
3 if matches!(self.bits_per_sample, 8 | 16)
&& matches!(self.sample_format, SampleFormat::Uint) =>
{
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,7 +677,11 @@ impl Image {
),
)),
PhotometricInterpretation::RGBPalette => Ok(ColorType::Palette(self.bits_per_sample)),
PhotometricInterpretation::TransparencyMask => Err(TiffError::UnsupportedError(
// TransparencyMask is a single-channel mask, treat as grayscale.
PhotometricInterpretation::TransparencyMask => {
Ok(ColorType::Gray(self.bits_per_sample))
}
PhotometricInterpretation::Unknown(_) => Err(TiffError::UnsupportedError(
TiffUnsupportedError::InterpretationWithBits(
self.photometric_interpretation,
vec![self.bits_per_sample; self.samples as usize],
@@ -678,7 +699,8 @@ impl Image {
dimensions: (u32, u32),
samples: u16,
#[cfg_attr(not(feature = "fax"), allow(unused_variables))] fill_order: u16,
) -> TiffResult<Box<dyn Read + 'r>> {
engines: &mut CompressionEngines,
) -> TiffResult<Box<dyn ReadSamples + 'r>> {
Ok(match compression_method {
CompressionMethod::None => Box::new(reader),
CompressionMethod::SgiLog => match samples {
@@ -709,9 +731,12 @@ impl Image {
CompressionMethod::LZW => Box::new(super::stream::LZWReader::new(
reader,
usize::try_from(compressed_length)?,
engines,
)),
#[cfg(feature = "zstd")]
CompressionMethod::ZSTD => Box::new(zstd::Decoder::new(reader)?),
#[cfg(all(not(feature = "zstd"), feature = "zstd-safe-rust"))]
CompressionMethod::ZSTD => Box::new(zrip_decode::streaming::FrameDecoder::new(reader)),
CompressionMethod::PackBits => Box::new(PackBitsReader::new(reader, compressed_length)),
#[cfg(feature = "deflate")]
CompressionMethod::Deflate | CompressionMethod::OldDeflate => {
@@ -1011,6 +1036,7 @@ impl Image {
layout: &ReadoutLayout,
chunk_index: u32,
scratch: &mut Vec<u8>,
engines: &mut CompressionEngines,
) -> TiffResult<()> {
let ValueReader {
reader,
@@ -1036,6 +1062,7 @@ impl Image {
| ColorType::YCbCr(n)
| ColorType::Gray(n)
| ColorType::Palette(n)
| ColorType::Lab(n)
| ColorType::Multiband {
bit_depth: n,
num_samples: _,
@@ -1047,6 +1074,7 @@ impl Image {
| ColorType::YCbCr(n)
| ColorType::Gray(n)
| ColorType::Palette(n)
| ColorType::Lab(n)
| ColorType::Multiband {
bit_depth: n,
num_samples: _,
@@ -1155,6 +1183,7 @@ impl Image {
chunk_dims,
self.samples,
self.fill_order,
engines,
)?;
// Extended bit depths (9-15, 17-31): packed N-bit samples must be unpacked to
@@ -1190,15 +1219,26 @@ impl Image {
super::fix_endianness_and_predict(
out_row,
color_type.bit_depth(),
samples,
data_samples,
ByteOrder::native(),
predictor,
scratch,
);
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
super::invert_colors(out_row, color_type, self.sample_format)?;
}
// An n-bit predictor delta is stored modulo 2^n, but the
// reconstruction above accumulated it in the promoted u16/u32
// storage width. Reduce back modulo the declared depth so both
// the inverted and non-inverted paths see exact, in-range
// values (2^n divides the storage modulus, so one mask after
// the row is exact).
super::mask_samples_to_bit_depth(out_row, tiff_bps, out_bps, self.sample_format);
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.
@@ -1216,9 +1256,22 @@ impl Image {
);
}
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
super::invert_colors(tile, color_type, self.sample_format)?;
}
// No-op in this branch: promoted layouts (declared depth < storage
// width) are handled exclusively by the unpacking branch above.
// Called in every integer branch so they cannot diverge.
super::mask_samples_to_bit_depth(
tile,
layout.tiff_bits_per_sample,
layout.output_bits_per_sample,
self.sample_format,
);
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.
@@ -1242,9 +1295,14 @@ impl Image {
}
_ => unreachable!(),
}
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
super::invert_colors(row, color_type, self.sample_format)?;
}
// No masking here: the floating point predictor requires
// IEEEFP samples, which are never depth-promoted.
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.
@@ -1255,11 +1313,12 @@ impl Image {
// Two ways how we get here: we have more bytes in our chunk data than in the image
// we are to read. Then we need to skip the rest of the data. Or we have a bigger
// row stride than the chunk contains data, then we need to fill only the front.
reader.read_exact(&mut row[..used])?;
// Skip horizontal padding
if chunk_row_bytes > data_row_bytes {
let len = u64::try_from(chunk_row_bytes - data_row_bytes)?;
io::copy(&mut reader.by_ref().take(len), &mut io::sink())?;
// Skip horizontal padding
let skip = u64::try_from(chunk_row_bytes - data_row_bytes)?;
reader.read_padded_samples(&mut row[..used], skip)?;
} else {
reader.read_exact(&mut row[..used])?;
}
super::fix_endianness_and_predict(
@@ -1271,9 +1330,22 @@ impl Image {
scratch,
);
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
super::invert_colors(row, color_type, self.sample_format)?;
}
// No-op in this branch: promoted layouts only occur in the
// unpacking branch. Called so the integer branches cannot
// diverge.
super::mask_samples_to_bit_depth(
row,
layout.tiff_bits_per_sample,
layout.output_bits_per_sample,
self.sample_format,
);
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
@@ -1300,6 +1372,10 @@ impl Image {
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(layout.row_stride).take(data_dims.1 as usize) {
// Clip to the chunk's data width: border chunks are stored at the full chunk
// width, and writing their horizontal padding would spill into the following
// output row (and predict/invert would run past the chunk's own columns).
let row = &mut row[..data_row_bytes];
reader.read_exact(&mut encoded)?;
Self::compact_photometric_bytes(&mut encoded, row, &photo_range);
@@ -1313,12 +1389,26 @@ impl Image {
scratch,
);
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
super::invert_colors(row, color_type, self.sample_format)?;
}
// No-op in this branch: promoted layouts only occur in the
// unpacking branch. Called so the integer branches cannot
// diverge.
super::mask_samples_to_bit_depth(
row,
layout.tiff_bits_per_sample,
layout.output_bits_per_sample,
self.sample_format,
);
super::post_process_row(
row,
photometric_interpretation,
color_type,
self.sample_format,
)?;
}
}
reader.cache_or_destroy(engines);
Ok(())
}
+24
View File
@@ -83,6 +83,14 @@ impl<R: Read> Read for LogLuv24<R> {
}
}
impl<R: Read> super::stream::ReadSamples for LogLuv24<R> {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> std::io::Result<()> {
self.read_exact(buf)?;
std::io::copy(&mut self.by_ref().take(discard), &mut std::io::sink())?;
Ok(())
}
}
fn packbits_decode<R: Read, const N: usize>(
reader: &mut R,
buf: &mut [[u8; N]],
@@ -212,6 +220,14 @@ impl<R: Read> Read for LogLuv32<R> {
}
}
impl<R: Read> super::stream::ReadSamples for LogLuv32<R> {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> std::io::Result<()> {
self.read_exact(buf)?;
std::io::copy(&mut self.by_ref().take(discard), &mut std::io::sink())?;
Ok(())
}
}
/// Pack-Bits like encoding for a single 16-bit log-L sample.
pub struct LogLuv16<R> {
reader: Take<R>,
@@ -278,6 +294,14 @@ impl<R: Read> Read for LogLuv16<R> {
}
}
impl<R: Read> super::stream::ReadSamples for LogLuv16<R> {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> std::io::Result<()> {
self.read_exact(buf)?;
std::io::copy(&mut self.by_ref().take(discard), &mut std::io::sink())?;
Ok(())
}
}
// Table information from: `libtiff`, 1997 by Greg Ward Larson, SGI
#[doc(alias = "UV_SQSIZ")]
const UV_QUANTIZATION_WIDTH: f32 = 0.003500;
+143 -10
View File
@@ -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,
@@ -257,6 +257,9 @@ pub struct Decoder<R> {
/// Reusable scratch buffer for the floating-point predictor. Stored here so that the
/// allocation persists across strips/tiles and images, avoiding per-row heap allocation.
scratch: Vec<u8>,
/// Leniency flag that allows libtiff-like TIFF color interpretation behavior
lenient: bool,
compression: stream::CompressionEngines,
}
/// All the information needed to read and interpret byte slices from the underlying file, i.e. to
@@ -566,6 +569,56 @@ fn fix_endianness_and_predict(
}
}
/// Reduce promoted extended-depth samples (9–15 bits → `u16`, 17–31 bits →
/// `u32`) modulo their declared bit depth, i.e. mask with `(1 << n) - 1`.
///
/// An n-bit horizontal-predicted sample stores its delta in exactly n bits,
/// i.e. modulo `2^n`, so reconstruction is also defined modulo `2^n`. The
/// predictor accumulates `prev + delta` in the promoted storage width
/// (`u16`/`u32`, see `rev_hpredict_nsamp`); since `2^n` divides the storage
/// modulus, masking once after the predictor yields exactly the mod-`2^n`
/// value for every sample — including legitimate wrap-arounds (a 9-bit
/// `511, 0` pair is stored as delta `1`, and `511 + 1 = 512` masks back to
/// `0`). Masking also bounds any out-of-range sample in malformed input, so
/// all downstream consumers (`invert_colors`' `max - v` and the non-inverted
/// output path) see in-range values. For samples already in range it is a
/// no-op.
///
/// This runs in every integer decode branch of `expand_chunk` so the
/// branches cannot diverge. Outside the unpacking branch it reduces to the
/// two guards below and touches nothing: promoted layouts (`declared <
/// storage`) exist only there — standard depths have `declared == storage`,
/// and sub-byte depths keep byte-granular storage.
#[inline]
fn mask_samples_to_bit_depth(
buf: &mut [u8],
declared_bits: u8,
storage_bits: u8,
sample_format: SampleFormat,
) {
// Only unsigned samples are promoted with zero-extension; signed
// extended depths would need sign extension instead and are not
// supported by the unpacking path.
if sample_format != SampleFormat::Uint {
return;
}
match storage_bits {
16 if declared_bits < 16 => {
let mask = (1u16 << declared_bits) - 1;
for chunk in buf.as_chunks_mut::<2>().0 {
*chunk = (u16::from_ne_bytes(*chunk) & mask).to_ne_bytes();
}
}
32 if declared_bits < 32 => {
let mask = (1u32 << declared_bits) - 1;
for chunk in buf.as_chunks_mut::<4>().0 {
*chunk = (u32::from_ne_bytes(*chunk) & mask).to_ne_bytes();
}
}
_ => {}
}
}
fn invert_colors(
buf: &mut [u8],
color_type: ColorType,
@@ -659,6 +712,56 @@ fn invert_colors(
Ok(())
}
/// Re-bias CIE L*a*b* a/b channels from signed to unsigned encoding.
///
/// CIELab (photometric=8) stores a/b as signed integers where 0 is neutral;
/// ICCLab (photometric=9) stores them as unsigned with the neutral shifted to
/// the middle of the range. The two differ by exactly half the sample range, so
/// the conversion is a wrapping add of 2^(bits-1) — i.e. flipping the sign bit —
/// applied per a/b sample. `bit_depth` is 8 or 16 (guaranteed by `colortype`);
/// samples are in native byte order here (post `fix_endianness_and_predict`).
/// See libtiff's `TIFFCIELabToXYZ` for reference (tif_color.c line 45
/// @ 04884181e8903915038be553bfab404302d98ea0).
fn cielab_to_icclab(buf: &mut [u8], bit_depth: u8) {
match bit_depth {
16 => {
for pixel in buf.as_chunks_mut::<6>().0 {
// pixel is [L, a, b] as three native-endian u16 samples.
let (samples, _) = pixel.as_chunks_mut::<2>();
for s in &mut samples[1..=2] {
*s = u16::from_ne_bytes(*s).wrapping_add(0x8000).to_ne_bytes();
}
}
}
_ => {
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, color_type.bit_depth()),
_ => {}
}
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();
@@ -750,6 +853,8 @@ impl TiffHeader {
seen_ifds: cycles::IfdCycles::new(),
image: None,
scratch: Vec::new(),
lenient: false,
compression: stream::CompressionEngines::default(),
}
}
}
@@ -832,25 +937,41 @@ impl<R: Read + Seek> Decoder<R> {
self
}
/// Use a lenient decoder.
///
/// Configuring this make the parser respect libtiff's behavior to maintain compatibility with
/// some older softwares (e.g. some old Adobe suite products).
pub fn with_lenient(mut self, lenient: bool) -> Decoder<R> {
self.lenient = lenient;
self
}
// FIXME: this tuple would be a good candidate for a proxy, like IfdDecoder. That has been
// requested in some form or another in a number of issues.
#[expect(clippy::unnecessary_unwrap)] // Lifetimes would overlap due to return.
fn ensure_image_and_reader(
&mut self,
) -> TiffResult<(&mut Image, &mut ValueReader<R>, &mut Vec<u8>)> {
) -> TiffResult<(
&mut Image,
&mut ValueReader<R>,
&mut Vec<u8>,
&mut stream::CompressionEngines,
)> {
if self.image.is_some() {
return Ok((
self.image.as_mut().unwrap(),
&mut self.value_reader,
&mut self.scratch,
&mut self.compression,
));
}
let image = Image::from_reader(&mut self.value_reader, &self.directory)?;
let image = Image::from_reader(&mut self.value_reader, &self.directory, self.lenient)?;
Ok((
self.image.insert(image),
&mut self.value_reader,
&mut self.scratch,
&mut self.compression,
))
}
@@ -982,6 +1103,8 @@ impl<R: Read + Seek> Decoder<R> {
current_ifd_pointer: self.current_ifd_pointer,
image: core::mem::take(&mut self.image),
scratch: core::mem::take(&mut self.scratch),
lenient: self.lenient,
compression: core::mem::take(&mut self.compression),
}
}
@@ -1030,7 +1153,11 @@ impl<R: Read + Seek> Decoder<R> {
/// error is returned.
pub fn seek_to_image(&mut self, ifd_index: usize) -> TiffResult<()> {
let (offset, directory) = self.find_nth_ifd(ifd_index)?;
self.image = Some(Image::from_reader(&mut self.value_reader, &directory)?);
self.image = Some(Image::from_reader(
&mut self.value_reader,
&directory,
self.lenient,
)?);
self.current_ifd_pointer = Some(offset);
self.directory = directory;
Ok(())
@@ -1123,7 +1250,11 @@ impl<R: Read + Seek> Decoder<R> {
pub fn next_image(&mut self) -> TiffResult<()> {
let next_ifd = self.directory.next();
let (offset, directory) = self.read_chained_ifd(self.current_ifd_pointer, next_ifd)?;
self.image = Some(Image::from_reader(&mut self.value_reader, &directory)?);
self.image = Some(Image::from_reader(
&mut self.value_reader,
&directory,
self.lenient,
)?);
self.current_ifd_pointer = Some(offset);
self.directory = directory;
Ok(())
@@ -1457,10 +1588,10 @@ impl<R: Read + Seek> Decoder<R> {
chunk_index: u32,
layout: &image::ReadoutLayout,
) -> TiffResult<()> {
let (image, value_reader, scratch) = self.ensure_image_and_reader()?;
let (image, value_reader, scratch, engines) = self.ensure_image_and_reader()?;
let offset = image.chunk_file_range(chunk_index)?.0;
value_reader.reader.goto_offset(offset)?;
image.expand_chunk(value_reader, buffer, layout, chunk_index, scratch)?;
image.expand_chunk(value_reader, buffer, layout, chunk_index, scratch, engines)?;
Ok(())
}
@@ -1612,7 +1743,7 @@ impl<R: Read + Seek> Decoder<R> {
slice: TiffCodingUnit,
buffer: &mut [u8],
) -> TiffResult<()> {
let (image, value_reader, scratch) = self.ensure_image_and_reader()?;
let (image, value_reader, scratch, engines) = self.ensure_image_and_reader()?;
let (width, height) = image.chunk_data_dimensions(slice.0)?;
let readout = image.readout_for_size(width, height)?;
@@ -1645,6 +1776,7 @@ impl<R: Read + Seek> Decoder<R> {
readout,
chunk,
scratch,
engines,
)?;
}
@@ -1816,7 +1948,7 @@ impl<R: Read + Seek> Decoder<R> {
/// Returns an error if the buffer fits less than one plane. In particular, for non-planar
/// images returns an error if the buffer does not fit the required size.
pub fn read_image_bytes(&mut self, buffer: &mut [u8]) -> TiffResult<()> {
let (image, value_reader, scratch) = self.ensure_image_and_reader()?;
let (image, value_reader, scratch, engines) = self.ensure_image_and_reader()?;
let readout = image.readout_for_image()?;
let ref layout @ image::PlaneLayout {
@@ -1850,6 +1982,7 @@ impl<R: Read + Seek> Decoder<R> {
readout,
chunk,
scratch,
engines,
)?;
}
}
+210 -10
View File
@@ -122,9 +122,86 @@ impl<R: Read> EndianReader<R> {
// # READERS
//
#[derive(Default)]
pub(crate) struct CompressionEngines {
#[cfg(feature = "lzw")]
lzw: Option<weezl::decode::Decoder>,
}
impl core::fmt::Debug for CompressionEngines {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut dbg = f.debug_struct("CompressionEngines");
#[cfg(feature = "lzw")]
dbg.field("lzw", &self.lzw.is_some().then_some("<weezl>"));
dbg.finish()
}
}
/// A variant of `io::Read` that takes one block to the end.
///
/// This is derived from `Read` so that any special optimization done by `std` (or any nightly crate
/// for that matter) surely remains usable.
///
/// We want our decompression engines to be much more specialized than the standard library trait.
/// Firstly there is more information available to communicate (the tiff Type, known decompressed
/// size, …) and secondly they can potentially *reset*. This could be solved with an extension trait
/// but since we want to avoid `read` in favor of `read_exact` (or an equivalent if we want to pass
/// in a strided block buffer) a separate trait that's auto derived is favored.
///
/// FIXME: Also consider that we will want to read subsets of samples. Via IO this is done by
/// discarding data *after* it is put into the output buffer. Yet, for some decompression that write
/// alone is considerable work where it could simply skip bits more efficiently. So the reader would
/// likely want to know about a sample mask.
pub trait ReadSamples: Read {
/// Read a row of samples and throw away some additional padding samples at the end.
///
/// This is automatically implemented for `Self: Read` as a copy into `io::Sink`.
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> io::Result<()>;
/// Put this back into our cache of compression engines.
fn cache_or_destroy(self: Box<Self>, _: &mut CompressionEngines) {}
}
/// Note: the `Seek` bound ensures this does not overlap actual decompression.. This is an odd
/// design problem born out of wanting both the underlying `Read` for uncompressed streams due to
/// performance as well as the override to `cache_or_destroy` for all compressed streams.
impl<R: Read + Seek> ReadSamples for R {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> io::Result<()> {
self.read_exact(buf)?;
if discard > 0 {
std::io::copy(&mut self.by_ref().take(discard), &mut std::io::sink())?;
}
Ok(())
}
}
/// Type alias for the deflate Reader
#[cfg(feature = "deflate")]
pub type DeflateReader<R> = flate2::read::ZlibDecoder<R>;
pub struct DeflateReader<R> {
inner: flate2::read::ZlibDecoder<R>,
}
impl<R: Read> DeflateReader<R> {
pub fn new(inner: R) -> Self {
DeflateReader {
inner: flate2::read::ZlibDecoder::new(inner),
}
}
}
impl<R: Read> Read for DeflateReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.inner.read(buf)
}
}
impl<R: Read> ReadSamples for DeflateReader<R> {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> io::Result<()> {
self.read_exact(buf)?;
std::io::copy(&mut self.by_ref().take(discard), &mut std::io::sink())?;
Ok(())
}
}
//
// ## LZW Reader
@@ -135,31 +212,103 @@ pub type DeflateReader<R> = flate2::read::ZlibDecoder<R>;
pub struct LZWReader<R: Read> {
reader: BufReader<Take<R>>,
decoder: weezl::decode::Decoder,
is_old_style: bool,
}
#[cfg(feature = "lzw")]
impl<R: Read> LZWReader<R> {
/// Wraps a reader
pub fn new(reader: R, compressed_length: usize) -> LZWReader<R> {
let configuration =
weezl::decode::Configuration::with_tiff_size_switch(weezl::BitOrder::Msb, 8)
.with_yield_on_full_buffer(true);
Self {
reader: BufReader::with_capacity(
pub fn new(
reader: R,
compressed_length: usize,
engine: &mut CompressionEngines,
) -> LZWReader<R> {
let mut buffered = BufReader::with_capacity(
(32 * 1024).min(compressed_length),
reader.take(u64::try_from(compressed_length).unwrap()),
),
decoder: configuration.build(),
);
// `Compression = 5` covers both the current LZW variant and the pre-TIFF-6.0
// "old-style" one, with no tag to distinguish them. New-style is MSB-first with
// the "early change" code-size switch, and its stream begins with the Clear code
// (256) -> first byte 0x80. Old-style is LSB-first *without* the early change
// (GIF-style increment timing), and begins with 0x00 followed by a byte with bit
// 0 set. This is the heuristic libtiff uses to select its compatibility decoder;
// on a short/failed peek we default to the modern variant.
let old_style = matches!(
buffered.fill_buf(),
Ok(head) if head.len() >= 2 && head[0] == 0x00 && head[1] & 0x01 != 0
);
let decoder = if old_style {
weezl::decode::Configuration::new(weezl::BitOrder::Lsb, 8)
.with_yield_on_full_buffer(true)
.build()
} else {
// All standard LZW decoders have the same configuration. We can thus reuse any that we
// have previously constructed, skipping internal buffer allocation. We only need to
// reset them to a new stream.
if let Some(mut lzw) = engine.lzw.take() {
lzw.reset();
lzw
} else {
weezl::decode::Configuration::with_tiff_size_switch(weezl::BitOrder::Msb, 8)
.with_yield_on_full_buffer(true)
.build()
}
};
Self {
reader: buffered,
decoder: decoder,
is_old_style: old_style,
}
}
fn discard_n(&mut self, mut n: u64) -> io::Result<()> {
let mut buf = [0u8; 2048];
while n > 0 {
let limit = n.min(2048) as usize;
let result = self
.decoder
.decode_bytes(self.reader.fill_buf()?, &mut buf[..limit]);
self.reader.consume(result.consumed_in);
n -= result.consumed_out as u64;
match result.status {
Ok(weezl::LzwStatus::Ok) => {
continue;
}
Ok(weezl::LzwStatus::NoProgress) => {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"no lzw end code found",
));
}
Ok(weezl::LzwStatus::Done) => {
if n != 0 {
// stream is too short.
return Err(std::io::ErrorKind::UnexpectedEof)?;
}
}
Err(err) => return Err(io::Error::new(io::ErrorKind::InvalidData, err)),
}
}
Ok(())
}
}
#[cfg(feature = "lzw")]
impl<R: Read> Read for LZWReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
fn read(&mut self, mut buf: &mut [u8]) -> io::Result<usize> {
loop {
let result = self.decoder.decode_bytes(self.reader.fill_buf()?, buf);
self.reader.consume(result.consumed_in);
let _ = buf.split_off_mut(..result.consumed_out);
match result.status {
Ok(weezl::LzwStatus::Ok) => {
@@ -184,6 +333,24 @@ impl<R: Read> Read for LZWReader<R> {
}
}
impl<R: Read> ReadSamples for LZWReader<R> {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> io::Result<()> {
self.read_exact(buf)?;
if discard > 0 {
self.discard_n(discard)?;
}
Ok(())
}
fn cache_or_destroy(self: Box<Self>, engines: &mut CompressionEngines) {
if !self.is_old_style && engines.lzw.is_none() {
engines.lzw = Some(self.decoder);
}
}
}
//
// ## PackBits Reader
//
@@ -256,6 +423,14 @@ impl<R: Read> Read for PackBitsReader<R> {
}
}
impl<R: Read> ReadSamples for PackBitsReader<R> {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> io::Result<()> {
self.read_exact(buf)?;
std::io::copy(&mut self.by_ref().take(discard), &mut std::io::sink())?;
Ok(())
}
}
/// Iterator adapter that reverses bits in each byte for FillOrder=2 (LSB-to-MSB).
#[cfg(feature = "fax")]
type FaxBytes<R> =
@@ -372,6 +547,14 @@ impl<R: Read> Read for Group4Reader<R> {
}
}
impl<R: Read> ReadSamples for Group4Reader<R> {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> io::Result<()> {
self.read_exact(buf)?;
std::io::copy(&mut self.by_ref().take(discard), &mut std::io::sink())?;
Ok(())
}
}
/// Owning byte reader for Group 3 decoding: the compressed data is consumed
/// into an iterator so the reader doesn't borrow the buffer.
#[cfg(feature = "fax")]
@@ -510,6 +693,14 @@ impl Read for Group3Reader {
}
}
impl ReadSamples for Group3Reader {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> io::Result<()> {
self.read_exact(buf)?;
std::io::copy(&mut self.by_ref().take(discard), &mut std::io::sink())?;
Ok(())
}
}
#[cfg(feature = "webp")]
pub struct WebPReader {
inner: Cursor<Vec<u8>>,
@@ -565,6 +756,15 @@ impl Read for WebPReader {
}
}
#[cfg(feature = "webp")]
impl ReadSamples for WebPReader {
fn read_padded_samples(&mut self, buf: &mut [u8], discard: u64) -> io::Result<()> {
self.read_exact(buf)?;
std::io::copy(&mut self.by_ref().take(discard), &mut std::io::sink())?;
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
+4 -1
View File
@@ -295,7 +295,10 @@ pub enum CompressionMethod(u16) unknown(
}
tags! {
pub enum PhotometricInterpretation(u16) {
pub enum PhotometricInterpretation(u16) unknown(
/// A private or extension tag
unknown
) {
WhiteIsZero = 0,
BlackIsZero = 1,
RGB = 2,
+330 -5
View File
@@ -1,11 +1,11 @@
extern crate tiff;
use tiff::decoder::{ifd, Decoder, DecodingSampleBuffer};
use tiff::tags::{ByteOrder, Type};
use tiff::tags::{ByteOrder, PhotometricInterpretation, SampleFormat, 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/";
@@ -16,7 +16,9 @@ macro_rules! test_image_sum {
fn $name(file: &str, expected_type: ColorType, expected_sum: $sum_ty) {
let path = PathBuf::from(TEST_IMAGE_DIR).join(file);
let img_file = File::open(path).expect("Cannot find test image!");
let mut decoder = Decoder::open(img_file).expect("Cannot create decoder");
let mut decoder = Decoder::open(img_file)
.expect("Cannot create decoder")
.with_lenient(true);
decoder.next_image().expect("Cannot read image IFD");
assert_eq!(decoder.colortype().unwrap(), expected_type);
@@ -473,6 +475,37 @@ fn test_tiled_rect_rgb_u8() {
test_image_sum_u8("tiled-rect-rgb-u8.tif", ColorType::RGB(8), 62081032);
}
#[test]
fn test_tiled_extra_samples_border_u8() {
// Regression test for https://github.com/image-rs/image-tiff/issues/400
//
// A tiled image whose dimensions are not multiples of the tile size, with
// an extra sample that is discarded on decode. The horizontal padding of
// the right border tiles used to be written past the end of the output
// row, spilling into the leftmost columns of the following row. The
// fixture's tile padding is filled with 0xEE so any spill is visible.
let path = PathBuf::from(TEST_IMAGE_DIR).join("tiled-extra-samples-border-u8.tif");
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.dimensions().unwrap(), (40, 40));
assert_eq!(decoder.colortype().unwrap(), ColorType::RGB(8));
let DecodingSampleBuffer::U8(data) = decoder.read_image().unwrap() else {
panic!("Wrong bit depth")
};
let mut expected = Vec::with_capacity(40 * 40 * 3);
for y in 0..40u32 {
for x in 0..40u32 {
expected.push(((x * 3) % 251) as u8);
expected.push(((y * 7) % 251) as u8);
expected.push(((x + y) % 251) as u8);
}
}
assert_eq!(data, expected);
}
#[test]
fn test_inner_access() {
let path = PathBuf::from(TEST_IMAGE_DIR).join("tiled-rect-rgb-u8.tif");
@@ -825,7 +858,7 @@ fn test_predictor_3_gray_f32() {
}
#[test]
#[cfg(feature = "zstd")]
#[cfg(any(feature = "zstd", feature = "zstd-safe-rust"))]
fn test_zstd_compression() {
// gdal_translate -co COMPRESS=ZSTD -co ZSTD_LEVEL=20 int16.tif int16_zstd.tif
test_image_sum_i16("int16_zstd.tif", ColorType::Gray(16), 354396);
@@ -975,7 +1008,7 @@ fn extra_bits_gray() {
#[test]
fn extra_bits_rgb() {
test_image_sum_u8("extra_bits_rgb_8b.tiff", ColorType::RGB(8), 0);
test_image_sum_u8("extra_bits_rgb_8b.tiff", ColorType::RGBA(8), 64);
}
#[test]
@@ -1057,3 +1090,295 @@ 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);
}
// ---- Old-style LZW ----
#[test]
fn test_old_style_lzw() {
// Pre-TIFF-6.0 "old-style" LZW: LSB-first bit order with no early-change size
// switch. Auto-detected from the stream (first byte 0x00, second byte odd).
// Same image as libtiffpic's quad-lzw.tif; the decoded pixels must match.
test_image_sum_u8("quad-lzw-compat.tiff", ColorType::RGB(8), 21048924);
}
// ---- TransparencyMask ----
/// Build a minimal uncompressed 2x2 TIFF in memory, in the requested byte order,
/// with the given photometric interpretation and the pixel data as a single strip.
/// `sample_format` is emitted as a SampleFormat tag when `Some`; when `None` the
/// tag is omitted (the decoder then defaults to unsigned). Pixel bytes must already
/// be in `byte_order`.
fn build_raw_tiff(
byte_order: ByteOrder,
photometric: PhotometricInterpretation,
bits_per_sample: u16,
samples_per_pixel: u16,
sample_format: Option<SampleFormat>,
pixel_data: &[u8],
) -> Vec<u8> {
let le = matches!(byte_order, ByteOrder::LittleEndian);
let put16 = |buf: &mut Vec<u8>, v: u16| {
if le {
buf.extend_from_slice(&v.to_le_bytes());
} else {
buf.extend_from_slice(&v.to_be_bytes());
}
};
let put32 = |buf: &mut Vec<u8>, v: u32| {
if le {
buf.extend_from_slice(&v.to_le_bytes());
} else {
buf.extend_from_slice(&v.to_be_bytes());
}
};
let width: u16 = 2;
let height: u16 = 2;
let num_tags: u16 = if sample_format.is_some() { 9 } else { 8 };
let ifd_offset: u32 = 8;
let ifd_size = 2 + (num_tags as u32) * 12 + 4;
let strip_offset = 8 + ifd_size;
// IFD entries (must be ascending tag order). Tag IDs stay raw for the
// ascending-order property; the field type uses the `Type` enum.
let mut tags: Vec<(u16, Type, u32)> = vec![
(256, 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
];
if let Some(sf) = sample_format {
tags.push((339, Type::SHORT, sf.to_u16() as u32)); // SampleFormat (tag 339 > 279)
}
let mut buf = Vec::new();
buf.extend_from_slice(if le { b"II" } else { b"MM" });
put16(&mut buf, 42);
put32(&mut buf, ifd_offset);
put16(&mut buf, num_tags);
for (tag, typ, value) in tags {
put16(&mut buf, tag);
put16(&mut buf, typ.to_u16());
put32(&mut buf, 1); // count
// A SHORT value is left-justified in the 4-byte value field (value in the
// first two bytes, then padding); a LONG fills all four.
match typ {
Type::SHORT => {
put16(&mut buf, value as u16);
put16(&mut buf, 0);
}
_ => put32(&mut buf, value),
}
}
put32(&mut buf, 0); // 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(
ByteOrder::LittleEndian,
PhotometricInterpretation::TransparencyMask,
8,
1,
None,
&[0, 255, 128, 64],
);
let mut decoder = Decoder::open(Cursor::new(&data)).unwrap();
decoder.next_image().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<u8> = 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(
ByteOrder::LittleEndian,
PhotometricInterpretation::CIELab,
8,
3,
None,
&pixels,
);
let mut decoder = Decoder::open(Cursor::new(&data)).unwrap();
decoder.next_image().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"),
}
}
#[test]
fn test_cielab_decode_16() {
// 2x2 16-bit CIELab image: 3 u16 samples per pixel (L, a, b), a/b signed.
// Re-biasing maps signed a/b to unsigned by adding 0x8000 (wrapping):
// 0 -> 32768, +1000 -> 33768, -1000 -> 31768, -32768 -> 0, +32767 -> 65535.
let samples: [u16; 12] = [
32768, 0, 0, // pixel 0: L, a=0, b=0 (neutral)
65535, 1000, 1000, // pixel 1: a=+1000, b=+1000
10000, 64536, 64536, // pixel 2: a=-1000, b=-1000 (as u16)
0, 32768, 32767, // pixel 3: a=-32768, b=+32767 (as u16)
];
let mut pixels = Vec::new();
for s in samples {
pixels.extend_from_slice(&s.to_le_bytes());
}
let data = build_raw_tiff(
ByteOrder::LittleEndian,
PhotometricInterpretation::CIELab,
16,
3,
None,
&pixels,
);
let mut decoder = Decoder::open(Cursor::new(&data)).unwrap();
decoder.next_image().unwrap();
assert_eq!(decoder.colortype().unwrap(), ColorType::Lab(16));
let result = decoder.read_image().unwrap();
match result {
DecodingSampleBuffer::U16(decoded) => {
assert_eq!(decoded.len(), 12); // 4 pixels * 3 channels
// L unchanged; a/b re-biased by +0x8000 (wrapping).
let expected: [u16; 12] = [
32768, 32768, 32768, // pixel 0
65535, 33768, 33768, // pixel 1
10000, 31768, 31768, // pixel 2
0, 0, 65535, // pixel 3
];
assert_eq!(decoded, expected);
}
_ => panic!("Expected U16 buffer"),
}
}
#[test]
fn test_cielab_decode_16_big_endian() {
// Same image and expected output as `test_cielab_decode_16`, but stored as a
// big-endian ("MM") TIFF. 16-bit is the case where byte order matters: the
// decoder must swap to native before the a/b re-bias, so the result is
// identical to the little-endian case.
let samples: [u16; 12] = [
32768, 0, 0, // pixel 0
65535, 1000, 1000, // pixel 1
10000, 64536, 64536, // pixel 2
0, 32768, 32767, // pixel 3
];
let mut pixels = Vec::new();
for s in samples {
pixels.extend_from_slice(&s.to_be_bytes());
}
let data = build_raw_tiff(
ByteOrder::BigEndian,
PhotometricInterpretation::CIELab,
16,
3,
None,
&pixels,
);
let mut decoder = Decoder::open(Cursor::new(&data)).unwrap();
decoder.next_image().unwrap();
assert_eq!(decoder.colortype().unwrap(), ColorType::Lab(16));
let result = decoder.read_image().unwrap();
match result {
DecodingSampleBuffer::U16(decoded) => {
let expected: [u16; 12] = [
32768, 32768, 32768, // pixel 0
65535, 33768, 33768, // pixel 1
10000, 31768, 31768, // pixel 2
0, 0, 65535, // pixel 3
];
assert_eq!(decoded, expected);
}
_ => panic!("Expected U16 buffer"),
}
}
#[test]
fn test_cielab_rejects_non_uint_sample_format() {
// CIELab treats a/b as signed via the photometric interpretation and requires
// SampleFormat::Uint (matching the ICCLab arm). A file declaring Int must be
// rejected rather than silently producing a signed output buffer.
let pixels = vec![0u8; 12]; // 2x2, 3 samples, 8-bit
let data = build_raw_tiff(
ByteOrder::LittleEndian,
PhotometricInterpretation::CIELab,
8,
3,
Some(SampleFormat::Int),
&pixels,
);
let mut decoder = Decoder::open(Cursor::new(&data)).unwrap();
// Rejection may surface at either next_image() or colortype().
let rejected = decoder.next_image().is_err() || decoder.colortype().is_err();
assert!(
rejected,
"CIELab with SampleFormat=Int must be rejected, not decoded"
);
}
+1 -1
View File
@@ -190,7 +190,7 @@ const FILES: &[(&str, Option<u32>)] = &[
("tests/libtiffpic/oxford.tif", Some(0x355d2e3e)),
("tests/libtiffpic/pc260001.tif", Some(0xdb56b753)),
("tests/libtiffpic/quad-jpeg.tif", Some(0xcba22475)),
("tests/libtiffpic/quad-lzw.tif", Some(0)), // invalid LZW stream
("tests/libtiffpic/quad-lzw.tif", Some(0x6055f9ee)), // old-style (LSB) LZW
("tests/libtiffpic/quad-tile.tif", Some(0x6055f9ee)),
("tests/libtiffpic/README", None),
("tests/libtiffpic/smallliz.tif", Some(0)), // unsupported YCbCr chroma subsampling
Binary file not shown.
+164
View File
@@ -233,3 +233,167 @@ fn test_rgb_u64_predict_roundtrip() {
fn test_ycbcr_u8_predict_roundtrip() {
test_u8_predict_roundtrip::<colortype::YCbCr8>("tiled-jpeg-ycbcr.tif", ColorType::YCbCr(8));
}
// ---------------------------------------------------------------------------
// Extended bit depths (9-15 promoted to u16, 17-31 promoted to u32).
//
// A horizontal-predicted n-bit sample stores its delta in exactly n bits,
// i.e. modulo 2^n, so reconstruction must also be modulo 2^n. The decoder
// accumulates in the promoted storage width and reduces back to n bits;
// these tests pin that reduction down with exact pixel values, including
// deltas that wrap modulo 2^n. The encoder does not support extended
// depths, so the TIFFs are hand-built.
// ---------------------------------------------------------------------------
/// Hand-build a minimal little-endian, single-strip, uncompressed grayscale
/// TIFF with `Predictor::Horizontal` and the given (possibly non-standard)
/// bit depth. `pixels` are the intended sample values; they are converted to
/// horizontal differences modulo `2^bits` and bit-packed MSB-first with each
/// row padded to a byte boundary — the only representation an n-bit field
/// can hold.
fn build_gray_hpredict_tiff(
width: u32,
height: u32,
bits: u8,
photometric: u16,
pixels: &[u32],
) -> Vec<u8> {
let max = ((1u64 << bits) - 1) as u32;
let row_samples = width as usize;
assert_eq!(pixels.len(), row_samples * height as usize);
assert!(pixels.iter().all(|&v| v <= max));
// Per-row horizontal differencing modulo 2^bits.
let mut deltas = Vec::with_capacity(pixels.len());
for row in pixels.chunks(row_samples) {
for (i, &v) in row.iter().enumerate() {
deltas.push(if i == 0 {
v
} else {
v.wrapping_sub(row[i - 1]) & max
});
}
}
// Bit-pack MSB-first, each row padded to a byte boundary.
let row_bytes = (row_samples * bits as usize).div_ceil(8);
let mut data = vec![0u8; row_bytes * height as usize];
for (r, row) in deltas.chunks(row_samples).enumerate() {
for (i, &d) in row.iter().enumerate() {
let start = r * row_bytes * 8 + i * bits as usize;
for (off, b) in (start..).zip((0..bits).rev()) {
if (d >> b) & 1 == 1 {
data[off / 8] |= 0x80 >> (off % 8);
}
}
}
}
// Classic little-endian TIFF: header, strip data, then a single IFD.
let mut out = Vec::new();
out.extend_from_slice(b"II");
out.extend_from_slice(&42u16.to_le_bytes());
let data_offset = 8u32;
let ifd_offset = data_offset + data.len() as u32;
out.extend_from_slice(&ifd_offset.to_le_bytes());
out.extend_from_slice(&data);
const SHORT: u16 = 3;
const LONG: u16 = 4;
let entries: &[(u16, u16, u32)] = &[
(256, LONG, width), // ImageWidth
(257, LONG, height), // ImageLength
(258, SHORT, u32::from(bits)), // BitsPerSample
(259, SHORT, 1), // Compression: none
(262, SHORT, u32::from(photometric)), // PhotometricInterpretation
(273, LONG, data_offset), // StripOffsets
(277, SHORT, 1), // SamplesPerPixel
(278, LONG, height), // RowsPerStrip
(279, LONG, data.len() as u32), // StripByteCounts
(317, SHORT, 2), // Predictor: horizontal
(339, SHORT, 1), // SampleFormat: unsigned integer
];
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for &(tag, ty, value) in entries {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&ty.to_le_bytes());
out.extend_from_slice(&1u32.to_le_bytes());
// A count-1 SHORT payload occupies the low bytes of the value field.
out.extend_from_slice(&value.to_le_bytes());
}
out.extend_from_slice(&0u32.to_le_bytes()); // no next IFD
out
}
fn decode_gray_u16(tiff: Vec<u8>, bits: u8) -> Vec<u16> {
let mut decoder = Decoder::open(Cursor::new(tiff)).expect("Cannot create decoder");
decoder.next_image().unwrap();
assert_eq!(decoder.colortype().unwrap(), ColorType::Gray(bits));
match decoder.read_image().unwrap() {
DecodingSampleBuffer::U16(res) => res,
_ => panic!("Wrong data type"),
}
}
fn decode_gray_u32(tiff: Vec<u8>, bits: u8) -> Vec<u32> {
let mut decoder = Decoder::open(Cursor::new(tiff)).expect("Cannot create decoder");
decoder.next_image().unwrap();
assert_eq!(decoder.colortype().unwrap(), ColorType::Gray(bits));
match decoder.read_image().unwrap() {
DecodingSampleBuffer::U32(res) => res,
_ => panic!("Wrong data type"),
}
}
#[test]
fn test_gray12_hpredict_wraparound_exact() {
// Pixel row [4095, 0, 1] encodes as deltas [4095, 1, 1] (mod 2^12).
// u16 accumulation yields [4095, 4096, 4097]; reduction modulo 2^12
// must restore [4095, 0, 1]. Saturating instead would corrupt the two
// wrapped samples to 4095.
let tiff = build_gray_hpredict_tiff(3, 1, 12, 1, &[4095, 0, 1]);
assert_eq!(decode_gray_u16(tiff, 12), vec![4095, 0, 1]);
}
#[test]
fn test_gray12_hpredict_wraparound_white_is_zero_exact() {
// Same stored samples with PhotometricInterpretation = WhiteIsZero:
// after the mod-2^12 reduction the decoder inverts as (2^12 - 1) - v.
let tiff = build_gray_hpredict_tiff(3, 1, 12, 0, &[4095, 0, 1]);
assert_eq!(decode_gray_u16(tiff, 12), vec![0, 4095, 4094]);
}
#[test]
fn test_gray20_hpredict_wraparound_exact() {
// The same wrap-around shape on the u32 storage path (17-31 bits).
let tiff = build_gray_hpredict_tiff(3, 1, 20, 1, &[0xF_FFFF, 0, 1]);
assert_eq!(decode_gray_u32(tiff, 20), vec![0xF_FFFF, 0, 1]);
}
#[test]
fn test_extended_depth_hpredict_random_roundtrip() {
// Deterministic pseudo-random pixels across a spread of extended depths;
// encode-then-decode must be the identity for every sample, including
// every mod-2^n wrap-around the differencing produces. Three rows verify
// that prediction restarts per row.
let mut state = 0x243F_6A88_85A3_08D3u64;
let mut next = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as u32
};
for &bits in &[9u8, 11, 12, 15] {
let max = (1u32 << bits) - 1;
let pixels: Vec<u32> = (0..16 * 3).map(|_| next() & max).collect();
let tiff = build_gray_hpredict_tiff(16, 3, bits, 1, &pixels);
let expected: Vec<u16> = pixels.iter().map(|&v| v as u16).collect();
assert_eq!(decode_gray_u16(tiff, bits), expected, "bits={bits}");
}
for &bits in &[17u8, 20, 25, 31] {
let max = ((1u64 << bits) - 1) as u32;
let pixels: Vec<u32> = (0..16 * 3).map(|_| next() & max).collect();
let tiff = build_gray_hpredict_tiff(16, 3, bits, 1, &pixels);
assert_eq!(decode_gray_u32(tiff, bits), pixels, "bits={bits}");
}
}