From 495a8dbb033aa8dead060ad38fb547d7cc0940e1 Mon Sep 17 00:00:00 2001 From: umtong Date: Tue, 27 Oct 2026 03:08:58 +0900 Subject: [PATCH 1/2] Fix MPO frame sizes and offsets when saving multiple images --- Tests/test_file_mpo.py | 46 ++++++++++++++++++++++++++++++++++++ docs/releasenotes/13.1.0.rst | 7 ++++++ src/PIL/MpoImagePlugin.py | 3 ++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 1f335985afc..918a65b27e3 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -374,6 +374,52 @@ def test_save_all() -> None: assert "mp" not in jpg.info +@pytest.mark.parametrize("n_frames", (2, 3, 4, 5)) +@pytest.mark.parametrize("with_exif", (False, True)) +def test_save_all_frame_offsets(n_frames: int, with_exif: bool) -> None: + colors = ("red", "green", "blue", "yellow", "purple") + frames = [ + Image.new("RGB", (16 * (i + 1), 8 * (i + 1)), colors[i]) + for i in range(n_frames) + ] + options: dict[str, Any] = {} + if with_exif: + exif = Image.Exif() + exif[270] = "Multiple frames" + options["exif"] = exif + + jpeg_frames = [] + for frame in frames: + out = BytesIO() + frame.save(out, "JPEG", **options) + jpeg_frames.append(out.getvalue()) + + out = BytesIO() + frames[0].save(out, "MPO", save_all=True, append_images=frames[1:], **options) + data = out.getvalue() + mpf_offset = data.index(b"MPF\0") + 4 + + with Image.open(out) as reloaded: + assert isinstance(reloaded, MpoImagePlugin.MpoImageFile) + assert reloaded.n_frames == n_frames + for i, jpeg_frame in enumerate(jpeg_frames): + reloaded.seek(i) + with Image.open(BytesIO(jpeg_frame)) as expected: + assert_image_equal(reloaded, expected) + + assert reloaded.mpinfo is not None + offset = 0 + for i, entry in enumerate(reloaded.mpinfo[0xB002]): + assert entry["DataOffset"] == (offset - mpf_offset if i else 0) + frame_data = data[offset : offset + entry["Size"]] + assert frame_data.startswith(b"\xff\xd8") + assert frame_data.endswith(b"\xff\xd9") + if i: + assert entry["Size"] == len(jpeg_frames[i]) + offset += entry["Size"] + assert offset == len(data) + + def test_save_xmp() -> None: im = Image.new("RGB", (1, 1)) im2 = Image.new("RGB", (1, 1), "#f00") diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 5edc0d28693..bc2e5a2ebf9 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -49,6 +49,13 @@ TODO Other changes ============= +Saving MPO images with more than two frames +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +MPO images saved with more than two frames had incorrect frame sizes in their +MP Index. For images with four or more frames, this also resulted in incorrect +frame offsets, preventing later frames from being read. This has been fixed. + Resizing I;16B and I;16N images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index a2c89d9df1e..d6d882978f2 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -75,9 +75,10 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: offsets.append(fp.tell()) else: encoderinfo = im_frame._attach_default_encoderinfo(im) + offset = fp.tell() im_frame.save(fp, "JPEG") im_frame.encoderinfo = encoderinfo - offsets.append(fp.tell() - offsets[-1]) + offsets.append(fp.tell() - offset) ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd[0xB000] = b"0100" From 287c21d5c1650493beaf94a0323c33b1fcadc286 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 27 Oct 2026 22:57:54 +1100 Subject: [PATCH 2/2] Simplify code --- Tests/test_file_mpo.py | 48 +----------------------------------- docs/releasenotes/13.1.0.rst | 7 ------ src/PIL/MpoImagePlugin.py | 7 +++--- 3 files changed, 5 insertions(+), 57 deletions(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 918a65b27e3..ac802dd0717 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -356,7 +356,7 @@ def test_save_all() -> None: assert_image_similar(im, im_reloaded, 30) im_rgb = Image.new("RGB", (1, 1)) - for colors in (("#f00",), ("#f00", "#0f0")): + for colors in (("#f00",), ("#f00", "#0f0"), ("#f00", "#0f0", "#00f")): append_images = [Image.new("RGB", (1, 1), color) for color in colors] im_reloaded = roundtrip(im_rgb, save_all=True, append_images=append_images) @@ -374,52 +374,6 @@ def test_save_all() -> None: assert "mp" not in jpg.info -@pytest.mark.parametrize("n_frames", (2, 3, 4, 5)) -@pytest.mark.parametrize("with_exif", (False, True)) -def test_save_all_frame_offsets(n_frames: int, with_exif: bool) -> None: - colors = ("red", "green", "blue", "yellow", "purple") - frames = [ - Image.new("RGB", (16 * (i + 1), 8 * (i + 1)), colors[i]) - for i in range(n_frames) - ] - options: dict[str, Any] = {} - if with_exif: - exif = Image.Exif() - exif[270] = "Multiple frames" - options["exif"] = exif - - jpeg_frames = [] - for frame in frames: - out = BytesIO() - frame.save(out, "JPEG", **options) - jpeg_frames.append(out.getvalue()) - - out = BytesIO() - frames[0].save(out, "MPO", save_all=True, append_images=frames[1:], **options) - data = out.getvalue() - mpf_offset = data.index(b"MPF\0") + 4 - - with Image.open(out) as reloaded: - assert isinstance(reloaded, MpoImagePlugin.MpoImageFile) - assert reloaded.n_frames == n_frames - for i, jpeg_frame in enumerate(jpeg_frames): - reloaded.seek(i) - with Image.open(BytesIO(jpeg_frame)) as expected: - assert_image_equal(reloaded, expected) - - assert reloaded.mpinfo is not None - offset = 0 - for i, entry in enumerate(reloaded.mpinfo[0xB002]): - assert entry["DataOffset"] == (offset - mpf_offset if i else 0) - frame_data = data[offset : offset + entry["Size"]] - assert frame_data.startswith(b"\xff\xd8") - assert frame_data.endswith(b"\xff\xd9") - if i: - assert entry["Size"] == len(jpeg_frames[i]) - offset += entry["Size"] - assert offset == len(data) - - def test_save_xmp() -> None: im = Image.new("RGB", (1, 1)) im2 = Image.new("RGB", (1, 1), "#f00") diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 4ea8795a156..380154ae109 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -49,13 +49,6 @@ TODO Other changes ============= -Saving MPO images with more than two frames -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -MPO images saved with more than two frames had incorrect frame sizes in their -MP Index. For images with four or more frames, this also resulted in incorrect -frame offsets, preventing later frames from being read. This has been fixed. - Resizing I;16B and I;16N images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index d6d882978f2..14d1b901d7a 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -51,6 +51,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: return mpf_offset = 28 + prev_offset = 0 offsets: list[int] = [] im_sequences = [im, *append_images] total = sum(getattr(seq, "n_frames", 1) for seq in im_sequences) @@ -72,13 +73,13 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: mpf_offset += 4 + len(exif) JpegImagePlugin._save(im_frame, fp, filename) - offsets.append(fp.tell()) else: encoderinfo = im_frame._attach_default_encoderinfo(im) - offset = fp.tell() im_frame.save(fp, "JPEG") im_frame.encoderinfo = encoderinfo - offsets.append(fp.tell() - offset) + offset = fp.tell() + offsets.append(offset - prev_offset) + prev_offset = offset ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd[0xB000] = b"0100"