diff --git a/.gitignore b/.gitignore index 3033c2ea7ae..0194086a6e2 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,6 @@ pillow-test-images.zip # pyinstaller *.spec + +# mypy +.mypy_cache diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 5f6b263a1e3..6da1ba71377 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -60,8 +60,7 @@ def test_save(tmp_path: Path) -> None: assert_image_similar_tofile(im, f, 8) im = hopper() - with pytest.raises(ValueError, match="Unsupported BLP image mode"): - im.save(f) + im.save(f) @pytest.mark.parametrize( diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 931ff02f1fb..21ec6e2520e 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -411,13 +411,6 @@ def test_not_implemented(test_file: str, message: str) -> None: pass -def test_save_unsupported_mode(tmp_path: Path) -> None: - out = tmp_path / "temp.dds" - im = hopper("HSV") - with pytest.raises(OSError, match="cannot write mode HSV as DDS"): - im.save(out) - - @pytest.mark.parametrize( "mode, test_file", [ diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index d4e8db4f43c..c1ae4a3cf2e 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -11,7 +11,6 @@ assert_image_equal_tofile, assert_image_similar, assert_image_similar_tofile, - hopper, is_win32, mark_if_feature_version, skip_unless_feature, @@ -285,13 +284,6 @@ def test_1(filename: str) -> None: assert_image_equal_tofile(im, "Tests/images/eps/1.bmp") -def test_image_mode_not_supported(tmp_path: Path) -> None: - im = hopper("RGBA") - tmpfile = tmp_path / "temp.eps" - with pytest.raises(ValueError): - im.save(tmpfile) - - @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @skip_unless_feature("zlib") def test_render_scale1() -> None: diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index 55c6b730599..5f5ae2560d0 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -107,13 +107,6 @@ def test_small_palette(tmp_path: Path) -> None: assert reloaded.getpalette() == colors + [0] * 765 -def test_save_unsupported_mode(tmp_path: Path) -> None: - out = tmp_path / "temp.im" - im = hopper("HSV") - with pytest.raises(ValueError): - im.save(out) - - def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index f4c8318a926..c4992c2083d 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -772,14 +772,6 @@ def test_save_correct_modes(self, mode: str) -> None: 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: - # 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") - def test_save_tiff_with_dpi(self, tmp_path: Path) -> None: # Arrange outfile = tmp_path / "temp.tif" @@ -1107,11 +1099,6 @@ def test_repr_jpeg(self) -> None: assert repr_jpeg.format == "JPEG" assert_image_similar(im, repr_jpeg, 17) - def test_repr_jpeg_error_returns_none(self) -> None: - im = hopper("F") - - assert im._repr_jpeg_() is None - @pytest.mark.skipif(not is_win32(), reason="Windows only") @skip_unless_feature("jpg") diff --git a/Tests/test_file_msp.py b/Tests/test_file_msp.py index 8c91922bd0b..8fa96b55781 100644 --- a/Tests/test_file_msp.py +++ b/Tests/test_file_msp.py @@ -79,13 +79,3 @@ def test_msp_v2() -> None: continue path = os.path.join(YA_EXTRA_DIR, f) _assert_file_image_equal(path, path.replace(".MSP", ".png")) - - -def test_cannot_save_wrong_mode(tmp_path: Path) -> None: - # Arrange - im = hopper() - filename = tmp_path / "temp.msp" - - # Act/Assert - with pytest.raises(OSError): - im.save(filename) diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index 58208ba99fa..c0953aff861 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -4,8 +4,6 @@ import subprocess from pathlib import Path -import pytest - from PIL import Image from .helper import assert_image_equal, hopper, magick_command @@ -67,9 +65,3 @@ def test_p_mode(tmp_path: Path) -> None: # Act / Assert helper_save_as_palm(tmp_path, mode) roundtrip(tmp_path, mode) - - -@pytest.mark.parametrize("mode", ("L", "RGB")) -def test_oserror(tmp_path: Path, mode: str) -> None: - with pytest.raises(OSError): - helper_save_as_palm(tmp_path, mode) diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index 509d93469e6..b3b0d32c097 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -30,12 +30,6 @@ def test_sanity(tmp_path: Path) -> None: im.putpalette((255, 0, 0)) _roundtrip(tmp_path, im) - # Test an unsupported mode - f = tmp_path / "temp.pcx" - im = hopper("RGBA") - with pytest.raises(ValueError): - im.save(f) - @pytest.mark.parametrize("size", ((0, 1), (1, 0), (0, 0))) def test_save_zero(size: tuple[int, int]) -> None: diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index a2218673b44..2a86f5be9f5 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -83,14 +83,6 @@ def test_monochrome(tmp_path: Path) -> None: assert os.path.getsize(outfile) < (5000 if features.check("libtiff") else 15000) -def test_unsupported_mode(tmp_path: Path) -> None: - im = hopper("PA") - outfile = tmp_path / "temp_PA.pdf" - - with pytest.raises(ValueError): - im.save(outfile) - - def test_resolution(tmp_path: Path) -> None: im = hopper() diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 2e0af504183..df56d6ee814 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -564,11 +564,6 @@ def test_repr_png(self) -> None: assert repr_png.format == "PNG" assert_image_equal(im, repr_png) - def test_repr_png_error_returns_none(self) -> None: - im = hopper("F") - - assert im._repr_png_() is None - def test_chunk_order(self, tmp_path: Path) -> None: with Image.open("Tests/images/icc_profile.png") as im: test_file = tmp_path / "temp.png" diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index fbca46be513..31e8f3d0c6e 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -141,12 +141,6 @@ def test_pfm_big_endian(tmp_path: Path) -> None: assert_image_equal_tofile(im, filename) -def test_save_unsupported_mode(tmp_path: Path) -> None: - im = hopper("P") - with pytest.raises(OSError, match="cannot write mode P as PPM"): - im.save(tmp_path / "out.ppm") - - @pytest.mark.parametrize( "data", [ diff --git a/Tests/test_file_qoi.py b/Tests/test_file_qoi.py index b9becb24f77..f28148d7199 100644 --- a/Tests/test_file_qoi.py +++ b/Tests/test_file_qoi.py @@ -51,7 +51,3 @@ def test_save(tmp_path: Path) -> None: im.save(f) assert_image_equal_tofile(im, f) - - im = hopper("P") - with pytest.raises(ValueError, match="Unsupported QOI image mode"): - im.save(f) diff --git a/Tests/test_file_sgi.py b/Tests/test_file_sgi.py index abf424dbf11..72e3489820c 100644 --- a/Tests/test_file_sgi.py +++ b/Tests/test_file_sgi.py @@ -113,14 +113,6 @@ def test_write16(tmp_path: Path) -> None: assert_image_equal_tofile(im, out) -def test_unsupported_mode(tmp_path: Path) -> None: - im = hopper("LA") - out = tmp_path / "temp.sgi" - - with pytest.raises(ValueError): - im.save(out, format="sgi") - - def test_unsupported_number_of_bytes_per_pixel(tmp_path: Path) -> None: im = hopper() out = tmp_path / "temp.sgi" diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 903632cffb0..b827d80b36e 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -72,7 +72,7 @@ def test_save(tmp_path: Path) -> None: def test_save_zero(size: tuple[int, int]) -> None: b = BytesIO() im = Image.new("1", size) - with pytest.raises(SystemError): + with pytest.raises((SystemError, ZeroDivisionError)): im.save(b, "SPIDER") diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index bb8d3eefcc6..490faa6549a 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -7,7 +7,7 @@ from PIL import Image, UnidentifiedImageError -from .helper import assert_image_equal, assert_image_equal_tofile, hopper +from .helper import assert_image_equal, assert_image_equal_tofile _TGA_DIR = os.path.join("Tests", "images", "tga") _TGA_DIR_COMMON = os.path.join(_TGA_DIR, "common") @@ -157,14 +157,6 @@ def test_missing_palette() -> None: assert im.mode == "L" -def test_save_wrong_mode(tmp_path: Path) -> None: - im = hopper("PA") - out = tmp_path / "temp.tga" - - with pytest.raises(OSError): - im.save(out) - - def test_save_mapdepth() -> None: # This image has been manually hexedited from 200x32_p_bl_raw.tga # to include an origin diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index c6c8467d629..6645a8c4f0a 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -229,12 +229,6 @@ def test_save_rgba(self, tmp_path: Path) -> None: outfile = tmp_path / "temp.tif" im.save(outfile) - def test_save_unsupported_mode(self, tmp_path: Path) -> None: - im = hopper("HSV") - outfile = tmp_path / "temp.tif" - with pytest.raises(OSError): - im.save(outfile) - def test_8bit_s(self) -> None: with Image.open("Tests/images/8bit.s.tif") as im: im.load() diff --git a/Tests/test_file_xbm.py b/Tests/test_file_xbm.py index 154f3dcc061..353f56453c3 100644 --- a/Tests/test_file_xbm.py +++ b/Tests/test_file_xbm.py @@ -71,14 +71,6 @@ def test_invalid_file() -> None: XbmImagePlugin.XbmImageFile(invalid_file) -def test_save_wrong_mode(tmp_path: Path) -> None: - im = hopper() - out = tmp_path / "temp.xbm" - - with pytest.raises(OSError): - im.save(out) - - def test_hotspot(tmp_path: Path) -> None: im = hopper("1") out = tmp_path / "temp.xbm" diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 547a6c2c678..dbc142b4f2f 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -15,21 +15,7 @@ def convert(im: Image.Image, mode: str) -> None: assert out.mode == mode assert out.size == im.size - modes = ( - "1", - "L", - "LA", - "P", - "PA", - "I", - "F", - "RGB", - "RGBA", - "RGBX", - "CMYK", - "YCbCr", - "HSV", - ) + modes = Image.MODES for input_mode in modes: im = hopper(input_mode) diff --git a/Tests/test_image_save.py b/Tests/test_image_save.py new file mode 100644 index 00000000000..b29fdc85684 --- /dev/null +++ b/Tests/test_image_save.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from io import BytesIO + +from PIL import Image, features + + +def test_image_save() -> None: + # Some extensions specify the mode, and not all file objects are named. + im = Image.new("RGBA", (1, 1)) + out = BytesIO() + im.save(out, format=".bw") + im = Image.open(out) + assert im.mode == "L" + + for format in Image.SAVE.keys(): + if format in ("JPEG2000", "PDF") and not features.check_codec("jpg_2000"): + # A test skip for this is logged elsewhere. + continue + for mode in Image.MODES: + im = Image.new(mode, (1, 1)) + out = BytesIO() + try: + im.save(out, format=format) + except Exception as ex: + msg = f"Mode {mode} to format {format}: {ex}" + if "handler not installed" in str(ex): + print(msg) + break + else: + raise Exception(msg) + + +def test_image_save_all() -> None: + ims = [Image.new(mode, (1, 1)) for mode in Image.MODES] + for format in Image.SAVE_ALL.keys(): + if format in ("JPEG2000", "PDF") and not features.check_codec("jpg_2000"): + # A test skip for this is logged elsewhere. + continue + out = BytesIO() + try: + ims[0].save(out, format=format, append_images=ims) + except Exception as ex: + msg = f"Multiframe to format {format}: {ex}" + raise Exception(msg) diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 6bb92edf891..22d14b03ec1 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -463,11 +463,12 @@ def encode(self, bufsize: int) -> tuple[int, int, bytes]: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + encoderinfo = im.encoderinfo + # PA is possible according to https://wowwiki-archive.fandom.com/wiki/BLP_file if im.mode != "P": - msg = "Unsupported BLP image mode" - raise ValueError(msg) + im = im.convert("P") - magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2" + magic = b"BLP1" if encoderinfo.get("blp_version") == "BLP1" else b"BLP2" fp.write(magic) assert im.palette is not None diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 5ee61b35b17..56401268b38 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -429,14 +429,11 @@ def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def _save( im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True ) -> None: - try: - rawmode, bits, colors = SAVE[im.mode] - except KeyError as e: - msg = f"cannot write mode {im.mode} as BMP" - raise OSError(msg) from e - info = im.encoderinfo + if im.mode not in SAVE: + im = im.convert("RGBA") + rawmode, bits, colors = SAVE[im.mode] dpi = info.get("dpi", (96, 96)) # 1 meter == 39.3701 inches diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 312f602a6b1..75a5e243d98 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -523,13 +523,13 @@ def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + encoderinfo = im.encoderinfo if im.mode not in ("RGB", "RGBA", "L", "LA"): - msg = f"cannot write mode {im.mode} as DDS" - raise OSError(msg) + im = im.convert("RGBA") flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT bitcount = len(im.getbands()) * 8 - pixel_format = im.encoderinfo.get("pixel_format") + pixel_format = encoderinfo.get("pixel_format") args: tuple[int] | str if pixel_format: codec_name = "bcn" diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index aeb7b0c93b3..d11f2475232 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -426,6 +426,12 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) - # make sure image data is available im.load() + if im.mode not in ("L", "RGB", "CMYK"): + if im.mode == "LAB": + im = im.convert("RGB") + else: + im = im.convert("CMYK") + # determine PostScript image mode if im.mode == "L": operator = (8, 1, b"image") diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 390b3b374ab..bcca6bcc7e2 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -527,6 +527,8 @@ def _normalize_mode(im: Image.Image) -> Image.Image: if im.mode in RAWMODE: im.load() return im + if im.mode in ("CMYK", "HSV", "LAB", "PA", "RGBa", "RGBX", "YCbCr"): + im = im.convert("RGBA") if Image.getmodebase(im.mode) == "RGB": im = im.convert("P", palette=Image.Palette.ADAPTIVE) assert im.palette is not None diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index ef54f16e97e..3078873ab4c 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -341,13 +341,11 @@ def tell(self) -> int: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - try: - image_type, rawmode = SAVE[im.mode] - except KeyError as e: - msg = f"Cannot save {im.mode} images as IM" - raise ValueError(msg) from e - - frames = im.encoderinfo.get("frames", 1) + encoderinfo = im.encoderinfo + if im.mode not in SAVE: + im = im.convert("RGBA") + image_type, rawmode = SAVE[im.mode] + frames = encoderinfo.get("frames", 1) fp.write(f"Image type: {image_type} image\r\n".encode("ascii")) if filename: diff --git a/src/PIL/Image.py b/src/PIL/Image.py index cc431a86a5d..7852b29cf9f 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1066,6 +1066,9 @@ def convert( :rtype: :py:class:`~PIL.Image.Image` :returns: An :py:class:`~PIL.Image.Image` object. """ + # colorspace conversion + if dither is None: + dither = Dither.FLOYDSTEINBERG self.load() @@ -1212,7 +1215,7 @@ def convert_transparency( im = self if mode == "LAB": if im.mode not in ("RGB", "RGBA", "RGBX"): - im = im.convert("RGBA") + im = im.convert("RGBA", dither=dither) other_mode = im.mode else: other_mode = mode @@ -1227,19 +1230,39 @@ def convert_transparency( ) return transform.apply(im) - # colorspace conversion - if dither is None: - dither = Dither.FLOYDSTEINBERG + if self.im.mode == "LAB" and ( + mode + in ("1", "CMYK", "F", "HSV", "L", "LA", "La", "P", "PA", "RGBa", "YCbCr") + or mode.startswith("I") + ): + return self.convert("RGBA", dither=dither).convert(mode, dither=dither) + + if ( + self.im.mode in ("1", "F", "L") or self.im.mode.startswith("I") + ) and mode in ("La", "RGBa"): + return self.convert("RGB").convert(mode) + + if self.im.mode in ("La", "LA", "P") and mode == "RGBa": + return self.convert("RGBA").convert(mode) + + if self.im.mode == "P" and (mode.startswith("I") or mode in ("La",)): + return self.convert("RGBA").convert(mode, dither=dither) + + if self.im.mode == "La": + im = self.im.convert("LA") + else: + im = self.im try: - im = self.im.convert(mode, dither) + im = im.convert(mode, dither) except ValueError: try: - # normalize source image and try again + # normalize source image modebase = getmodebase(self.mode) if modebase == self.mode: raise - im = self.im.convert(modebase) + im = im.convert(modebase, dither) + # try again im = im.convert(mode, dither) except KeyError as e: msg = "illegal conversion" @@ -2591,8 +2614,8 @@ def save( <../handbook/image-file-formats>` for each writer. You can use a file object instead of a filename. In this case, - you must always specify the format. The file object must - implement the ``seek``, ``tell``, and ``write`` + you must always specify the format or the name property. + The file object must implement the ``seek``, ``tell``, and ``write`` methods, and be opened in binary mode. :param fp: A filename (string), os.PathLike object or file object. @@ -2636,6 +2659,13 @@ def save( # only set the name for metadata purposes filename = os.fspath(fp.name) + # Accept extension as format so plugins can use + # the filename to set the proper mode. + if format in EXTENSION: + if not filename and not hasattr(fp, "name"): + filename = os.fspath(format) + format = EXTENSION[format] + if format: preinit() else: diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index d6ec38d4310..f42753fe360 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -20,7 +20,9 @@ import struct from typing import cast -from . import Image, ImageFile, ImagePalette, _binary +from packaging.version import parse + +from . import Image, ImageFile, ImagePalette, _binary, features TYPE_CHECKING = False if TYPE_CHECKING: @@ -371,6 +373,15 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Get the keyword arguments info = im.encoderinfo + # Prevent OSError: broken data stream when writing image file + supported_modes = ["I;16", "L", "LA", "RGB", "RGBA"] + # CMYK fails on Ubuntu + if version := features.version_codec("jpg_2000"): + if parse(version) >= parse("2.5.3"): + supported_modes.append("CMYK") + if im.mode not in supported_modes: + im = im.convert("RGBA") + if isinstance(filename, str): filename = filename.encode() if filename.endswith(b".j2k") or info.get("no_jp2", False): diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 894c1547d7b..ff920284b09 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -665,14 +665,12 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: msg = "cannot write empty image as JPEG" raise ValueError(msg) - try: - rawmode = RAWMODE[im.mode] - except KeyError as e: - msg = f"cannot write mode {im.mode} as JPEG" - raise OSError(msg) from e - info = im.encoderinfo + if im.mode not in RAWMODE: + im = im.convert("RGB") + rawmode = RAWMODE[im.mode] + dpi = [round(x) for x in info.get("dpi", (0, 0))] quality = info.get("quality", -1) diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index fa0f52fe8db..b4c16df194d 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -166,8 +166,7 @@ def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "1": - msg = f"cannot write mode {im.mode} as MSP" - raise OSError(msg) + im = im.convert("1") # create MSP header header = [0] * 16 diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 232adf3d3bb..a7222b8038d 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -115,17 +115,25 @@ def build_prototype_image() -> Image.Image: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + encoderinfo = im.encoderinfo + if ( + im.mode not in ("1", "L", "P") + or im.mode == "L" + and im.info.get("bpp") not in (1, 2, 4) + ): + im = im.convert("P") + if im.mode == "P": rawmode = "P" bpp = 8 version = 1 elif im.mode == "L": - if im.encoderinfo.get("bpp") in (1, 2, 4): + if encoderinfo.get("bpp") in (1, 2, 4): # this is 8-bit grayscale, so we shift it to get the high-order bits, # and invert it because # Palm does grayscale from white (0) to black (1) - bpp = im.encoderinfo["bpp"] + bpp = encoderinfo["bpp"] maxval = (1 << bpp) - 1 shift = 8 - bpp im = im.point(lambda x: maxval - (x >> shift)) @@ -151,11 +159,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: bpp = 1 version = 0 - else: - msg = f"cannot write mode {im.mode} as Palm" - raise OSError(msg) - - # # make sure image data is available im.load() diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 3e34e3c63ba..ef75407bcc8 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -150,11 +150,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: msg = "Cannot write empty image as PCX" raise ValueError(msg) - try: - version, bits, planes, rawmode = SAVE[im.mode] - except KeyError as e: - msg = f"Cannot save {im.mode} images as PCX" - raise ValueError(msg) from e + if im.mode not in SAVE: + im = im.convert("RGB") + version, bits, planes, rawmode = SAVE[im.mode] # bytes per plane stride = (im.size[0] * bits + 7) // 8 diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 5594c7e0f2b..8cab4efa72b 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -66,6 +66,25 @@ def _write_image( width, height = im.size + encoderinfo = im.encoderinfo + if im.mode in ( + "F", + "HSV", + "I", + "I;16", + "I;16B", + "I;16L", + "I;16N", + "La", + "LAB", + "PA", + "RGBa", + "RGBX", + "YCbCr", + ): + im = im.convert("RGBA") + im.encoderinfo = encoderinfo # for Jpeg2K plugin + dict_obj: dict[str, Any] = {"BitsPerComponent": 8} if im.mode == "1": if features.check("libtiff"): diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 572762e6c83..d09a0d76b44 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1340,6 +1340,8 @@ def _save( append_images = im.encoderinfo.get("append_images", []) for im_seq in itertools.chain([im], append_images): for im_frame in ImageSequence.Iterator(im_seq): + if im_frame.mode not in _OUTMODES: + im_frame = im_frame.convert("RGBA") modes.add(im_frame.mode) sizes.add(im_frame.size) for mode in ("RGBA", "RGB", "P"): @@ -1350,6 +1352,10 @@ def _save( size = tuple(max(frame_size[i] for frame_size in sizes) for i in range(2)) else: size = im.size + if im.mode not in _OUTMODES: + encoderinfo = im.encoderinfo + im = im.convert("RGBA") + im.encoderinfo = encoderinfo mode = im.mode outmode = mode diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 307bc97ff65..75985f48eda 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -331,6 +331,13 @@ def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if str(filename).endswith(".pbm"): + im = im.convert("1") + elif str(filename).endswith(".pgm") and im.mode not in ("1", "L", "I", "I;16", "F"): + im = im.convert("L") + elif im.mode not in ("1", "L", "I", "I;16", "RGB", "RGBA", "F"): + im = im.convert("RGBA") + if im.mode == "1": rawmode, head = "1;I", b"P4" elif im.mode == "L": @@ -339,11 +346,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: rawmode, head = "I;16B", b"P5" elif im.mode in ("RGB", "RGBA"): rawmode, head = "RGB", b"P6" - elif im.mode == "F": + else: # im.mode == "F" rawmode, head = "F;32F", b"Pf" - else: - msg = f"cannot write mode {im.mode} as PPM" - raise OSError(msg) + fp.write(head + b"\n%d %d\n" % im.size) if head == b"P6": fp.write(b"255\n") diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index d0709b1198a..dd9c02baeed 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -115,15 +115,13 @@ def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - if im.mode == "RGB": - channels = 3 - elif im.mode == "RGBA": - channels = 4 - else: - msg = "Unsupported QOI image mode" - raise ValueError(msg) - - colorspace = 0 if im.encoderinfo.get("colorspace") == "sRGB" else 1 + encoderinfo = im.encoderinfo + + if im.mode not in ("RGB", "RGBA"): + im = im.convert("RGBA") + + channels = 3 if im.mode == "RGB" else 4 + colorspace = 0 if encoderinfo.get("colorspace") == "sRGB" else 1 fp.write(b"qoif") fp.write(o32(im.size[0])) diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index 853022150ae..a95e987b4af 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -128,13 +128,16 @@ def _open(self) -> None: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - if im.mode not in {"RGB", "RGBA", "L"}: - msg = "Unsupported SGI image mode" - raise ValueError(msg) - # Get the keyword arguments info = im.encoderinfo + if str(filename).endswith(".rgb"): + im = im.convert("RGB") + elif str(filename).endswith(".bw"): + im = im.convert("L") + elif str(filename).endswith(".rgba") or im.mode not in {"RGB", "RGBA", "L"}: + im = im.convert("RGBA") + # Byte-per-pixel precision, 1 = 8bits per pixel bpc = info.get("bpc", 1) diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 90d5b5cf4ee..a68e1d52878 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -177,21 +177,20 @@ def load_end(self) -> None: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - try: - rawmode, bits, colormaptype, imagetype = SAVE[im.mode] - except KeyError as e: - msg = f"cannot write mode {im.mode} as TGA" - raise OSError(msg) from e - - if "rle" in im.encoderinfo: - rle = im.encoderinfo["rle"] + encoderinfo = im.encoderinfo + if im.mode not in SAVE: + im = im.convert("RGBA") + rawmode, bits, colormaptype, imagetype = SAVE[im.mode] + + if "rle" in encoderinfo: + rle = encoderinfo["rle"] else: - compression = im.encoderinfo.get("compression", im.info.get("compression")) + compression = encoderinfo.get("compression", im.info.get("compression")) rle = compression == "tga_rle" if rle: imagetype += 8 - id_section = im.encoderinfo.get("id_section", im.info.get("id_section", "")) + id_section = encoderinfo.get("id_section", im.info.get("id_section", "")) id_len = len(id_section) if id_len > 255: id_len = 255 @@ -209,7 +208,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: else: flags = 0 - orientation = im.encoderinfo.get("orientation", im.info.get("orientation", -1)) + orientation = encoderinfo.get("orientation", im.info.get("orientation", -1)) if orientation > 0: flags = flags | 0x20 diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index de2ce066ebf..2635625d1cb 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1694,14 +1694,12 @@ def _setup(self) -> None: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - try: - rawmode, prefix, photo, format, bits, extra = SAVE_INFO[im.mode] - except KeyError as e: - msg = f"cannot write mode {im.mode} as TIFF" - raise OSError(msg) from e - encoderinfo = im.encoderinfo encoderconfig = im.encoderconfig + if im.mode not in SAVE_INFO: + im = im.convert("RGBA") + + rawmode, prefix, photo, format, bits, extra = SAVE_INFO[im.mode] ifd = ImageFileDirectory_v2(prefix=prefix) if encoderinfo.get("big_tiff"): @@ -2306,6 +2304,19 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if not hasattr(im, "n_frames") and not append_images: return _save(im, fp, filename) + if im.mode not in SAVE_INFO: + info = im.encoderinfo + im = im.convert("RGBA") + im.encoderinfo = info + + for i in range(len(append_images)): + frame = append_images[i] + if frame.mode != im.mode: + info = frame.encoderinfo if hasattr(frame, "encoderinfo") else {} + frame = frame.convert(im.mode) + frame.encoderinfo = info + append_images[i] = frame + cur_idx = im.tell() try: with AppendingTiffWriter(fp) as tf: diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index 1e57aa162ea..9c9ca186494 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -71,14 +71,14 @@ def _open(self) -> None: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + encoderinfo = im.encoderinfo if im.mode != "1": - msg = f"cannot write mode {im.mode} as XBM" - raise OSError(msg) + im = im.convert("1") fp.write(f"#define im_width {im.size[0]}\n".encode("ascii")) fp.write(f"#define im_height {im.size[1]}\n".encode("ascii")) - hotspot = im.encoderinfo.get("hotspot") + hotspot = encoderinfo.get("hotspot") if hotspot: fp.write(f"#define im_x_hot {hotspot[0]}\n".encode("ascii")) fp.write(f"#define im_y_hot {hotspot[1]}\n".encode("ascii"))