Compare commits

..
Author SHA1 Message Date
Wim Pomp a6e8ed772d doc typo
Fuzz / Fuzz (decode_comprehensive) (push) Failing after 1m14s
Fuzz / Fuzz (decode_image) (push) Failing after 39s
Fuzz / Fuzz regression (push) Successful in 40s
Rust CI / build (1.88.0) (push) Successful in 58s
Rust CI / build (beta) (push) Successful in 1m8s
Rust CI / build (nightly) (push) Successful in 56s
Rust CI / build (stable) (push) Successful in 58s
Rust CI / features (default,zstd) (push) Successful in 41s
Rust CI / features (jpeg) (push) Successful in 41s
Rust CI / features (lzw) (push) Successful in 41s
Rust CI / features (zstd) (push) Successful in 40s
Rust CI / other_archs (i686-unknown-linux-gnu) (push) Failing after 17s
Rust CI / clippy (push) Successful in 36s
Rust CI / test-platforms (ubuntu-latest) (push) Successful in 45s
Rust CI / features () (push) Successful in 37s
Rust CI / features (default) (push) Successful in 41s
Rust CI / features (deflate) (push) Successful in 45s
Rust CI / other_archs (powerpc-unknown-linux-gnu) (push) Failing after 11s
Rust CI / msrv (push) Successful in 1m0s
Rust CI / rustfmt (push) Successful in 29s
Rust CI / test-platforms (macos-26-intel) (push) Canceled after 0s
Rust CI / test-platforms (macos-latest) (push) Canceled after 0s
Rust CI / test-platforms (ubuntu-24.04-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-11-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-latest) (push) Canceled after 0s
2026-10-10 12:31:33 +02:00
Wim Pomp 9b452e93b7 - fix tiled image reading
Fuzz / Fuzz (decode_comprehensive) (push) Failing after 37s
Fuzz / Fuzz (decode_image) (push) Failing after 35s
Fuzz / Fuzz regression (push) Successful in 28s
Rust CI / build (1.88.0) (push) Successful in 46s
Rust CI / build (beta) (push) Successful in 38s
Rust CI / build (nightly) (push) Successful in 36s
Rust CI / build (stable) (push) Successful in 39s
Rust CI / test-platforms (ubuntu-latest) (push) Successful in 33s
Rust CI / features () (push) Successful in 29s
Rust CI / features (default) (push) Successful in 37s
Rust CI / features (default,zstd) (push) Successful in 36s
Rust CI / features (deflate) (push) Successful in 35s
Rust CI / features (jpeg) (push) Successful in 36s
Rust CI / features (lzw) (push) Successful in 35s
Rust CI / features (zstd) (push) Successful in 35s
Rust CI / other_archs (i686-unknown-linux-gnu) (push) Failing after 19s
Rust CI / other_archs (powerpc-unknown-linux-gnu) (push) Failing after 18s
Rust CI / rustfmt (push) Successful in 25s
Rust CI / msrv (push) Successful in 57s
Rust CI / clippy (push) Successful in 33s
Rust CI / test-platforms (macos-26-intel) (push) Canceled after 0s
Rust CI / test-platforms (macos-latest) (push) Canceled after 0s
Rust CI / test-platforms (ubuntu-24.04-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-11-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-latest) (push) Canceled after 0s
2026-10-03 23:07:34 +02:00
Wim Pomp 96d37bb087 - use a fork of the tiff crate to open RGBPalette tiffs
Fuzz / Fuzz (decode_comprehensive) (push) Failing after 27s
Fuzz / Fuzz (decode_image) (push) Failing after 36s
Fuzz / Fuzz regression (push) Successful in 29s
Rust CI / build (beta) (push) Successful in 40s
Rust CI / build (nightly) (push) Successful in 39s
Rust CI / features (default,zstd) (push) Successful in 35s
Rust CI / features (jpeg) (push) Successful in 35s
Rust CI / features (lzw) (push) Successful in 36s
Rust CI / features (zstd) (push) Successful in 36s
Rust CI / other_archs (i686-unknown-linux-gnu) (push) Failing after 19s
Rust CI / msrv (push) Successful in 56s
Rust CI / build (1.88.0) (push) Successful in 47s
Rust CI / build (stable) (push) Successful in 39s
Rust CI / test-platforms (ubuntu-latest) (push) Successful in 32s
Rust CI / features () (push) Successful in 31s
Rust CI / features (default) (push) Successful in 35s
Rust CI / features (deflate) (push) Successful in 37s
Rust CI / other_archs (powerpc-unknown-linux-gnu) (push) Failing after 17s
Rust CI / rustfmt (push) Successful in 25s
Rust CI / clippy (push) Successful in 33s
Rust CI / test-platforms (macos-26-intel) (push) Canceled after 0s
Rust CI / test-platforms (macos-latest) (push) Canceled after 0s
Rust CI / test-platforms (ubuntu-24.04-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-11-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-latest) (push) Canceled after 0s
2026-10-03 19:35:01 +02:00
Wim Pomp 12f16dbfdd - ignore missing colormap
Fuzz / Fuzz (decode_comprehensive) (push) Successful in 1m39s
Fuzz / Fuzz regression (push) Successful in 27s
Fuzz / Fuzz (decode_image) (push) Failing after 37s
Rust CI / build (1.88.0) (push) Successful in 47s
Rust CI / build (beta) (push) Successful in 37s
Rust CI / build (nightly) (push) Successful in 39s
Rust CI / build (stable) (push) Successful in 37s
Rust CI / test-platforms (ubuntu-latest) (push) Successful in 31s
Rust CI / features () (push) Successful in 29s
Rust CI / features (default) (push) Failing after 35s
Rust CI / features (default,zstd) (push) Canceled after 0s
Rust CI / features (deflate) (push) Canceled after 0s
Rust CI / features (jpeg) (push) Canceled after 0s
Rust CI / features (lzw) (push) Canceled after 0s
Rust CI / features (zstd) (push) Canceled after 0s
Rust CI / other_archs (i686-unknown-linux-gnu) (push) Failing after 10s
Rust CI / other_archs (powerpc-unknown-linux-gnu) (push) Failing after 10s
Rust CI / msrv (push) Successful in 47s
Rust CI / rustfmt (push) Successful in 24s
Rust CI / clippy (push) Failing after 33s
Rust CI / test-platforms (macos-26-intel) (push) Canceled after 0s
Rust CI / test-platforms (macos-latest) (push) Canceled after 0s
Rust CI / test-platforms (ubuntu-24.04-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-11-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-latest) (push) Canceled after 0s
2026-10-03 16:50:04 +02:00
Wim Pomp a8abdd2484 update README
Fuzz / Fuzz (decode_comprehensive) (push) Successful in 2m31s
Fuzz / Fuzz (decode_image) (push) Failing after 37s
Fuzz / Fuzz regression (push) Successful in 35s
Rust CI / build (1.88.0) (push) Successful in 52s
Rust CI / build (beta) (push) Successful in 47s
Rust CI / build (nightly) (push) Successful in 48s
Rust CI / test-platforms (ubuntu-latest) (push) Successful in 41s
Rust CI / features (default) (push) Failing after 35s
Rust CI / features (default,zstd) (push) Canceled after 0s
Rust CI / features (deflate) (push) Canceled after 0s
Rust CI / features (jpeg) (push) Canceled after 0s
Rust CI / features (lzw) (push) Canceled after 0s
Rust CI / features (zstd) (push) Canceled after 0s
Rust CI / build (stable) (push) Successful in 47s
Rust CI / features () (push) Successful in 29s
Rust CI / other_archs (i686-unknown-linux-gnu) (push) Failing after 16s
Rust CI / other_archs (powerpc-unknown-linux-gnu) (push) Failing after 9s
Rust CI / msrv (push) Successful in 55s
Rust CI / rustfmt (push) Successful in 24s
Rust CI / clippy (push) Failing after 32s
Rust CI / test-platforms (macos-26-intel) (push) Canceled after 0s
Rust CI / test-platforms (macos-latest) (push) Canceled after 0s
Rust CI / test-platforms (ubuntu-24.04-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-11-arm) (push) Canceled after 0s
Rust CI / test-platforms (windows-latest) (push) Canceled after 0s
2026-10-03 15:00:48 +02:00
Wim Pomp 934fde185a Default missing PhotometricInterpretation to BlackIsZero 2026-10-03 14:52:07 +02:00
Aurelia Molzer 3302936b08 Merge pull request #415 from paolobarbolini/zstd-0.14
Upgrade zstd to 0.14
2026-09-06 18:22:06 +00:00
Paolo Barbolini a5b9a57e09 Upgrade zstd to 0.14
Bumps the optional `zstd` dependency from `0.13` to `0.14` (pulls in
`zstd-safe` 8.0.0 / `zstd-sys` 2.1.0).

We only use `zstd::Decoder::new` for streaming decode, which is unchanged
in 0.14.0, so this is a pure version bump with no code changes.

