from __future__ import annotations import io import re import sys import warnings from pathlib import Path from typing import Any import pytest from PIL import Image, WebPImagePlugin, features from .helper import ( assert_image_equal, assert_image_similar, assert_image_similar_tofile, hopper, skip_unless_feature, ) try: from PIL import _webp HAVE_WEBP = True except ImportError: HAVE_WEBP = False class TestUnsupportedWebp: def test_unsupported(self) -> None: if HAVE_WEBP: WebPImagePlugin.SUPPORTED = False file_path = "Tests/images/hopper.webp" with pytest.warns(UserWarning): with pytest.raises(OSError): with Image.open(file_path): pass if HAVE_WEBP: WebPImagePlugin.SUPPORTED = True @skip_unless_feature("webp") class TestFileWebp: def setup_method(self) -> None: self.rgb_mode = "RGB" def test_version(self) -> None: version = features.version_module("webp") assert version is not None assert re.search(r"\d+\.\d+\.\d+$", version) def test_read_rgb(self) -> None: """ Can we read a RGB mode WebP file without error? Does it have the bits we expect? """ with Image.open("Tests/images/hopper.webp") as image: assert image.mode == self.rgb_mode assert image.size == (128, 128) assert image.format == "WEBP" image.load() image.getdata() # generated with: # dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm assert_image_similar_tofile(image, "Tests/images/hopper_webp_bits.ppm", 1.0) def _roundtrip( self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {} ) -> None: temp_file = str(tmp_path / "temp.webp") hopper(mode).save(temp_file, **args) with Image.open(temp_file) as image: assert image.mode == self.rgb_mode assert image.size == (128, 128) assert image.format == "WEBP" image.load() image.getdata() if mode == self.rgb_mode: # generated with: dwebp -ppm temp.webp -o hopper_webp_write.ppm assert_image_similar_tofile( image, "Tests/images/hopper_webp_write.ppm", 12.0 ) # This test asserts that the images are similar. If the average pixel # difference between the two images is less than the epsilon value, # then we're going to accept that it's a reasonable lossy version of # the image. target = hopper(mode) if mode != self.rgb_mode: target = target.convert(self.rgb_mode) assert_image_similar(image, target, epsilon) def test_write_rgb(self, tmp_path: Path) -> None: """ Can we write a RGB mode file to webp without error? Does it have the bits we expect? """ self._roundtrip(tmp_path, self.rgb_mode, 12.5) def test_write_method(self, tmp_path: Path) -> None: self._roundtrip(tmp_path, self.rgb_mode, 12.0, {"method": 6}) buffer_no_args = io.BytesIO() hopper().save(buffer_no_args, format="WEBP") buffer_method = io.BytesIO() hopper().save(buffer_method, format="WEBP", method=6) assert buffer_no_args.getbuffer() != buffer_method.getbuffer() def test_save_all(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") im = Image.new("RGB", (1, 1)) im2 = Image.new("RGB", (1, 1), "#f00") im.save(temp_file, save_all=True, append_images=[im2]) with Image.open(temp_file) as reloaded: assert_image_equal(im, reloaded) reloaded.seek(1) assert_image_similar(im2, reloaded, 1) def test_icc_profile(self, tmp_path: Path) -> None: self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) self._roundtrip( tmp_path, self.rgb_mode, 12.5, {"icc_profile": None, "save_all": True} ) def test_write_unsupported_mode_L(self, tmp_path: Path) -> None: """ Saving a black-and-white file to WebP format should work, and be similar to the original file. """ self._roundtrip(tmp_path, "L", 10.0) def test_write_unsupported_mode_P(self, tmp_path: Path) -> None: """ Saving a palette-based file to WebP format should work, and be similar to the original file. """ self._roundtrip(tmp_path, "P", 50.0) @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_write_encoding_error_message(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") im = Image.new("RGB", (15000, 15000)) with pytest.raises(ValueError) as e: im.save(temp_file, method=0) assert str(e.value) == "encoding error 6" @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") im = Image.new("L", (16384, 16384)) with pytest.raises(ValueError) as e: im.save(temp_file) assert ( str(e.value) == "encoding error 5: Image size exceeds WebP limit of 16383 pixels" ) def test_WebPEncode_with_invalid_args(self) -> None: """ Calling encoder functions with no arguments should result in an error. """ with pytest.raises(TypeError): _webp.WebPAnimEncoder() with pytest.raises(TypeError): _webp.WebPEncode() def test_WebPAnimDecoder_with_invalid_args(self) -> None: """ Calling decoder functions with no arguments should result in an error. """ with pytest.raises(TypeError): _webp.WebPAnimDecoder() def test_no_resource_warning(self, tmp_path: Path) -> None: file_path = "Tests/images/hopper.webp" with Image.open(file_path) as image: temp_file = str(tmp_path / "temp.webp") with warnings.catch_warnings(): image.save(temp_file) def test_file_pointer_could_be_reused(self) -> None: file_path = "Tests/images/hopper.webp" with open(file_path, "rb") as blob: Image.open(blob).load() Image.open(blob).load() @pytest.mark.parametrize( "background", (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), ) def test_invalid_background( self, background: int | tuple[int, ...], tmp_path: Path ) -> None: temp_file = str(tmp_path / "temp.webp") im = hopper() with pytest.raises(OSError): im.save(temp_file, save_all=True, append_images=[im], background=background) def test_background_from_gif(self, tmp_path: Path) -> None: # Save L mode GIF with background with Image.open("Tests/images/no_palette_with_background.gif") as im: out_webp = str(tmp_path / "temp.webp") im.save(out_webp, save_all=True) # Save P mode GIF with background with Image.open("Tests/images/chi.gif") as im: original_value = im.convert("RGB").getpixel((1, 1)) # Save as WEBP out_webp = str(tmp_path / "temp.webp") im.save(out_webp, save_all=True) # Save as GIF out_gif = str(tmp_path / "temp.gif") with Image.open(out_webp) as im: im.save(out_gif) with Image.open(out_gif) as reread: reread_value = reread.convert("RGB").getpixel((1, 1)) difference = sum(abs(original_value[i] - reread_value[i]) for i in range(0, 3)) assert difference < 5 def test_duration(self, tmp_path: Path) -> None: with Image.open("Tests/images/dispose_bgnd.gif") as im: assert im.info["duration"] == 1000 out_webp = str(tmp_path / "temp.webp") im.save(out_webp, save_all=True) with Image.open(out_webp) as reloaded: reloaded.load() assert reloaded.info["duration"] == 1000 def test_roundtrip_rgba_palette(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") im = Image.new("RGBA", (1, 1)).convert("P") assert im.mode == "P" assert im.palette is not None assert im.palette.mode == "RGBA" im.save(temp_file) with Image.open(temp_file) as im: assert im.getpixel((0, 0)) == (0, 0, 0, 0)