Compare commits

...
80 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
A. Molzer 740c5cdeea Transfer decoder to new reader 2026-04-04 15:45:31 +02:00
Aurelia Molzer 86ac5b601e Merge pull request #375 from image-rs/image-as-option
Make decoder's `Image` an option
2026-04-04 11:57:46 +00:00
A. Molzer 97d357d446 Prepare ImageDecoder split, avoid as_mut|unwrap 2026-04-04 10:20:10 +02:00
A. Molzer 61867b46f9 Make decoder's Image an option
Instead all methods with mutable self will initialize it on-demand.
There are only few methods that take `&self` and can not do that. For
those that have no error/option return we panic but both of these could
panic previously already. This should be fine by documentation alone. In
particular, the image size can be queried safely.
2026-04-04 10:20:08 +02:00
Aurelia Molzer 0f1f075abb Merge pull request #372 from image-rs/deprecate-and-hide-tag-reading
Deprecate and hide direct tag reading
2026-04-04 08:13:45 +00:00
Aurelia Molzer 5576417abe Merge pull request #374 from image-rs/value-read-provider
Add a `Read`-er for entry values
2026-03-31 16:47:14 +00:00
A. Molzer 52b9cc7a19 Rename EntryBytesReader module 2026-03-31 18:35:19 +02:00
A. Molzer 006f0b0fa4 Directly implement Seek::stream_position 2026-03-31 18:34:16 +02:00
A. Molzer 5b8a2832c3 Add design note 2026-03-31 18:34:15 +02:00
A. Molzer f93a6380e6 Extra utils for reading tags 2026-03-31 18:34:14 +02:00
A. Molzer 8a1b65fe68 Expand testing 2026-03-31 14:01:39 +02:00
A. Molzer a2996ab34e Basic test for EntryByteReader 2026-03-31 09:16:33 +02:00
A. Molzer f890d22657 Add EntryBytesReader: impl Read + Seek 2026-03-31 09:16:33 +02:00
A. Molzer 6b04bff6b2 Relax Decoder, new interface documentation
The bounds `Read + Seek` are not representational so we may remove them
from the struct itself. This also allows us to use more precise bounds
for direct access to the inner stream, especially were that does not
seek.