Also worth noting: `zstd-safe` 8.0.0 fixes a dangling dictionary pointer
reachable from safe code, and `zstd-sys` 2.1.0 no longer needs `libclang`
by default. The crate stack moved from MIT to BSD-3-Clause (still
permissive, compatible with our MIT).
2026-09-06 16:59:40 +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
A. Molzer a1426ca802 Fix benches/lzw.rs directory iteration 2026-05-16 02:28:36 +02:00
Aurelia Molzer 66e6567acc Merge pull request #392 from image-rs/upgrade-weezl
Upgrade weezl to 0.2
2026-05-15 22:51:46 +00:00
A. Molzer 5947d3b72e Make newer clippy happy 2026-05-16 00:46:02 +02:00
A. Molzer 0fdf1e0502 Upgrade weezl to 0.2 2026-05-16 00:40:00 +02: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
Aurelia Molzer cf9eee64ad Merge pull request #383 from lilith/chore/fuzz-dict-and-target
chore: replace decode_image fuzz target with comprehensive decoder exerciser
2026-04-09 22:26:34 +00:00
Lilith River a59db23341 chore: replace decode_image fuzz target with comprehensive decoder exerciser
Replace decode_image with decode_comprehensive, which exercises
per-IFD via a probe_directory helper:
- Tag iteration and value parsing for every tag
- SubIFD, EXIF, and GPS directory traversal via read_directory
- Image metadata (dimensions, colortype, color_map, chunk info)
- Layout queries (image_buffer_layout, image_chunk_buffer_layout)
- Per-chunk reads at first and last indices
- Full image decode via read_image

Three IFD traversal strategies:
- Linear iteration via next_directory (tolerates invalid
  intermediate IFDs)
- Random-access via seek_to_directory (find_nth_ifd code path)
- Image-aware random-access via seek_to_image

Dictionary covers classic TIFF and BigTIFF byte order marks, and
common IFD tags in both LE and BE (image geometry, strips, tiles,
SubIFD, EXIF, GPS, ColorMap).

Strict resource limits (512 KB decode/intermediate, 64 KB IFD).
Bounded iteration (max 8 linear, max 4 seek).
2026-04-09 14:14:35 -06:00
Aurelia Molzer e8103509fa Merge pull request #385 from lilith/fix/colormap-shl-overflow
fix: prevent shift overflow panic in ColorMap size validation
2026-04-09 12:25:51 +00:00
Lilith River 26b18c5184 fix: prevent shift overflow panic in ColorMap size validation
`1usize << bits_per_sample` panics when bits_per_sample >= 64.
Use checked_shl to return a LimitsExceeded error instead.

Found by fuzzing decode_comprehensive with a BE TIFF containing
PhotometricInterpretation=RGBPalette and BitsPerSample=83.
2026-04-09 04:46:05 -06:00
Lilith River b0712855b1 ci: add nightly fuzz workflow (60s on push, 5min nightly) 2026-04-09 04:43:12 -06:00
Lilith River ea6e2a8d80 feat: add decode_comprehensive fuzz target with safe metadata probing
New fuzz target exercises TiffHeader::parse, dimensions/colortype
probing, chunk-by-chunk reading, and multi-IFD iteration. Guards
get_chunk_type/chunk_dimensions behind dimensions() success check
to avoid documented panics on non-image IFDs.
2026-04-09 04:43:12 -06:00
Lilith River 032421634f chore: add fuzz dictionary for TIFF
Format-specific dictionary with LE/BE byte order marks, TIFF magic
number, and common IFD tag numbers as 2-byte LE and BE pairs.
Also add coverage to fuzz/.gitignore.
2026-04-09 04:43:12 -06:00
Aurelia Molzer d760bd3442 Merge pull request #384 from image-rs/renovate-decode-iteration
Fix decode example/fuzz with new iteration
2026-04-08 18:49:52 +00:00
A. Molzer a70e05e51e Fix decode example/fuzz with new iteration 2026-04-08 20:43:12 +02:00
Aurelia Molzer dc25c4eaea Merge pull request #382 from lilith/fix/fax-overflow-panic
fix: prevent u16 overflow panics in Group3 fax run-length decode
2026-04-08 05:42:03 +00:00
Lilith River d7961a2fcf fix: prevent u16 overflow panic in Group3 fax run-length decode
Fuzz testing found that crafted TIFF files with corrupt Group3 fax data
could cause a u16 addition overflow when accumulating run lengths. Use
checked_add and break on overflow (treating as corrupt data) instead of
panicking.
2026-04-07 17:45:59 -06:00
Aurelia Molzer 2166fd591a Merge pull request #380 from lilith/fix/ifd-limits-and-packbits
fix: enforce ifd_value_size limits and detect PackBits under-decode
2026-04-07 18:13:40 +00:00
Aurelia Molzer 7380611a7b Merge pull request #381 from lilith/refactor/typed-predictor-slices
refactor: use typed slices for float predictor output
2026-04-07 13:03:16 +00:00
Lilith River 149492ca6e refactor: use typed slices for float predictor output
Change predict_f32/f16/f64 to take &mut [[u8; N]] output instead of
&mut [u8], making sample alignment a compile-time guarantee rather
than a silent runtime truncation. The caller uses as_chunks_mut at the
boundary, with a debug_assert that the remainder is empty.

This follows 197g's suggestion in #376 to move alignment validation
to the caller via typed slices, keeping the predictor functions clean
for future SIMD dispatch.
2026-04-07 03:03:23 -06:00
Lilith River e4c3c1fcfd fix: enforce ifd_value_size limits and detect PackBits under-decode
Two input validation fixes:

- vec_with_capacity now checks raw IFD value bytes against
  ifd_value_size before allocating. Previously only the in-memory
  Value representation was bounded (by decoding_buffer_size), so a
  crafted IFD with a large count of small-typed values could allocate
  based on raw byte count without limit checking. Also fix ASCII value
  parsing to check ifd_value_size instead of decoding_buffer_size.

- LogLuv PackBits decoder now verifies that the expected number of
  pixels was actually decoded per plane, returning UnexpectedEof if
  the RLE stream ends short.
2026-04-07 02:53:43 -06:00
Aurelia Molzer 6a3282e9f2 Merge pull request #379 from lilith/main
Optimize horizontal and float predictor decode (4-43% faster)
2026-04-06 17:25:25 +00:00
Lilith River fa8a911f79 perf: optimize horizontal and float predictor decode
Horizontal predictor (one_byte_predict, all integer TIFFs with
Predictor=2): rewrite with explicit [u8; N] accumulator so LLVM unrolls
and keeps each channel in a register. For N=3 (RGB8), this replaces a
scalar 1-byte-per-iteration loop with a 2x-unrolled 3-register loop.

Float predictor (Predictor=3, f16/f32/f64): split deinterleave into
per-format functions with plane slicing up front; f32 batches 4 samples
through a u128 wide store to eliminate per-sample bounds checks. Thread
a reusable scratch buffer through expand_chunk to eliminate per-row
heap allocation.

Benchmarked on real 21MP photos and GeoTIFF DEMs (vs upstream main):

  Strip RGB8 deflate+hpred:      253ms -> 224ms (-11%)
  Strip RGB8 LZW:                405ms -> 370ms (-9%)
  Tiled RGB8 deflate+hpred:      241ms -> 222ms (-8%)
  Strip RGB f32 deflate+fpred:   726ms -> 527ms (-27%)
  Gray f32 tiled 512:            290ms -> 166ms (-43%)
  Copernicus DEM GeoTIFF:        239ms -> 152ms (-37%)
  USGS DEM GeoTIFF:              284ms -> 198ms (-30%)
2026-04-06 08:49:00 -06:00
Lilith River dc94cd4436 bench: add comprehensive decode benchmarks
Add decode benchmark covering 16 TIFF configurations with both a real
photo corpus (if available) and synthetic in-memory images:

Strip: uncompressed, PackBits, LZW, LZW+hpred, deflate+hpred, RGB16
deflate+hpred, gray8 LZW, gray8 deflate+hpred, RGB f32 deflate+fpred,
gray f32 deflate+fpred, palette8 LZW, bilevel fax4.
Tiled: RGB8 LZW/deflate/JPEG 256, gray f32 deflate+fpred 512.

