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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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: