Affected version
Summary
Any TIFF that is tiled (TileWidth/TileLength present) and uses PlanarConfiguration = 2 (separate planes) panics while decoding, as soon as the image has more than one tile row or column in a plane (i.e. an "edge" tile is not the very first tile of the whole file). Striped planar images decode fine — only the tiled+planar combination is affected.
Panic:
thread 'main' panicked at src/decoder/image.rs:1153:9:
assertion failed: buf.len() >= layout.row_stride * (data_dims.1 as usize - 1) + data_row_bytes
Root cause
TileAttributes::get_padding (src/decoder/image.rs:45-61) computes a tile's row/column purely from its raw tile index:
pub fn get_padding(&self, tile: usize) -> (usize, usize) {
let row = tile / self.tiles_across();
let column = tile % self.tiles_across();
...
}
tiles_across()/tiles_down() (lines 33-38) describe the grid of a single plane. But the caller, chunk_data_dimensions (src/decoder/image.rs:1003-1011, specifically the call at line 1005), is invoked with a global chunk index that already has the per-plane offset baked in:
// src/decoder/mod.rs, Decoder::read_image_bytes (~line 1857-1866)
for chunk in 0..readout.chunks_per_plane {
...
for (idx, &plane_offset) in plane_offsets[..used_plane_offsets].iter().enumerate() {
let chunk = chunk + idx as u32 * readout.chunks_per_plane; // <-- global index across planes
...
image.expand_chunk(value_reader, ..., readout, chunk, scratch)?;
}
}
For plane idx >= 1, chunk is offset by idx * chunks_per_plane, so get_padding divides/mods a value outside the single-plane grid it was designed for. E.g. for a 128x72 image with 32x32 tiles (4x3 tiles/plane = 12 tiles/plane) and chunk 32 (plane 2, tile row 2 = last row within that plane):
row = 32 / tiles_across() = 32 / 4 = 8 // should be 32 % 12 / 4 = 2
column = 32 % tiles_across() = 32 % 4 = 0 // should be (32 % 12) % 4 = 0 (ok by luck)
row == 8 never equals tiles_down() - 1 == 2, so padding_down is silently computed as 0 — the bottom-edge tile is not recognized as such, and chunk_data_dimensions returns the full, unclamped tile_length (e.g. 32) instead of the true remaining row count (e.g. 8).
expand_chunk (src/decoder/image.rs:1153) then asserts the output buffer is large enough for that (wrongly inflated) data_dims.1, and since the real remaining space in the output buffer was correctly sized for the true (smaller) edge-tile height, the assertion fails.
get_padding needs to operate on the chunk index modulo chunks_per_plane (or equivalently the caller should pass a per-plane-relative index), not the raw global index that includes the plane offset.
Because PlanarConfiguration::Chunky never offsets the chunk index by plane (chunks_per_plane == total tile count, loop always uses idx == 0), chunky tiled images are unaffected — this is specific to the planar + tiled combination. Striped planar is also unaffected because chunk_data_dimensions's ChunkType::Strip arm (lines 987-1002) derives padding via chunk_index % strips_per_band, which already reduces modulo the per-plane strip count.
Minimal synthetic reproducer (no attachments needed)
Generate a tiny fully-synthetic tiled+planar TIFF with Python/tifffile (any tile size smaller than the image in both dimensions triggers it, since it just needs one edge tile in plane > 0):
import numpy as np
import tifffile
# 24x24 RGB, 16x16 tiles -> 2x2 tile grid per plane (edge tiles on
# right column and bottom row), PlanarConfiguration=2 (separate planes).
data = np.zeros((3, 24, 24), dtype=np.uint8)
for c in range(3):
data[c] = (np.arange(24 * 24).reshape(24, 24) + c * 7) % 256
tifffile.imwrite(
"tiled_planar_repro.tif",
data,
tile=(16, 16),
planarconfig="separate",
photometric="rgb",
compression=None,
)
Decode with upstream's own example:
// examples/decode.rs (unmodified)
let mut reader = tiff::decoder::Decoder::open(std::io::BufReader::new(
std::fs::File::open("tiled_planar_repro.tif")?,
))?;
reader.next_directory()?;
let mut data = tiff::decoder::DecodingSampleBuffer::I8(vec![]);
reader.read_image_to_buffer(&mut data)?; // panics
$ cargo run --example decode -- tiled_planar_repro.tif
thread 'main' panicked at src/decoder/image.rs:1153:9:
assertion failed: buf.len() >= layout.row_stride * (data_dims.1 as usize - 1) + data_row_bytes
stack backtrace:
...
3: tiff::decoder::image::Image::expand_chunk
at ./src/decoder/image.rs:1153:9
4: tiff::decoder::Decoder<R>::read_image_bytes
at ./src/decoder/mod.rs:1868:23
5: tiff::decoder::Decoder<R>::read_image_to_buffer
at ./src/decoder/mod.rs:1824:14
Also independently reproduced with two real-world 128x72 RGB / 32x32-tile files (one LZW-compressed, one uncompressed) — same panic, same line, confirming compression is irrelevant.
Notes for the fix
TileAttributes::get_padding should take (or the caller should compute) a chunk index relative to the current plane, e.g. chunk_index % chunks_per_plane, before doing / tiles_across() and % tiles_across().
- Given the existing
// TODO: Should these return errors instead? comment directly above the assertions at src/decoder/image.rs:1151-1153, it may also be worth turning these into proper TiffErrors rather than panicking, as a defense-in-depth measure for any other buffer-sizing miscalculation.
Affected version
tiff, master @8ad05b85dbc488caa17e0b75b35033b81ca24ff5(merge of Fix: add a lenient mode for image decoder #397, 2026-07-03). Also present on the0.11.3-adjacent history — the buggy code (TileAttributes::get_padding) is unrelated to Fix: add a lenient mode for image decoder #397 and predates it.Compression::NoneandCompression::Lzw; compression is not the trigger.Summary
Any TIFF that is tiled (
TileWidth/TileLengthpresent) and usesPlanarConfiguration = 2(separate planes) panics while decoding, as soon as the image has more than one tile row or column in a plane (i.e. an "edge" tile is not the very first tile of the whole file). Striped planar images decode fine — only the tiled+planar combination is affected.Panic:
Root cause
TileAttributes::get_padding(src/decoder/image.rs:45-61) computes a tile's row/column purely from its raw tile index:tiles_across()/tiles_down()(lines 33-38) describe the grid of a single plane. But the caller,chunk_data_dimensions(src/decoder/image.rs:1003-1011, specifically the call at line 1005), is invoked with a global chunk index that already has the per-plane offset baked in:For plane
idx >= 1,chunkis offset byidx * chunks_per_plane, soget_paddingdivides/mods a value outside the single-plane grid it was designed for. E.g. for a 128x72 image with 32x32 tiles (4x3 tiles/plane = 12 tiles/plane) and chunk 32 (plane 2, tile row 2 = last row within that plane):row == 8never equalstiles_down() - 1 == 2, sopadding_downis silently computed as0— the bottom-edge tile is not recognized as such, andchunk_data_dimensionsreturns the full, unclampedtile_length(e.g. 32) instead of the true remaining row count (e.g. 8).expand_chunk(src/decoder/image.rs:1153) then asserts the output buffer is large enough for that (wrongly inflated)data_dims.1, and since the real remaining space in the output buffer was correctly sized for the true (smaller) edge-tile height, the assertion fails.get_paddingneeds to operate on the chunk index modulochunks_per_plane(or equivalently the caller should pass a per-plane-relative index), not the raw global index that includes the plane offset.Because
PlanarConfiguration::Chunkynever offsets the chunk index by plane (chunks_per_plane == total tile count, loop always usesidx == 0), chunky tiled images are unaffected — this is specific to the planar + tiled combination. Striped planar is also unaffected becausechunk_data_dimensions'sChunkType::Striparm (lines 987-1002) derives padding viachunk_index % strips_per_band, which already reduces modulo the per-plane strip count.Minimal synthetic reproducer (no attachments needed)
Generate a tiny fully-synthetic tiled+planar TIFF with Python/tifffile (any tile size smaller than the image in both dimensions triggers it, since it just needs one edge tile in plane > 0):
Decode with upstream's own example:
Also independently reproduced with two real-world 128x72 RGB / 32x32-tile files (one LZW-compressed, one uncompressed) — same panic, same line, confirming compression is irrelevant.
Notes for the fix
TileAttributes::get_paddingshould take (or the caller should compute) a chunk index relative to the current plane, e.g.chunk_index % chunks_per_plane, before doing/ tiles_across()and% tiles_across().// TODO: Should these return errors instead?comment directly above the assertions atsrc/decoder/image.rs:1151-1153, it may also be worth turning these into properTiffErrors rather than panicking, as a defense-in-depth measure for any other buffer-sizing miscalculation.