Synthetic benchmarks isolate predictor and compression overhead with
controlled 2048x2048 images. A callgrind profiling example is included
for instruction-level analysis.
2026-04-05 22:00:22 -06:00
Aurelia Molzer a42b16ab72 Merge pull request #377 from image-rs/change-reader-of-decoder
Transfer decoder to new reader
2026-04-05 08:05:41 +00:00
Aurelia Molzer 8246b55e8f Merge pull request #378 from image-rs/forgotten-dbg-statement
Cleanup dbg! left in error path
2026-04-04 21:16:36 +00:00
A. Molzer 399469f512 Cleanup dbg! left in error path 2026-04-04 23:13:07 +02:00
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
28 changed files with 2081 additions and 223 deletions

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
name: Fuzz
on:
push:
branches: [main]
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
duration:
description: 'Seconds per fuzz target'
required: false
default: '300'
concurrency:
group: fuzz-${{ github.ref }}
cancel-in-progress: true
jobs:
fuzz:
name: Fuzz (${{ matrix.target }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target:
- decode_comprehensive
- decode_image
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
key: fuzz-${{ matrix.target }}
cache-directories: fuzz/target
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
- name: Determine duration
id: duration
run: |
if [ "${{ github.event_name }}" = "schedule" ]; then
echo "seconds=300" >> "$GITHUB_OUTPUT"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "seconds=${{ github.event.inputs.duration || '300' }}" >> "$GITHUB_OUTPUT"
else
echo "seconds=60" >> "$GITHUB_OUTPUT"
fi
- name: Seed corpus
run: |
mkdir -p fuzz/corpus/${{ matrix.target }}
cp fuzz/regression/* fuzz/corpus/${{ matrix.target }}/ 2>/dev/null || true
cp fuzz/seed_corpus/${{ matrix.target }}/* fuzz/corpus/${{ matrix.target }}/ 2>/dev/null || true
- name: Fuzz ${{ matrix.target }}
run: |
cargo +nightly fuzz run ${{ matrix.target }} \
-- -max_total_time=${{ steps.duration.outputs.seconds }} \
-dict=fuzz/tiff.dict \
-rss_limit_mb=2048
- name: Upload crashes
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes-${{ matrix.target }}
path: fuzz/artifacts/
retention-days: 30
regression:
name: Fuzz regression
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build
- name: Regression seeds
run: |
if [ -d fuzz/regression ] && [ "$(ls fuzz/regression/ 2>/dev/null | wc -l)" -gt 0 ]; then
cargo test --test fuzz_regression 2>/dev/null || echo "No regression test found — seeds exist but no test harness"
fi
+3 -1
View File
@@ -1,4 +1,6 @@
/target/
**/*.rs.bk
Cargo.lock
tests/benches/corpus/
.idea
.agentbridge
+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
+14 -4
View File
@@ -1,8 +1,7 @@
[package]
name = "tiff"
name = "ndbioimage-tiff"
version = "0.11.3"
edition = "2021"
resolver = "2"
# note: when changed, also update test runner in `.github/workflows/rust.yml`
rust-version = "1.88.0"
@@ -16,6 +15,9 @@ categories = ["multimedia::images", "multimedia::encoding"]
exclude = ["tests/images/*", "tests/fuzz_images/*", "tests/**/*.tif"]
[lib]
name = "tiff"
[dependencies]
half = { version = "2.4.1" }
quick-error = "2.0.1"
@@ -23,8 +25,9 @@ quick-error = "2.0.1"
# Rename to avoid confusion with the feature name.
fax34 = { package = "fax", version = "0.2.6", optional = true }
flate2 = { version = "1.0.20", optional = true }
weezl = { version = "0.1.10", optional = true }
zstd = { version = "0.13", optional = true }
weezl = { version = "0.2.1", optional = true }
zstd = { version = "0.14", 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 +48,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"
@@ -54,6 +60,10 @@ harness = false
name = "encode"
harness = false
[[bench]]
name = "decode"
harness = false
[[test]]
name = "decode_libtiffpic"
+3 -1
View File
@@ -5,6 +5,8 @@
TIFF decoding and encoding library in pure Rust
Forked from https://github.com/image-rs/image-tiff. Changes: read images containing RGBPalette frames, ignoring colormaps.
## Supported
### Features
@@ -55,4 +57,4 @@ This crate uses [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) in order t
After installing it with `cargo install cargo-fuzz` on a nightly rustc, the
fuzzing harness can be run with recommended settings using
`cargo fuzz run decode_image -snone -- -timeout=5`.
`cargo fuzz run decode_comprehensive -snone -- -dict=fuzz/tiff.dict -timeout=5`.
+461
View File
@@ -0,0 +1,461 @@
extern crate criterion;
extern crate tiff;
use criterion::{black_box, Criterion, Throughput};
use std::io::Cursor;
use tiff::decoder::Decoder;
use tiff::encoder::colortype::{Gray16, Gray8, RGB32Float, RGB16, RGB8, RGBA8};
use tiff::encoder::{Compression, Predictor, TiffEncoder};
const WIDTH: u32 = 2048;
const HEIGHT: u32 = 2048;
// === Corpus paths (real photo-based TIFFs) ===
// Set TIFF_BENCH_DIR / TIFF_GEOTIFF_DIR env vars to a directory containing the corpus files.
// If the files are not found, the corresponding benchmarks are silently skipped.
// The corpus can be generated with vips/imagemagick from any large photo; see the benchmark
// names for the expected filenames and compression settings.
const CORPUS_DIR: &str = "tests/benches/corpus";
fn bench_dir() -> String {
std::env::var("TIFF_BENCH_DIR").unwrap_or_else(|_| CORPUS_DIR.to_string())
}
fn geotiff_dir() -> String {
std::env::var("TIFF_GEOTIFF_DIR").unwrap_or_else(|_| CORPUS_DIR.to_string())
}
/// Load a file from the corpus directory, returning None if not found.
fn load_corpus(dir: &str, name: &str) -> Option<Vec<u8>> {
let path = format!("{}/{}", dir, name);
std::fs::read(&path).ok()
}
fn decode_tiff_bytes(data: &[u8]) {
let cursor = Cursor::new(data);
let mut decoder = Decoder::open(black_box(cursor)).unwrap();
decoder.next_image().unwrap();
decoder.read_image().unwrap();
}
// === Synthetic image generators ===
fn generate_rgb8(w: u32, h: u32) -> Vec<u8> {
(0..w * h * 3)
.map(|i| ((i * 7 + i / w * 3) & 0xFF) as u8)
.collect()
}
fn generate_rgba8(w: u32, h: u32) -> Vec<u8> {
(0..w * h * 4)
.map(|i| ((i * 11 + i / w * 5) & 0xFF) as u8)
.collect()
}
fn generate_gray8(w: u32, h: u32) -> Vec<u8> {
(0..w * h)
.map(|i| ((i * 7 + i / w * 3) & 0xFF) as u8)
.collect()
}
fn generate_rgb16(w: u32, h: u32) -> Vec<u16> {
(0..w * h * 3)
.map(|i| ((i * 257 + i / w * 131) & 0xFFFF) as u16)
.collect()
}
fn generate_gray16(w: u32, h: u32) -> Vec<u16> {
(0..w * h)
.map(|i| ((i * 257 + i / w * 131) & 0xFFFF) as u16)
.collect()
}
fn generate_rgb_f32(w: u32, h: u32) -> Vec<f32> {
let mut data = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let fx = x as f32 / w as f32;
let fy = y as f32 / h as f32;
data.push(fx * 0.5 + fy * 0.3);
data.push(fy * 0.7 + fx * 0.2);
data.push((fx + fy) * 0.25);
}
}
data
}
// === Synthetic encoders (strip-only, encoder doesn't support tiles) ===
fn encode<C: tiff::encoder::colortype::ColorType>(
data: &[C::Inner],
w: u32,
h: u32,
compression: Compression,
predictor: Predictor,
) -> Vec<u8>
where
[C::Inner]: tiff::encoder::TiffValue,
{
let mut buf = Cursor::new(Vec::new());
TiffEncoder::new(&mut buf)
.unwrap()
.with_predictor(predictor)
.with_compression(compression)
.write_image::<C>(w, h, data)
.unwrap();
buf.into_inner()
}
fn main() {
let mut c = Criterion::default().configure_from_args();
let bd = bench_dir();
let gd = geotiff_dir();
// ========================================================================
// PART 1: Real photo corpus (5616x3744, ~21MP)
// ========================================================================
// Raw pixel sizes for throughput calculation:
// RGB8: 5616*3744*3 = 63,078,912
// RGB16: 5616*3744*6 = 126,157,824
// F32x3: 5616*3744*12 = 252,315,648
// Gray8: 5616*3744*1 = 21,026,304
// GrayF32: 5616*3744*4 = 84,105,216
// Palette8: 5616*3744*1 = 21,026,304 (output)
struct CorpusBench {
file: &'static str,
name: &'static str,
raw_bytes: u64,
}
let photo_benches = [
// Strip-based
CorpusBench {
file: "photo-rgb8-none.tif",
name: "strip-rgb8-none",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-packbits.tif",
name: "strip-rgb8-packbits",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-lzw.tif",
name: "strip-rgb8-lzw",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-lzw-hpred.tif",
name: "strip-rgb8-lzw-hpred",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-deflate-hpred.tif",
name: "strip-rgb8-deflate-hpred",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb16-deflate-hpred.tif",
name: "strip-rgb16-deflate-hpred",
raw_bytes: 126_157_824,
},
CorpusBench {
file: "photo-gray8-lzw.tif",
name: "strip-gray8-lzw",
raw_bytes: 21_026_304,
},
CorpusBench {
file: "photo-gray8-deflate-hpred.tif",
name: "strip-gray8-deflate-hpred",
raw_bytes: 21_026_304,
},
CorpusBench {
file: "photo-rgb-f32-deflate-fpred.tif",
name: "strip-rgb-f32-deflate-fpred",
raw_bytes: 252_315_648,
},
CorpusBench {
file: "photo-gray-f32-deflate-fpred.tif",
name: "strip-gray-f32-deflate-fpred",
raw_bytes: 84_105_216,
},
CorpusBench {
file: "photo-palette8-lzw.tif",
name: "strip-palette8-lzw",
raw_bytes: 21_026_304,
},
CorpusBench {
file: "scan-bilevel-fax4.tif",
name: "strip-bilevel-fax4",
raw_bytes: 5616 * 3744 / 8,
},
// Tiled
CorpusBench {
file: "photo-rgb8-lzw-hpred-tiled256.tif",
name: "tiled256-rgb8-lzw-hpred",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-deflate-hpred-tiled256.tif",
name: "tiled256-rgb8-deflate-hpred",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-jpeg-tiled256.tif",
name: "tiled256-rgb8-jpeg",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-gray-f32-deflate-fpred-tiled512.tif",
name: "tiled512-gray-f32-deflate-fpred",
raw_bytes: 84_105_216,
},
];
{
let mut group = c.benchmark_group("photo-21mp");
group.sample_size(10);
for bench in &photo_benches {
if let Some(data) = load_corpus(&bd, bench.file) {
group.throughput(Throughput::Bytes(bench.raw_bytes));
let data_ref = &data;
group.bench_function(bench.name, |b| b.iter(|| decode_tiff_bytes(data_ref)));
}
}
group.finish();
}
// ========================================================================
// PART 2: GeoTIFF (real tiled DEM data)
// ========================================================================
let geotiff_benches = [
// Copernicus DEM: 3600x3600, tiled 1024x1024, Gray f32, deflate + fpredict
CorpusBench {
file: "Copernicus_DSM_COG_10_N40_00_W075_00_DEM.tif",
name: "copernicus-dem-f32-deflate-fpred-1024tile",
raw_bytes: 3600 * 3600 * 4,
},
// USGS DEM: 3612x3612, tiled 512x512, Gray f32, LZW + fpredict
CorpusBench {
file: "USGS_1_n40w075.tif",
name: "usgs-dem-f32-lzw-fpred-512tile",
raw_bytes: 3612 * 3612 * 4,
},
];
{
let mut group = c.benchmark_group("geotiff");
group.sample_size(10);
for bench in &geotiff_benches {
if let Some(data) = load_corpus(&gd, bench.file) {
group.throughput(Throughput::Bytes(bench.raw_bytes));
let data_ref = &data;
group.bench_function(bench.name, |b| b.iter(|| decode_tiff_bytes(data_ref)));
}
}
group.finish();
}
// ========================================================================
// PART 3: Synthetic (2048x2048, controlled comparisons)
// ========================================================================
let rgb8 = generate_rgb8(WIDTH, HEIGHT);
let rgba8 = generate_rgba8(WIDTH, HEIGHT);
let gray8 = generate_gray8(WIDTH, HEIGHT);
let rgb16 = generate_rgb16(WIDTH, HEIGHT);
let gray16 = generate_gray16(WIDTH, HEIGHT);
let rgb_f32 = generate_rgb_f32(WIDTH, HEIGHT);
let raw_rgb8 = (WIDTH * HEIGHT * 3) as u64;
let raw_rgba8 = (WIDTH * HEIGHT * 4) as u64;
let raw_gray8 = (WIDTH * HEIGHT) as u64;
let raw_rgb16 = (WIDTH * HEIGHT * 6) as u64;
let raw_gray16 = (WIDTH * HEIGHT * 2) as u64;
let raw_f32x3 = (WIDTH * HEIGHT * 12) as u64;
// --- Uncompressed baseline ---
{
let mut group = c.benchmark_group("synth-uncompressed");
group.sample_size(10);
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGBA8>(
&rgba8,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgba8))
.bench_function("rgba8", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<Gray8>(
&gray8,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_gray8))
.bench_function("gray8", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB16>(
&rgb16,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgb16))
.bench_function("rgb16", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<Gray16>(
&gray16,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_gray16))
.bench_function("gray16", |b| b.iter(|| decode_tiff_bytes(&t)));
group.finish();
}
// --- Predictor overhead isolation (deflate-1, with vs without predictor) ---
{
let mut group = c.benchmark_group("synth-predictor");
group.sample_size(10);
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-deflate-none", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-deflate-hpred", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB16>(
&rgb16,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgb16))
.bench_function("rgb16-deflate-none", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB16>(
&rgb16,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
);
group
.throughput(Throughput::Bytes(raw_rgb16))
.bench_function("rgb16-deflate-hpred", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB32Float>(
&rgb_f32,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_f32x3))
.bench_function("rgb-f32-deflate-none", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB32Float>(
&rgb_f32,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::FloatingPoint,
);
group
.throughput(Throughput::Bytes(raw_f32x3))
.bench_function("rgb-f32-deflate-fpred", |b| {
b.iter(|| decode_tiff_bytes(&t))
});
group.finish();
}
// --- LZW vs Deflate comparison ---
{
let mut group = c.benchmark_group("synth-compression");
group.sample_size(10);
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-deflate-hpred", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Lzw,
Predictor::Horizontal,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-lzw-hpred", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB8>(&rgb8, WIDTH, HEIGHT, Compression::Lzw, Predictor::None);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-lzw-none", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB8>(&rgb8, WIDTH, HEIGHT, Compression::Packbits, Predictor::None);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-packbits", |b| b.iter(|| decode_tiff_bytes(&t)));
group.finish();
}
}
+1 -1
View File
@@ -9,9 +9,9 @@ use tiff::decoder::Decoder;
fn read_image(image: &[u8]) {
let image = std::io::Cursor::new(image);
let decoder = Decoder::open(black_box(image));
let mut reader = decoder.unwrap();
while reader.more_images() {
reader.next_directory().unwrap();
reader.read_image().unwrap();
}
}
+10 -8
View File
@@ -2,28 +2,30 @@ 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![]);
for i in 0u32.. {
if !reader.more_images() {
break;
}
reader.next_directory()?;
let colortype = reader.colortype()?;
let dimensions = reader.dimensions()?;
let layout = reader.read_image_to_buffer(&mut data)?;
debug_planes(i, &mut data, &layout, &colortype, dimensions);
if !reader.more_images() {
break;
}
reader.next_image()?
}
Ok(())
+130
View File
@@ -0,0 +1,130 @@
//! Profiling helper: encodes and decodes several TIFF configurations.
//! Run under callgrind: `valgrind --tool=callgrind target/release/examples/profile_decode`
use std::io::Cursor;
use tiff::decoder::Decoder;
use tiff::encoder::colortype::{Gray8, RGB32Float, RGB16, RGB8};
use tiff::encoder::{Compression, Predictor, TiffEncoder};
const WIDTH: u32 = 2048;
const HEIGHT: u32 = 2048;
const ITERS: usize = 3;
fn generate_rgb8(w: u32, h: u32) -> Vec<u8> {
(0..w * h * 3).map(|i| (i % 256) as u8).collect()
}
fn generate_rgb16(w: u32, h: u32) -> Vec<u16> {
(0..w * h * 3).map(|i| (i % 65536) as u16).collect()
}
fn generate_rgb_f32(w: u32, h: u32) -> Vec<f32> {
(0..w * h * 3)
.map(|i| (i as f32) / (w * h * 3) as f32)
.collect()
}
fn generate_gray8(w: u32, h: u32) -> Vec<u8> {
(0..w * h).map(|i| (i % 256) as u8).collect()
}
fn encode<C: tiff::encoder::colortype::ColorType>(
data: &[C::Inner],
w: u32,
h: u32,
compression: Compression,
predictor: Predictor,
) -> Vec<u8>
where
[C::Inner]: tiff::encoder::TiffValue,
{
let mut buf = Cursor::new(Vec::new());
TiffEncoder::new(&mut buf)
.unwrap()
.with_predictor(predictor)
.with_compression(compression)
.write_image::<C>(w, h, data)
.unwrap();
buf.into_inner()
}
fn decode(data: &[u8]) {
let cursor = Cursor::new(data);
let mut decoder = Decoder::open(cursor).unwrap();
decoder.next_image().unwrap();
let _ = decoder.read_image().unwrap();
}
fn main() {
let rgb8 = generate_rgb8(WIDTH, HEIGHT);
let rgb16 = generate_rgb16(WIDTH, HEIGHT);
let rgb_f32 = generate_rgb_f32(WIDTH, HEIGHT);
let gray8 = generate_gray8(WIDTH, HEIGHT);
let cases: Vec<(&str, Vec<u8>)> = vec![
(
"deflate-rgb8-hpredict",
encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
),
),
(
"deflate-gray8-hpredict",
encode::<Gray8>(
&gray8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
),
),
(
"deflate-rgb16-hpredict",
encode::<RGB16>(
&rgb16,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
),
),
(
"deflate-rgb-f32-fpredict",
encode::<RGB32Float>(
&rgb_f32,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::FloatingPoint,
),
),
(
"lzw-rgb8-no-pred",
encode::<RGB8>(&rgb8, WIDTH, HEIGHT, Compression::Lzw, Predictor::None),
),
(
"lzw-rgb8-hpredict",
encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Lzw,
Predictor::Horizontal,
),
),
];
for (name, tiff_data) in &cases {
eprintln!(
"=== Profiling: {} ({} bytes encoded) ===",
name,
tiff_data.len()
);
for _ in 0..ITERS {
decode(tiff_data);
}
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ path = ".."
members = ["."]
[[bin]]
name = "decode_image"
path = "fuzz_targets/decode_image.rs"
name = "decode_comprehensive"
path = "fuzz_targets/decode_comprehensive.rs"
test = false
doc = false
+96
View File
@@ -0,0 +1,96 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use std::io::{Cursor, Read, Seek};
use tiff::decoder::{Decoder, Limits};
use tiff::tags::Tag;
fuzz_target!(|data: &[u8]| {
// Standalone header parse (separate code path from Decoder::open)
let _ = tiff::decoder::TiffHeader::parse(Cursor::new(data));
let mut decoder = match Decoder::open(Cursor::new(data)) {
Ok(d) => d,
Err(_) => return,
};
let mut limits = Limits::default();
limits.decoding_buffer_size = 512 * 1024;
limits.ifd_value_size = 64 * 1024;
limits.intermediate_buffer_size = 512 * 1024;
decoder = decoder.with_limits(limits);
// Probe first IFD (opened but image not yet parsed)
probe_directory(&mut decoder);
// Linear IFD iteration via next_directory — tolerates invalid
// intermediate IFDs that aren't decodable as images
let mut ifd_count = 1u32;
while decoder.more_images() && ifd_count < 8 {
if decoder.next_directory().is_err() {
break;
}
probe_directory(&mut decoder);
ifd_count += 1;
}
// Random-access traversal via seek (different code path: find_nth_ifd
// re-walks the IFD chain from the start each time)
for i in 0..ifd_count.min(4) as usize {
if decoder.seek_to_directory(i).is_ok() {
probe_directory(&mut decoder);
}
// seek_to_image eagerly parses the directory as an Image
let _ = decoder.seek_to_image(i);
}
});
fn probe_directory<R: Read + Seek>(decoder: &mut Decoder<R>) {
// Tag iteration — exercises value parsing for every tag in the IFD
for result in decoder.current_ifd().tag_iter() {
let _ = result;
}
// SubIFD / EXIF / GPS directory traversal — exercises read_directory
// with potentially adversarial offsets
if let Ok(Some(val)) = decoder.current_ifd().find_tag(Tag::SubIfd) {
if let Ok(ptrs) = val.into_ifd_vec() {
for ptr in ptrs.into_iter().take(4) {
let _ = decoder.read_directory(ptr);
}
}
}
for tag in [Tag::ExifDirectory, Tag::GpsDirectory] {
if let Ok(Some(val)) = decoder.current_ifd().find_tag(tag) {
if let Ok(ptr) = val.into_ifd_pointer() {
let _ = decoder.read_directory(ptr);
}
}
}
// Image-level probing — dimensions() triggers ensure_image,
// which lazily parses the directory as an Image
if decoder.dimensions().is_ok() {
let _ = decoder.colortype();
let _ = decoder.color_map();
let _ = decoder.get_chunk_type();
let _ = decoder.chunk_dimensions();
let _ = decoder.strip_count();
let _ = decoder.tile_count();
let _ = decoder.image_buffer_layout();
let _ = decoder.image_chunk_buffer_layout(0);
// Chunk reads at boundaries — first and last exercise
// different offset calculations
let _ = decoder.read_chunk(0);
let chunk_count = decoder
.strip_count()
.or_else(|_| decoder.tile_count())
.unwrap_or(0);
if chunk_count > 1 {
let _ = decoder.read_chunk(chunk_count - 1);
}
// Full image decode
let _ = decoder.read_image();
}
}
-31
View File
@@ -1,31 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let mut decoder = if let Ok(d) = tiff::decoder::Decoder::new(std::io::Cursor::new(data)) {
d
} else {
return;
};
let mut limits = tiff::decoder::Limits::default();
limits.decoding_buffer_size = 1_000_000;
limits.ifd_value_size = 1_000_000;
limits.intermediate_buffer_size = 1_000_000;
decoder = decoder.with_limits(limits);
loop {
if let Err(_) = decoder.read_image() {
break;
}
if !decoder.more_images() {
break;
}
if let Err(_) = decoder.next_image() {
break;
}
}
});
+139
View File
@@ -0,0 +1,139 @@
# === File headers ===
# Classic TIFF (LE, BE)
"\x49\x49\x2A\x00"
"\x4D\x4D\x00\x2A"
# BigTIFF (LE, BE) — version 43, 8-byte offsets, different parsing paths
"\x49\x49\x2B\x00\x08\x00\x00\x00"
"\x4D\x4D\x00\x2B\x00\x08\x00\x00"
# === IFD field type codes (2-byte LE) ===
# Decoder switches on these; wrong types exercise error paths
"\x01\x00"
"\x02\x00"
"\x03\x00"
"\x04\x00"
"\x05\x00"
"\x06\x00"
"\x07\x00"
"\x08\x00"
"\x09\x00"
"\x0A\x00"
"\x0B\x00"
"\x0C\x00"
"\x0D\x00"
"\x10\x00"
"\x11\x00"
"\x12\x00"
# === IFD tag numbers (2-byte LE) ===
# 256=ImageWidth 257=ImageLength 258=BitsPerSample
"\x00\x01"
"\x01\x01"
"\x02\x01"
# 259=Compression 262=PhotometricInterpretation
"\x03\x01"
"\x06\x01"
# 273=StripOffsets 274=Orientation
"\x11\x01"
"\x12\x01"
# 277=SamplesPerPixel 278=RowsPerStrip 279=StripByteCounts
"\x15\x01"
"\x16\x01"
"\x17\x01"
# 282=XResolution 283=YResolution 284=PlanarConfiguration
"\x1A\x01"
"\x1B\x01"
"\x1C\x01"
# 317=Predictor 320=ColorMap
"\x3D\x01"
"\x40\x01"
# 322=TileWidth 323=TileLength 324=TileOffsets 325=TileByteCounts
"\x42\x01"
"\x43\x01"
"\x44\x01"
"\x45\x01"
# 330=SubIfd 338=ExtraSamples 339=SampleFormat
"\x4A\x01"
"\x52\x01"
"\x53\x01"
# 347=JPEGTables 530=ChromaSubsampling
"\x5B\x01"
"\x12\x02"
# 34665=ExifDirectory 34675=IccProfile 34853=GpsDirectory
"\x69\x87"
"\x73\x87"
"\x25\x88"
# === IFD tag numbers (2-byte BE) ===
"\x01\x00"
"\x01\x01"
"\x01\x02"
"\x01\x03"
"\x01\x06"
"\x01\x11"
"\x01\x12"
"\x01\x15"
"\x01\x16"
"\x01\x17"
"\x01\x1A"
"\x01\x1B"
"\x01\x1C"
"\x01\x3D"
"\x01\x40"
"\x01\x42"
"\x01\x43"
"\x01\x44"
"\x01\x45"
"\x01\x4A"
"\x01\x52"
"\x01\x53"
"\x01\x5B"
"\x02\x12"
"\x87\x69"
"\x87\x73"
"\x88\x25"
# === Compression codes (4-byte LE inline SHORT values) ===
# These are the values stored in the IFD entry for tag 259
# 1=None 2=Huffman 3=Fax3 4=Fax4 5=LZW
"\x01\x00\x00\x00"
"\x02\x00\x00\x00"
"\x03\x00\x00\x00"
"\x04\x00\x00\x00"
"\x05\x00\x00\x00"
# 6=OldJPEG 7=ModernJPEG 8=Deflate
"\x06\x00\x00\x00"
"\x07\x00\x00\x00"
"\x08\x00\x00\x00"
# 0x8005=PackBits 0x80B2=OldDeflate
"\x05\x80\x00\x00"
"\xB2\x80\x00\x00"
# === Useful small values (4-byte LE) ===
# BitsPerSample=16, =32 (8 already covered as Deflate compression above)
"\x10\x00\x00\x00"
"\x20\x00\x00\x00"
# === Minimal IFD entries (12-byte LE: tag + type SHORT + count 1 + value) ===
# These let the fuzzer splice complete valid entries into an IFD
# ImageWidth=1
"\x00\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# ImageLength=1
"\x01\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# BitsPerSample=8
"\x02\x01\x03\x00\x01\x00\x00\x00\x08\x00\x00\x00"
# Compression=None
"\x03\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# PhotometricInterpretation=BlackIsZero
"\x06\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# SamplesPerPixel=1
"\x15\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# RowsPerStrip=1
"\x16\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
+70 -40
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
))
}
}
@@ -583,81 +583,93 @@ impl Entry {
match self.type_ {
Type::BYTE | Type::UNDEFINED => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.iter().copied().map(Byte))
})
}
Type::SBYTE => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.iter().copied().map(|v| SignedByte(v as i8)))
})
}
Type::SHORT => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(2)
.map(|ch| Short(u16::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<2>()
.0
.iter()
.map(|&ch| Short(u16::from_ne_bytes(ch))),
)
})
}
Type::SSHORT => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(2)
.map(|ch| SignedShort(i16::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<2>()
.0
.iter()
.map(|&ch| SignedShort(i16::from_ne_bytes(ch))),
)
})
}
Type::LONG => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(4)
.map(|ch| Unsigned(u32::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<4>()
.0
.iter()
.map(|&ch| Unsigned(u32::from_ne_bytes(ch))),
)
})
}
Type::SLONG => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(4)
.map(|ch| Signed(i32::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<4>()
.0
.iter()
.map(|&ch| Signed(i32::from_ne_bytes(ch))),
)
})
}
Type::FLOAT => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(4)
.map(|ch| Float(f32::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<4>()
.0
.iter()
.map(|&ch| Float(f32::from_ne_bytes(ch))),
)
})
}
Type::DOUBLE => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(8)
.map(|ch| Double(f64::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<8>()
.0
.iter()
.map(|&ch| Double(f64::from_ne_bytes(ch))),
)
})
}
Type::RATIONAL => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.chunks_exact(8).map(|ch| {
v.extend(bytes.as_chunks::<8>().0.iter().map(|ch| {
Rational(
u32::from_ne_bytes(ch[..4].try_into().unwrap()),
u32::from_ne_bytes(ch[4..].try_into().unwrap()),
@@ -666,9 +678,9 @@ impl Entry {
})
}
Type::SRATIONAL => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.chunks_exact(8).map(|ch| {
v.extend(bytes.as_chunks::<8>().0.iter().map(|ch| {
SRational(
i32::from_ne_bytes(ch[..4].try_into().unwrap()),
i32::from_ne_bytes(ch[4..].try_into().unwrap()),
@@ -677,50 +689,58 @@ impl Entry {
})
}
Type::LONG8 => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(8)
.map(|ch| UnsignedBig(u64::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<8>()
.0
.iter()
.map(|&ch| UnsignedBig(u64::from_ne_bytes(ch))),
)
})
}
Type::SLONG8 => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(8)
.map(|ch| SignedBig(i64::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<8>()
.0
.iter()
.map(|&ch| SignedBig(i64::from_ne_bytes(ch))),
)
})
}
Type::IFD => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(4)
.map(|ch| Ifd(u32::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<4>()
.0
.iter()
.map(|&ch| Ifd(u32::from_ne_bytes(ch))),
)
})
}
Type::IFD8 => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
.chunks_exact(8)
.map(|ch| IfdBig(u64::from_ne_bytes(ch.try_into().unwrap()))),
.as_chunks::<8>()
.0
.iter()
.map(|&ch| IfdBig(u64::from_ne_bytes(ch))),
)
})
}
Type::ASCII => {
let n = usize::try_from(self.count)?;
if n > limits.decoding_buffer_size {
return Err(dbg!(TiffError::LimitsExceeded));
if n > limits.ifd_value_size {
return Err(TiffError::LimitsExceeded);
}
let mut out = vec![0; n];
@@ -846,10 +866,20 @@ impl Entry {
fn vec_with_capacity(
value_count: u64,
type_: Type,
limits: &super::Limits,
) -> Result<Vec<Value>, TiffError> {
let value_count = usize::try_from(value_count)?;
// Check raw file bytes against ifd_value_size.
if !type_
.value_bytes(value_count)
.and_then(|b| usize::try_from(b).map_err(|_| TiffError::LimitsExceeded))
.is_ok_and(|raw_len| raw_len <= limits.ifd_value_size)
{
return Err(TiffError::LimitsExceeded);
}
// Check in-memory Value representation against decoding_buffer_size.
let value_count = usize::try_from(value_count)?;
if value_count > limits.decoding_buffer_size / mem::size_of::<Value>() {
return Err(TiffError::LimitsExceeded);
}
+151 -42
View File
@@ -36,6 +36,9 @@ impl TileAttributes {
pub fn tiles_down(&self) -> usize {
self.image_height.div_ceil(self.tile_length)
}
pub fn tiles_per_plane(&self) -> usize {
self.tiles_across() * self.tiles_down()
}
fn padding_right(&self) -> usize {
(self.tile_width - self.image_width % self.tile_width) % self.tile_width
}
@@ -97,6 +100,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 +162,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 };
@@ -174,7 +179,7 @@ impl Image {
.map(Value::into_u16)
.transpose()?
.and_then(PhotometricInterpretation::from_u16)
.ok_or(TiffUnsupportedError::UnknownInterpretation)?;
.unwrap_or(PhotometricInterpretation::BlackIsZero);
// Try to parse both the compression method and the number, format, and bits of the included samples.
// If they are not explicitly specified, those tags are reset to their default values and not carried from previous images.
@@ -454,8 +459,9 @@ impl Image {
match tag_reader.find_tag(Tag::ColorMap)? {
Some(v) => {
let map = v.into_u16_vec()?;
let expected_len = 3usize
.checked_mul(1usize << bits_per_sample)
let expected_len = 1usize
.checked_shl(bits_per_sample as u32)
.and_then(|n| n.checked_mul(3))
.ok_or(TiffError::LimitsExceeded)?;
if map.len() != expected_len {
return Err(TiffError::FormatError(
@@ -464,11 +470,11 @@ impl Image {
}
Some(map)
}
None => {
return Err(TiffError::FormatError(
TiffFormatError::RequiredTagNotFound(Tag::ColorMap),
));
}
// A missing palette is not an error. The pixel values of an indexed image are just
// indices into the palette, and the palette itself is only display information, so
// the image still decodes. Callers observe the absence as `None` from
// `Decoder::color_map`.
None => None,
}
} else {
None
@@ -499,6 +505,7 @@ impl Image {
compression_method,
CompressionMethod::SgiLog | CompressionMethod::SgiLog24
),
lenient,
})
}
@@ -514,10 +521,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 {
@@ -590,20 +600,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) =>
@@ -659,7 +679,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],
@@ -711,6 +735,8 @@ impl Image {
)),
#[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 => {
@@ -993,7 +1019,15 @@ impl Image {
}
ChunkType::Tile => {
let tile_attrs = self.tile_attributes.as_ref().unwrap();
let (padding_right, padding_down) = tile_attrs.get_padding(chunk_index as usize);
// The chunk index counts the tiles of all planes, but the tile grid repeats per
// plane, so only the position within the plane decides the padding.
let tiles_per_plane = tile_attrs.tiles_per_plane();
let tile = if tiles_per_plane == 0 {
0
} else {
chunk_index as usize % tiles_per_plane
};
let (padding_right, padding_down) = tile_attrs.get_padding(tile);
let tile_width = tile_attrs.tile_width - padding_right;
let tile_length = tile_attrs.tile_length - padding_down;
@@ -1009,6 +1043,7 @@ impl Image {
buf: &mut [u8],
layout: &ReadoutLayout,
chunk_index: u32,
scratch: &mut Vec<u8>,
) -> TiffResult<()> {
let ValueReader {
reader,
@@ -1034,6 +1069,7 @@ impl Image {
| ColorType::YCbCr(n)
| ColorType::Gray(n)
| ColorType::Palette(n)
| ColorType::Lab(n)
| ColorType::Multiband {
bit_depth: n,
num_samples: _,
@@ -1045,6 +1081,7 @@ impl Image {
| ColorType::YCbCr(n)
| ColorType::Gray(n)
| ColorType::Palette(n)
| ColorType::Lab(n)
| ColorType::Multiband {
bit_depth: n,
num_samples: _,
@@ -1188,14 +1225,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.
@@ -1209,12 +1258,26 @@ impl Image {
samples,
decompressed_byte_order,
predictor,
scratch,
);
}
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.
@@ -1224,14 +1287,28 @@ impl Image {
let row = &mut row[..data_row_bytes];
match color_type.bit_depth() {
16 => predict_f16(&mut encoded, row, samples),
32 => predict_f32(&mut encoded, row, samples),
64 => predict_f64(&mut encoded, row, samples),
16 => {
let (out, _) = row.as_chunks_mut::<2>();
predict_f16(&mut encoded, out, samples);
}
32 => {
let (out, _) = row.as_chunks_mut::<4>();
predict_f32(&mut encoded, out, samples);
}
64 => {
let (out, _) = row.as_chunks_mut::<8>();
predict_f64(&mut encoded, out, samples);
}
_ => 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 +1332,25 @@ impl Image {
samples,
decompressed_byte_order,
predictor,
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
@@ -1286,6 +1377,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);
@@ -1296,11 +1391,25 @@ impl Image {
samples,
decompressed_byte_order,
predictor,
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,
)?;
}
}
+9 -5
View File
@@ -87,15 +87,12 @@ fn packbits_decode<R: Read, const N: usize>(
reader: &mut R,
buf: &mut [[u8; N]],
) -> std::io::Result<()> {
let expected = buf.len();
for offset in (0..N).rev() {
let mut i = 0;
let mut code = [0u8; 1];
loop {
let Some(pixels) = buf.get_mut(i..) else {
break;
};
while let Some(pixels) = buf.get_mut(i..) {
if pixels.is_empty() {
break;
}
@@ -124,6 +121,13 @@ fn packbits_decode<R: Read, const N: usize>(
i += usize::from(repeat);
}
}
if i < expected {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"PackBits RLE decoded fewer pixels than expected",
));
}
}
Ok(())
+321 -59
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,
@@ -254,6 +254,11 @@ pub struct Decoder<R> {
/// Map from the ifd into the `ifd_offsets` ordered list.
seen_ifds: cycles::IfdCycles,
image: Option<Image>,
/// 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,
}
/// All the information needed to read and interpret byte slices from the underlying file, i.e. to
@@ -273,13 +278,37 @@ pub struct IfdDecoder<'lt> {
inner: tag_reader::TagReader<'lt, dyn tag_reader::EntryDecoder + 'lt>,
}
fn rev_hpredict_nsamp(buf: &mut [u8], bit_depth: u8, samples: u16) {
fn one_byte_predict<const N: usize>(buf: &mut [u8]) {
for i in N..buf.len() {
buf[i] = buf[i].wrapping_add(buf[i - N]);
/// Byte-level prefix sum with compile-time stride. Each element is the wrapping sum of itself and
/// the element N positions earlier. For N >= 2, the N interleaved channels are independent streams.
///
/// For N=3 (RGB) and N=4 (RGBA), we use an explicit `[u8; N]` accumulator so LLVM sees each
/// channel as an independent scalar chain and can unroll effectively. Without this, LLVM emits a
/// scalar byte-at-a-time loop for N=3 because 3 doesn't map cleanly to SIMD widths.
fn one_byte_predict<const N: usize>(buf: &mut [u8]) {
if N == 0 || buf.len() <= N {
return;
}
// Copy the first N bytes as the initial accumulator state.
let mut acc = [0u8; N];
acc.copy_from_slice(&buf[..N]);
// Process N bytes per iteration. Each channel's prefix sum is independent.
let chunks = buf[N..].as_chunks_mut::<N>();
for chunk in &mut chunks.0.iter_mut() {
for c in 0..N {
acc[c] = chunk[c].wrapping_add(acc[c]);
chunk[c] = acc[c];
}
}
// Handle trailing bytes (when buf.len() - N is not a multiple of N).
for (c, byte) in chunks.1.iter_mut().enumerate() {
*byte = byte.wrapping_add(acc[c]);
}
}
fn rev_hpredict_nsamp(buf: &mut [u8], bit_depth: u8, samples: u16) {
fn two_bytes_predict<const N: usize>(buf: &mut [u8]) {
for i in (2 * N..buf.len()).step_by(2) {
let v = u16::from_ne_bytes(buf[i..][..2].try_into().unwrap());
@@ -364,65 +393,145 @@ fn rev_hpredict_nsamp(buf: &mut [u8], bit_depth: u8, samples: u16) {
}
}
fn predict_f32(input: &mut [u8], output: &mut [u8], samples: u16) {
let samples = usize::from(samples);
/// Undo the floating-point predictor: byte-level prefix sum, then de-interleave byte planes back
/// into native-endian samples.
///
/// The TIFF float predictor splits each N-byte sample into N separate byte planes within the row,
/// then delta-encodes each plane. Decoding reverses this: prefix-sum to undo the deltas, then
/// transpose the N planes back into packed samples.
fn predict_f32(input: &mut [u8], output: &mut [[u8; 4]], samples: u16) {
one_byte_predict_generic(input, usize::from(samples));
deinterleave_f32_planes(input, output);
}
for i in samples..input.len() {
input[i] = input[i].wrapping_add(input[i - samples]);
fn predict_f16(input: &mut [u8], output: &mut [[u8; 2]], samples: u16) {
one_byte_predict_generic(input, usize::from(samples));
deinterleave_f16_planes(input, output);
}
fn predict_f64(input: &mut [u8], output: &mut [[u8; 8]], samples: u16) {
one_byte_predict_generic(input, usize::from(samples));
deinterleave_f64_planes(input, output);
}
/// Byte-level prefix sum: `buf[i] += buf[i - stride]` for all `i >= stride`.
///
/// Same operation as `one_byte_predict<N>` but dispatched at runtime. The most common float
/// channel counts (1, 3, 4) get dedicated loops so LLVM can see the independent channel streams
/// and vectorize each one separately.
fn one_byte_predict_generic(buf: &mut [u8], stride: usize) {
match stride {
1 => one_byte_predict::<1>(buf),
2 => one_byte_predict::<2>(buf),
3 => one_byte_predict::<3>(buf),
4 => one_byte_predict::<4>(buf),
_ => {
for i in stride..buf.len() {
buf[i] = buf[i].wrapping_add(buf[i - stride]);
}
}
for (i, chunk) in output.chunks_mut(4).enumerate() {
chunk.copy_from_slice(&u32::to_ne_bytes(u32::from_be_bytes([
input[i],
input[input.len() / 4 + i],
input[input.len() / 4 * 2 + i],
input[input.len() / 4 * 3 + i],
])));
}
}
fn predict_f16(input: &mut [u8], output: &mut [u8], samples: u16) {
let samples = usize::from(samples);
/// Transpose byte-planes back into packed f16/f32/f64 samples with big-endian to native conversion.
///
/// Input layout: `[plane0: n bytes][plane1: n bytes]...[planeP: n bytes]` where each plane holds
/// one byte position of each sample. Output: packed native-endian samples.
///
/// Each function splits the input into equal-sized plane slices, then gathers one byte per plane
/// per sample. Using `from_be_bytes` for endian conversion lets LLVM recognise the byte-swap idiom
/// and emit efficient code (bswap or movbe).
fn deinterleave_f32_planes(input: &[u8], output: &mut [[u8; 4]]) {
let n = output.len();
if n == 0 || input.len() < n * 4 {
return;
}
let (p0, rest) = input.split_at(n);
let (p1, rest) = rest.split_at(n);
let (p2, p3) = rest.split_at(n);
for i in samples..input.len() {
input[i] = input[i].wrapping_add(input[i - samples]);
// Batch 4 samples per iteration. Fixed-size array slicing eliminates bounds checks.
let (out_chunks, out_rest) = output.as_chunks_mut::<4>();
let (p0_chunks, _) = p0.as_chunks::<4>();
let (p1_chunks, _) = p1.as_chunks::<4>();
let (p2_chunks, _) = p2.as_chunks::<4>();
let (p3_chunks, _) = p3.as_chunks::<4>();
for ((((out, b0), b1), b2), b3) in out_chunks
.iter_mut()
.zip(p0_chunks)
.zip(p1_chunks)
.zip(p2_chunks)
.zip(p3_chunks)
{
let s0 = u32::from_be_bytes([b0[0], b1[0], b2[0], b3[0]]).to_ne_bytes();
let s1 = u32::from_be_bytes([b0[1], b1[1], b2[1], b3[1]]).to_ne_bytes();
let s2 = u32::from_be_bytes([b0[2], b1[2], b2[2], b3[2]]).to_ne_bytes();
let s3 = u32::from_be_bytes([b0[3], b1[3], b2[3], b3[3]]).to_ne_bytes();
out[0] = s0;
out[1] = s1;
out[2] = s2;
out[3] = s3;
}
for (i, chunk) in output.chunks_mut(2).enumerate() {
chunk.copy_from_slice(&u16::to_ne_bytes(u16::from_be_bytes([
input[i],
input[input.len() / 2 + i],
])));
// Remaining 0–3 samples.
let base = out_chunks.len() * 4;
for (j, out) in out_rest.iter_mut().enumerate() {
*out = u32::from_be_bytes([p0[base + j], p1[base + j], p2[base + j], p3[base + j]])
.to_ne_bytes();
}
}
fn predict_f64(input: &mut [u8], output: &mut [u8], samples: u16) {
let samples = usize::from(samples);
for i in samples..input.len() {
input[i] = input[i].wrapping_add(input[i - samples]);
fn deinterleave_f16_planes(input: &[u8], output: &mut [[u8; 2]]) {
let n = output.len();
if n == 0 || input.len() < n * 2 {
return;
}
let (p0, p1) = input.split_at(n);
for (i, chunk) in output.chunks_mut(8).enumerate() {
chunk.copy_from_slice(&u64::to_ne_bytes(u64::from_be_bytes([
input[i],
input[input.len() / 8 + i],
input[input.len() / 8 * 2 + i],
input[input.len() / 8 * 3 + i],
input[input.len() / 8 * 4 + i],
input[input.len() / 8 * 5 + i],
input[input.len() / 8 * 6 + i],
input[input.len() / 8 * 7 + i],
])));
for (out, (b0, b1)) in output.iter_mut().zip(p0.iter().zip(p1)) {
*out = u16::from_be_bytes([*b0, *b1]).to_ne_bytes();
}
}
fn deinterleave_f64_planes(input: &[u8], output: &mut [[u8; 8]]) {
let n = output.len();
if n == 0 || input.len() < n * 8 {
return;
}
let (p0, rest) = input.split_at(n);
let (p1, rest) = rest.split_at(n);
let (p2, rest) = rest.split_at(n);
let (p3, rest) = rest.split_at(n);
let (p4, rest) = rest.split_at(n);
let (p5, rest) = rest.split_at(n);
let (p6, p7) = rest.split_at(n);
for (out, (((((((b0, b1), b2), b3), b4), b5), b6), b7)) in output.iter_mut().zip(
p0.iter()
.zip(p1)
.zip(p2)
.zip(p3)
.zip(p4)
.zip(p5)
.zip(p6)
.zip(p7),
) {
*out = u64::from_be_bytes([*b0, *b1, *b2, *b3, *b4, *b5, *b6, *b7]).to_ne_bytes();
}
}
/// Fix byte order and apply the inverse predictor for one row of decoded data.
///
/// `scratch` is a reusable buffer for the floating-point predictor (which needs a working copy of
/// the input). Callers should pass the same buffer across rows to avoid per-row heap allocation.
fn fix_endianness_and_predict(
buf: &mut [u8],
bit_depth: u8,
samples: u16,
byte_order: ByteOrder,
predictor: Predictor,
scratch: &mut Vec<u8>,
) {
match predictor {
Predictor::None => {
@@ -433,17 +542,82 @@ fn fix_endianness_and_predict(
rev_hpredict_nsamp(buf, bit_depth, samples);
}
Predictor::FloatingPoint => {
let mut buffer_copy = buf.to_vec();
// The float predictor reads from scattered byte planes, so it needs a separate input
// buffer.
scratch.resize(buf.len(), 0);
scratch.copy_from_slice(buf);
match bit_depth {
16 => predict_f16(&mut buffer_copy, buf, samples),
32 => predict_f32(&mut buffer_copy, buf, samples),
64 => predict_f64(&mut buffer_copy, buf, samples),
16 => {
let (output, rest) = buf.as_chunks_mut::<2>();
debug_assert!(rest.is_empty(), "f16 row buffer not a multiple of 2 bytes");
predict_f16(scratch, output, samples);
}
32 => {
let (output, rest) = buf.as_chunks_mut::<4>();
debug_assert!(rest.is_empty(), "f32 row buffer not a multiple of 4 bytes");
predict_f32(scratch, output, samples);
}
64 => {
let (output, rest) = buf.as_chunks_mut::<8>();
debug_assert!(rest.is_empty(), "f64 row buffer not a multiple of 8 bytes");
predict_f64(scratch, output, samples);
}
_ => unreachable!("Caller should have validated arguments. Please file a bug."),
}
}
}
}
/// 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,
@@ -537,6 +711,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();
@@ -627,6 +851,8 @@ impl TiffHeader {
},
seen_ifds: cycles::IfdCycles::new(),
image: None,
scratch: Vec::new(),
lenient: false,
}
}
}
@@ -709,16 +935,35 @@ 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>)> {
fn ensure_image_and_reader(
&mut self,
) -> TiffResult<(&mut Image, &mut ValueReader<R>, &mut Vec<u8>)> {
if self.image.is_some() {
return Ok((self.image.as_mut().unwrap(), &mut self.value_reader));
return Ok((
self.image.as_mut().unwrap(),
&mut self.value_reader,
&mut self.scratch,
));
}
let image = Image::from_reader(&mut self.value_reader, &self.directory)?;
Ok((self.image.insert(image), &mut self.value_reader))
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,
))
}
fn ensure_image(&mut self) -> TiffResult<&mut Image> {
@@ -848,6 +1093,8 @@ impl<R: Read + Seek> Decoder<R> {
directory: self.directory.clone(),
current_ifd_pointer: self.current_ifd_pointer,
image: core::mem::take(&mut self.image),
scratch: core::mem::take(&mut self.scratch),
lenient: self.lenient,
}
}
@@ -896,7 +1143,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(())
@@ -989,7 +1240,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(())
@@ -1033,7 +1288,7 @@ impl<R: Read + Seek> Decoder<R> {
/// Utility to create a reader like [`Self::read_entry`], from the current IFD.
///
/// Returns an `Ok(None)` if the tag is not found in the current directory. See
/// [`Self::read_entry´] of which this is a simple wrapper.
/// [`Self::read_entry`] of which this is a simple wrapper.
pub fn read_tag(&mut self, tag: Tag) -> TiffResult<Option<EntryBytesReader<'_, R>>> {
match self.current_ifd().find_entry(tag) {
None => Ok(None),
@@ -1323,10 +1578,10 @@ impl<R: Read + Seek> Decoder<R> {
chunk_index: u32,
layout: &image::ReadoutLayout,
) -> TiffResult<()> {
let (image, value_reader) = self.ensure_image_and_reader()?;
let (image, value_reader, scratch) = 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)?;
image.expand_chunk(value_reader, buffer, layout, chunk_index, scratch)?;
Ok(())
}
@@ -1478,7 +1733,7 @@ impl<R: Read + Seek> Decoder<R> {
slice: TiffCodingUnit,
buffer: &mut [u8],
) -> TiffResult<()> {
let (image, value_reader) = self.ensure_image_and_reader()?;
let (image, value_reader, scratch) = self.ensure_image_and_reader()?;
let (width, height) = image.chunk_data_dimensions(slice.0)?;
let readout = image.readout_for_size(width, height)?;
@@ -1505,7 +1760,13 @@ impl<R: Read + Seek> Decoder<R> {
let chunk = slice.0 + idx as u32 * readout.chunks_per_plane;
let offset = image.chunk_offsets[chunk as usize];
value_reader.reader.goto_offset(offset)?;
image.expand_chunk(value_reader, &mut buffer[plane_offset..], readout, chunk)?;
image.expand_chunk(
value_reader,
&mut buffer[plane_offset..],
readout,
chunk,
scratch,
)?;
}
Ok(())
@@ -1676,7 +1937,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) = self.ensure_image_and_reader()?;
let (image, value_reader, scratch) = self.ensure_image_and_reader()?;
let readout = image.readout_for_image()?;
let ref layout @ image::PlaneLayout {
@@ -1709,6 +1970,7 @@ impl<R: Read + Seek> Decoder<R> {
&mut buffer[plane_offset..][buffer_offset..],
readout,
chunk,
scratch,
)?;
}
}
+29 -8
View File
@@ -141,14 +141,32 @@ pub struct LZWReader<R: Read> {
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(
let mut buffered = BufReader::with_capacity(
(32 * 1024).min(compressed_length),
reader.take(u64::try_from(compressed_length).unwrap()),
),
);
// `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 configuration = if old_style {
weezl::decode::Configuration::new(weezl::BitOrder::Lsb, 8)
} else {
weezl::decode::Configuration::with_tiff_size_switch(weezl::BitOrder::Msb, 8)
}
.with_yield_on_full_buffer(true);
Self {
reader: buffered,
decoder: configuration.build(),
}
}
@@ -448,7 +466,7 @@ impl Group3Reader {
) -> Option<u16> {
let mut sum = 0u16;
while let Some(n) = decoder(reader) {
sum += n;
sum = sum.checked_add(n)?;
if n < 64 {
return Some(sum);
}
@@ -475,7 +493,10 @@ impl Read for Group3Reader {
};
match run {
Some(len) => {
a0 += len;
a0 = match a0.checked_add(len) {
Some(v) => v,
None => break, // overflow = corrupt data
};
transitions.push(a0);
is_white = !is_white;
}
+1 -1
View File
@@ -171,7 +171,7 @@ mod tests {
dir.get(Tag::Unknown(1))
.expect("tag 1 should be present after this chain")
.count(),
u16::MAX.into()
u64::from(u16::MAX)
);
}
+7 -8
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,
@@ -614,26 +617,22 @@ impl ByteOrder {
return;
}
// FIXME: at MSRV 1.89 or higher use `slice::as_chunks_mut`.
match cls {
EndianBytes::One => {
// No change needed
}
EndianBytes::Two => {
for chunk in buffer.chunks_exact_mut(2) {
let chunk: &mut [u8; 2] = chunk.try_into().unwrap();
for chunk in buffer.as_chunks_mut::<2>().0 {
*chunk = u16::from_be_bytes(*chunk).to_le_bytes();
}
}
EndianBytes::Four => {
for chunk in buffer.chunks_exact_mut(4) {
let chunk: &mut [u8; 4] = chunk.try_into().unwrap();
for chunk in buffer.as_chunks_mut::<4>().0 {
*chunk = u32::from_be_bytes(*chunk).to_le_bytes();
}
}
EndianBytes::Eight => {
for chunk in buffer.chunks_exact_mut(8) {
let chunk: &mut [u8; 8] = chunk.try_into().unwrap();
for chunk in buffer.as_chunks_mut::<8>().0 {
*chunk = u64::from_be_bytes(*chunk).to_le_bytes();
}
}
+377 -11
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,78 @@ 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_tiled_planar_border_u16() {
// A planar (separate) tiled image whose dimensions are not multiples of the tile size.
// The tile grid repeats for every plane, but the chunk (tile) index counts all planes, so
// the border padding of the second plane used to be looked up with the wrong tile index.
// The bottom-right tile of plane 1 then claimed a full tile height, and the decoder ran
// past the end of the output buffer.
//
// Note that `read_image` only decodes the first plane, so this has to go through
// `read_image_to_buffer` to reach the tiles that were mis-sized.
let path = PathBuf::from(TEST_IMAGE_DIR).join("tiled-planar-2c-16b.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(), (30, 20));
assert_eq!(
decoder.colortype().unwrap(),
ColorType::Multiband {
bit_depth: 16,
num_samples: 2
}
);
let mut buffer = DecodingSampleBuffer::U16(Vec::new());
decoder.read_image_to_buffer(&mut buffer).unwrap();
let DecodingSampleBuffer::U16(data) = buffer else {
panic!("Wrong bit depth")
};
assert_eq!(data.len(), 2 * 20 * 30);
let mut expected = Vec::with_capacity(2 * 20 * 30);
for plane in 0..2u16 {
for y in 0..20u16 {
for x in 0..30u16 {
expected.push(1000 * plane + 10 * y + x);
}
}
}
assert_eq!(data, expected);
}
#[test]
fn test_inner_access() {
let path = PathBuf::from(TEST_IMAGE_DIR).join("tiled-rect-rgb-u8.tif");
@@ -825,7 +899,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);
@@ -865,22 +939,22 @@ fn test_image_bytes(
}
fn byte_order_u16(bytes: &mut [u8]) {
for chunk in bytes.chunks_exact_mut(2) {
let n = u16::from_ne_bytes(chunk.try_into().unwrap());
for chunk in bytes.as_chunks_mut::<2>().0 {
let n = u16::from_ne_bytes(*chunk);
chunk.copy_from_slice(&n.to_le_bytes());
}
}
fn byte_order_u32(bytes: &mut [u8]) {
for chunk in bytes.chunks_exact_mut(4) {
let n = u32::from_ne_bytes(chunk.try_into().unwrap());
for chunk in bytes.as_chunks_mut::<4>().0 {
let n = u32::from_ne_bytes(*chunk);
chunk.copy_from_slice(&n.to_le_bytes());
}
}
fn byte_order_u64(bytes: &mut [u8]) {
for chunk in bytes.chunks_exact_mut(8) {
let n = u64::from_ne_bytes(chunk.try_into().unwrap());
for chunk in bytes.as_chunks_mut::<8>().0 {
let n = u64::from_ne_bytes(*chunk);
chunk.copy_from_slice(&n.to_le_bytes());
}
}
@@ -975,7 +1049,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 +1131,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.
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}");
}
}