From 6be0ce2c395a0ffc2e151ac508dc4db4d4e533c7 Mon Sep 17 00:00:00 2001 From: lazerg Date: Fri, 16 Oct 2026 14:23:26 +0500 Subject: [PATCH 1/4] Memory map rotated TIFF images at stored size --- Tests/test_file_tiff.py | 21 +++++++++++++++++++++ src/PIL/ImageFile.py | 7 ++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 523db1d0a29..f165e1c486c 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -556,6 +556,27 @@ def test_exif_frames(self) -> None: im.seek(1) assert im.getexif()[273] == (1408, 1907) + @pytest.mark.parametrize( + "orientation, method", + ( + (5, Image.Transpose.TRANSPOSE), + (6, Image.Transpose.ROTATE_270), + (7, Image.Transpose.TRANSVERSE), + (8, Image.Transpose.ROTATE_90), + ), + ) + def test_orientation_mmap( + self, orientation: int, method: Image.Transpose, tmp_path: Path + ) -> None: + im = hopper("L").crop((0, 0, 128, 64)) + filename = tmp_path / "temp.tif" + im.save(filename, tiffinfo={274: orientation}) + + with Image.open(filename) as reloaded: + assert reloaded.size == (64, 128) + reloaded.load() + assert_image_equal(reloaded, im.transpose(method)) + @pytest.mark.parametrize("mode", ("1", "L")) def test_photometric(self, mode: str, tmp_path: Path) -> None: filename = tmp_path / "temp.tif" diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 188b4130b92..062749d9a65 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -346,18 +346,19 @@ def load(self) -> Image.core.PixelAccess | None: with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) + size = getattr(self, "_tile_size", self.size) if self.mode in {"L", "P"}: pixelsize = 1 elif self.mode.startswith("I;16"): pixelsize = 2 else: pixelsize = 4 - stride = max(args[1], self.size[0] * pixelsize) - if offset + self.size[1] * stride > self.map.size(): + stride = max(args[1], size[0] * pixelsize) + if offset + size[1] * stride > self.map.size(): msg = "buffer is not large enough" raise OSError(msg) self.im = Image.core.map_buffer( - self.map, self.size, decoder_name, offset, args + self.map, size, decoder_name, offset, args ) readonly = 1 # After trashing self.im, From a46f7b74f8ce45ab91ef2f8bf1d4089bc3990d3a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 18 Oct 2026 14:44:24 +1100 Subject: [PATCH 2/4] Handle custom TIFF loading inside TiffImageFile --- Tests/test_file_tiff.py | 4 +--- src/PIL/ImageFile.py | 7 +++---- src/PIL/TiffImagePlugin.py | 26 +++++++++++++++----------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index f165e1c486c..5a4b7ef25a7 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -568,13 +568,11 @@ def test_exif_frames(self) -> None: def test_orientation_mmap( self, orientation: int, method: Image.Transpose, tmp_path: Path ) -> None: - im = hopper("L").crop((0, 0, 128, 64)) filename = tmp_path / "temp.tif" + im = hopper().crop((0, 0, 128, 64)) im.save(filename, tiffinfo={274: orientation}) - with Image.open(filename) as reloaded: assert reloaded.size == (64, 128) - reloaded.load() assert_image_equal(reloaded, im.transpose(method)) @pytest.mark.parametrize("mode", ("1", "L")) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 062749d9a65..188b4130b92 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -346,19 +346,18 @@ def load(self) -> Image.core.PixelAccess | None: with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) - size = getattr(self, "_tile_size", self.size) if self.mode in {"L", "P"}: pixelsize = 1 elif self.mode.startswith("I;16"): pixelsize = 2 else: pixelsize = 4 - stride = max(args[1], size[0] * pixelsize) - if offset + size[1] * stride > self.map.size(): + stride = max(args[1], self.size[0] * pixelsize) + if offset + self.size[1] * stride > self.map.size(): msg = "buffer is not large enough" raise OSError(msg) self.im = Image.core.map_buffer( - self.map, size, decoder_name, offset, args + self.map, self.size, decoder_name, offset, args ) readonly = 1 # After trashing self.im, diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 35b490bd4e3..edf4cc76e6d 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1316,15 +1316,20 @@ def get_photoshop_blocks(self) -> dict[int, dict[str, bytes]]: return blocks def load(self) -> Image.core.PixelAccess | None: - if self.tile and self.use_load_libtiff: - return self._load_libtiff() - return super().load() + if not self.tile: + return super().load() - def load_prepare(self) -> None: - if self._im is None: - Image._decompression_bomb_check(self._tile_size) - self.im = Image.core.new(self.mode, self._tile_size) - ImageFile.ImageFile.load_prepare(self) + original_size = self._size + self._size = self._tile_size + + if self.use_load_libtiff: + self._load_libtiff() + px = Image.Image.load(self) + else: + px = super().load() + + self._size = original_size + return px def load_end(self) -> None: # allow closing if we're on the first frame, there's no next @@ -1343,7 +1348,7 @@ def load_end(self) -> None: if ExifTags.Base.Orientation in self.tag_v2: del self.tag_v2[ExifTags.Base.Orientation] - def _load_libtiff(self) -> Image.core.PixelAccess | None: + def _load_libtiff(self) -> None: """Overload method triggered when we detect a compressed tiff Calls out to libtiff""" @@ -1436,8 +1441,6 @@ def _load_libtiff(self) -> Image.core.PixelAccess | None: msg = f"decoder error {err}" raise OSError(msg) - return Image.Image.load(self) - def _setup(self) -> None: """Setup this image object based on current tags""" @@ -1477,6 +1480,7 @@ def _setup(self) -> None: msg = "Invalid dimensions" raise ValueError(msg) self._tile_size = xsize, ysize + Image._decompression_bomb_check(self._tile_size) orientation = self.tag_v2.get(ExifTags.Base.Orientation) if orientation in (5, 6, 7, 8): self._size = ysize, xsize From 85dc3022df0700d8cad3bed8401c61c23f5acfae Mon Sep 17 00:00:00 2001 From: lazerg Date: Sun, 18 Oct 2026 10:24:34 +0500 Subject: [PATCH 3/4] Use L mode in test so memory mapping is used --- Tests/test_file_tiff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 5a4b7ef25a7..3fad75a2474 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -569,7 +569,7 @@ def test_orientation_mmap( self, orientation: int, method: Image.Transpose, tmp_path: Path ) -> None: filename = tmp_path / "temp.tif" - im = hopper().crop((0, 0, 128, 64)) + im = hopper("L").crop((0, 0, 128, 64)) im.save(filename, tiffinfo={274: orientation}) with Image.open(filename) as reloaded: assert reloaded.size == (64, 128) From b3919d209dd765ceca393b51c09f75163827170d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 19 Oct 2026 00:40:40 +1100 Subject: [PATCH 4/4] Restore original size if an error is raised --- Tests/test_file_libtiff.py | 11 ++++++++++- src/PIL/TiffImagePlugin.py | 15 ++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index af7d2d9f525..d85b7513dcb 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1256,7 +1256,8 @@ def test_jpeg_short_strip(self, tiled: bool) -> None: def test_orientation(self) -> None: with Image.open("Tests/images/g4_orientation_1.tif") as base_im: for i in range(2, 9): - with Image.open("Tests/images/g4_orientation_" + str(i) + ".tif") as im: + infile = "Tests/images/g4_orientation_" + str(i) + ".tif" + with Image.open(infile) as im: assert isinstance(im, TiffImagePlugin.TiffImageFile) assert 274 in im.tag_v2 @@ -1265,6 +1266,14 @@ def test_orientation(self) -> None: assert_image_similar(base_im, im, 0.7) + with Image.open(infile) as im: + assert im.size == (590, 88) + + im.tile.append(im.tile[0]) + with pytest.raises(OSError, match="Not exactly one tile"): + im.load() + assert im.size == (590, 88) + def test_exif_transpose(self) -> None: with Image.open("Tests/images/g4_orientation_1.tif") as base_im: for i in range(2, 9): diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index edf4cc76e6d..356b9e79465 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1322,13 +1322,14 @@ def load(self) -> Image.core.PixelAccess | None: original_size = self._size self._size = self._tile_size - if self.use_load_libtiff: - self._load_libtiff() - px = Image.Image.load(self) - else: - px = super().load() - - self._size = original_size + try: + if self.use_load_libtiff: + self._load_libtiff() + px = Image.Image.load(self) + else: + px = super().load() + finally: + self._size = original_size return px def load_end(self) -> None: