From 0e6eb1240612e952668e63ab704ba8d69257e51b Mon Sep 17 00:00:00 2001 From: Cees Timmerman Date: Mon, 23 Feb 2026 01:05:49 +0100 Subject: [PATCH] Save all MODES as all SAVE formats Document name feature of file pointer --- .gitignore | 3 +++ Tests/test_file_blp.py | 3 +-- Tests/test_file_dds.py | 7 ------- Tests/test_file_eps.py | 8 -------- Tests/test_file_im.py | 7 ------- Tests/test_file_jpeg.py | 13 ------------- Tests/test_file_msp.py | 10 ---------- Tests/test_file_palm.py | 8 -------- Tests/test_file_pcx.py | 9 ++------- Tests/test_file_pdf.py | 8 -------- Tests/test_file_png.py | 5 ----- Tests/test_file_ppm.py | 6 ------ Tests/test_file_qoi.py | 4 ---- Tests/test_file_sgi.py | 8 -------- Tests/test_file_spider.py | 2 +- Tests/test_file_tga.py | 10 +--------- Tests/test_file_tiff.py | 6 ------ Tests/test_file_xbm.py | 8 -------- Tests/test_image_save.py | 35 +++++++++++++++++++++++++++++++++++ src/PIL/BlpImagePlugin.py | 7 ++++--- src/PIL/BmpImagePlugin.py | 9 +++------ src/PIL/DdsImagePlugin.py | 6 +++--- src/PIL/EpsImagePlugin.py | 6 ++++++ src/PIL/GifImagePlugin.py | 2 ++ src/PIL/ImImagePlugin.py | 12 +++++------- src/PIL/Image.py | 25 ++++++++++++++++++++----- src/PIL/Jpeg2KImagePlugin.py | 9 +++++++++ src/PIL/JpegImagePlugin.py | 10 ++++------ src/PIL/MspImagePlugin.py | 3 +-- src/PIL/PalmImagePlugin.py | 17 ++++++++++------- src/PIL/PcxImagePlugin.py | 12 +++--------- src/PIL/PdfImagePlugin.py | 19 +++++++++++++++++++ src/PIL/PngImagePlugin.py | 27 ++++++++++++++------------- src/PIL/PpmImagePlugin.py | 13 +++++++++---- src/PIL/QoiImagePlugin.py | 16 +++++++--------- src/PIL/SgiImagePlugin.py | 11 +++++++---- src/PIL/SpiderImagePlugin.py | 2 +- src/PIL/TgaImagePlugin.py | 21 ++++++++++----------- src/PIL/TiffImagePlugin.py | 10 ++++------ src/PIL/XbmImagePlugin.py | 6 +++--- 40 files changed, 187 insertions(+), 216 deletions(-) create mode 100644 Tests/test_image_save.py 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..7996fb2e70d 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -1,6 +1,7 @@ from __future__ import annotations import io +import struct from pathlib import Path import pytest @@ -30,18 +31,12 @@ 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: b = io.BytesIO() im = Image.new("1", size) - with pytest.raises(ValueError): + with pytest.raises(struct.error): im.save(b, "PCX") 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_save.py b/Tests/test_image_save.py new file mode 100644 index 00000000000..ff807401740 --- /dev/null +++ b/Tests/test_image_save.py @@ -0,0 +1,35 @@ +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" + + # This works: + Image.new("LAB", (1, 1)).convert("RGBA").convert("PA") + # But this fails on internal convert to RGBA: + # Image.new('LAB', (1,1)).convert('PA') + + 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) + else: + raise Exception(msg) diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 6bb92edf891..e1b9d5d1071 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("RGBA").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..4568bf582f3 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1231,15 +1231,23 @@ def convert_transparency( if dither is None: dither = Dither.FLOYDSTEINBERG + if self.im.mode == "La": + im = self.im.convert("LA") + elif self.im.mode == "LAB": + im = self.im.convert("RGBA", dither) + 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 +2599,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 +2644,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..38d8685c932 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -18,6 +18,7 @@ import io import os import struct +import sys from typing import cast from . import Image, ImageFile, ImagePalette, _binary @@ -371,6 +372,14 @@ 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 sys.platform != "linux": + 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..32f1df1e576 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("RGBA").convert("1") # create MSP header header = [0] * 16 diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 232adf3d3bb..437f69be3b7 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("RGBA").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..6da239c6452 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -146,15 +146,9 @@ def _open(self) -> None: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - if im.width == 0 or im.height == 0: - 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 9826a4cd148..222cd99ec89 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1330,14 +1330,13 @@ def _save( save_all: bool = False, ) -> None: # save an image to disk (called by the save method) + encoderinfo = im.encoderinfo if save_all: - default_image = im.encoderinfo.get( - "default_image", im.info.get("default_image") - ) + default_image = encoderinfo.get("default_image", im.info.get("default_image")) modes = set() sizes = set() - append_images = im.encoderinfo.get("append_images", []) + append_images = encoderinfo.get("append_images", []) for im_seq in itertools.chain([im], append_images): for im_frame in ImageSequence.Iterator(im_seq): modes.add(im_frame.mode) @@ -1350,15 +1349,17 @@ 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: + im = im.convert("RGBA") mode = im.mode outmode = mode if mode == "P": # # attempt to minimize storage requirements for palette images - if "bits" in im.encoderinfo: + if "bits" in encoderinfo: # number of bits specified by user - colors = min(1 << im.encoderinfo["bits"], 256) + colors = min(1 << encoderinfo["bits"], 256) else: # check palette contents if im.palette: @@ -1403,7 +1404,7 @@ def _save( chunks = [b"cHRM", b"cICP", b"gAMA", b"sBIT", b"sRGB", b"tIME"] - icc = im.encoderinfo.get("icc_profile", im.info.get("icc_profile")) + icc = encoderinfo.get("icc_profile", im.info.get("icc_profile")) if icc: # ICC profile # according to PNG spec, the iCCP chunk contains: @@ -1419,7 +1420,7 @@ def _save( # Disallow sRGB chunks when an iCCP-chunk has been emitted. chunks.remove(b"sRGB") - info = im.encoderinfo.get("pnginfo") + info = encoderinfo.get("pnginfo") if info: chunks_multiple_allowed = [b"sPLT", b"iTXt", b"tEXt", b"zTXt"] for info_chunk in info.chunks: @@ -1442,7 +1443,7 @@ def _save( palette_bytes += b"\0" chunk(fp, b"PLTE", palette_bytes) - transparency = im.encoderinfo.get("transparency", im.info.get("transparency", None)) + transparency = encoderinfo.get("transparency", im.info.get("transparency", None)) if transparency or transparency == 0: if im.mode == "P": @@ -1461,7 +1462,7 @@ def _save( red, green, blue = transparency chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue)) else: - if "transparency" in im.encoderinfo: + if "transparency" in encoderinfo: # don't bother with transparency if it's an RGBA # and it's in the info dict. It's probably just stale. msg = "cannot use transparency for this mode" @@ -1472,7 +1473,7 @@ def _save( alpha_bytes = colors chunk(fp, b"tRNS", alpha[:alpha_bytes]) - dpi = im.encoderinfo.get("dpi") + dpi = encoderinfo.get("dpi") if dpi: chunk( fp, @@ -1490,7 +1491,7 @@ def _save( chunks.remove(cid) chunk(fp, cid, data) - exif = im.encoderinfo.get("exif") + exif = encoderinfo.get("exif") if exif: if isinstance(exif, Image.Exif): exif = exif.tobytes(8) @@ -1504,7 +1505,7 @@ def _save( im, fp, chunk, mode, rawmode, default_image, append_images ) if single_im: - _apply_encoderinfo(single_im, im.encoderinfo) + _apply_encoderinfo(single_im, encoderinfo) ImageFile._save( single_im, cast(IO[bytes], _idat(fp, chunk)), 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/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 11d90699d10..701446c12e8 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -274,7 +274,7 @@ def makeSpiderHeader(im: Image.Image) -> list[bytes]: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "F": - im = im.convert("F") + im = im.convert("RGBA").convert("F") hdr = makeSpiderHeader(im) if len(hdr) < 256: 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..1edeee67e7c 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"): diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index 1e57aa162ea..9c871279858 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("RGBA").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"))