From 6002678a5ab89683dac1582871b306c8f031006b Mon Sep 17 00:00:00 2001 From: niukanen1 <57656076+niukanen1@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:02:34 +0400 Subject: [PATCH 1/3] Restore image position when all_frames copying fails --- Tests/test_imagesequence.py | 22 ++++++++++++++++++++++ docs/releasenotes/13.0.0.rst | 6 ++++++ src/PIL/ImageSequence.py | 8 ++++---- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Tests/test_imagesequence.py b/Tests/test_imagesequence.py index 16cecd0c4eb..b2cd700b225 100644 --- a/Tests/test_imagesequence.py +++ b/Tests/test_imagesequence.py @@ -110,3 +110,25 @@ def test_all_frames() -> None: for i, im_frame in enumerate(ims): im.seek(i) assert_image_equal(im.rotate(90), im_frame) + + +def test_all_frames_restores_position_after_copy_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + error = RuntimeError("copy failed") + original_copy = Image.Image.copy + + def copy(frame: Image.Image) -> Image.Image: + if frame.tell() == 1: + raise error + return original_copy(frame) + + with Image.open("Tests/images/multipage.tiff") as im: + im.seek(2) + monkeypatch.setattr(Image.Image, "copy", copy) + + with pytest.raises(RuntimeError) as exc_info: + ImageSequence.all_frames(im) + + assert exc_info.value is error + assert im.tell() == 2 diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 46dbed3b58b..ecf50fcba08 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -126,6 +126,12 @@ Two new filters are available for :py:meth:`~PIL.Image.Image.resize` and Other changes ============= +Restore image position if ImageSequence.all_frames() fails +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:func:`~PIL.ImageSequence.all_frames` now restores each input image to its +original frame if copying a frame raises an exception. + Python 3.15 ^^^^^^^^^^^ diff --git a/src/PIL/ImageSequence.py b/src/PIL/ImageSequence.py index 0a6ad66091d..21e9971b2e4 100644 --- a/src/PIL/ImageSequence.py +++ b/src/PIL/ImageSequence.py @@ -82,8 +82,8 @@ def all_frames( ims = [] for imSequence in im: current = imSequence.tell() - - ims += [im_frame.copy() for im_frame in Iterator(imSequence)] - - imSequence.seek(current) + try: + ims += [im_frame.copy() for im_frame in Iterator(imSequence)] + finally: + imSequence.seek(current) return [func(im) for im in ims] if func else ims From 28e324c7289d6db2f15f957855fd14fc2dab346d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 14 Sep 2026 10:45:39 +1000 Subject: [PATCH 2/3] Use custom image format --- Tests/test_imagesequence.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/Tests/test_imagesequence.py b/Tests/test_imagesequence.py index b2cd700b225..984e1bad417 100644 --- a/Tests/test_imagesequence.py +++ b/Tests/test_imagesequence.py @@ -112,23 +112,22 @@ def test_all_frames() -> None: assert_image_equal(im.rotate(90), im_frame) -def test_all_frames_restores_position_after_copy_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - error = RuntimeError("copy failed") - original_copy = Image.Image.copy - - def copy(frame: Image.Image) -> Image.Image: - if frame.tell() == 1: - raise error - return original_copy(frame) - - with Image.open("Tests/images/multipage.tiff") as im: - im.seek(2) - monkeypatch.setattr(Image.Image, "copy", copy) - - with pytest.raises(RuntimeError) as exc_info: +def test_all_frames_restores_position_after_seek_error() -> None: + class CustomImage(Image.Image): + def seek(self, frame: int) -> None: + if frame == 3: + msg = "seek failed" + raise ValueError(msg) + self.__frame = frame + self.im = Image.core.new("1", (1, 1)) + + def tell(self) -> int: + return self.__frame + + with CustomImage() as im: + im.seek(1) + + with pytest.raises(ValueError, match="seek failed"): ImageSequence.all_frames(im) - assert exc_info.value is error - assert im.tell() == 2 + assert im.tell() == 1 From 5f9d3aed8e1f96d0c4aff65d2b5b6dd5c3c69a05 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 14 Sep 2026 10:34:32 +1000 Subject: [PATCH 3/3] Only the failing image will be affected --- docs/releasenotes/13.0.0.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index ecf50fcba08..99913213411 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -126,12 +126,6 @@ Two new filters are available for :py:meth:`~PIL.Image.Image.resize` and Other changes ============= -Restore image position if ImageSequence.all_frames() fails -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -:py:func:`~PIL.ImageSequence.all_frames` now restores each input image to its -original frame if copying a frame raises an exception. - Python 3.15 ^^^^^^^^^^^ @@ -141,6 +135,12 @@ immediately at the release of 3.15.0 final (2026-10-01, :pep:`790`). Pillow 13.0.0 now officially supports Python 3.15. +Restore image position if ImageSequence.all_frames() fails +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If processing a frame in :py:func:`~PIL.ImageSequence.all_frames` raises an exception, +the given image will now seek back to its original frame. + Fixed ImageChops.offset() for 16-bit images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^