diff --git a/Tests/images/pil123rgba_red.jpg b/Tests/images/pil123rgba_red.jpg new file mode 100644 index 000000000..073a65436 Binary files /dev/null and b/Tests/images/pil123rgba_red.jpg differ diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index f5c2f360c..6529b9804 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1411,6 +1411,18 @@ def test_save_I(tmp_path: Path) -> None: assert_image_equal(reloaded.convert("L"), im.convert("L")) +def test_save_wrong_modes() -> None: + out = BytesIO() + for mode in ["CMYK"]: + img = Image.new(mode, (20, 20)) + with pytest.raises(ValueError): + img.save(out, "GIF") + + for mode in ["CMYK", "LA"]: + img = Image.new(mode, (20, 20)) + img.save(out, "GIF", convert_mode=True) + + def test_getdata(monkeypatch: pytest.MonkeyPatch) -> None: # Test getheader/getdata against legacy values. # Create a 'P' image with holes in the palette. diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 08e879807..dd665d9a0 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -778,13 +778,24 @@ class TestFileJpeg: img = Image.new(mode, (20, 20)) img.save(out, "JPEG") - @pytest.mark.parametrize("mode", ("LA", "La", "RGBA", "RGBa", "P")) - def test_save_wrong_modes(self, mode: str) -> None: + def test_save_wrong_modes(self, tmp_path: Path) -> None: # ref https://github.com/python-pillow/Pillow/issues/2005 out = BytesIO() - img = Image.new(mode, (20, 20)) - with pytest.raises(OSError): - img.save(out, "JPEG") + for mode in ["LA", "La", "RGBA", "RGBa", "P", "I"]: + img = Image.new(mode, (20, 20)) + with pytest.raises(OSError): + img.save(out, "JPEG") + + for mode in ["LA", "RGBA", "P", "I"]: + img = Image.new(mode, (20, 20)) + img.save(out, "JPEG", convert_mode=True) + + temp_file = tmp_path / "temp.jpg" + with Image.open("Tests/images/pil123rgba.png") as img: + img.save(temp_file, convert_mode=True, fill_color="red") + + with Image.open(temp_file) as reloaded: + assert_image_similar_tofile(reloaded, "Tests/images/pil123rgba_red.jpg", 4) def test_save_tiff_with_dpi(self, tmp_path: Path) -> None: # Arrange diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 0a51fd493..165d821d7 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -243,6 +243,14 @@ class TestFilePng: # image has 876 transparent pixels assert im.getchannel("A").getcolors()[0][0] == 876 + def test_save_CMYK(self) -> None: + out = BytesIO() + im = Image.new("CMYK", (20, 20)) + with pytest.raises(IOError): + im.save(out, "PNG") + + im.save(out, "PNG", convert_mode=True) + def test_save_p_transparent_palette(self, tmp_path: Path) -> None: in_file = "Tests/images/pil123p.png" with Image.open(in_file) as im: diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 5456adf59..3273824cc 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -94,6 +94,12 @@ class TestFileWebp: target = target.convert(self.rgb_mode) assert_image_similar(image, target, epsilon) + def test_save_convert_mode(self) -> None: + out = io.BytesIO() + for mode in ["CMYK", "I", "L", "LA", "P"]: + img = Image.new(mode, (20, 20)) + img.save(out, "WEBP", convert_mode=True) + def test_write_rgb(self, tmp_path: Path) -> None: """ Can we write a RGB mode file to webp without error? diff --git a/Tests/test_image.py b/Tests/test_image.py index 83b027aa2..35ce5ea4b 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -19,6 +19,7 @@ from PIL import ( ImageDraw, ImageFile, ImagePalette, + TiffImagePlugin, UnidentifiedImageError, features, ) @@ -475,6 +476,77 @@ class TestImage: for ext in [".cur", ".icns", ".tif", ".tiff"]: assert ext in extensions + def test_supported_modes(self) -> None: + for format in Image.MIME.keys(): + try: + save_handler = Image.SAVE[format] + except KeyError: + continue + plugin = sys.modules[save_handler.__module__] + if not hasattr(plugin, "_supported_modes"): + continue + + # Check that the supported modes list is accurate + supported_modes = plugin._supported_modes() + for mode in [ + "1", + "L", + "P", + "RGB", + "RGBA", + "CMYK", + "YCbCr", + "LAB", + "HSV", + "I", + "F", + "LA", + "La", + "RGBX", + "RGBa", + ]: + out = io.BytesIO() + im = Image.new(mode, (100, 100)) + if mode in supported_modes: + im.save(out, format) + else: + with pytest.raises(Exception): + im.save(out, format) + + def test_no_supported_modes_method(self, tmp_path: Path) -> None: + assert not hasattr(TiffImagePlugin, "_supported_modes") + + temp_file = tmp_path / "temp.tiff" + + im = hopper() + im.save(temp_file, convert_mode=True) + + @pytest.mark.parametrize( + "mode, modes", + ( + ("P", ["RGB"]), + ("P", ["L"]), # converting to a non-preferred mode + ("LA", ["P"]), + ("I", ["L"]), + ("RGB", ["L"]), + ("RGB", ["CMYK"]), + ), + ) + def test_convert_mode(self, mode: str, modes: list[str]) -> None: + im = Image.new(mode, (100, 100)) + assert im._convert_mode(modes) is not None + + @pytest.mark.parametrize( + "mode, modes", + ( + ("P", []), # no mode + ("P", ["P"]), # same mode + ), + ) + def test_convert_mode_noop(self, mode: str, modes: list[str]) -> None: + im = Image.new(mode, (100, 100)) + assert im._convert_mode(modes) is None + def test_effect_mandelbrot(self) -> None: # Arrange size = (512, 512) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index b03aa7f15..fff7ed8e2 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -1197,6 +1197,10 @@ def getdata( return fp.data +def _supported_modes() -> list[str]: + return ["RGB", "RGBA", "P", "I", "F", "LA", "L", "1"] + + # -------------------------------------------------------------------- # Registry diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 262b5478b..edb15dbe1 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2553,6 +2553,14 @@ class Image: else: save_handler = SAVE[format.upper()] + if params.get("convert_mode"): + plugin = sys.modules[save_handler.__module__] + if hasattr(plugin, "_supported_modes"): + modes = plugin._supported_modes() + converted_im = self._convert_mode(modes, params) + if converted_im: + return converted_im.save(fp, format, **params) + created = False if open_fp: created = not os.path.exists(filename) @@ -2586,6 +2594,66 @@ class Image: self.encoderinfo = {**im._default_encoderinfo, **encoderinfo} return encoderinfo + def _convert_mode( + self, modes: list[str], params: dict[str, Any] = {} + ) -> Image | None: + if not modes or self.mode in modes: + return None + if self.mode == "P": + preferred_modes = [] + if "A" in self.im.getpalettemode(): + preferred_modes.append("RGBA") + preferred_modes.append("RGB") + else: + preferred_modes = { + "CMYK": ["RGB"], + "RGB": ["CMYK"], + "RGBX": ["RGB"], + "RGBa": ["RGBA", "RGB"], + "RGBA": ["RGB"], + "LA": ["RGBA", "P", "L"], + "La": ["LA", "L"], + "L": ["RGB"], + "F": ["I"], + "I": ["L", "RGB"], + "1": ["L"], + "YCbCr": ["RGB"], + "LAB": ["RGB"], + "HSV": ["RGB"], + }.get(self.mode, []) + for new_mode in preferred_modes: + if new_mode in modes: + break + else: + new_mode = modes[0] + if self.mode == "LA" and new_mode == "P": + alpha = self.getchannel("A") + # Convert the image into P mode but only use 255 colors + # in the palette out of 256. + im = self.convert("L").convert("P", palette=Palette.ADAPTIVE, colors=255) + # Set all pixel values below 128 to 255, and the rest to 0. + mask = eval(alpha, lambda px: 255 if px < 128 else 0) + # Paste the color of index 255 and use alpha as a mask. + im.paste(255, mask) + # The transparency index is 255. + im.info["transparency"] = 255 + return im + + elif self.mode == "I": + im = self.point([i // 256 for i in range(65536)], "L") + return im.convert(new_mode) if new_mode != "L" else im + + elif self.mode in ("RGBA", "LA") and new_mode in ("RGB", "L"): + fill_color = params.get("fill_color", "white") + background = new(new_mode, self.size, fill_color) + background.paste(self, self.getchannel("A")) + return background + + elif new_mode: + return self.convert(new_mode) + + return None + def seek(self, frame: int) -> None: """ Seeks to the given frame in this sequence file. If you seek diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 082f3551a..d638d6573 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -884,6 +884,10 @@ def jpeg_factory( return im +def _supported_modes() -> list[str]: + return ["RGB", "CMYK", "YCbCr", "RGBX", "L", "1"] + + # --------------------------------------------------------------------- # Registry stuff diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 1b9a89aef..39957cace 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1539,6 +1539,10 @@ def getchunks(im: Image.Image, **params: Any) -> list[tuple[bytes, bytes, bytes] return chunks +def _supported_modes() -> list[str]: + return ["RGB", "RGBA", "P", "I", "LA", "L", "1"] + + # -------------------------------------------------------------------- # Registry diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 1716a18cc..4353e7e15 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -312,6 +312,25 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(data) +def _supported_modes() -> list[str]: + return [ + "RGB", + "RGBA", + "RGBa", + "RGBX", + "CMYK", + "YCbCr", + "HSV", + "I", + "F", + "P", + "LA", + "LAB", + "L", + "1", + ] + + Image.register_open(WebPImageFile.format, WebPImageFile, _accept) if SUPPORTED: Image.register_save(WebPImageFile.format, _save)