Groups operations according to their use case.
2026-03-31 09:05:43 +02:00
A. Molzer bd0dccef3a Deprecate Decoder::*tag methods
Funnel users towards the `IfdDecoder` instead. That type does not have
a generic over the underlying reader. It also works with a directory of
your own choosing. So the duplicates that should go are those directly
on the decoder.
2026-03-31 05:47:04 +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
34 changed files with 2931 additions and 409 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/ /target/
**/*.rs.bk **/*.rs.bk
Cargo.lock Cargo.lock
tests/benches/corpus/
.idea
.agentbridge
+7
View File
@@ -1,5 +1,12 @@
# Version 0.12.0 (unreleased) # 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: Additions:
- Added the `TiffHeader` type. It can be used to create a `Decoder` in a state - 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 before the first IFD and allows opening files at an arbitrary first IFD
+14 -4
View File
@@ -1,8 +1,7 @@
[package] [package]
name = "tiff" name = "ndbioimage-tiff"
version = "0.11.3" version = "0.11.3"
edition = "2021" edition = "2021"
resolver = "2"
# note: when changed, also update test runner in `.github/workflows/rust.yml` # note: when changed, also update test runner in `.github/workflows/rust.yml`
rust-version = "1.88.0" rust-version = "1.88.0"
@@ -16,6 +15,9 @@ categories = ["multimedia::images", "multimedia::encoding"]
exclude = ["tests/images/*", "tests/fuzz_images/*", "tests/**/*.tif"] exclude = ["tests/images/*", "tests/fuzz_images/*", "tests/**/*.tif"]
[lib]
name = "tiff"
[dependencies] [dependencies]
half = { version = "2.4.1" } half = { version = "2.4.1" }
quick-error = "2.0.1" quick-error = "2.0.1"
@@ -23,8 +25,9 @@ quick-error = "2.0.1"
# Rename to avoid confusion with the feature name. # Rename to avoid confusion with the feature name.
fax34 = { package = "fax", version = "0.2.6", optional = true } fax34 = { package = "fax", version = "0.2.6", optional = true }
flate2 = { version = "1.0.20", optional = true } flate2 = { version = "1.0.20", optional = true }
weezl = { version = "0.1.10", optional = true } weezl = { version = "0.2.1", optional = true }
zstd = { version = "0.13", 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 } zune-jpeg = { version = "0.5.1", optional = true }
image-webp = {version = "0.2.4", optional = true } image-webp = {version = "0.2.4", optional = true }
@@ -45,6 +48,9 @@ jpeg = ["dep:zune-jpeg"]
lzw = ["dep:weezl"] lzw = ["dep:weezl"]
webp = ["dep:image-webp"] webp = ["dep:image-webp"]
zstd = ["dep:zstd"] 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]] [[bench]]
name = "lzw" name = "lzw"
@@ -54,6 +60,10 @@ harness = false
name = "encode" name = "encode"
harness = false harness = false
[[bench]]
name = "decode"
harness = false
[[test]] [[test]]
name = "decode_libtiffpic" name = "decode_libtiffpic"
+3 -1
View File
@@ -5,6 +5,8 @@
TIFF decoding and encoding library in pure Rust 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 ## Supported
### Features ### 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 After installing it with `cargo install cargo-fuzz` on a nightly rustc, the
fuzzing harness can be run with recommended settings using 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]) { fn read_image(image: &[u8]) {
let image = std::io::Cursor::new(image); let image = std::io::Cursor::new(image);
let decoder = Decoder::open(black_box(image)); let decoder = Decoder::open(black_box(image));
let mut reader = decoder.unwrap(); let mut reader = decoder.unwrap();
while reader.more_images() { while reader.more_images() {
reader.next_directory().unwrap();
reader.read_image().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>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let Some(image) = std::env::args_os().nth(1) else { let Some(image) = std::env::args_os().nth(1) else {
eprintln!("Usage: decode FILE"); eprintln!("Usage: decode FILE [--lenient]");
return Ok(()); return Ok(());
}; };
let lenient = std::env::args().nth(2).as_deref() == Some("--lenient");
let file = std::fs::File::open(image)?; let file = std::fs::File::open(image)?;
let io = std::io::BufReader::new(file); 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![]); let mut data = DecodingSampleBuffer::I8(vec![]);
for i in 0u32.. { for i in 0u32.. {
if !reader.more_images() {
break;
}
reader.next_directory()?;
let colortype = reader.colortype()?; let colortype = reader.colortype()?;
let dimensions = reader.dimensions()?; let dimensions = reader.dimensions()?;
let layout = reader.read_image_to_buffer(&mut data)?; let layout = reader.read_image_to_buffer(&mut data)?;
debug_planes(i, &mut data, &layout, &colortype, dimensions); debug_planes(i, &mut data, &layout, &colortype, dimensions);
if !reader.more_images() {
break;
}
reader.next_image()?
} }
Ok(()) 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 = ["."] members = ["."]
[[bin]] [[bin]]
name = "decode_image" name = "decode_comprehensive"
path = "fuzz_targets/decode_image.rs" path = "fuzz_targets/decode_comprehensive.rs"
test = false test = false
doc = 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"
+1 -1
View File
@@ -11,7 +11,7 @@ use std::collections::HashMap;
/// ///
/// Since we only consider one child, we run union find to assign each pointer to a known chain. /// Since we only consider one child, we run union find to assign each pointer to a known chain.
/// Then, when we insert a new edge we check if they belong to the same component. /// Then, when we insert a new edge we check if they belong to the same component.
#[derive(Default, Debug)] #[derive(Clone, Default, Debug)]
pub struct IfdCycles { pub struct IfdCycles {
/// The root of each component union. /// The root of each component union.
component_union: HashMap<ComponentId, ComponentId>, component_union: HashMap<ComponentId, ComponentId>,
+362
View File
@@ -0,0 +1,362 @@
use std::io::{self, Cursor, Read, Seek};
use crate::{
decoder::{ifd::Entry, stream::EndianReader, ValueReader},
tags::{ByteOrder, IfdPointer, Type},
TiffResult,
};
pub struct EntryBytesReader<'lt, R> {
inner: &'lt mut EndianReader<R>,
variant: Variant,
}
const OFFSET_BUFFER_LEN: usize = 8;
enum Variant {
Immediate {
/// An in-value reader if the offset field contains the data. Note that we rotate it to the
/// back of this buffer, not the front, so that `Cursor` already contains the right end.
/// We do not let you seek back beyond our start.
data: Cursor<[u8; OFFSET_BUFFER_LEN]>,
value_bytes: u8,
},
InFile {
// the length relative to the base so we can seek relative without querying the position.
value_bytes: u64,
remainder: u64,
base: u64,
},
}
impl<'lt, R> EntryBytesReader<'lt, R> {
pub(super) fn from_entry(inner: &'lt mut ValueReader<R>, entry: Entry) -> TiffResult<Self>
where
R: Read + Seek,
{
let offset: [_; OFFSET_BUFFER_LEN] = *entry.offset_raw();
let limit = if inner.bigtiff { 8 } else { 4 };
assert!(limit as usize <= OFFSET_BUFFER_LEN);
let value_bytes = entry.field_type().value_bytes(entry.count())?;
if value_bytes <= limit {
// We move the data to the back of the array whereas it is in the front right now.
let mut value = offset;
value.rotate_left(value_bytes as usize);
let mut cursor = Cursor::new(value);
cursor.set_position(OFFSET_BUFFER_LEN as u64 - value_bytes);
Ok(EntryBytesReader {
inner: &mut inner.reader,
variant: Variant::Immediate {
data: cursor,
value_bytes: value_bytes as u8,
},
})
} else {
// remainder > limit >= 0 so this is an unreachable panic.
let pointer = if inner.bigtiff {
let mut offset = offset;
inner
.reader
.byte_order
.convert(Type::IFD8, &mut offset[..], ByteOrder::native());
IfdPointer(u64::from_ne_bytes(offset))
} else {
let mut offset = *offset.first_chunk::<4>().unwrap();
inner
.reader
.byte_order
.convert(Type::IFD, &mut offset[..], ByteOrder::native());
let offset = u32::from_ne_bytes(offset);
IfdPointer(u64::from(offset))
};
inner.reader.goto_offset(pointer.0)?;
Ok(EntryBytesReader {
inner: &mut inner.reader,
variant: Variant::InFile {
value_bytes,
remainder: value_bytes,
base: pointer.0,
},
})
}
}
}
impl<R: Read> Read for EntryBytesReader<'_, R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match &mut self.variant {
Variant::Immediate { data, .. } => data.read(buf),
Variant::InFile { remainder, .. } => {
let bound = usize::try_from(*remainder).unwrap_or(usize::MAX);
let avail = buf.len().min(bound);
let read = self.inner.inner().read(&mut buf[..avail])?;
// On a well-behaved `Read`: read <= avail <= bound <=(mathematical) remainder
let consume = read as u64;
*remainder -= consume;
Ok(read)
}
}
}
fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()> {
match &mut self.variant {
Variant::Immediate { data, .. } => data.read_exact(buf),
Variant::InFile { remainder, .. } => {
let bound = usize::try_from(*remainder).unwrap_or(usize::MAX);
// Fail early if we would be sure to over-read.
if buf.len() > bound {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"Tiff value shorter than the requested buffer size",
));
}
// since buf.len() <= bound <=(mathematical) remainder
let consume = buf.len() as u64;
self.inner.inner().read_exact(buf)?;
*remainder -= consume;
Ok(())
}
}
}
}
impl<R: Read + Seek> Seek for EntryBytesReader<'_, R> {
fn seek(&mut self, offset: io::SeekFrom) -> io::Result<u64> {
let (value_bytes, remainder) = match &self.variant {
Variant::Immediate { data, value_bytes } => {
let remainder = data.get_ref().len() as u64 - data.position();
(*value_bytes as u64, remainder)
}
&Variant::InFile {
value_bytes,
remainder,
base: _,
} => (value_bytes, remainder),
};
// Normalize to an offset from the start
let offset_from_start = match offset {
io::SeekFrom::Start(n) => Some(n),
io::SeekFrom::Current(n) => {
let front = value_bytes - remainder;
front.checked_add_signed(n)
}
io::SeekFrom::End(n) => value_bytes.checked_add_signed(n),
};
let Some((offset, remainder)) = offset_from_start.and_then(|n| {
let remainder = value_bytes.checked_sub(n)?;
Some((n, remainder))
}) else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"seek out of bounds of entry value",
));
};
match &mut self.variant {
Variant::Immediate { data, .. } => {
let start = OFFSET_BUFFER_LEN as u64 - value_bytes;
data.set_position(start + offset);
Ok(offset)
}
&mut Variant::InFile { base, .. } => {
// We checked this against overflow on construction. Ignore the offset we get.
self.inner
.inner()
.seek(io::SeekFrom::Start(base + offset))?;
self.variant = Variant::InFile {
value_bytes,
remainder,
base,
};
Ok(offset)
}
}
}
fn stream_position(&mut self) -> io::Result<u64> {
Ok(match &self.variant {
Variant::Immediate { data, value_bytes } => {
// Recall the value is at the end of the buffer.
let from_end = OFFSET_BUFFER_LEN as u64 - data.position();
u64::from(*value_bytes) - from_end
}
// Note: we might think about using the inner `stream_position` here. However that
// exposes something interesting: If the internal stream is modified concurrently from
// another instance (e.g. a shared `File`) then we may lose track of it anyways.
// Especially the `Read` implementation is not bounded on `Seek` so it could not
// acquire the position. And it should not since that is just a different form of race
// where we risk the position being modified by a different thread just as well.
//
// So in the interest of least surprise we just assume that we can track the position
// accurately here and that the inner reader uses for instance `read_at` consistently.
// We own the value (or mutable reference) it after all.
//
// Also this one MUST be compatible with `seek(SeekFrom::Current(0))` as per trait
// defintion—so at least keep those consistent if changing anything.
&Variant::InFile {
remainder,
value_bytes,
..
} => value_bytes - remainder,
})
}
}
#[cfg(test)]
mod tests {
use crate::{decoder::Decoder, encoder, tags};
use std::io::{Read as _, Seek as _, SeekFrom};
#[test]
fn can_seek_tag_bytes() -> Result<(), crate::error::TiffError> {
let file = std::fs::File::open(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/images/int8_rgb.tif"
))
.unwrap();
let mut decoder = Decoder::open(file).unwrap();
decoder.next_directory().unwrap();
let mut buffer = crate::tags::ValueBuffer::default();
decoder
.current_ifd()
.find_tag_buf(tags::Tag::StripByteCounts, &mut buffer)
.expect("some strip byte count is present");
let bytes_of_value = buffer.as_bytes().len() as u64;
let mut reader = decoder
.read_tag(tags::Tag::StripByteCounts)?
.expect("some strip byte count is present");
let mut data = vec![];
reader.read_to_end(&mut data)?;
// `ValueBuffer` also stores its data in the file endianess until converted.
assert_eq!(reader.stream_position()?, bytes_of_value);
assert_eq!(buffer.as_bytes(), data);
reader.seek(SeekFrom::Start(0))?;
assert_eq!(reader.stream_position()?, 0);
reader.read_to_end(&mut data)?;
assert_eq!(reader.stream_position()?, bytes_of_value);
let (first, second) = data.split_at(bytes_of_value as usize);
assert_eq!(first, second);
Ok(())
}
#[test]
#[allow(clippy::seek_from_current)] // We want to test that functionality.
fn seek_from_end() -> Result<(), crate::error::TiffError> {
const ARTIST: &str = "99 little bugs in the code";
let data = {
let mut data = std::io::Cursor::new(vec![]);
let mut encoder = encoder::TiffEncoder::new(&mut data)?;
let mut dir = encoder.image_directory()?;
dir.write_tag(tags::Tag::Artist, ARTIST)?;
dir.finish()?;
data.into_inner()
};
let file = std::io::Cursor::new(data);
let mut decoder = Decoder::open(file).unwrap();
decoder.next_directory().unwrap();
let mut reader = decoder
.read_tag(tags::Tag::Artist)?
.expect("the artist is present?");
let mut value = vec![];
reader.read_to_end(&mut value)?;
let (&nul, value) = value.split_last().unwrap();
assert_eq!(value, ARTIST.as_bytes());
assert_eq!(nul, b'\0');
assert_eq!(reader.seek(SeekFrom::End(0))?, ARTIST.len() as u64 + 1);
assert!(reader.seek(SeekFrom::End(1)).is_err());
assert_eq!(reader.stream_position()?, ARTIST.len() as u64 + 1);
// Check relative seeks by comparing against the "code"
let code_offset = reader.seek(SeekFrom::End(-5))?;
let mut is_code = [0u8; 4];
reader.read_exact(&mut is_code)?;
assert_eq!(is_code, *value.last_chunk::<4>().unwrap());
assert_eq!(reader.seek(SeekFrom::Start(0))?, 0);
assert_eq!(reader.stream_position()?, 0);
assert_eq!(reader.seek(SeekFrom::Current(0))?, 0);
assert_eq!(reader.stream_position()?, 0);
assert!(reader.seek(SeekFrom::Current(-1)).is_err());
assert_eq!(reader.stream_position()?, 0);
reader.seek(SeekFrom::Start(code_offset))?;
reader.read_exact(&mut is_code)?;
assert_eq!(reader.stream_position()?, ARTIST.len() as u64);
assert_eq!(is_code, *value.last_chunk::<4>().unwrap());
Ok(())
}
/// Check that the reader from an `offset` field also works.
#[test]
fn read_from_offset() -> Result<(), crate::error::TiffError> {
let data = {
let mut data = std::io::Cursor::new(vec![]);
let mut encoder = encoder::TiffEncoder::new(&mut data)?;
let mut dir = encoder.image_directory()?;
dir.write_tag(
tags::Tag::PlanarConfiguration,
tags::PlanarConfiguration::Planar,
)?;
dir.finish()?;
data.into_inner()
};
let file = std::io::Cursor::new(data);
let mut decoder = Decoder::open(file).unwrap();
decoder.next_directory().unwrap();
let bo = decoder.byte_order();
let mut reader = decoder
.read_tag(tags::Tag::PlanarConfiguration)?
.expect("some artist is present");
let mut buf = [0; 2];
reader.read_exact(&mut buf)?;
bo.convert(tags::Type::SHORT, &mut buf, tags::ByteOrder::BigEndian);
assert_eq!(buf, [0, 2]);
// Check relative seeks, this item is only two bytes long.
assert!(reader.seek(SeekFrom::End(-3)).is_err());
Ok(())
}
}
+71 -41
View File
@@ -363,7 +363,7 @@ impl ::std::fmt::Debug for Entry {
fn fmt(&self, fmt: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> { fn fmt(&self, fmt: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> {
fmt.write_str(&format!( fmt.write_str(&format!(
"Entry {{ type_: {:?}, count: {:?}, offset: {:?} }}", "Entry {{ type_: {:?}, count: {:?}, offset: {:?} }}",
self.type_, self.count, &self.offset self.type_, self.count, self.offset
)) ))
} }
} }
@@ -393,7 +393,7 @@ impl Entry {
self.count self.count
} }
pub(crate) fn offset(&self) -> &[u8] { pub(crate) fn offset_raw(&self) -> &[u8; 8] {
&self.offset &self.offset
} }
@@ -583,81 +583,93 @@ impl Entry {
match self.type_ { match self.type_ {
Type::BYTE | Type::UNDEFINED => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.iter().copied().map(Byte)) v.extend(bytes.iter().copied().map(Byte))
}) })
} }
Type::SBYTE => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.iter().copied().map(|v| SignedByte(v as i8))) v.extend(bytes.iter().copied().map(|v| SignedByte(v as i8)))
}) })
} }
Type::SHORT => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(2) .as_chunks::<2>()
.map(|ch| Short(u16::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| Short(u16::from_ne_bytes(ch))),
) )
}) })
} }
Type::SSHORT => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(2) .as_chunks::<2>()
.map(|ch| SignedShort(i16::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| SignedShort(i16::from_ne_bytes(ch))),
) )
}) })
} }
Type::LONG => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(4) .as_chunks::<4>()
.map(|ch| Unsigned(u32::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| Unsigned(u32::from_ne_bytes(ch))),
) )
}) })
} }
Type::SLONG => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(4) .as_chunks::<4>()
.map(|ch| Signed(i32::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| Signed(i32::from_ne_bytes(ch))),
) )
}) })
} }
Type::FLOAT => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(4) .as_chunks::<4>()
.map(|ch| Float(f32::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| Float(f32::from_ne_bytes(ch))),
) )
}) })
} }
Type::DOUBLE => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(8) .as_chunks::<8>()
.map(|ch| Double(f64::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| Double(f64::from_ne_bytes(ch))),
) )
}) })
} }
Type::RATIONAL => { 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| { 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( Rational(
u32::from_ne_bytes(ch[..4].try_into().unwrap()), u32::from_ne_bytes(ch[..4].try_into().unwrap()),
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 => { 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| { 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( SRational(
i32::from_ne_bytes(ch[..4].try_into().unwrap()), i32::from_ne_bytes(ch[..4].try_into().unwrap()),
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 => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(8) .as_chunks::<8>()
.map(|ch| UnsignedBig(u64::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| UnsignedBig(u64::from_ne_bytes(ch))),
) )
}) })
} }
Type::SLONG8 => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(8) .as_chunks::<8>()
.map(|ch| SignedBig(i64::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| SignedBig(i64::from_ne_bytes(ch))),
) )
}) })
} }
Type::IFD => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(4) .as_chunks::<4>()
.map(|ch| Ifd(u32::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| Ifd(u32::from_ne_bytes(ch))),
) )
}) })
} }
Type::IFD8 => { 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| { self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend( v.extend(
bytes bytes
.chunks_exact(8) .as_chunks::<8>()
.map(|ch| IfdBig(u64::from_ne_bytes(ch.try_into().unwrap()))), .0
.iter()
.map(|&ch| IfdBig(u64::from_ne_bytes(ch))),
) )
}) })
} }
Type::ASCII => { Type::ASCII => {
let n = usize::try_from(self.count)?; let n = usize::try_from(self.count)?;
if n > limits.decoding_buffer_size { if n > limits.ifd_value_size {
return Err(dbg!(TiffError::LimitsExceeded)); return Err(TiffError::LimitsExceeded);
} }
let mut out = vec![0; n]; let mut out = vec![0; n];
@@ -846,10 +866,20 @@ impl Entry {
fn vec_with_capacity( fn vec_with_capacity(
value_count: u64, value_count: u64,
type_: Type,
limits: &super::Limits, limits: &super::Limits,
) -> Result<Vec<Value>, TiffError> { ) -> 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>() { if value_count > limits.decoding_buffer_size / mem::size_of::<Value>() {
return Err(TiffError::LimitsExceeded); return Err(TiffError::LimitsExceeded);
} }
+162 -68
View File
@@ -36,6 +36,9 @@ impl TileAttributes {
pub fn tiles_down(&self) -> usize { pub fn tiles_down(&self) -> usize {
self.image_height.div_ceil(self.tile_length) 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 { fn padding_right(&self) -> usize {
(self.tile_width - self.image_width % self.tile_width) % self.tile_width (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 /// run on samples directly and thus skip the outer byte order entirely, except potentially
/// they have interior byte order treatment. /// they have interior byte order treatment.
pub decompression_to_host_endian: bool, pub decompression_to_host_endian: bool,
pub lenient: bool,
} }
/// Describes how to read a tile-aligned portion of the image. /// Describes how to read a tile-aligned portion of the image.
@@ -155,35 +159,10 @@ pub(crate) struct ReadoutLayout {
} }
impl Image { impl Image {
pub(crate) fn no_image() -> Image {
Image {
width: 0,
height: 0,
bits_per_sample: 1,
samples: 1,
extra_samples: vec![],
photometric_samples: 1,
sample_format: SampleFormat::Uint,
photometric_interpretation: PhotometricInterpretation::BlackIsZero,
compression_method: CompressionMethod::None,
jpeg_tables: None,
predictor: Predictor::None,
chunk_type: ChunkType::Strip,
planar_config: PlanarConfiguration::Chunky,
strip_decoder: None,
tile_attributes: None,
chunk_offsets: Vec::new(),
chunk_bytes: Vec::new(),
chroma_subsampling: (2, 2),
fill_order: 1,
color_map: None,
decompression_to_host_endian: false,
}
}
pub fn from_reader<R: Read + Seek>( pub fn from_reader<R: Read + Seek>(
decoder: &mut ValueReader<R>, decoder: &mut ValueReader<R>,
ifd: &Directory, ifd: &Directory,
lenient: bool,
) -> TiffResult<Image> { ) -> TiffResult<Image> {
let mut tag_reader = TagReader { decoder, ifd }; let mut tag_reader = TagReader { decoder, ifd };
@@ -200,7 +179,7 @@ impl Image {
.map(Value::into_u16) .map(Value::into_u16)
.transpose()? .transpose()?
.and_then(PhotometricInterpretation::from_u16) .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. // 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. // If they are not explicitly specified, those tags are reset to their default values and not carried from previous images.
@@ -480,8 +459,9 @@ impl Image {
match tag_reader.find_tag(Tag::ColorMap)? { match tag_reader.find_tag(Tag::ColorMap)? {
Some(v) => { Some(v) => {
let map = v.into_u16_vec()?; let map = v.into_u16_vec()?;
let expected_len = 3usize let expected_len = 1usize
.checked_mul(1usize << bits_per_sample) .checked_shl(bits_per_sample as u32)
.and_then(|n| n.checked_mul(3))
.ok_or(TiffError::LimitsExceeded)?; .ok_or(TiffError::LimitsExceeded)?;
if map.len() != expected_len { if map.len() != expected_len {
return Err(TiffError::FormatError( return Err(TiffError::FormatError(
@@ -490,11 +470,11 @@ impl Image {
} }
Some(map) Some(map)
} }
None => { // A missing palette is not an error. The pixel values of an indexed image are just
return Err(TiffError::FormatError( // indices into the palette, and the palette itself is only display information, so
TiffFormatError::RequiredTagNotFound(Tag::ColorMap), // the image still decodes. Callers observe the absence as `None` from
)); // `Decoder::color_map`.
} None => None,
} }
} else { } else {
None None
@@ -525,14 +505,29 @@ impl Image {
compression_method, compression_method,
CompressionMethod::SgiLog | CompressionMethod::SgiLog24 CompressionMethod::SgiLog | CompressionMethod::SgiLog24
), ),
lenient,
}) })
} }
pub(crate) fn check_chunk_type(&self, expected: ChunkType) -> TiffResult<()> {
if expected != self.chunk_type {
return Err(TiffError::UsageError(UsageError::InvalidChunkType(
expected,
self.chunk_type,
)));
}
Ok(())
}
pub(crate) fn colortype(&self) -> TiffResult<ColorType> { pub(crate) fn colortype(&self) -> TiffResult<ColorType> {
let is_alpha_extra_samples = matches!( let is_alpha_extra_samples = match self.extra_samples.as_slice().first() {
self.extra_samples.as_slice(), Some(ExtraSamples::AssociatedAlpha) | Some(ExtraSamples::UnassociatedAlpha) => true,
[ExtraSamples::AssociatedAlpha, ..] | [ExtraSamples::UnassociatedAlpha, ..] // 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 { match self.photometric_interpretation {
PhotometricInterpretation::RGB => match self.photometric_samples { PhotometricInterpretation::RGB => match self.photometric_samples {
@@ -605,20 +600,30 @@ impl Image {
), ),
)), )),
}, },
// Unsupported due to inherently heterogeneous sample types. This is represented as: // 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*
// struct TiffLab /* Interpretation 8* { // (photometric=9) uses unsigned samples with the neutral at the middle of
// pub L: u8, // SampleFormat::Uint // the range. `SampleFormat` applies uniformly to all samples, so it can't
// pub a: i8, // SampleFormat::Int // describe the L=unsigned + a/b=signed mix; instead we take a/b as signed
// pub b: i8, // SampleFormat::Int // 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
PhotometricInterpretation::CIELab => Err(TiffError::UnsupportedError( // 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( TiffUnsupportedError::InterpretationWithBits(
PhotometricInterpretation::CIELab, PhotometricInterpretation::CIELab,
vec![self.bits_per_sample; self.samples as usize], vec![self.bits_per_sample; self.samples as usize],
), ),
)), )),
},
PhotometricInterpretation::CIELogL => match self.samples { PhotometricInterpretation::CIELogL => match self.samples {
1 if matches!(self.bits_per_sample, 32) 1 if matches!(self.bits_per_sample, 32)
&& matches!(self.sample_format, SampleFormat::IEEEFP) => && matches!(self.sample_format, SampleFormat::IEEEFP) =>
@@ -674,7 +679,11 @@ impl Image {
), ),
)), )),
PhotometricInterpretation::RGBPalette => Ok(ColorType::Palette(self.bits_per_sample)), PhotometricInterpretation::RGBPalette => Ok(ColorType::Palette(self.bits_per_sample)),
PhotometricInterpretation::TransparencyMask => Err(TiffError::UnsupportedError( // TransparencyMask is a single-channel mask, treat as grayscale.
PhotometricInterpretation::TransparencyMask => {
Ok(ColorType::Gray(self.bits_per_sample))
}
PhotometricInterpretation::Unknown(_) => Err(TiffError::UnsupportedError(
TiffUnsupportedError::InterpretationWithBits( TiffUnsupportedError::InterpretationWithBits(
self.photometric_interpretation, self.photometric_interpretation,
vec![self.bits_per_sample; self.samples as usize], vec![self.bits_per_sample; self.samples as usize],
@@ -726,6 +735,8 @@ impl Image {
)), )),
#[cfg(feature = "zstd")] #[cfg(feature = "zstd")]
CompressionMethod::ZSTD => Box::new(zstd::Decoder::new(reader)?), 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)), CompressionMethod::PackBits => Box::new(PackBitsReader::new(reader, compressed_length)),
#[cfg(feature = "deflate")] #[cfg(feature = "deflate")]
CompressionMethod::Deflate | CompressionMethod::OldDeflate => { CompressionMethod::Deflate | CompressionMethod::OldDeflate => {
@@ -1008,7 +1019,15 @@ impl Image {
} }
ChunkType::Tile => { ChunkType::Tile => {
let tile_attrs = self.tile_attributes.as_ref().unwrap(); 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_width = tile_attrs.tile_width - padding_right;
let tile_length = tile_attrs.tile_length - padding_down; let tile_length = tile_attrs.tile_length - padding_down;
@@ -1024,6 +1043,7 @@ impl Image {
buf: &mut [u8], buf: &mut [u8],
layout: &ReadoutLayout, layout: &ReadoutLayout,
chunk_index: u32, chunk_index: u32,
scratch: &mut Vec<u8>,
) -> TiffResult<()> { ) -> TiffResult<()> {
let ValueReader { let ValueReader {
reader, reader,
@@ -1049,6 +1069,7 @@ impl Image {
| ColorType::YCbCr(n) | ColorType::YCbCr(n)
| ColorType::Gray(n) | ColorType::Gray(n)
| ColorType::Palette(n) | ColorType::Palette(n)
| ColorType::Lab(n)
| ColorType::Multiband { | ColorType::Multiband {
bit_depth: n, bit_depth: n,
num_samples: _, num_samples: _,
@@ -1060,6 +1081,7 @@ impl Image {
| ColorType::YCbCr(n) | ColorType::YCbCr(n)
| ColorType::Gray(n) | ColorType::Gray(n)
| ColorType::Palette(n) | ColorType::Palette(n)
| ColorType::Lab(n)
| ColorType::Multiband { | ColorType::Multiband {
bit_depth: n, bit_depth: n,
num_samples: _, num_samples: _,
@@ -1203,14 +1225,26 @@ impl Image {
super::fix_endianness_and_predict( super::fix_endianness_and_predict(
out_row, out_row,
color_type.bit_depth(), color_type.bit_depth(),
samples, data_samples,
ByteOrder::native(), ByteOrder::native(),
predictor, predictor,
scratch,
); );
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { // An n-bit predictor delta is stored modulo 2^n, but the
super::invert_colors(out_row, color_type, self.sample_format)?; // 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 { } else if is_output_chunk_rows && is_all_bits {
// Here we can read directly into the output buffer itself. // Here we can read directly into the output buffer itself.
@@ -1224,12 +1258,26 @@ impl Image {
samples, samples,
decompressed_byte_order, decompressed_byte_order,
predictor, predictor,
scratch,
); );
} }
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { // No-op in this branch: promoted layouts (declared depth < storage
super::invert_colors(tile, color_type, self.sample_format)?; // 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 { } else if chunk_row_bytes > data_row_bytes && self.predictor == Predictor::FloatingPoint {
// The floating point predictor shuffles the padding bytes into the encoded output, so // The floating point predictor shuffles the padding bytes into the encoded output, so
// this case is handled specially when needed. // this case is handled specially when needed.
@@ -1239,14 +1287,28 @@ impl Image {
let row = &mut row[..data_row_bytes]; let row = &mut row[..data_row_bytes];
match color_type.bit_depth() { match color_type.bit_depth() {
16 => predict_f16(&mut encoded, row, samples), 16 => {
32 => predict_f32(&mut encoded, row, samples), let (out, _) = row.as_chunks_mut::<2>();
64 => predict_f64(&mut encoded, row, samples), 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!(), _ => unreachable!(),
} }
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { // No masking here: the floating point predictor requires
super::invert_colors(row, color_type, self.sample_format)?; // IEEEFP samples, which are never depth-promoted.
} super::post_process_row(
row,
photometric_interpretation,
color_type,
self.sample_format,
)?;
} }
} else if is_all_bits { } else if is_all_bits {
// We read row-by-row but each row fits in its output buffer. // We read row-by-row but each row fits in its output buffer.
@@ -1270,11 +1332,25 @@ impl Image {
samples, samples,
decompressed_byte_order, decompressed_byte_order,
predictor, predictor,
scratch,
); );
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { // No-op in this branch: promoted layouts only occur in the
super::invert_colors(row, color_type, self.sample_format)?; // 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 { } else {
// The encoded data potentially takes up more space than the output data so we must be // The encoded data potentially takes up more space than the output data so we must be
@@ -1301,6 +1377,10 @@ impl Image {
let photo_range = photometric_bit_end / 8..bits_per_pixel / 8; let photo_range = photometric_bit_end / 8..bits_per_pixel / 8;
let mut encoded = vec![0u8; chunk_row_bytes]; let mut encoded = vec![0u8; chunk_row_bytes];
for row in buf.chunks_mut(layout.row_stride).take(data_dims.1 as usize) { 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)?; reader.read_exact(&mut encoded)?;
Self::compact_photometric_bytes(&mut encoded, row, &photo_range); Self::compact_photometric_bytes(&mut encoded, row, &photo_range);
@@ -1311,11 +1391,25 @@ impl Image {
samples, samples,
decompressed_byte_order, decompressed_byte_order,
predictor, predictor,
scratch,
); );
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { // No-op in this branch: promoted layouts only occur in the
super::invert_colors(row, color_type, self.sample_format)?; // 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, reader: &mut R,
buf: &mut [[u8; N]], buf: &mut [[u8; N]],
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let expected = buf.len();
for offset in (0..N).rev() { for offset in (0..N).rev() {
let mut i = 0; let mut i = 0;
let mut code = [0u8; 1]; let mut code = [0u8; 1];
loop { while let Some(pixels) = buf.get_mut(i..) {
let Some(pixels) = buf.get_mut(i..) else {
break;
};
if pixels.is_empty() { if pixels.is_empty() {
break; break;
} }
@@ -124,6 +121,13 @@ fn packbits_decode<R: Read, const N: usize>(
i += usize::from(repeat); 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(()) Ok(())
+727 -187
View File
File diff suppressed because it is too large. Load diff
+29 -8
View File
@@ -141,14 +141,32 @@ pub struct LZWReader<R: Read> {
impl<R: Read> LZWReader<R> { impl<R: Read> LZWReader<R> {
/// Wraps a reader /// Wraps a reader
pub fn new(reader: R, compressed_length: usize) -> LZWReader<R> { pub fn new(reader: R, compressed_length: usize) -> LZWReader<R> {
let configuration = let mut buffered = BufReader::with_capacity(
weezl::decode::Configuration::with_tiff_size_switch(weezl::BitOrder::Msb, 8)
.with_yield_on_full_buffer(true);
Self {
reader: BufReader::with_capacity(
(32 * 1024).min(compressed_length), (32 * 1024).min(compressed_length),
reader.take(u64::try_from(compressed_length).unwrap()), 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(), decoder: configuration.build(),
} }
} }
@@ -448,7 +466,7 @@ impl Group3Reader {
) -> Option<u16> { ) -> Option<u16> {
let mut sum = 0u16; let mut sum = 0u16;
while let Some(n) = decoder(reader) { while let Some(n) = decoder(reader) {
sum += n; sum = sum.checked_add(n)?;
if n < 64 { if n < 64 {
return Some(sum); return Some(sum);
} }
@@ -475,7 +493,10 @@ impl Read for Group3Reader {
}; };
match run { match run {
Some(len) => { Some(len) => {
a0 += len; a0 = match a0.checked_add(len) {
Some(v) => v,
None => break, // overflow = corrupt data
};
transitions.push(a0); transitions.push(a0);
is_white = !is_white; is_white = !is_white;
} }
+2 -1
View File
@@ -18,6 +18,7 @@ use crate::{
/// [`Decoder::read_directory_tags`](crate::decoder::Decoder::read_directory_tags) to read the /// [`Decoder::read_directory_tags`](crate::decoder::Decoder::read_directory_tags) to read the
/// values associated with tags from an underlying file. /// values associated with tags from an underlying file.
#[doc(alias = "IFD")] #[doc(alias = "IFD")]
#[derive(Clone)]
pub struct Directory { pub struct Directory {
/// There are at most `u16::MAX` entries in any single directory, the count is stored as a /// There are at most `u16::MAX` entries in any single directory, the count is stored as a
/// 2-byte value. The order in the file is implied to be ascending by tag value (the decoder /// 2-byte value. The order in the file is implied to be ascending by tag value (the decoder
@@ -170,7 +171,7 @@ mod tests {
dir.get(Tag::Unknown(1)) dir.get(Tag::Unknown(1))
.expect("tag 1 should be present after this chain") .expect("tag 1 should be present after this chain")
.count(), .count(),
u16::MAX.into() u64::from(u16::MAX)
); );
} }
+2 -1
View File
@@ -488,7 +488,8 @@ impl<'a, W: 'a + Write + Seek, K: TiffKind> DirectoryEncoder<'a, W, K> {
self.writer.write_u16(entry.field_type().to_u16())?; self.writer.write_u16(entry.field_type().to_u16())?;
let count = K::convert_offset(entry.count())?; let count = K::convert_offset(entry.count())?;
count.write(self.writer)?; count.write(self.writer)?;
self.writer.write_bytes(&entry.offset()[..offset_bytes])?; self.writer
.write_bytes(&entry.offset_raw()[..offset_bytes])?;
} }
Ok(offset) Ok(offset)
+14 -8
View File
@@ -295,7 +295,10 @@ pub enum CompressionMethod(u16) unknown(
} }
tags! { tags! {
pub enum PhotometricInterpretation(u16) { pub enum PhotometricInterpretation(u16) unknown(
/// A private or extension tag
unknown
) {
WhiteIsZero = 0, WhiteIsZero = 0,
BlackIsZero = 1, BlackIsZero = 1,
RGB = 2, RGB = 2,
@@ -567,6 +570,13 @@ impl ValueBuffer {
} }
} }
/// Create an empty value buffer with `Byte` type. This type choice is arbitrary.
impl Default for ValueBuffer {
fn default() -> Self {
ValueBuffer::empty(Type::BYTE)
}
}
/// Byte order of the TIFF file. /// Byte order of the TIFF file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ByteOrder { pub enum ByteOrder {
@@ -607,26 +617,22 @@ impl ByteOrder {
return; return;
} }
// FIXME: at MSRV 1.89 or higher use `slice::as_chunks_mut`.
match cls { match cls {
EndianBytes::One => { EndianBytes::One => {
// No change needed // No change needed
} }
EndianBytes::Two => { EndianBytes::Two => {
for chunk in buffer.chunks_exact_mut(2) { for chunk in buffer.as_chunks_mut::<2>().0 {
let chunk: &mut [u8; 2] = chunk.try_into().unwrap();
*chunk = u16::from_be_bytes(*chunk).to_le_bytes(); *chunk = u16::from_be_bytes(*chunk).to_le_bytes();
} }
} }
EndianBytes::Four => { EndianBytes::Four => {
for chunk in buffer.chunks_exact_mut(4) { for chunk in buffer.as_chunks_mut::<4>().0 {
let chunk: &mut [u8; 4] = chunk.try_into().unwrap();
*chunk = u32::from_be_bytes(*chunk).to_le_bytes(); *chunk = u32::from_be_bytes(*chunk).to_le_bytes();
} }
} }
EndianBytes::Eight => { EndianBytes::Eight => {
for chunk in buffer.chunks_exact_mut(8) { for chunk in buffer.as_chunks_mut::<8>().0 {
let chunk: &mut [u8; 8] = chunk.try_into().unwrap();
*chunk = u64::from_be_bytes(*chunk).to_le_bytes(); *chunk = u64::from_be_bytes(*chunk).to_le_bytes();
} }
} }
+5 -6
View File
@@ -26,21 +26,20 @@ fn test_big_tiff() {
decoder.colortype().expect("Cannot get colortype"), decoder.colortype().expect("Cannot get colortype"),
ColorType::RGB(8) ColorType::RGB(8)
); );
let mut ifd = decoder.current_ifd();
assert_eq!( assert_eq!(
decoder ifd.get_tag_u64(Tag::StripOffsets)
.get_tag_u64(Tag::StripOffsets)
.expect("Cannot get StripOffsets"), .expect("Cannot get StripOffsets"),
16 16
); );
assert_eq!( assert_eq!(
decoder ifd.get_tag_u64(Tag::RowsPerStrip)
.get_tag_u64(Tag::RowsPerStrip)
.expect("Cannot get RowsPerStrip"), .expect("Cannot get RowsPerStrip"),
64 64
); );
assert_eq!( assert_eq!(
decoder ifd.get_tag_u64(Tag::StripByteCounts)
.get_tag_u64(Tag::StripByteCounts)
.expect("Cannot get StripByteCounts"), .expect("Cannot get StripByteCounts"),
12288 12288
) )
+6 -8
View File
@@ -30,27 +30,25 @@ fn test_geo_tiff() {
num_samples: 5 num_samples: 5
} }
); );
let mut ifd = decoder.current_ifd();
assert_eq!( assert_eq!(
decoder ifd.get_tag_u64(Tag::StripOffsets)
.get_tag_u64(Tag::StripOffsets)
.expect("Cannot get StripOffsets"), .expect("Cannot get StripOffsets"),
418 418
); );
assert_eq!( assert_eq!(
decoder ifd.get_tag_u64(Tag::RowsPerStrip)
.get_tag_u64(Tag::RowsPerStrip)
.expect("Cannot get RowsPerStrip"), .expect("Cannot get RowsPerStrip"),
10 10
); );
assert_eq!( assert_eq!(
decoder ifd.get_tag_u64(Tag::StripByteCounts)
.get_tag_u64(Tag::StripByteCounts)
.expect("Cannot get StripByteCounts"), .expect("Cannot get StripByteCounts"),
1000 1000
); );
assert_eq!( assert_eq!(
decoder ifd.get_tag(Tag::ModelPixelScaleTag)
.get_tag(Tag::ModelPixelScaleTag)
.expect("Cannot get pixel scale") .expect("Cannot get pixel scale")
.into_f64_vec() .into_f64_vec()
.expect("Cannot get pixel scale"), .expect("Cannot get pixel scale"),
+385 -15
View File
@@ -1,11 +1,11 @@
extern crate tiff; extern crate tiff;
use tiff::decoder::{ifd, Decoder, DecodingSampleBuffer}; use tiff::decoder::{ifd, Decoder, DecodingSampleBuffer};
use tiff::tags::{ByteOrder, Type}; use tiff::tags::{ByteOrder, PhotometricInterpretation, SampleFormat, Type};
use tiff::ColorType; use tiff::ColorType;
use std::fs::File; use std::fs::File;
use std::io::{Read, Seek}; use std::io::{Cursor, Read, Seek};
use std::path::PathBuf; use std::path::PathBuf;
const TEST_IMAGE_DIR: &str = "./tests/images/"; 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) { fn $name(file: &str, expected_type: ColorType, expected_sum: $sum_ty) {
let path = PathBuf::from(TEST_IMAGE_DIR).join(file); let path = PathBuf::from(TEST_IMAGE_DIR).join(file);
let img_file = File::open(path).expect("Cannot find test image!"); 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"); decoder.next_image().expect("Cannot read image IFD");
assert_eq!(decoder.colortype().unwrap(), expected_type); assert_eq!(decoder.colortype().unwrap(), expected_type);
@@ -404,7 +406,10 @@ fn test_string_tags() {
let img_file = File::open(path).expect("Cannot find test image!"); 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");
decoder.next_image().expect("Cannot read image IFD"); decoder.next_image().expect("Cannot read image IFD");
let software = decoder.get_tag(tiff::tags::Tag::Software).unwrap(); let software = decoder
.current_ifd()
.get_tag(tiff::tags::Tag::Software)
.unwrap();
match software { match software {
ifd::Value::Ascii(s) => assert_eq!( ifd::Value::Ascii(s) => assert_eq!(
&s, &s,
@@ -470,6 +475,78 @@ fn test_tiled_rect_rgb_u8() {
test_image_sum_u8("tiled-rect-rgb-u8.tif", ColorType::RGB(8), 62081032); 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] #[test]
fn test_inner_access() { fn test_inner_access() {
let path = PathBuf::from(TEST_IMAGE_DIR).join("tiled-rect-rgb-u8.tif"); let path = PathBuf::from(TEST_IMAGE_DIR).join("tiled-rect-rgb-u8.tif");
@@ -478,7 +555,8 @@ fn test_inner_access() {
decoder.next_image().expect("Cannot read image IFD"); decoder.next_image().expect("Cannot read image IFD");
assert_eq!(decoder.colortype().unwrap(), ColorType::RGB(8)); assert_eq!(decoder.colortype().unwrap(), ColorType::RGB(8));
let c = decoder let mut ifd = decoder.current_ifd();
let c = ifd
.get_tag(tiff::tags::Tag::Compression) .get_tag(tiff::tags::Tag::Compression)
.unwrap() .unwrap()
.into_u16() .into_u16()
@@ -487,13 +565,13 @@ fn test_inner_access() {
// Because the image is uncompressed, reading the first tile directly with the inner reader // Because the image is uncompressed, reading the first tile directly with the inner reader
// should yield the same result as reading it with the decoder's read_chunk method. // should yield the same result as reading it with the decoder's read_chunk method.
let first_offset = decoder let first_offset = ifd
.get_tag_u32_vec(tiff::tags::Tag::TileOffsets) .get_tag_u32_vec(tiff::tags::Tag::TileOffsets)
.unwrap() .unwrap()
.into_iter() .into_iter()
.next() .next()
.unwrap(); .unwrap();
let first_byte_count = decoder let first_byte_count = ifd
.get_tag_u32_vec(tiff::tags::Tag::TileByteCounts) .get_tag_u32_vec(tiff::tags::Tag::TileByteCounts)
.unwrap() .unwrap()
.into_iter() .into_iter()
@@ -821,7 +899,7 @@ fn test_predictor_3_gray_f32() {
} }
#[test] #[test]
#[cfg(feature = "zstd")] #[cfg(any(feature = "zstd", feature = "zstd-safe-rust"))]
fn test_zstd_compression() { fn test_zstd_compression() {
// gdal_translate -co COMPRESS=ZSTD -co ZSTD_LEVEL=20 int16.tif int16_zstd.tif // 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); test_image_sum_i16("int16_zstd.tif", ColorType::Gray(16), 354396);
@@ -861,22 +939,22 @@ fn test_image_bytes(
} }
fn byte_order_u16(bytes: &mut [u8]) { fn byte_order_u16(bytes: &mut [u8]) {
for chunk in bytes.chunks_exact_mut(2) { for chunk in bytes.as_chunks_mut::<2>().0 {
let n = u16::from_ne_bytes(chunk.try_into().unwrap()); let n = u16::from_ne_bytes(*chunk);
chunk.copy_from_slice(&n.to_le_bytes()); chunk.copy_from_slice(&n.to_le_bytes());
} }
} }
fn byte_order_u32(bytes: &mut [u8]) { fn byte_order_u32(bytes: &mut [u8]) {
for chunk in bytes.chunks_exact_mut(4) { for chunk in bytes.as_chunks_mut::<4>().0 {
let n = u32::from_ne_bytes(chunk.try_into().unwrap()); let n = u32::from_ne_bytes(*chunk);
chunk.copy_from_slice(&n.to_le_bytes()); chunk.copy_from_slice(&n.to_le_bytes());
} }
} }
fn byte_order_u64(bytes: &mut [u8]) { fn byte_order_u64(bytes: &mut [u8]) {
for chunk in bytes.chunks_exact_mut(8) { for chunk in bytes.as_chunks_mut::<8>().0 {
let n = u64::from_ne_bytes(chunk.try_into().unwrap()); let n = u64::from_ne_bytes(*chunk);
chunk.copy_from_slice(&n.to_le_bytes()); chunk.copy_from_slice(&n.to_le_bytes());
} }
} }
@@ -971,7 +1049,7 @@ fn extra_bits_gray() {
#[test] #[test]
fn extra_bits_rgb() { 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] #[test]
@@ -1053,3 +1131,295 @@ fn test_gray_u12_hpredict() {
// Delta-encoded 12-bit values: [100, 200, 300, 400, 1000, 2000, 3000, 4000] // 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); 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/oxford.tif", Some(0x355d2e3e)),
("tests/libtiffpic/pc260001.tif", Some(0xdb56b753)), ("tests/libtiffpic/pc260001.tif", Some(0xdb56b753)),
("tests/libtiffpic/quad-jpeg.tif", Some(0xcba22475)), ("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/quad-tile.tif", Some(0x6055f9ee)),
("tests/libtiffpic/README", None), ("tests/libtiffpic/README", None),
("tests/libtiffpic/smallliz.tif", Some(0)), // unsupported YCbCr chroma subsampling ("tests/libtiffpic/smallliz.tif", Some(0)), // unsupported YCbCr chroma subsampling
+40 -11
View File
@@ -40,6 +40,7 @@ fn encode_decode() {
assert_eq!(decoder.dimensions().unwrap(), (100, 100)); assert_eq!(decoder.dimensions().unwrap(), (100, 100));
let mut all_tags = decoder let mut all_tags = decoder
.current_ifd()
.tag_iter() .tag_iter()
.filter_map(Result::ok) .filter_map(Result::ok)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -80,7 +81,7 @@ fn encode_decode() {
); );
assert_eq!( assert_eq!(
decoder.get_tag(Tag::Artist).unwrap(), decoder.current_ifd().get_tag(Tag::Artist).unwrap(),
ifd::Value::Ascii("Image-tiff".into()) ifd::Value::Ascii("Image-tiff".into())
); );
@@ -125,6 +126,7 @@ fn encode_decode_big() {
assert_eq!(decoder.dimensions().unwrap(), (100, 100)); assert_eq!(decoder.dimensions().unwrap(), (100, 100));
let mut all_tags = decoder let mut all_tags = decoder
.current_ifd()
.tag_iter() .tag_iter()
.filter_map(Result::ok) .filter_map(Result::ok)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -165,7 +167,7 @@ fn encode_decode_big() {
); );
assert_eq!( assert_eq!(
decoder.get_tag(Tag::Artist).unwrap(), decoder.current_ifd().get_tag(Tag::Artist).unwrap(),
ifd::Value::Ascii("Image-tiff".into()) ifd::Value::Ascii("Image-tiff".into())
); );
let mut buffer = tiff::decoder::DecodingSampleBuffer::U8(vec![]); let mut buffer = tiff::decoder::DecodingSampleBuffer::U8(vec![]);
@@ -539,37 +541,57 @@ trait AssertDecode {
impl<R: std::io::Read + std::io::Seek> AssertDecode for Decoder<R> { impl<R: std::io::Read + std::io::Seek> AssertDecode for Decoder<R> {
fn assert_tag_u32(&mut self, tag: u16) -> u32 { fn assert_tag_u32(&mut self, tag: u16) -> u32 {
self.get_tag(Tag::Unknown(tag)).unwrap().into_u32().unwrap() self.current_ifd()
.get_tag(Tag::Unknown(tag))
.unwrap()
.into_u32()
.unwrap()
} }
fn assert_tag_u32_vec(&mut self, tag: u16) -> Vec<u32> { fn assert_tag_u32_vec(&mut self, tag: u16) -> Vec<u32> {
self.get_tag(Tag::Unknown(tag)) self.current_ifd()
.get_tag(Tag::Unknown(tag))
.unwrap() .unwrap()
.into_u32_vec() .into_u32_vec()
.unwrap() .unwrap()
} }
fn assert_tag_i32(&mut self, tag: u16) -> i32 { fn assert_tag_i32(&mut self, tag: u16) -> i32 {
self.get_tag(Tag::Unknown(tag)).unwrap().into_i32().unwrap() self.current_ifd()
.get_tag(Tag::Unknown(tag))
.unwrap()
.into_i32()
.unwrap()
} }
fn assert_tag_i32_vec(&mut self, tag: u16) -> Vec<i32> { fn assert_tag_i32_vec(&mut self, tag: u16) -> Vec<i32> {
self.get_tag(Tag::Unknown(tag)) self.current_ifd()
.get_tag(Tag::Unknown(tag))
.unwrap() .unwrap()
.into_i32_vec() .into_i32_vec()
.unwrap() .unwrap()
} }
fn assert_tag_u64(&mut self, tag: u16) -> u64 { fn assert_tag_u64(&mut self, tag: u16) -> u64 {
self.get_tag(Tag::Unknown(tag)).unwrap().into_u64().unwrap() self.current_ifd()
.get_tag(Tag::Unknown(tag))
.unwrap()
.into_u64()
.unwrap()
} }
fn assert_tag_u64_vec(&mut self, tag: u16) -> Vec<u64> { fn assert_tag_u64_vec(&mut self, tag: u16) -> Vec<u64> {
self.get_tag(Tag::Unknown(tag)) self.current_ifd()
.get_tag(Tag::Unknown(tag))
.unwrap() .unwrap()
.into_u64_vec() .into_u64_vec()
.unwrap() .unwrap()
} }
fn assert_tag_i64(&mut self, tag: u16) -> i64 { fn assert_tag_i64(&mut self, tag: u16) -> i64 {
self.get_tag(Tag::Unknown(tag)).unwrap().into_i64().unwrap() self.current_ifd()
.get_tag(Tag::Unknown(tag))
.unwrap()
.into_i64()
.unwrap()
} }
fn assert_tag_i64_vec(&mut self, tag: u16) -> Vec<i64> { fn assert_tag_i64_vec(&mut self, tag: u16) -> Vec<i64> {
self.get_tag(Tag::Unknown(tag)) self.current_ifd()
.get_tag(Tag::Unknown(tag))
.unwrap() .unwrap()
.into_i64_vec() .into_i64_vec()
.unwrap() .unwrap()
@@ -776,7 +798,13 @@ fn test_rows_per_strip() {
{ {
let mut decoder = Decoder::open(&mut file).unwrap(); let mut decoder = Decoder::open(&mut file).unwrap();
decoder.next_image().unwrap(); decoder.next_image().unwrap();
assert_eq!(decoder.get_tag_u64(Tag::RowsPerStrip).unwrap(), 2); assert_eq!(
decoder
.current_ifd()
.get_tag_u64(Tag::RowsPerStrip)
.unwrap(),
2
);
assert_eq!(decoder.strip_count().unwrap(), 50); assert_eq!(decoder.strip_count().unwrap(), 50);
for i in 0..50 { for i in 0..50 {
@@ -829,6 +857,7 @@ fn test_auxiliary_directory() {
let _ = decoder.read_image().unwrap(); let _ = decoder.read_image().unwrap();
let exif_location = decoder let exif_location = decoder
.current_ifd()
.get_tag(Tag::ExifDirectory) .get_tag(Tag::ExifDirectory)
.expect("second directory missing Exif") .expect("second directory missing Exif")
.into_ifd_pointer() .into_ifd_pointer()
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() { fn test_ycbcr_u8_predict_roundtrip() {
test_u8_predict_roundtrip::<colortype::YCbCr8>("tiled-jpeg-ycbcr.tif", ColorType::YCbCr(8)); 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}");
}
}