From d865c31aeee1e23b0d9084f7d7dd42ddbe73285d Mon Sep 17 00:00:00 2001 From: Muguett-DBY <85372096+Muguett-DBY@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:13:12 +1100 Subject: [PATCH 01/10] Fix MPO index placement after Adobe JPEG headers --- Tests/test_file_mpo.py | 39 ++++++++++++++++++++++++++++++++++++ docs/releasenotes/13.1.0.rst | 7 +++++++ src/PIL/MpoImagePlugin.py | 7 +++++++ 3 files changed, 53 insertions(+) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 1f335985afc..3bd98e592af 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -374,6 +374,45 @@ def test_save_all() -> None: assert "mp" not in jpg.info +@pytest.mark.parametrize("mode", ("1", "L", "RGB", "RGBX", "YCbCr", "CMYK")) +@pytest.mark.parametrize("keep_rgb", (False, True)) +@pytest.mark.parametrize("exif_type", ("none", "object", "bytes")) +def test_save_all_adobe_header(mode: str, keep_rgb: bool, exif_type: str) -> None: + frames = [Image.new(mode, (16, 8), 0), Image.new(mode, (24, 16), 1)] + options: dict[str, Any] = {"keep_rgb": keep_rgb} + if exif_type != "none": + exif = Image.Exif() + exif[270] = "Metadata before the MP Index" + options["exif"] = exif if exif_type == "object" else exif.tobytes() + + 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 + assert data[mpf_offset : mpf_offset + 4] == b"II\x2a\x00" + + with Image.open(out) as reloaded: + assert isinstance(reloaded, MpoImagePlugin.MpoImageFile) + assert reloaded.n_frames == 2 + 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 + first, second = reloaded.mpinfo[0xB002] + assert first["DataOffset"] == 0 + assert second["DataOffset"] + mpf_offset == first["Size"] + assert second["Size"] == len(jpeg_frames[1]) + assert first["Size"] + second["Size"] == 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..2cb6580fcfe 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 Adobe JPEG headers +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Saving CMYK images as MPO, or RGB images with ``keep_rgb=True``, wrote the +MP Index at an incorrect offset after the Adobe JPEG header. The resulting +files were reopened as a single JPEG instead of an MPO. 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..ab7a1c97b75 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -57,6 +57,13 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: for im_sequence in im_sequences: for im_frame in ImageSequence.Iterator(im_sequence): if not offsets: + # Adobe APP14 is two bytes shorter than JFIF APP0. + if im_frame.mode == "CMYK" or ( + im_frame.mode in ("RGB", "RGBX") + and im_frame.encoderinfo.get("keep_rgb") + ): + mpf_offset -= 2 + # APP2 marker ifd_length = 66 + 16 * total im_frame.encoderinfo["extra"] = ( From 3627a43be530bcf68f5c78cbc1f3857e8d0ead4b Mon Sep 17 00:00:00 2001 From: Muguett-DBY <85372096+Muguett-DBY@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:07:27 +1100 Subject: [PATCH 02/10] Account for JFIF marker when saving MPO with DPI --- Tests/test_file_mpo.py | 7 ++++++- src/PIL/MpoImagePlugin.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 3bd98e592af..b799825b8dd 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -377,9 +377,14 @@ def test_save_all() -> None: @pytest.mark.parametrize("mode", ("1", "L", "RGB", "RGBX", "YCbCr", "CMYK")) @pytest.mark.parametrize("keep_rgb", (False, True)) @pytest.mark.parametrize("exif_type", ("none", "object", "bytes")) -def test_save_all_adobe_header(mode: str, keep_rgb: bool, exif_type: str) -> None: +@pytest.mark.parametrize("dpi", (None, (72, 72), (0, 72), (0.4, 72))) +def test_save_all_adobe_header( + mode: str, keep_rgb: bool, exif_type: str, dpi: tuple[float, float] | None +) -> None: frames = [Image.new(mode, (16, 8), 0), Image.new(mode, (24, 16), 1)] options: dict[str, Any] = {"keep_rgb": keep_rgb} + if dpi is not None: + options["dpi"] = dpi if exif_type != "none": exif = Image.Exif() exif[270] = "Metadata before the MP Index" diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index ab7a1c97b75..7ed444618fb 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -63,6 +63,10 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: and im_frame.encoderinfo.get("keep_rgb") ): mpf_offset -= 2 + dpi = im_frame.encoderinfo.get("dpi", (0, 0)) + if all(round(value) > 0 for value in dpi): + # libjpeg also writes JFIF APP0 when DPI is set. + mpf_offset += 18 # APP2 marker ifd_length = 66 + 16 * total From 7e2f256fa4870b837ccb1abef6b85cf7f7fd70b6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 28 Oct 2026 11:58:52 +1100 Subject: [PATCH 03/10] Simplify code by using searching for APP2 marker --- Tests/test_file_mpo.py | 50 +++++++-------------------------------- src/PIL/MpoImagePlugin.py | 16 ++++--------- 2 files changed, 12 insertions(+), 54 deletions(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index b799825b8dd..2b9a0eb2b1c 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -374,48 +374,14 @@ def test_save_all() -> None: assert "mp" not in jpg.info -@pytest.mark.parametrize("mode", ("1", "L", "RGB", "RGBX", "YCbCr", "CMYK")) -@pytest.mark.parametrize("keep_rgb", (False, True)) -@pytest.mark.parametrize("exif_type", ("none", "object", "bytes")) -@pytest.mark.parametrize("dpi", (None, (72, 72), (0, 72), (0.4, 72))) -def test_save_all_adobe_header( - mode: str, keep_rgb: bool, exif_type: str, dpi: tuple[float, float] | None -) -> None: - frames = [Image.new(mode, (16, 8), 0), Image.new(mode, (24, 16), 1)] - options: dict[str, Any] = {"keep_rgb": keep_rgb} - if dpi is not None: - options["dpi"] = dpi - if exif_type != "none": - exif = Image.Exif() - exif[270] = "Metadata before the MP Index" - options["exif"] = exif if exif_type == "object" else exif.tobytes() - - 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 - assert data[mpf_offset : mpf_offset + 4] == b"II\x2a\x00" - - with Image.open(out) as reloaded: - assert isinstance(reloaded, MpoImagePlugin.MpoImageFile) - assert reloaded.n_frames == 2 - 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 - first, second = reloaded.mpinfo[0xB002] - assert first["DataOffset"] == 0 - assert second["DataOffset"] + mpf_offset == first["Size"] - assert second["Size"] == len(jpeg_frames[1]) - assert first["Size"] + second["Size"] == len(data) +def test_save_all_cmyk() -> None: + first = Image.new("CMYK", (1, 1)) + second = Image.new("CMYK", (1, 1), (255, 255, 255, 255)) + im_reloaded = roundtrip(first, save_all=True, append_images=[second]) + assert_image_equal(first, im_reloaded) + + im_reloaded.seek(1) + assert_image_equal(second, im_reloaded) def test_save_xmp() -> None: diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 7ed444618fb..dbf5c9e02b4 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -50,24 +50,13 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename) return - mpf_offset = 28 + mpf_offset = 18 offsets: list[int] = [] im_sequences = [im, *append_images] total = sum(getattr(seq, "n_frames", 1) for seq in im_sequences) for im_sequence in im_sequences: for im_frame in ImageSequence.Iterator(im_sequence): if not offsets: - # Adobe APP14 is two bytes shorter than JFIF APP0. - if im_frame.mode == "CMYK" or ( - im_frame.mode in ("RGB", "RGBX") - and im_frame.encoderinfo.get("keep_rgb") - ): - mpf_offset -= 2 - dpi = im_frame.encoderinfo.get("dpi", (0, 0)) - if all(round(value) > 0 for value in dpi): - # libjpeg also writes JFIF APP0 when DPI is set. - mpf_offset += 18 - # APP2 marker ifd_length = 66 + 16 * total im_frame.encoderinfo["extra"] = ( @@ -90,6 +79,9 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: im_frame.encoderinfo = encoderinfo offsets.append(fp.tell() - offsets[-1]) + fp.seek(mpf_offset) + mpf_offset += 8 + fp.read(20).rindex(b"\xff\xe2") + ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd[0xB000] = b"0100" ifd[0xB001] = len(offsets) From ab7ad7ea90029ec51623463ccf9d2f714f6519fc Mon Sep 17 00:00:00 2001 From: Muguett-DBY <85372096+Muguett-DBY@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:29:48 +1100 Subject: [PATCH 04/10] Preserve write-only output for MPO saves --- Tests/test_file_mpo.py | 35 +++++++++++++++++++++++++++++++++++ src/PIL/MpoImagePlugin.py | 27 ++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index e1c7ce8e1f7..7f1878e8744 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -2,6 +2,7 @@ import warnings from io import BytesIO +from pathlib import Path from typing import Any import pytest @@ -384,6 +385,40 @@ def test_save_all_cmyk() -> None: assert_image_equal(second, im_reloaded) +@pytest.mark.parametrize( + ("mode", "options"), + [ + ("RGB", {}), + ("CMYK", {}), + ("RGB", {"keep_rgb": True, "dpi": (72, 72)}), + ], +) +def test_save_all_write_only( + tmp_path: Path, mode: str, options: dict[str, Any] +) -> None: + first = Image.new(mode, (16, 8)) + second = Image.new(mode, (24, 16)) + references = [] + for frame in (first, second): + buffer = BytesIO() + frame.save(buffer, "JPEG", **options) + references.append(buffer.getvalue()) + path = tmp_path / "write-only.mpo" + with path.open("wb") as fp: + first.save(fp, "MPO", save_all=True, append_images=[second], **options) + + with Image.open(path) as reopened: + assert reopened.format == "MPO" + assert reopened.n_frames == 2 + assert reopened.size == first.size + with Image.open(BytesIO(references[0])) as expected: + assert_image_equal(expected, reopened) + reopened.seek(1) + assert reopened.size == second.size + with Image.open(BytesIO(references[1])) as expected: + assert_image_equal(expected, reopened) + + def test_save_xmp() -> None: im = Image.new("RGB", (1, 1)) im2 = Image.new("RGB", (1, 1), "#f00") diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 6ea61be8881..ace2fdfff9f 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -40,6 +40,24 @@ from typing import IO, Any +class _MarkerCapture: + """Forward JPEG data while retaining only the marker's search window.""" + + def __init__(self, fp: IO[bytes], start: int) -> None: + self.fp = fp + self.start = start + self.position = 0 + self.window = bytearray() + + def write(self, data: bytes) -> int: + begin = max(0, self.start - self.position) + end = min(len(data), self.start + 20 - self.position) + if end > begin: + self.window.extend(data[begin:end]) + self.position += len(data) + return self.fp.write(data) + + def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: JpegImagePlugin._save(im, fp, filename) @@ -52,6 +70,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: mpf_offset = 18 prev_offset = 0 + capture: _MarkerCapture | None = None offsets: list[int] = [] im_sequences = [im, *append_images] total = sum(getattr(seq, "n_frames", 1) for seq in im_sequences) @@ -72,7 +91,9 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: im_frame.encoderinfo["exif"] = exif mpf_offset += 4 + len(exif) - JpegImagePlugin._save(im_frame, fp, filename) + # Do not expose fileno: JPEG data must pass through write(). + capture = _MarkerCapture(fp, mpf_offset) + JpegImagePlugin._save(im_frame, cast("IO[bytes]", capture), filename) else: encoderinfo = im_frame._attach_default_encoderinfo(im) im_frame.save(fp, "JPEG") @@ -81,8 +102,8 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: offsets.append(offset - prev_offset) prev_offset = offset - fp.seek(mpf_offset) - mpf_offset += 8 + fp.read(20).rindex(b"\xff\xe2") + assert capture is not None + mpf_offset += 8 + capture.window.rindex(b"\xff\xe2") ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd[0xB000] = b"0100" From 3ed530ef55e27f2d8dd42f2684980ab1165ea77f Mon Sep 17 00:00:00 2001 From: Muguett-DBY <85372096+Muguett-DBY@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:31:41 +1100 Subject: [PATCH 05/10] Fix MPO regression test lint --- Tests/test_file_mpo.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 7f1878e8744..1079c35c0b6 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -2,8 +2,7 @@ import warnings from io import BytesIO -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pytest @@ -23,6 +22,9 @@ skip_unless_feature, ) +if TYPE_CHECKING: + from pathlib import Path + test_files = ["Tests/images/sugarshack.mpo", "Tests/images/frozenpond.mpo"] pytestmark = skip_unless_feature("jpg") @@ -386,7 +388,7 @@ def test_save_all_cmyk() -> None: @pytest.mark.parametrize( - ("mode", "options"), + "mode,options", [ ("RGB", {}), ("CMYK", {}), From 7b456b04a44b17133453f3fd478b5c4045fa09a7 Mon Sep 17 00:00:00 2001 From: Muguett-DBY <85372096+Muguett-DBY@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:33:47 +1100 Subject: [PATCH 06/10] Narrow reopened MPO image type in test --- Tests/test_file_mpo.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 1079c35c0b6..c1e7afd5037 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -411,6 +411,7 @@ def test_save_all_write_only( with Image.open(path) as reopened: assert reopened.format == "MPO" + assert isinstance(reopened, MpoImagePlugin.MpoImageFile) assert reopened.n_frames == 2 assert reopened.size == first.size with Image.open(BytesIO(references[0])) as expected: From a138b975168e424484d0ba131f2864bfc2e1ec07 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 28 Oct 2026 16:42:26 +1100 Subject: [PATCH 07/10] Write first frame to BytesIO --- Tests/test_file_mpo.py | 40 +-------------------------------------- src/PIL/MpoImagePlugin.py | 36 ++++++++--------------------------- 2 files changed, 9 insertions(+), 67 deletions(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index c1e7afd5037..e1c7ce8e1f7 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -2,7 +2,7 @@ import warnings from io import BytesIO -from typing import TYPE_CHECKING, Any +from typing import Any import pytest @@ -22,9 +22,6 @@ skip_unless_feature, ) -if TYPE_CHECKING: - from pathlib import Path - test_files = ["Tests/images/sugarshack.mpo", "Tests/images/frozenpond.mpo"] pytestmark = skip_unless_feature("jpg") @@ -387,41 +384,6 @@ def test_save_all_cmyk() -> None: assert_image_equal(second, im_reloaded) -@pytest.mark.parametrize( - "mode,options", - [ - ("RGB", {}), - ("CMYK", {}), - ("RGB", {"keep_rgb": True, "dpi": (72, 72)}), - ], -) -def test_save_all_write_only( - tmp_path: Path, mode: str, options: dict[str, Any] -) -> None: - first = Image.new(mode, (16, 8)) - second = Image.new(mode, (24, 16)) - references = [] - for frame in (first, second): - buffer = BytesIO() - frame.save(buffer, "JPEG", **options) - references.append(buffer.getvalue()) - path = tmp_path / "write-only.mpo" - with path.open("wb") as fp: - first.save(fp, "MPO", save_all=True, append_images=[second], **options) - - with Image.open(path) as reopened: - assert reopened.format == "MPO" - assert isinstance(reopened, MpoImagePlugin.MpoImageFile) - assert reopened.n_frames == 2 - assert reopened.size == first.size - with Image.open(BytesIO(references[0])) as expected: - assert_image_equal(expected, reopened) - reopened.seek(1) - assert reopened.size == second.size - with Image.open(BytesIO(references[1])) as expected: - assert_image_equal(expected, reopened) - - def test_save_xmp() -> None: im = Image.new("RGB", (1, 1)) im2 = Image.new("RGB", (1, 1), "#f00") diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index ace2fdfff9f..d34aacc959b 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -23,6 +23,7 @@ import os import struct +from io import BytesIO from typing import cast from . import ( @@ -40,24 +41,6 @@ from typing import IO, Any -class _MarkerCapture: - """Forward JPEG data while retaining only the marker's search window.""" - - def __init__(self, fp: IO[bytes], start: int) -> None: - self.fp = fp - self.start = start - self.position = 0 - self.window = bytearray() - - def write(self, data: bytes) -> int: - begin = max(0, self.start - self.position) - end = min(len(data), self.start + 20 - self.position) - if end > begin: - self.window.extend(data[begin:end]) - self.position += len(data) - return self.fp.write(data) - - def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: JpegImagePlugin._save(im, fp, filename) @@ -68,9 +51,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename) return - mpf_offset = 18 prev_offset = 0 - capture: _MarkerCapture | None = None offsets: list[int] = [] im_sequences = [im, *append_images] total = sum(getattr(seq, "n_frames", 1) for seq in im_sequences) @@ -79,21 +60,23 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if not offsets: # APP2 marker ifd_length = 66 + 16 * total - im_frame.encoderinfo["extra"] = ( + extra = ( b"\xff\xe2" + struct.pack(">H", 6 + ifd_length) + b"MPF\0" + b" " * ifd_length ) + im_frame.encoderinfo["extra"] = extra if exif := im_frame.encoderinfo.get("exif"): if isinstance(exif, Image.Exif): exif = exif.tobytes() im_frame.encoderinfo["exif"] = exif - mpf_offset += 4 + len(exif) - # Do not expose fileno: JPEG data must pass through write(). - capture = _MarkerCapture(fp, mpf_offset) - JpegImagePlugin._save(im_frame, cast("IO[bytes]", capture), filename) + with BytesIO() as first_frame_fp: + JpegImagePlugin._save(im_frame, first_frame_fp, filename) + first_frame_data = first_frame_fp.getvalue() + fp.write(first_frame_data) + mpf_offset = first_frame_data.index(extra) + 8 else: encoderinfo = im_frame._attach_default_encoderinfo(im) im_frame.save(fp, "JPEG") @@ -102,9 +85,6 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: offsets.append(offset - prev_offset) prev_offset = offset - assert capture is not None - mpf_offset += 8 + capture.window.rindex(b"\xff\xe2") - ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd[0xB000] = b"0100" ifd[0xB001] = len(offsets) From 16eca932f7a3042ac6479da493fbfae30d29c812 Mon Sep 17 00:00:00 2001 From: Muguett-DBY <85372096+Muguett-DBY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:05:36 +1100 Subject: [PATCH 08/10] Skip EXIF when locating the MPO placeholder --- Tests/test_file_mpo.py | 69 ++++++++++++++++++++++++++++++++++++++- src/PIL/MpoImagePlugin.py | 4 ++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index e1c7ce8e1f7..a70ad3ddada 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -1,8 +1,9 @@ from __future__ import annotations +import struct import warnings from io import BytesIO -from typing import Any +from typing import TYPE_CHECKING, Any import pytest @@ -22,6 +23,9 @@ skip_unless_feature, ) +if TYPE_CHECKING: + from pathlib import Path + test_files = ["Tests/images/sugarshack.mpo", "Tests/images/frozenpond.mpo"] pytestmark = skip_unless_feature("jpg") @@ -384,6 +388,69 @@ def test_save_all_cmyk() -> None: assert_image_equal(second, im_reloaded) +@pytest.mark.parametrize( + "mode,options", + [ + ("RGB", {}), + ("CMYK", {}), + ("RGB", {"keep_rgb": True, "dpi": (72, 72)}), + ], +) +def test_save_all_write_only( + tmp_path: Path, mode: str, options: dict[str, Any] +) -> None: + first = Image.new(mode, (16, 8)) + second = Image.new(mode, (24, 16)) + references = [] + for frame in (first, second): + buffer = BytesIO() + frame.save(buffer, "JPEG", **options) + references.append(buffer.getvalue()) + path = tmp_path / "write-only.mpo" + with path.open("wb") as fp: + first.save(fp, "MPO", save_all=True, append_images=[second], **options) + + with Image.open(path) as reopened: + assert reopened.format == "MPO" + assert isinstance(reopened, MpoImagePlugin.MpoImageFile) + assert reopened.n_frames == 2 + assert reopened.size == first.size + with Image.open(BytesIO(references[0])) as expected: + assert_image_equal(expected, reopened) + reopened.seek(1) + assert reopened.size == second.size + with Image.open(BytesIO(references[1])) as expected: + assert_image_equal(expected, reopened) + + +@pytest.mark.parametrize("as_bytes", (False, True)) +def test_save_all_exif_contains_mpf_placeholder(as_bytes: bool) -> None: + ifd_length = 66 + 16 * 2 + placeholder = b"\xff\xe2" + struct.pack(">H", 6 + ifd_length) + placeholder += b"MPF\0" + b" " * ifd_length + comment = b"ASCII\0\0\0" + placeholder + exif = Image.Exif() + exif[0x9286] = comment + first = Image.new("RGB", (16, 8)) + second = Image.new("RGB", (24, 16)) + output = BytesIO() + first.save( + output, + "MPO", + save_all=True, + append_images=[second], + exif=exif.tobytes() if as_bytes else exif, + ) + output.seek(0) + with Image.open(output) as reopened: + assert isinstance(reopened, MpoImagePlugin.MpoImageFile) + assert reopened.n_frames == 2 + assert reopened.size == first.size + assert reopened.getexif()[0x9286] == comment + reopened.seek(1) + assert reopened.size == second.size + + def test_save_xmp() -> None: im = Image.new("RGB", (1, 1)) im2 = Image.new("RGB", (1, 1), "#f00") diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index d34aacc959b..cc19afbb511 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -76,7 +76,9 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: JpegImagePlugin._save(im_frame, first_frame_fp, filename) first_frame_data = first_frame_fp.getvalue() fp.write(first_frame_data) - mpf_offset = first_frame_data.index(extra) + 8 + # The EXIF payload can itself contain the placeholder bytes. + search_start = 2 + (4 + len(exif) if exif else 0) + mpf_offset = first_frame_data.index(extra, search_start) + 8 else: encoderinfo = im_frame._attach_default_encoderinfo(im) im_frame.save(fp, "JPEG") From 58f4307b006b8ecac1ae75095d3c8bd972fc8b62 Mon Sep 17 00:00:00 2001 From: Muguett-DBY <85372096+Muguett-DBY@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:00:00 +1100 Subject: [PATCH 09/10] Remove constructed EXIF collision handling --- Tests/test_file_mpo.py | 29 ----------------------------- src/PIL/MpoImagePlugin.py | 4 +--- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index a70ad3ddada..c1e7afd5037 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -1,6 +1,5 @@ from __future__ import annotations -import struct import warnings from io import BytesIO from typing import TYPE_CHECKING, Any @@ -423,34 +422,6 @@ def test_save_all_write_only( assert_image_equal(expected, reopened) -@pytest.mark.parametrize("as_bytes", (False, True)) -def test_save_all_exif_contains_mpf_placeholder(as_bytes: bool) -> None: - ifd_length = 66 + 16 * 2 - placeholder = b"\xff\xe2" + struct.pack(">H", 6 + ifd_length) - placeholder += b"MPF\0" + b" " * ifd_length - comment = b"ASCII\0\0\0" + placeholder - exif = Image.Exif() - exif[0x9286] = comment - first = Image.new("RGB", (16, 8)) - second = Image.new("RGB", (24, 16)) - output = BytesIO() - first.save( - output, - "MPO", - save_all=True, - append_images=[second], - exif=exif.tobytes() if as_bytes else exif, - ) - output.seek(0) - with Image.open(output) as reopened: - assert isinstance(reopened, MpoImagePlugin.MpoImageFile) - assert reopened.n_frames == 2 - assert reopened.size == first.size - assert reopened.getexif()[0x9286] == comment - reopened.seek(1) - assert reopened.size == second.size - - def test_save_xmp() -> None: im = Image.new("RGB", (1, 1)) im2 = Image.new("RGB", (1, 1), "#f00") diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index cc19afbb511..d34aacc959b 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -76,9 +76,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: JpegImagePlugin._save(im_frame, first_frame_fp, filename) first_frame_data = first_frame_fp.getvalue() fp.write(first_frame_data) - # The EXIF payload can itself contain the placeholder bytes. - search_start = 2 + (4 + len(exif) if exif else 0) - mpf_offset = first_frame_data.index(extra, search_start) + 8 + mpf_offset = first_frame_data.index(extra) + 8 else: encoderinfo = im_frame._attach_default_encoderinfo(im) im_frame.save(fp, "JPEG") From f1b495438bc5d9dd1b2e48fb3e0b516699415247 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 29 Oct 2026 21:02:05 +1100 Subject: [PATCH 10/10] Remove test --- Tests/test_file_mpo.py | 40 +----------------------------------- docs/releasenotes/13.1.0.rst | 7 ------- 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index c1e7afd5037..e1c7ce8e1f7 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -2,7 +2,7 @@ import warnings from io import BytesIO -from typing import TYPE_CHECKING, Any +from typing import Any import pytest @@ -22,9 +22,6 @@ skip_unless_feature, ) -if TYPE_CHECKING: - from pathlib import Path - test_files = ["Tests/images/sugarshack.mpo", "Tests/images/frozenpond.mpo"] pytestmark = skip_unless_feature("jpg") @@ -387,41 +384,6 @@ def test_save_all_cmyk() -> None: assert_image_equal(second, im_reloaded) -@pytest.mark.parametrize( - "mode,options", - [ - ("RGB", {}), - ("CMYK", {}), - ("RGB", {"keep_rgb": True, "dpi": (72, 72)}), - ], -) -def test_save_all_write_only( - tmp_path: Path, mode: str, options: dict[str, Any] -) -> None: - first = Image.new(mode, (16, 8)) - second = Image.new(mode, (24, 16)) - references = [] - for frame in (first, second): - buffer = BytesIO() - frame.save(buffer, "JPEG", **options) - references.append(buffer.getvalue()) - path = tmp_path / "write-only.mpo" - with path.open("wb") as fp: - first.save(fp, "MPO", save_all=True, append_images=[second], **options) - - with Image.open(path) as reopened: - assert reopened.format == "MPO" - assert isinstance(reopened, MpoImagePlugin.MpoImageFile) - assert reopened.n_frames == 2 - assert reopened.size == first.size - with Image.open(BytesIO(references[0])) as expected: - assert_image_equal(expected, reopened) - reopened.seek(1) - assert reopened.size == second.size - with Image.open(BytesIO(references[1])) as expected: - assert_image_equal(expected, reopened) - - 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 85d37bf949e..7782570bba8 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 Adobe JPEG headers -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Saving CMYK images as MPO, or RGB images with ``keep_rgb=True``, wrote the -MP Index at an incorrect offset after the Adobe JPEG header. The resulting -files were reopened as a single JPEG instead of an MPO. This has been fixed. - Writing to read-only images ^^^^^^^^^^^^^^^^^^^^^^^^^^^