Skip to content

Commit 31d81ee

Browse files
chuenchen309claude
andcommitted
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) <noreply@anthropic.com> Signed-off-by: Andrew Chen <48723787+chuenchen309@users.noreply.github.com>
1 parent be48bcb commit 31d81ee

2 files changed

Lines changed: 40 additions & 3 deletions

File tree

Tests/test_file_im.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
from __future__ import annotations
22

33
import filecmp
4+
import io
45
import warnings
56
from pathlib import Path
67

78
import pytest
89

910
from PIL import Image, ImImagePlugin
1011

11-
from .helper import assert_image_equal_tofile, hopper, is_pypy
12+
from .helper import assert_image_equal, assert_image_equal_tofile, hopper, is_pypy
1213

1314
# sample im
1415
TEST_IM = "Tests/images/hopper.im"
@@ -83,6 +84,41 @@ def test_eoferror() -> None:
8384
im.seek(n_frames - 1)
8485

8586

87+
@pytest.mark.parametrize(
88+
"image_type, rawmode, mode",
89+
(
90+
("L 16", "I;16", "I;16"),
91+
("L 32S", "I;32S", "I"),
92+
("L 32F", "F;32F", "F"),
93+
("YCC", "YCbCr;L", "YCbCr"),
94+
),
95+
)
96+
def test_seek_non_8bit(image_type: str, rawmode: str, mode: str) -> None:
97+
# The frame stride must be derived from the actual bytes per pixel, not
98+
# from the length of the mode name (which only matches for 8-bit bands).
99+
w, h = 4, 4
100+
frames = []
101+
for base in (0, 1000):
102+
frame = Image.new(mode, (w, h))
103+
for y in range(h):
104+
for x in range(w):
105+
frame.putpixel((x, y), base + y * w + x)
106+
frames.append(frame)
107+
108+
header = (
109+
f"Image type: {image_type} image\r\n"
110+
f"Image size (x*y): {w}*{h}\r\n"
111+
f"File size (no of images): {len(frames)}\r\n"
112+
).encode("ascii")
113+
header += b"\x00" * (511 - len(header)) + b"\x1a"
114+
data = b"".join(frame.tobytes("raw", rawmode, 0, -1) for frame in frames)
115+
116+
with Image.open(io.BytesIO(header + data)) as im:
117+
for index, frame in enumerate(frames):
118+
im.seek(index)
119+
assert_image_equal(im, frame)
120+
121+
86122
@pytest.mark.parametrize("mode", ("RGB", "P", "PA"))
87123
def test_roundtrip(mode: str, tmp_path: Path) -> None:
88124
out = tmp_path / "temp.im"

src/PIL/ImImagePlugin.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import re
3131
from typing import IO, Any
3232

33-
from . import Image, ImageFile, ImagePalette
33+
from . import Image, ImageFile, ImageMode, ImagePalette
3434
from ._util import DeferredError
3535

3636
# --------------------------------------------------------------------
@@ -300,7 +300,8 @@ def seek(self, frame: int) -> None:
300300
if self.mode == "1":
301301
bits = 1
302302
else:
303-
bits = 8 * len(self.mode)
303+
mode = ImageMode.getmode(self.mode)
304+
bits = 8 * len(mode.bands) * int(mode.typestr[-1])
304305

305306
size = ((self.size[0] * bits + 7) // 8) * self.size[1]
306307
offs = self.__offset + frame * size

0 commit comments

Comments
 (0)