diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 1bc134e90aa..9615497239c 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -204,6 +204,80 @@ def test_dpi_jfif_cm(self) -> None: with Image.open("Tests/images/jfif_unit_cm.jpg") as im: assert im.info["dpi"] == (2.54, 5.08) + def test_dpi_with_keep_rgb(self, tmp_path: Path) -> None: + im = Image.new("RGB", (20, 20), (255, 0, 0)) + f = tmp_path / "temp.jpg" + im.save(f, "JPEG", dpi=(96, 96), keep_rgb=True) + + with Image.open(f) as reloaded: + assert reloaded.info["dpi"] == (96, 96) + assert_image_equal(reloaded, im) + + @pytest.mark.parametrize("as_bytes", (False, True)) + def test_dpi_keep_rgb_existing_exif(self, as_bytes: bool) -> None: + exif = Image.Exif() + exif[0x010E] = "description" + exif[0x011A] = 300 + exif[0x011B] = 200 + exif[0x0128] = 3 + original = exif.tobytes() + output = BytesIO() + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + Image.new("RGB", (20, 20), (255, 0, 0)).save( + output, + "JPEG", + keep_rgb=True, + dpi=(96, 96), + exif=original if as_bytes else exif, + ) + assert exif.tobytes() == original + with Image.open(BytesIO(output.getvalue())) as reloaded: + stored = reloaded.getexif() + assert reloaded.info["dpi"] == (96, 96) + assert stored[0x011A] == 96 + assert stored[0x011B] == 96 + assert stored[0x0128] == 2 + assert stored[0x010E] == "description" + assert reloaded.getpixel((0, 0)) == (255, 0, 0) + + def test_dpi_keep_rgb_stores_both_axes(self) -> None: + output = BytesIO() + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + Image.new("RGB", (20, 20)).save( + output, "JPEG", keep_rgb=True, dpi=(96, 150) + ) + with Image.open(BytesIO(output.getvalue())) as reloaded: + exif = reloaded.getexif() + assert exif[0x011A] == 96 + assert exif[0x011B] == 150 + assert exif[0x0128] == 2 + + @pytest.mark.parametrize("mode", ("1", "L", "YCbCr", "CMYK")) + def test_dpi_keep_rgb_other_modes(self, mode: str) -> None: + output = BytesIO() + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + Image.new(mode, (20, 20)).save(output, "JPEG", keep_rgb=True, dpi=(96, 150)) + with Image.open(BytesIO(output.getvalue())) as reloaded: + assert reloaded.info["dpi"] == (96, 150) + + @pytest.mark.parametrize("dpi", ((0, 0), (0, 150), (96, 0))) + def test_dpi_keep_rgb_zero_density(self, dpi: tuple[int, int]) -> None: + output = BytesIO() + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + Image.new("RGB", (20, 20)).save(output, "JPEG", keep_rgb=True, dpi=dpi) + with Image.open(BytesIO(output.getvalue())) as reloaded: + assert "dpi" not in reloaded.info + assert not reloaded.getexif() + + @pytest.mark.parametrize("dpi", ((96,), (96, 96, 96))) + def test_dpi_keep_rgb_invalid_length(self, dpi: tuple[int, ...]) -> None: + with pytest.raises(TypeError): + Image.new("RGB", (20, 20)).save(BytesIO(), "JPEG", keep_rgb=True, dpi=dpi) + def test_icc(self, tmp_path: Path) -> None: # Test ICC support with Image.open("Tests/images/rgb.jpg") as im1: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 0fcb17e0919..5f33e0aa96a 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -681,8 +681,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: info = im.encoderinfo - dpi = [round(x) for x in info.get("dpi", (0, 0))] - quality = info.get("quality", -1) subsampling = info.get("subsampling", -1) qtables = info.get("qtables") @@ -813,7 +811,28 @@ def validate_qtables( optimize = info.get("optimize", False) + keep_rgb = info.get("keep_rgb", False) + jfif_dpi = (0, 0) exif = info.get("exif", b"") + if "dpi" in info: + dpi = tuple(round(x) for x in info["dpi"]) + if ( + keep_rgb + and im.mode in ("RGB", "RGBX") + and len(dpi) == 2 + and dpi[0] > 0 + and dpi[1] > 0 + ): + exif_object = Image.Exif() + exif_object.load(exif.tobytes() if isinstance(exif, Image.Exif) else exif) + exif = exif_object + + exif[0x011A] = dpi[0] + exif[0x011B] = dpi[1] + exif[0x0128] = 2 + else: + jfif_dpi = dpi + if isinstance(exif, Image.Exif): exif = exif.tobytes() if len(exif) > MAX_BYTES_IN_MARKER: @@ -826,9 +845,9 @@ def validate_qtables( progressive, info.get("smooth", 0), optimize, - info.get("keep_rgb", False), + keep_rgb, info.get("streamtype", 0), - dpi, + jfif_dpi, subsampling, info.get("restart_marker_blocks", 0), info.get("restart_marker_rows", 0),