Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added Tests/images/pil123rgba_red.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions Tests/test_file_gif.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 16 additions & 5 deletions Tests/test_file_jpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,13 +769,24 @@ 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:
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
Expand Down
8 changes: 8 additions & 0 deletions Tests/test_file_png.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ def test_load_transparent_rgb(self) -> None:
# 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:
Expand Down
6 changes: 6 additions & 0 deletions Tests/test_file_webp.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ def _roundtrip(
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?
Expand Down
72 changes: 72 additions & 0 deletions Tests/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ImageFile,
ImagePalette,
ImageShow,
TiffImagePlugin,
UnidentifiedImageError,
features,
)
Expand Down Expand Up @@ -507,6 +508,77 @@ def test_registered_extensions(self) -> None:
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)
Expand Down
4 changes: 4 additions & 0 deletions src/PIL/GifImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,10 @@ def write(self, data: Buffer) -> int:
return fp.data


def _supported_modes() -> list[str]:
return ["RGB", "RGBA", "P", "I", "F", "LA", "L", "1"]


# --------------------------------------------------------------------
# Registry

Expand Down
68 changes: 68 additions & 0 deletions src/PIL/Image.py
Original file line number Diff line number Diff line change
Expand Up @@ -2557,6 +2557,14 @@ def save(
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)
Expand Down Expand Up @@ -2590,6 +2598,66 @@ def _attach_default_encoderinfo(self, im: Image) -> dict[str, Any]:
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
Expand Down
4 changes: 4 additions & 0 deletions src/PIL/JpegImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,10 @@ def jpeg_factory(
return im


def _supported_modes() -> list[str]:
return ["RGB", "CMYK", "YCbCr", "RGBX", "L", "1"]


# ---------------------------------------------------------------------
# Registry stuff

Expand Down
4 changes: 4 additions & 0 deletions src/PIL/PngImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,10 @@ def append(fp: IO[bytes], cid: bytes, *data: bytes) -> None:
return chunks


def _supported_modes() -> list[str]:
return ["RGB", "RGBA", "P", "I", "LA", "L", "1"]


# --------------------------------------------------------------------
# Registry

Expand Down
19 changes: 19 additions & 0 deletions src/PIL/WebPImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,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)
Expand Down
Loading