From 31d81ee684935abf678872c0ae80ef61fb5d8ab2 Mon Sep 17 00:00:00 2001 From: Andrew Chen <48723787+chuenchen309@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:27:32 +0800 Subject: [PATCH] Fix ImImagePlugin.seek() frame stride for non-8-bit modes seek() computed the per-frame byte stride as 8 * len(self.mode), using the length of the mode *name* string as bits-per-pixel. That is only correct when len(mode) equals the band count and the bands are 8-bit (L, RGB, RGBA, CMYK, LA, P). For I;16 (16-bit), I / F (32-bit) and YCbCr (3-byte) the stride was wrong, so seeking to frame >= 1 in a multi-frame IM image either raised 'image file is truncated' or returned pixels from the wrong offset. Use 8 * bands * itemsize, which matches Image.new(mode).tobytes() for every IM-supported mode and leaves the working modes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Andrew Chen <48723787+chuenchen309@users.noreply.github.com> --- Tests/test_file_im.py | 38 +++++++++++++++++++++++++++++++++++++- src/PIL/ImImagePlugin.py | 5 +++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index dfd33078847..0f5960de291 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -1,6 +1,7 @@ from __future__ import annotations import filecmp +import io import warnings from pathlib import Path @@ -8,7 +9,7 @@ from PIL import Image, ImImagePlugin -from .helper import assert_image_equal_tofile, hopper, is_pypy +from .helper import assert_image_equal, assert_image_equal_tofile, hopper, is_pypy # sample im TEST_IM = "Tests/images/hopper.im" @@ -83,6 +84,41 @@ def test_eoferror() -> None: im.seek(n_frames - 1) +@pytest.mark.parametrize( + "image_type, rawmode, mode", + ( + ("L 16", "I;16", "I;16"), + ("L 32S", "I;32S", "I"), + ("L 32F", "F;32F", "F"), + ("YCC", "YCbCr;L", "YCbCr"), + ), +) +def test_seek_non_8bit(image_type: str, rawmode: str, mode: str) -> None: + # The frame stride must be derived from the actual bytes per pixel, not + # from the length of the mode name (which only matches for 8-bit bands). + w, h = 4, 4 + frames = [] + for base in (0, 1000): + frame = Image.new(mode, (w, h)) + for y in range(h): + for x in range(w): + frame.putpixel((x, y), base + y * w + x) + frames.append(frame) + + header = ( + f"Image type: {image_type} image\r\n" + f"Image size (x*y): {w}*{h}\r\n" + f"File size (no of images): {len(frames)}\r\n" + ).encode("ascii") + header += b"\x00" * (511 - len(header)) + b"\x1a" + data = b"".join(frame.tobytes("raw", rawmode, 0, -1) for frame in frames) + + with Image.open(io.BytesIO(header + data)) as im: + for index, frame in enumerate(frames): + im.seek(index) + assert_image_equal(im, frame) + + @pytest.mark.parametrize("mode", ("RGB", "P", "PA")) def test_roundtrip(mode: str, tmp_path: Path) -> None: out = tmp_path / "temp.im" diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index ef54f16e97e..ae6cf2956d0 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -30,7 +30,7 @@ import re from typing import IO, Any -from . import Image, ImageFile, ImagePalette +from . import Image, ImageFile, ImageMode, ImagePalette from ._util import DeferredError # -------------------------------------------------------------------- @@ -300,7 +300,8 @@ def seek(self, frame: int) -> None: if self.mode == "1": bits = 1 else: - bits = 8 * len(self.mode) + mode = ImageMode.getmode(self.mode) + bits = 8 * len(mode.bands) * int(mode.typestr[-1]) size = ((self.size[0] * bits + 7) // 8) * self.size[1] offs = self.__offset + frame * size