diff --git a/.appveyor.yml b/.appveyor.yml index e12987a5f..f490561cd 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -51,7 +51,7 @@ build_script: test_script: - cd c:\pillow -- '%PYTHON%\%EXECUTABLE% -m pip install pytest pytest-cov pytest-timeout defusedxml numpy olefile pyroma' +- '%PYTHON%\%EXECUTABLE% -m pip install pytest pytest-cov pytest-timeout defusedxml ipython numpy olefile pyroma' - c:\"Program Files (x86)"\"Windows Kits"\10\Debuggers\x86\gflags.exe /p /enable %PYTHON%\%EXECUTABLE% - '%PYTHON%\%EXECUTABLE% -c "from PIL import Image"' - '%PYTHON%\%EXECUTABLE% -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests' diff --git a/.ci/install.sh b/.ci/install.sh index 8e65f64c4..b2d615866 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -30,6 +30,7 @@ python3 -m pip install --upgrade pip python3 -m pip install --upgrade wheel python3 -m pip install coverage python3 -m pip install defusedxml +python3 -m pip install ipython python3 -m pip install olefile python3 -m pip install -U pytest python3 -m pip install -U pytest-cov diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index 23792281b..47fc64399 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -5,6 +5,7 @@ ipython numpy packaging pytest +sphinx types-defusedxml types-olefile types-setuptools diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index d35cfcd31..ddb421230 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -2,6 +2,9 @@ set -e +if [[ "$ImageOS" == "macos13" ]]; then + brew uninstall gradle maven +fi brew install \ freetype \ ghostscript \ @@ -20,6 +23,7 @@ export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" python3 -m pip install coverage python3 -m pip install defusedxml +python3 -m pip install ipython python3 -m pip install olefile python3 -m pip install -U pytest python3 -m pip install -U pytest-cov diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 8e2827099..0aa79e423 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -74,6 +74,7 @@ jobs: perl python3${{ matrix.python-minor-version }}-cython python3${{ matrix.python-minor-version }}-devel + python3${{ matrix.python-minor-version }}-ipython python3${{ matrix.python-minor-version }}-numpy python3${{ matrix.python-minor-version }}-sip python3${{ matrix.python-minor-version }}-tkinter diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 5b34d6703..2943bbe31 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -87,7 +87,7 @@ jobs: echo "C:\Program Files\NASM" >> $env:GITHUB_PATH choco install ghostscript --version=10.3.1 --no-progress - echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH + echo "C:\Program Files\gs\gs10.03.1\bin" >> $env:GITHUB_PATH # Install extra test images xcopy /S /Y Tests\test-images\* Tests\images diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74764752e..8ab478e84 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.6 + rev: v0.6.0 hooks: - id: ruff args: [--exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.4.2 + rev: 24.8.0 hooks: - id: black @@ -67,7 +67,7 @@ repos: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.18 + rev: v0.19 hooks: - id: validate-pyproject diff --git a/CHANGES.rst b/CHANGES.rst index ae995e0a0..9da76d82a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,21 @@ Changelog (Pillow) 11.0.0 (unreleased) ------------------- +- Updated error message when saving WebP with invalid width or height #8322 + [radarhere, hugovk] + +- Remove warning if NumPy failed to raise an error during conversion #8326 + [radarhere] + +- If left and right sides meet in ImageDraw.rounded_rectangle(), do not draw rectangle to fill gap #8304 + [radarhere] + +- Remove WebP support without anim, mux/demux, and with buggy alpha #8213 + [homm, radarhere] + +- Add missing TIFF CMYK;16B reader #8298 + [homm] + - Remove all WITH_* flags from _imaging.c and other flags #8211 [homm] diff --git a/Tests/images/imagedraw_rounded_rectangle_joined_x_different_corners.png b/Tests/images/imagedraw_rounded_rectangle_joined_x_different_corners.png new file mode 100644 index 000000000..b225afc2d Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_joined_x_different_corners.png differ diff --git a/Tests/test_features.py b/Tests/test_features.py index b7eefa09a..807782847 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -10,11 +10,6 @@ from PIL import features from .helper import skip_unless_feature -try: - from PIL import _webp -except ImportError: - pass - def test_check() -> None: # Check the correctness of the convenience function @@ -23,7 +18,11 @@ def test_check() -> None: for codec in features.codecs: assert features.check_codec(codec) == features.check(codec) for feature in features.features: - assert features.check_feature(feature) == features.check(feature) + if "webp" in feature: + with pytest.warns(DeprecationWarning): + assert features.check_feature(feature) == features.check(feature) + else: + assert features.check_feature(feature) == features.check(feature) def test_version() -> None: @@ -48,23 +47,26 @@ def test_version() -> None: for codec in features.codecs: test(codec, features.version_codec) for feature in features.features: - test(feature, features.version_feature) + if "webp" in feature: + with pytest.warns(DeprecationWarning): + test(feature, features.version_feature) + else: + test(feature, features.version_feature) -@skip_unless_feature("webp") def test_webp_transparency() -> None: - assert features.check("transp_webp") != _webp.WebPDecoderBuggyAlpha() - assert features.check("transp_webp") == _webp.HAVE_TRANSPARENCY + with pytest.warns(DeprecationWarning): + assert features.check("transp_webp") == features.check_module("webp") -@skip_unless_feature("webp") def test_webp_mux() -> None: - assert features.check("webp_mux") == _webp.HAVE_WEBPMUX + with pytest.warns(DeprecationWarning): + assert features.check("webp_mux") == features.check_module("webp") -@skip_unless_feature("webp") def test_webp_anim() -> None: - assert features.check("webp_anim") == _webp.HAVE_WEBPANIM + with pytest.warns(DeprecationWarning): + assert features.check("webp_anim") == features.check_module("webp") @skip_unless_feature("libjpeg_turbo") diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 85b017d29..8cefdb628 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -978,7 +978,7 @@ def test_webp_background(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") # Test opaque WebP background - if features.check("webp") and features.check("webp_anim"): + if features.check("webp"): with Image.open("Tests/images/hopper.webp") as im: assert im.info["background"] == (255, 255, 255, 255) im.save(out) diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index b0ea2bf42..8a7c59fb1 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -77,6 +77,16 @@ def test_getiptcinfo_zero_padding() -> None: assert len(iptc) == 3 +def test_getiptcinfo_tiff() -> None: + # Arrange + with Image.open("Tests/images/hopper.Lab.tif") as im: + # Act + iptc = IptcImagePlugin.getiptcinfo(im) + + # Assert + assert iptc == {(1, 90): b"\x1b%G", (2, 0): b"\xcf\xc0"} + + def test_getiptcinfo_tiff_none() -> None: # Arrange with Image.open("Tests/images/hopper.tif") as im: diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index df589e24a..8e6221750 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -1019,13 +1019,16 @@ class TestFileJpeg: # SOI, EOI for marker in b"\xff\xd8", b"\xff\xd9": - assert marker in data[1] and marker in data[2] + assert marker in data[1] + assert marker in data[2] # DHT, DQT for marker in b"\xff\xc4", b"\xff\xdb": - assert marker in data[1] and marker not in data[2] + assert marker in data[1] + assert marker not in data[2] # SOF0, SOS, APP0 (JFIF header) for marker in b"\xff\xc0", b"\xff\xda", b"\xff\xe0": - assert marker not in data[1] and marker in data[2] + assert marker not in data[1] + assert marker in data[2] with Image.open(BytesIO(data[0])) as interchange_im: with Image.open(BytesIO(data[1] + data[2])) as combined_im: diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index a5cfa7c6c..5e11465ca 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -233,7 +233,7 @@ def test_layers() -> None: ("foo.jp2", {"no_jp2": True}, 0, b"\xff\x4f"), ("foo.j2k", {"no_jp2": False}, 0, b"\xff\x4f"), ("foo.jp2", {"no_jp2": False}, 4, b"jP"), - ("foo.jp2", {"no_jp2": False}, 4, b"jP"), + (None, {"no_jp2": False}, 4, b"jP"), ), ) def test_no_jp2(name: str, args: dict[str, bool], offset: int, data: bytes) -> None: diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 8cad25272..190f83f40 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -684,6 +684,13 @@ class TestFileTiff: with Image.open(outfile) as reloaded: assert_image_equal_tofile(reloaded, infile) + def test_invalid_tiled_dimensions(self) -> None: + with open("Tests/images/tiff_tiled_planar_raw.tif", "rb") as fp: + data = fp.read() + b = BytesIO(data[:144] + b"\x02" + data[145:]) + with pytest.raises(ValueError): + Image.open(b) + @pytest.mark.parametrize("mode", ("P", "PA")) def test_palette(self, mode: str, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 3fb416ffe..e75e3ddd2 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -48,8 +48,6 @@ class TestFileWebp: self.rgb_mode = "RGB" def test_version(self) -> None: - _webp.WebPDecoderVersion() - _webp.WebPDecoderBuggyAlpha() version = features.version_module("webp") assert version is not None assert re.search(r"\d+\.\d+\.\d+$", version) @@ -117,7 +115,6 @@ class TestFileWebp: hopper().save(buffer_method, format="WEBP", method=6) assert buffer_no_args.getbuffer() != buffer_method.getbuffer() - @skip_unless_feature("webp_anim") def test_save_all(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") im = Image.new("RGB", (1, 1)) @@ -132,10 +129,9 @@ class TestFileWebp: def test_icc_profile(self, tmp_path: Path) -> None: self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) - if _webp.HAVE_WEBPANIM: - self._roundtrip( - tmp_path, self.rgb_mode, 12.5, {"icc_profile": None, "save_all": True} - ) + 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: """ @@ -161,27 +157,32 @@ class TestFileWebp: 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. """ - - if _webp.HAVE_WEBPANIM: - with pytest.raises(TypeError): - _webp.WebPAnimEncoder() + with pytest.raises(TypeError): + _webp.WebPAnimEncoder() with pytest.raises(TypeError): _webp.WebPEncode() - def test_WebPDecode_with_invalid_args(self) -> None: + def test_WebPAnimDecoder_with_invalid_args(self) -> None: """ Calling decoder functions with no arguments should result in an error. """ - - if _webp.HAVE_WEBPANIM: - with pytest.raises(TypeError): - _webp.WebPAnimDecoder() with pytest.raises(TypeError): - _webp.WebPDecode() + _webp.WebPAnimDecoder() def test_no_resource_warning(self, tmp_path: Path) -> None: file_path = "Tests/images/hopper.webp" @@ -200,7 +201,6 @@ class TestFileWebp: "background", (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), ) - @skip_unless_feature("webp_anim") def test_invalid_background( self, background: int | tuple[int, ...], tmp_path: Path ) -> None: @@ -209,7 +209,6 @@ class TestFileWebp: with pytest.raises(OSError): im.save(temp_file, save_all=True, append_images=[im], background=background) - @skip_unless_feature("webp_anim") 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: @@ -234,7 +233,6 @@ class TestFileWebp: difference = sum(abs(original_value[i] - reread_value[i]) for i in range(0, 3)) assert difference < 5 - @skip_unless_feature("webp_anim") def test_duration(self, tmp_path: Path) -> None: with Image.open("Tests/images/dispose_bgnd.gif") as im: assert im.info["duration"] == 1000 diff --git a/Tests/test_file_webp_alpha.py b/Tests/test_file_webp_alpha.py index c74452121..c88fe3589 100644 --- a/Tests/test_file_webp_alpha.py +++ b/Tests/test_file_webp_alpha.py @@ -13,12 +13,7 @@ from .helper import ( hopper, ) -_webp = pytest.importorskip("PIL._webp", reason="WebP support not installed") - - -def setup_module() -> None: - if _webp.WebPDecoderBuggyAlpha(): - pytest.skip("Buggy early version of WebP installed, not testing transparency") +pytest.importorskip("PIL._webp", reason="WebP support not installed") def test_read_rgba() -> None: @@ -81,9 +76,6 @@ def test_write_rgba(tmp_path: Path) -> None: pil_image = Image.new("RGBA", (10, 10), (255, 0, 0, 20)) pil_image.save(temp_file) - if _webp.WebPDecoderBuggyAlpha(): - return - with Image.open(temp_file) as image: image.load() @@ -93,12 +85,7 @@ def test_write_rgba(tmp_path: Path) -> None: image.load() image.getdata() - # Early versions of WebP are known to produce higher deviations: - # deal with it - if _webp.WebPDecoderVersion() <= 0x201: - assert_image_similar(image, pil_image, 3.0) - else: - assert_image_similar(image, pil_image, 1.0) + assert_image_similar(image, pil_image, 1.0) def test_keep_rgb_values_when_transparent(tmp_path: Path) -> None: diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index e0d7999e3..967a0aae8 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -15,10 +15,7 @@ from .helper import ( skip_unless_feature, ) -pytestmark = [ - skip_unless_feature("webp"), - skip_unless_feature("webp_anim"), -] +pytestmark = skip_unless_feature("webp") def test_n_frames() -> None: diff --git a/Tests/test_file_webp_lossless.py b/Tests/test_file_webp_lossless.py index 32e29de56..80429715e 100644 --- a/Tests/test_file_webp_lossless.py +++ b/Tests/test_file_webp_lossless.py @@ -8,14 +8,11 @@ from PIL import Image from .helper import assert_image_equal, hopper -_webp = pytest.importorskip("PIL._webp", reason="WebP support not installed") +pytest.importorskip("PIL._webp", reason="WebP support not installed") RGB_MODE = "RGB" def test_write_lossless_rgb(tmp_path: Path) -> None: - if _webp.WebPDecoderVersion() < 0x0200: - pytest.skip("lossless not included") - temp_file = str(tmp_path / "temp.webp") hopper(RGB_MODE).save(temp_file, lossless=True) diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index c3df4ad7b..d9a834c75 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -10,10 +10,7 @@ from PIL import Image from .helper import mark_if_feature_version, skip_unless_feature -pytestmark = [ - skip_unless_feature("webp"), - skip_unless_feature("webp_mux"), -] +pytestmark = skip_unless_feature("webp") ElementTree: ModuleType | None try: @@ -119,7 +116,15 @@ def test_read_no_exif() -> None: def test_getxmp() -> None: with Image.open("Tests/images/flower.webp") as im: assert "xmp" not in im.info - assert im.getxmp() == {} + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + xmp = im.getxmp() + else: + xmp = im.getxmp() + assert xmp == {} with Image.open("Tests/images/flower2.webp") as im: if ElementTree is None: @@ -136,7 +141,6 @@ def test_getxmp() -> None: ) -@skip_unless_feature("webp_anim") def test_write_animated_metadata(tmp_path: Path) -> None: iccp_data = b"" exif_data = b"" diff --git a/Tests/test_image.py b/Tests/test_image.py index 12b6c1fdd..4e0840cf1 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -42,6 +42,12 @@ try: except ImportError: ElementTree = None +PrettyPrinter: type | None +try: + from IPython.lib.pretty import PrettyPrinter +except ImportError: + PrettyPrinter = None + # Deprecation helper def helper_image_new(mode: str, size: tuple[int, int]) -> Image.Image: @@ -91,16 +97,15 @@ class TestImage: # with pytest.raises(MemoryError): # Image.new("L", (1000000, 1000000)) + @pytest.mark.skipif(PrettyPrinter is None, reason="IPython is not installed") def test_repr_pretty(self) -> None: - class Pretty: - def text(self, text: str) -> None: - self.pretty_output = text - im = Image.new("L", (100, 100)) - p = Pretty() + output = io.StringIO() + assert PrettyPrinter is not None + p = PrettyPrinter(output) im._repr_pretty_(p, False) - assert p.pretty_output == "" + assert output.getvalue() == "" def test_open_formats(self) -> None: PNGFILE = "Tests/images/hopper.png" @@ -818,7 +823,6 @@ class TestImage: assert reloaded_exif[305] == "Pillow test" @skip_unless_feature("webp") - @skip_unless_feature("webp_anim") def test_exif_webp(self, tmp_path: Path) -> None: with Image.open("Tests/images/hopper.webp") as im: exif = im.getexif() @@ -940,7 +944,15 @@ class TestImage: def test_empty_xmp(self) -> None: with Image.open("Tests/images/hopper.gif") as im: - assert im.getxmp() == {} + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + xmp = im.getxmp() + else: + xmp = im.getxmp() + assert xmp == {} def test_getxmp_padded(self) -> None: im = Image.new("RGB", (1, 1)) diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index 2b9ccd04e..1d50dfa40 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -47,7 +47,7 @@ def test_toarray() -> None: with pytest.raises(OSError): numpy.array(im_truncated) else: - with pytest.warns(UserWarning): + with pytest.warns(DeprecationWarning): numpy.array(im_truncated) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 4fc28cdb9..4309214f5 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -398,7 +398,8 @@ def test_logical() -> None: for y in (a, b): imy = Image.new("1", (1, 1), y) value = op(imx, imy).getpixel((0, 0)) - assert not isinstance(value, tuple) and value is not None + assert not isinstance(value, tuple) + assert value is not None out.append(value) return out diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index e397978cb..e852b8471 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -857,6 +857,27 @@ def test_rounded_rectangle_corners( ) +def test_rounded_rectangle_joined_x_different_corners() -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im, "RGBA") + + # Act + draw.rounded_rectangle( + (20, 10, 80, 90), + 30, + fill="red", + outline="green", + width=5, + corners=(True, False, False, False), + ) + + # Assert + assert_image_equal_tofile( + im, "Tests/images/imagedraw_rounded_rectangle_joined_x_different_corners.png" + ) + + @pytest.mark.parametrize( "xy, radius, type", [ diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 44a6e6a42..a8bd798c1 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -94,7 +94,6 @@ class TestImageFile: assert (48, 48) == p.image.size @skip_unless_feature("webp") - @skip_unless_feature("webp_anim") def test_incremental_webp(self) -> None: with ImageFile.Parser() as p: with open("Tests/images/hopper.webp", "rb") as f: @@ -318,7 +317,13 @@ class TestPyEncoder(CodecsTest): fp = BytesIO() ImageFile._save( - im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")] + im, + fp, + [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB" + ) + ], ) assert MockPyEncoder.last @@ -334,7 +339,7 @@ class TestPyEncoder(CodecsTest): im.tile = [("MOCK", None, 32, None)] fp = BytesIO() - ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")]) + ImageFile._save(im, fp, [ImageFile._Tile("MOCK", None, 0, "RGB")]) assert MockPyEncoder.last assert MockPyEncoder.last.state.xoff == 0 @@ -351,7 +356,9 @@ class TestPyEncoder(CodecsTest): MockPyEncoder.last = None with pytest.raises(ValueError): ImageFile._save( - im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")] + im, + fp, + [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")], ) last: MockPyEncoder | None = MockPyEncoder.last assert last @@ -359,7 +366,9 @@ class TestPyEncoder(CodecsTest): with pytest.raises(ValueError): ImageFile._save( - im, fp, [("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")] + im, + fp, + [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")], ) def test_oversize(self) -> None: @@ -372,14 +381,22 @@ class TestPyEncoder(CodecsTest): ImageFile._save( im, fp, - [("MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB")], + [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB" + ) + ], ) with pytest.raises(ValueError): ImageFile._save( im, fp, - [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB")], + [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB" + ) + ], ) def test_encode(self) -> None: @@ -395,9 +412,8 @@ class TestPyEncoder(CodecsTest): with pytest.raises(NotImplementedError): encoder.encode_to_pyfd() - fh = BytesIO() with pytest.raises(NotImplementedError): - encoder.encode_to_file(fh, 0) + encoder.encode_to_file(0, 0) def test_zero_height(self) -> None: with pytest.raises(UnidentifiedImageError): diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index 4363f456e..80d8c3815 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -46,7 +46,8 @@ def img_to_string(im: Image.Image) -> str: line = "" for c in range(im.width): value = im.getpixel((c, r)) - assert not isinstance(value, tuple) and value is not None + assert not isinstance(value, tuple) + assert value is not None line += chars[value > 0] result.append(line) return "\n".join(result) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index e33e6d4c8..2fb2a60b6 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -390,7 +390,7 @@ def test_colorize_3color_offset() -> None: def test_exif_transpose() -> None: exts = [".jpg"] - if features.check("webp") and features.check("webp_anim"): + if features.check("webp"): exts.append(".webp") for ext in exts: with Image.open("Tests/images/hopper" + ext) as base_im: diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index c3afa9089..a743d831f 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -5,8 +5,6 @@ import sys from io import BytesIO from pathlib import Path -import pytest - from PIL import Image, PSDraw @@ -49,15 +47,14 @@ def test_draw_postscript(tmp_path: Path) -> None: assert os.path.getsize(tempfile) > 0 -@pytest.mark.parametrize("buffer", (True, False)) -def test_stdout(buffer: bool) -> None: +def test_stdout() -> None: # Temporarily redirect stdout old_stdout = sys.stdout class MyStdOut: buffer = BytesIO() - mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() + mystdout = MyStdOut() sys.stdout = mystdout @@ -67,6 +64,4 @@ def test_stdout(buffer: bool) -> None: # Reset stdout sys.stdout = old_stdout - if isinstance(mystdout, MyStdOut): - mystdout = mystdout.buffer - assert mystdout.getvalue() != b"" + assert mystdout.buffer.getvalue() != b"" diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py index 28f66891c..0ed9fbfa5 100644 --- a/Tests/test_qt_image_qapplication.py +++ b/Tests/test_qt_image_qapplication.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING, Union import pytest @@ -8,6 +9,20 @@ from PIL import Image, ImageQt from .helper import assert_image_equal_tofile, assert_image_similar, hopper +if TYPE_CHECKING: + import PyQt6 + import PySide6 + + QApplication = Union[PyQt6.QtWidgets.QApplication, PySide6.QtWidgets.QApplication] + QHBoxLayout = Union[PyQt6.QtWidgets.QHBoxLayout, PySide6.QtWidgets.QHBoxLayout] + QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage] + QLabel = Union[PyQt6.QtWidgets.QLabel, PySide6.QtWidgets.QLabel] + QPainter = Union[PyQt6.QtGui.QPainter, PySide6.QtGui.QPainter] + QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap] + QPoint = Union[PyQt6.QtCore.QPoint, PySide6.QtCore.QPoint] + QRegion = Union[PyQt6.QtGui.QRegion, PySide6.QtGui.QRegion] + QWidget = Union[PyQt6.QtWidgets.QWidget, PySide6.QtWidgets.QWidget] + if ImageQt.qt_is_installed: from PIL.ImageQt import QPixmap @@ -20,7 +35,7 @@ if ImageQt.qt_is_installed: from PySide6.QtGui import QImage, QPainter, QRegion from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget - class Example(QWidget): + class Example(QWidget): # type: ignore[misc] def __init__(self) -> None: super().__init__() @@ -28,11 +43,12 @@ if ImageQt.qt_is_installed: qimage = ImageQt.ImageQt(img) - pixmap1 = ImageQt.QPixmap.fromImage(qimage) + pixmap1 = getattr(ImageQt.QPixmap, "fromImage")(qimage) - QHBoxLayout(self) # hbox + # hbox + QHBoxLayout(self) # type: ignore[operator] - lbl = QLabel(self) + lbl = QLabel(self) # type: ignore[operator] # Segfault in the problem lbl.setPixmap(pixmap1.copy()) @@ -46,7 +62,7 @@ def roundtrip(expected: Image.Image) -> None: @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") def test_sanity(tmp_path: Path) -> None: # Segfault test - app: QApplication | None = QApplication([]) + app: QApplication | None = QApplication([]) # type: ignore[operator] ex = Example() assert app # Silence warning assert ex # Silence warning @@ -56,7 +72,7 @@ def test_sanity(tmp_path: Path) -> None: im = hopper(mode) data = ImageQt.toqpixmap(im) - assert isinstance(data, QPixmap) + assert data.__class__.__name__ == "QPixmap" assert not data.isNull() # Test saving the file @@ -64,14 +80,14 @@ def test_sanity(tmp_path: Path) -> None: data.save(tempfile) # Render the image - qimage = ImageQt.ImageQt(im) - data = QPixmap.fromImage(qimage) - qt_format = QImage.Format if ImageQt.qt_version == "6" else QImage - qimage = QImage(128, 128, qt_format.Format_ARGB32) - painter = QPainter(qimage) - image_label = QLabel() + imageqt = ImageQt.ImageQt(im) + data = getattr(QPixmap, "fromImage")(imageqt) + qt_format = getattr(QImage, "Format") if ImageQt.qt_version == "6" else QImage + qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32")) # type: ignore[operator] + painter = QPainter(qimage) # type: ignore[operator] + image_label = QLabel() # type: ignore[operator] image_label.setPixmap(data) - image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) + image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) # type: ignore[operator] painter.end() rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png") qimage.save(rendered_tempfile) diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py index 6110be707..8cb7ffb9b 100644 --- a/Tests/test_qt_image_toqimage.py +++ b/Tests/test_qt_image_toqimage.py @@ -21,7 +21,7 @@ def test_sanity(mode: str, tmp_path: Path) -> None: src = hopper(mode) data = ImageQt.toqimage(src) - assert isinstance(data, QImage) + assert isinstance(data, QImage) # type: ignore[arg-type, misc] assert not data.isNull() # reload directly from the qimage diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index 9d06a9332..13f1f9c80 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -54,8 +54,8 @@ def test_nonetype() -> None: assert xres.denominator is not None assert yres._val is not None - assert xres and 1 - assert xres and yres + assert xres + assert yres @pytest.mark.parametrize( diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 058468cfe..a9498d5ed 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -126,6 +126,16 @@ JpegImageFile.huffman_ac and JpegImageFile.huffman_dc The ``huffman_ac`` and ``huffman_dc`` dictionaries on JPEG images were unused. They have been deprecated, and will be removed in Pillow 12 (2025-10-15). +Specific WebP Feature Checks +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +``features.check("transp_webp")``, ``features.check("webp_mux")`` and +``features.check("webp_anim")`` are now deprecated. They will always return +``True`` if the WebP module is installed, until they are removed in Pillow +12.0.0 (2025-10-15). + Removed features ---------------- diff --git a/docs/example/DdsImagePlugin.py b/docs/example/DdsImagePlugin.py index 2a2a0ba29..caa852b1f 100644 --- a/docs/example/DdsImagePlugin.py +++ b/docs/example/DdsImagePlugin.py @@ -14,6 +14,7 @@ from __future__ import annotations import struct from io import BytesIO +from typing import IO from PIL import Image, ImageFile @@ -94,26 +95,26 @@ DXT3_FOURCC = 0x33545844 DXT5_FOURCC = 0x35545844 -def _decode565(bits): +def _decode565(bits: int) -> tuple[int, int, int]: a = ((bits >> 11) & 0x1F) << 3 b = ((bits >> 5) & 0x3F) << 2 c = (bits & 0x1F) << 3 return a, b, c -def _c2a(a, b): +def _c2a(a: int, b: int) -> int: return (2 * a + b) // 3 -def _c2b(a, b): +def _c2b(a: int, b: int) -> int: return (a + b) // 2 -def _c3(a, b): +def _c3(a: int, b: int) -> int: return (2 * b + a) // 3 -def _dxt1(data, width, height): +def _dxt1(data: IO[bytes], width: int, height: int) -> bytes: # TODO implement this function as pixel format in decode.c ret = bytearray(4 * width * height) @@ -151,7 +152,7 @@ def _dxt1(data, width, height): return bytes(ret) -def _dxtc_alpha(a0, a1, ac0, ac1, ai): +def _dxtc_alpha(a0: int, a1: int, ac0: int, ac1: int, ai: int) -> int: if ai <= 12: ac = (ac0 >> ai) & 7 elif ai == 15: @@ -175,7 +176,7 @@ def _dxtc_alpha(a0, a1, ac0, ac1, ai): return alpha -def _dxt5(data, width, height): +def _dxt5(data: IO[bytes], width: int, height: int) -> bytes: # TODO implement this function as pixel format in decode.c ret = bytearray(4 * width * height) @@ -211,7 +212,7 @@ class DdsImageFile(ImageFile.ImageFile): format = "DDS" format_description = "DirectDraw Surface" - def _open(self): + def _open(self) -> None: if not _accept(self.fp.read(4)): msg = "not a DDS file" raise SyntaxError(msg) @@ -242,19 +243,20 @@ class DdsImageFile(ImageFile.ImageFile): elif fourcc == b"DXT5": self.decoder = "DXT5" else: - msg = f"Unimplemented pixel format {fourcc}" + msg = f"Unimplemented pixel format {repr(fourcc)}" raise NotImplementedError(msg) self.tile = [(self.decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))] - def load_seek(self, pos): + def load_seek(self, pos: int) -> None: pass class DXT1Decoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None try: self.set_as_raw(_dxt1(self.fd, self.state.xsize, self.state.ysize)) except struct.error as e: @@ -266,7 +268,8 @@ class DXT1Decoder(ImageFile.PyDecoder): class DXT5Decoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None try: self.set_as_raw(_dxt5(self.fd, self.state.xsize, self.state.ysize)) except struct.error as e: @@ -279,7 +282,7 @@ Image.register_decoder("DXT1", DXT1Decoder) Image.register_decoder("DXT5", DXT5Decoder) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"DDS " diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 1ec972149..ca0e05eb6 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -1220,8 +1220,7 @@ using the general tags available through tiffinfo. WebP ^^^^ -Pillow reads and writes WebP files. The specifics of Pillow's capabilities with -this format are currently undocumented. +Pillow reads and writes WebP files. Requires libwebp v0.5.0 or later. .. _webp-saving: @@ -1249,29 +1248,19 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: **exact** If true, preserve the transparent RGB values. Otherwise, discard invisible RGB values for better compression. Defaults to false. - Requires libwebp 0.5.0 or later. **icc_profile** - The ICC Profile to include in the saved file. Only supported if - the system WebP library was built with webpmux support. + The ICC Profile to include in the saved file. **exif** - The exif data to include in the saved file. Only supported if - the system WebP library was built with webpmux support. + The exif data to include in the saved file. **xmp** - The XMP data to include in the saved file. Only supported if - the system WebP library was built with webpmux support. + The XMP data to include in the saved file. Saving sequences ~~~~~~~~~~~~~~~~ -.. note:: - - Support for animated WebP files will only be enabled if the system WebP - library is v0.5.0 or later. You can check webp animation support at - runtime by calling ``features.check("webp_anim")``. - When calling :py:meth:`~PIL.Image.Image.save` to write a WebP file, by default only the first frame of a multiframe image will be saved. If the ``save_all`` argument is present and true, then all frames will be saved, and the following @@ -1528,19 +1517,21 @@ To add other read or write support, use :py:func:`PIL.WmfImagePlugin.register_handler` to register a WMF and EMF handler. :: - from PIL import Image + from typing import IO + + from PIL import Image, ImageFile from PIL import WmfImagePlugin - class WmfHandler: - def open(self, im): + class WmfHandler(ImageFile.StubHandler): + def open(self, im: ImageFile.StubImageFile) -> None: ... - def load(self, im): + def load(self, im: ImageFile.StubImageFile) -> Image.Image: ... return image - def save(self, im, fp, filename): + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: ... diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst index c36011362..3df8e0d20 100644 --- a/docs/handbook/tutorial.rst +++ b/docs/handbook/tutorial.rst @@ -186,7 +186,7 @@ Rolling an image :: - def roll(im, delta): + def roll(im: Image.Image, delta: int) -> Image.Image: """Roll an image sideways.""" xsize, ysize = im.size @@ -211,7 +211,7 @@ Merging images :: - def merge(im1, im2): + def merge(im1: Image.Image, im2: Image.Image) -> Image.Image: w = im1.size[0] + im2.size[0] h = max(im1.size[1], im2.size[1]) im = Image.new("RGBA", (w, h)) @@ -704,7 +704,7 @@ in the current directory can be saved as JPEGs at reduced quality. import glob from PIL import Image - def compress_image(source_path, dest_path): + def compress_image(source_path: str, dest_path: str) -> None: with Image.open(source_path) as img: if img.mode != "RGB": img = img.convert("RGB") diff --git a/docs/handbook/writing-your-own-image-plugin.rst b/docs/handbook/writing-your-own-image-plugin.rst index 956d63aa7..2e853224d 100644 --- a/docs/handbook/writing-your-own-image-plugin.rst +++ b/docs/handbook/writing-your-own-image-plugin.rst @@ -53,7 +53,7 @@ true color. from PIL import Image, ImageFile - def _accept(prefix): + def _accept(prefix: bytes) -> bool: return prefix[:4] == b"SPAM" @@ -62,7 +62,7 @@ true color. format = "SPAM" format_description = "Spam raster image" - def _open(self): + def _open(self) -> None: header = self.fp.read(128).split() @@ -82,7 +82,7 @@ true color. raise SyntaxError(msg) # data descriptor - self.tile = [("raw", (0, 0) + self.size, 128, (self.mode, 0, 1))] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 128, (self.mode, 0, 1))] Image.register_open(SpamImageFile.format, SpamImageFile, _accept) diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 7f7dfa6ff..71787311f 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -55,10 +55,6 @@ Many of Pillow's features require external libraries: * **libwebp** provides the WebP format. - * Pillow has been tested with version **0.1.3**, which does not read - transparent WebP files. Versions **0.3.0** and above support - transparency. - * **openjpeg** provides JPEG 2000 functionality. * Pillow has been tested with openjpeg **2.0.0**, **2.1.0**, **2.3.1**, @@ -275,18 +271,18 @@ Build Options * Config settings: ``-C zlib=disable``, ``-C jpeg=disable``, ``-C tiff=disable``, ``-C freetype=disable``, ``-C raqm=disable``, - ``-C lcms=disable``, ``-C webp=disable``, ``-C webpmux=disable``, + ``-C lcms=disable``, ``-C webp=disable``, ``-C jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``. Disable building the corresponding feature even if the development libraries are present on the building machine. * Config settings: ``-C zlib=enable``, ``-C jpeg=enable``, ``-C tiff=enable``, ``-C freetype=enable``, ``-C raqm=enable``, - ``-C lcms=enable``, ``-C webp=enable``, ``-C webpmux=enable``, + ``-C lcms=enable``, ``-C webp=enable``, ``-C jpeg2000=enable``, ``-C imagequant=enable``, ``-C xcb=enable``. Require that the corresponding feature is built. The build will raise - an exception if the libraries are not found. Webpmux (WebP metadata) - relies on WebP support. Tcl and Tk also must be used together. + an exception if the libraries are not found. Tcl and Tk must be used + together. * Config settings: ``-C raqm=vendor``, ``-C fribidi=vendor``. These flags are used to compile a modified version of libraqm and diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 66c5e5422..bc3758218 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -362,6 +362,7 @@ Classes :undoc-members: :show-inheritance: .. autoclass:: PIL.Image.ImagePointHandler +.. autoclass:: PIL.Image.ImagePointTransform .. autoclass:: PIL.Image.ImageTransformHandler Protocols diff --git a/docs/reference/features.rst b/docs/reference/features.rst index c66193061..fcff96735 100644 --- a/docs/reference/features.rst +++ b/docs/reference/features.rst @@ -54,12 +54,12 @@ Feature version numbers are available only where stated. Support for the following features can be checked: * ``libjpeg_turbo``: (compile time) Whether Pillow was compiled against the libjpeg-turbo version of libjpeg. Compile-time version number is available. -* ``transp_webp``: Support for transparency in WebP images. -* ``webp_mux``: (compile time) Support for EXIF data in WebP images. -* ``webp_anim``: (compile time) Support for animated WebP images. * ``raqm``: Raqm library, required for ``ImageFont.Layout.RAQM`` in :py:func:`PIL.ImageFont.truetype`. Run-time version number is available for Raqm 0.7.0 or newer. * ``libimagequant``: (compile time) ImageQuant quantization support in :py:func:`PIL.Image.Image.quantize`. Run-time version number is available. * ``xcb``: (compile time) Support for X11 in :py:func:`PIL.ImageGrab.grab` via the XCB library. +* ``transp_webp``: Deprecated. Always ``True`` if WebP module is installed. +* ``webp_mux``: Deprecated. Always ``True`` if WebP module is installed. +* ``webp_anim``: Deprecated. Always ``True`` if WebP module is installed. .. autofunction:: PIL.features.check_feature .. autofunction:: PIL.features.version_feature diff --git a/docs/releasenotes/11.0.0.rst b/docs/releasenotes/11.0.0.rst index 1d1afcde5..ac9237acf 100644 --- a/docs/releasenotes/11.0.0.rst +++ b/docs/releasenotes/11.0.0.rst @@ -58,6 +58,14 @@ JpegImageFile.huffman_ac and JpegImageFile.huffman_dc The ``huffman_ac`` and ``huffman_dc`` dictionaries on JPEG images were unused. They have been deprecated, and will be removed in Pillow 12 (2025-10-15). +Specific WebP Feature Checks +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``features.check("transp_webp")``, ``features.check("webp_mux")`` and +``features.check("webp_anim")`` are now deprecated. They will always return +``True`` if the WebP module is installed, until they are removed in Pillow +12.0.0 (2025-10-15). + API Changes =========== diff --git a/pyproject.toml b/pyproject.toml index 8bb21019c..e4ae73acf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ lint.select = [ "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "PGH", # pygrep-hooks - "PT006", # pytest-parametrize-names-wrong-type + "PT", # flake8-pytest-style "PYI", # flake8-pyi "RUF100", # unused noqa (yesqa) "UP", # pyupgrade @@ -121,6 +121,12 @@ lint.ignore = [ "E221", # Multiple spaces before operator "E226", # Missing whitespace around arithmetic operator "E241", # Multiple spaces after ',' + "PT001", # pytest-fixture-incorrect-parentheses-style + "PT007", # pytest-parametrize-values-wrong-type + "PT011", # pytest-raises-too-broad + "PT012", # pytest-raises-with-multiple-statements + "PT016", # pytest-fail-without-message + "PT017", # pytest-assert-in-except "PYI026", # flake8-pyi: typing.TypeAlias added in Python 3.10 "PYI034", # flake8-pyi: typing.Self added in Python 3.11 ] @@ -160,5 +166,4 @@ warn_unused_ignores = true exclude = [ '^Tests/oss-fuzz/fuzz_font.py$', '^Tests/oss-fuzz/fuzz_pillow.py$', - '^Tests/test_qt_image_qapplication.py$', ] diff --git a/setup.py b/setup.py index b26852b0b..863d102cf 100644 --- a/setup.py +++ b/setup.py @@ -295,7 +295,6 @@ class pil_build_ext(build_ext): "raqm", "lcms", "webp", - "webpmux", "jpeg2000", "imagequant", "xcb", @@ -794,28 +793,18 @@ class pil_build_ext(build_ext): if feature.want("webp"): _dbg("Looking for webp") - if _find_include_file(self, "webp/encode.h") and _find_include_file( - self, "webp/decode.h" + if all( + _find_include_file(self, "webp/" + include) + for include in ("encode.h", "decode.h", "mux.h", "demux.h") ): - # In Google's precompiled zip it is call "libwebp": - if _find_library_file(self, "webp"): - feature.webp = "webp" - elif _find_library_file(self, "libwebp"): - feature.webp = "libwebp" - - if feature.want("webpmux"): - _dbg("Looking for webpmux") - if _find_include_file(self, "webp/mux.h") and _find_include_file( - self, "webp/demux.h" - ): - if _find_library_file(self, "webpmux") and _find_library_file( - self, "webpdemux" - ): - feature.webpmux = "webpmux" - if _find_library_file(self, "libwebpmux") and _find_library_file( - self, "libwebpdemux" - ): - feature.webpmux = "libwebpmux" + # In Google's precompiled zip it is called "libwebp" + for prefix in ("", "lib"): + if all( + _find_library_file(self, prefix + library) + for library in ("webp", "webpmux", "webpdemux") + ): + feature.webp = prefix + "webp" + break if feature.want("xcb"): _dbg("Looking for xcb") @@ -904,15 +893,8 @@ class pil_build_ext(build_ext): self._remove_extension("PIL._imagingcms") if feature.webp: - libs = [feature.webp] - defs = [] - - if feature.webpmux: - defs.append(("HAVE_WEBPMUX", None)) - libs.append(feature.webpmux) - libs.append(feature.webpmux.replace("pmux", "pdemux")) - - self._update_extension("PIL._webp", libs, defs) + libs = [feature.webp, feature.webp + "mux", feature.webp + "demux"] + self._update_extension("PIL._webp", libs) else: self._remove_extension("PIL._webp") @@ -953,7 +935,6 @@ class pil_build_ext(build_ext): (feature.raqm, "RAQM (Text shaping)", raqm_extra_info), (feature.lcms, "LITTLECMS2"), (feature.webp, "WEBP"), - (feature.webpmux, "WEBPMUX"), (feature.xcb, "XCB (X protocol)"), ] diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index dc7192b99..1112276dc 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -478,7 +478,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(struct.pack(" None: if bits != 32: and_mask = Image.new("1", size) ImageFile._save( - and_mask, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))] + and_mask, + image_io, + [ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))], ) else: frame.save(image_io, "png") @@ -317,11 +319,11 @@ class IcoImageFile(ImageFile.ImageFile): self.load() @property - def size(self): + def size(self) -> tuple[int, int]: return self._size @size.setter - def size(self, value): + def size(self, value: tuple[int, int]) -> None: if value not in self.info["sizes"]: msg = "This is not one of the allowed sizes of this image" raise ValueError(msg) diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 2fb7ecd52..b94165089 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -360,7 +360,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: palette += im_palette[colors * i : colors * (i + 1)] palette += b"\x00" * (256 - colors) fp.write(palette) # 768 bytes - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))] + ) # diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 5ec416081..95b4c64ee 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -218,9 +218,12 @@ if hasattr(core, "DEFAULT_STRATEGY"): # Registries if TYPE_CHECKING: + import mmap from xml.etree.ElementTree import Element - from . import ImageFile, ImagePalette, TiffImagePlugin + from IPython.lib.pretty import PrettyPrinter + + from . import ImageFile, ImageFilter, ImagePalette, ImageQt, TiffImagePlugin from ._typing import NumpyArray, StrOrBytesPath, TypeGuard ID: list[str] = [] OPEN: dict[ @@ -467,43 +470,53 @@ def _getencoder( # Simple expression analyzer -class _E: +class ImagePointTransform: + """ + Used with :py:meth:`~PIL.Image.Image.point` for single band images with more than + 8 bits, this represents an affine transformation, where the value is multiplied by + ``scale`` and ``offset`` is added. + """ + def __init__(self, scale: float, offset: float) -> None: self.scale = scale self.offset = offset - def __neg__(self) -> _E: - return _E(-self.scale, -self.offset) + def __neg__(self) -> ImagePointTransform: + return ImagePointTransform(-self.scale, -self.offset) - def __add__(self, other: _E | float) -> _E: - if isinstance(other, _E): - return _E(self.scale + other.scale, self.offset + other.offset) - return _E(self.scale, self.offset + other) + def __add__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return ImagePointTransform( + self.scale + other.scale, self.offset + other.offset + ) + return ImagePointTransform(self.scale, self.offset + other) __radd__ = __add__ - def __sub__(self, other: _E | float) -> _E: + def __sub__(self, other: ImagePointTransform | float) -> ImagePointTransform: return self + -other - def __rsub__(self, other: _E | float) -> _E: + def __rsub__(self, other: ImagePointTransform | float) -> ImagePointTransform: return other + -self - def __mul__(self, other: _E | float) -> _E: - if isinstance(other, _E): + def __mul__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): return NotImplemented - return _E(self.scale * other, self.offset * other) + return ImagePointTransform(self.scale * other, self.offset * other) __rmul__ = __mul__ - def __truediv__(self, other: _E | float) -> _E: - if isinstance(other, _E): + def __truediv__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): return NotImplemented - return _E(self.scale / other, self.offset / other) + return ImagePointTransform(self.scale / other, self.offset / other) -def _getscaleoffset(expr) -> tuple[float, float]: - a = expr(_E(1, 0)) - return (a.scale, a.offset) if isinstance(a, _E) else (0, a) +def _getscaleoffset( + expr: Callable[[ImagePointTransform], ImagePointTransform | float] +) -> tuple[float, float]: + a = expr(ImagePointTransform(1, 0)) + return (a.scale, a.offset) if isinstance(a, ImagePointTransform) else (0, a) # -------------------------------------------------------------------- @@ -623,7 +636,7 @@ class Image: logger.debug("Error closing: %s", msg) if getattr(self, "map", None): - self.map = None + self.map: mmap.mmap | None = None # Instead of simply setting to None, we're setting up a # deferred error that will better explain that the core image @@ -687,7 +700,7 @@ class Image: id(self), ) - def _repr_pretty_(self, p, cycle: bool) -> None: + def _repr_pretty_(self, p: PrettyPrinter, cycle: bool) -> None: """IPython plain text display support""" # Same as __repr__ but without unpredictable id(self), @@ -734,24 +747,12 @@ class Image: def __array_interface__(self) -> dict[str, str | bytes | int | tuple[int, ...]]: # numpy array interface support new: dict[str, str | bytes | int | tuple[int, ...]] = {"version": 3} - try: - if self.mode == "1": - # Binary images need to be extended from bits to bytes - # See: https://github.com/python-pillow/Pillow/issues/350 - new["data"] = self.tobytes("raw", "L") - else: - new["data"] = self.tobytes() - except Exception as e: - if not isinstance(e, (MemoryError, RecursionError)): - try: - import numpy - from packaging.version import parse as parse_version - except ImportError: - pass - else: - if parse_version(numpy.__version__) < parse_version("1.23"): - warnings.warn(str(e)) - raise + if self.mode == "1": + # Binary images need to be extended from bits to bytes + # See: https://github.com/python-pillow/Pillow/issues/350 + new["data"] = self.tobytes("raw", "L") + else: + new["data"] = self.tobytes() new["shape"], new["typestr"] = _conv_type_shape(self) return new @@ -1352,9 +1353,6 @@ class Image: self.load() return self._new(self.im.expand(xmargin, ymargin)) - if TYPE_CHECKING: - from . import ImageFilter - def filter(self, filter: ImageFilter.Filter | type[ImageFilter.Filter]) -> Image: """ Filters this image using the given filter. For a list of @@ -1918,7 +1916,13 @@ class Image: def point( self, - lut: Sequence[float] | NumpyArray | Callable[[int], float] | ImagePointHandler, + lut: ( + Sequence[float] + | NumpyArray + | Callable[[int], float] + | Callable[[ImagePointTransform], ImagePointTransform | float] + | ImagePointHandler + ), mode: str | None = None, ) -> Image: """ @@ -1935,7 +1939,7 @@ class Image: object:: class Example(Image.ImagePointHandler): - def point(self, data): + def point(self, im: Image) -> Image: # Return result :param mode: Output mode (default is same as input). This can only be used if the source image has mode "L" or "P", and the output has mode "1" or the @@ -1954,10 +1958,10 @@ class Image: # check if the function can be used with point_transform # UNDONE wiredfool -- I think this prevents us from ever doing # a gamma function point transform on > 8bit images. - scale, offset = _getscaleoffset(lut) + scale, offset = _getscaleoffset(lut) # type: ignore[arg-type] return self._new(self.im.point_transform(scale, offset)) # for other modes, convert the function to a table - flatLut = [lut(i) for i in range(256)] * self.im.bands + flatLut = [lut(i) for i in range(256)] * self.im.bands # type: ignore[arg-type] else: flatLut = lut @@ -2894,11 +2898,11 @@ class Image: self, box: tuple[int, int, int, int], image: Image, - method, - data, + method: Transform, + data: Sequence[float], resample: int = Resampling.NEAREST, fill: bool = True, - ): + ) -> None: w = box[2] - box[0] h = box[3] - box[1] @@ -2999,7 +3003,7 @@ class Image: self.load() return self._new(self.im.effect_spread(distance)) - def toqimage(self): + def toqimage(self) -> ImageQt.ImageQt: """Returns a QImage copy of this image""" from . import ImageQt @@ -3008,7 +3012,7 @@ class Image: raise ImportError(msg) return ImageQt.toqimage(self) - def toqpixmap(self): + def toqpixmap(self) -> ImageQt.QPixmap: """Returns a QPixmap copy of this image""" from . import ImageQt @@ -3335,7 +3339,7 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) -def fromqimage(im) -> ImageFile.ImageFile: +def fromqimage(im: ImageQt.QImage) -> ImageFile.ImageFile: """Creates an image instance from a QImage image""" from . import ImageQt @@ -3345,7 +3349,7 @@ def fromqimage(im) -> ImageFile.ImageFile: return ImageQt.fromqimage(im) -def fromqpixmap(im) -> ImageFile.ImageFile: +def fromqpixmap(im: ImageQt.QPixmap) -> ImageFile.ImageFile: """Creates an image instance from a QPixmap image""" from . import ImageQt @@ -4033,15 +4037,19 @@ class Exif(_ExifBase): ifd[tag] = value return b"Exif\x00\x00" + head + ifd.tobytes(offset) - def get_ifd(self, tag): + def get_ifd(self, tag: int) -> dict[int, Any]: if tag not in self._ifds: if tag == ExifTags.IFD.IFD1: if self._info is not None and self._info.next != 0: - self._ifds[tag] = self._get_ifd_dict(self._info.next) + ifd = self._get_ifd_dict(self._info.next) + if ifd is not None: + self._ifds[tag] = ifd elif tag in [ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo]: offset = self._hidden_data.get(tag, self.get(tag)) if offset is not None: - self._ifds[tag] = self._get_ifd_dict(offset, tag) + ifd = self._get_ifd_dict(offset, tag) + if ifd is not None: + self._ifds[tag] = ifd elif tag in [ExifTags.IFD.Interop, ExifTags.IFD.Makernote]: if ExifTags.IFD.Exif not in self._ifds: self.get_ifd(ExifTags.IFD.Exif) @@ -4098,7 +4106,9 @@ class Exif(_ExifBase): (offset,) = struct.unpack(">L", data) self.fp.seek(offset) - camerainfo = {"ModelID": self.fp.read(4)} + camerainfo: dict[str, int | bytes] = { + "ModelID": self.fp.read(4) + } self.fp.read(4) # Seconds since 2000 @@ -4114,16 +4124,18 @@ class Exif(_ExifBase): ][1] camerainfo["Parallax"] = handler( ImageFileDirectory_v2(), parallax, False - ) + )[0] self.fp.read(4) camerainfo["Category"] = self.fp.read(2) - makernote = {0x1101: dict(self._fixup_dict(camerainfo))} + makernote = {0x1101: camerainfo} self._ifds[tag] = makernote else: # Interop - self._ifds[tag] = self._get_ifd_dict(tag_data, tag) + ifd = self._get_ifd_dict(tag_data, tag) + if ifd is not None: + self._ifds[tag] = ifd ifd = self._ifds.get(tag, {}) if tag == ExifTags.IFD.Exif and self._hidden_data: ifd = { diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 988e3d7c5..d8e4c0c60 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -504,7 +504,7 @@ class ImageDraw: if full_x: self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1) - else: + elif x1 - r - 1 > x0 + r + 1: self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1) if not full_x and not full_y: left = [x0, y0, x0 + r, y1] diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 24b3a9eb1..a1c33f219 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -31,6 +31,7 @@ from __future__ import annotations import abc import io import itertools +import os import struct import sys from typing import IO, Any, NamedTuple @@ -93,7 +94,7 @@ def _tilesort(t: _Tile) -> int: class _Tile(NamedTuple): codec_name: str - extents: tuple[int, int, int, int] + extents: tuple[int, int, int, int] | None offset: int args: tuple[Any, ...] | str | None @@ -174,7 +175,7 @@ class ImageFile(Image.Image): self.fp.close() self.fp = None - def load(self): + def load(self) -> Image.core.PixelAccess | None: """Load image data based on tile list""" if self.tile is None: @@ -185,7 +186,7 @@ class ImageFile(Image.Image): if not self.tile: return pixel - self.map = None + self.map: mmap.mmap | None = None use_mmap = self.filename and len(self.tile) == 1 # As of pypy 2.1.0, memory mapping was failing here. use_mmap = use_mmap and not hasattr(sys, "pypy_version_info") @@ -193,17 +194,17 @@ class ImageFile(Image.Image): readonly = 0 # look for read/seek overrides - try: + if hasattr(self, "load_read"): read = self.load_read # don't use mmap if there are custom read/seek functions use_mmap = False - except AttributeError: + else: read = self.fp.read - try: + if hasattr(self, "load_seek"): seek = self.load_seek use_mmap = False - except AttributeError: + else: seek = self.fp.seek if use_mmap: @@ -243,11 +244,8 @@ class ImageFile(Image.Image): # sort tiles in file order self.tile.sort(key=_tilesort) - try: - # FIXME: This is a hack to handle TIFF's JpegTables tag. - prefix = self.tile_prefix - except AttributeError: - prefix = b"" + # FIXME: This is a hack to handle TIFF's JpegTables tag. + prefix = getattr(self, "tile_prefix", b"") # Remove consecutive duplicates that only differ by their offset self.tile = [ @@ -525,7 +523,7 @@ class Parser: # -------------------------------------------------------------------- -def _save(im: Image.Image, fp: IO[bytes], tile, bufsize: int = 0) -> None: +def _save(im: Image.Image, fp: IO[bytes], tile: list[_Tile], bufsize: int = 0) -> None: """Helper to save image based on tile list :param im: Image object. @@ -558,7 +556,7 @@ def _encode_tile( fp: IO[bytes], tile: list[_Tile], bufsize: int, - fh, + fh: int | None, exc: BaseException | None = None, ) -> None: for encoder_name, extents, offset, args in tile: @@ -580,6 +578,7 @@ def _encode_tile( break else: # slight speedup: compress to real file object + assert fh is not None errcode = encoder.encode_to_file(fh, bufsize) if errcode < 0: raise _get_oserror(errcode, encoder=True) from exc @@ -804,7 +803,7 @@ class PyEncoder(PyCodec): self.fd.write(data) return bytes_consumed, errcode - def encode_to_file(self, fh: IO[bytes], bufsize: int) -> int: + def encode_to_file(self, fh: int, bufsize: int) -> int: """ :param fh: File handle. :param bufsize: Buffer size. @@ -817,5 +816,5 @@ class PyEncoder(PyCodec): while errcode == 0: status, errcode, buf = self.encode(bufsize) if status > 0: - fh.write(buf[status:]) + os.write(fh, buf[status:]) return errcode diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index a2d946714..a3d647138 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -19,14 +19,23 @@ from __future__ import annotations import sys from io import BytesIO -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Any, Callable, Union from . import Image from ._util import is_path if TYPE_CHECKING: + import PyQt6 + import PySide6 + from . import ImageFile + QBuffer: type + QByteArray = Union[PyQt6.QtCore.QByteArray, PySide6.QtCore.QByteArray] + QIODevice = Union[PyQt6.QtCore.QIODevice, PySide6.QtCore.QIODevice] + QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage] + QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap] + qt_version: str | None qt_versions = [ ["6", "PyQt6"], @@ -37,10 +46,6 @@ qt_versions = [ qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True) for version, qt_module in qt_versions: try: - QBuffer: type - QIODevice: type - QImage: type - QPixmap: type qRgba: Callable[[int, int, int, int], int] if qt_module == "PyQt6": from PyQt6.QtCore import QBuffer, QIODevice @@ -65,19 +70,20 @@ def rgb(r: int, g: int, b: int, a: int = 255) -> int: return qRgba(r, g, b, a) & 0xFFFFFFFF -def fromqimage(im): +def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile: """ :param im: QImage or PIL ImageQt object """ buffer = QBuffer() + qt_openmode: object if qt_version == "6": try: - qt_openmode = QIODevice.OpenModeFlag + qt_openmode = getattr(QIODevice, "OpenModeFlag") except AttributeError: - qt_openmode = QIODevice.OpenMode + qt_openmode = getattr(QIODevice, "OpenMode") else: qt_openmode = QIODevice - buffer.open(qt_openmode.ReadWrite) + buffer.open(getattr(qt_openmode, "ReadWrite")) # preserve alpha channel with png # otherwise ppm is more friendly with Image.open if im.hasAlphaChannel(): @@ -93,7 +99,7 @@ def fromqimage(im): return Image.open(b) -def fromqpixmap(im) -> ImageFile.ImageFile: +def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile: return fromqimage(im) @@ -123,7 +129,7 @@ def align8to32(bytes: bytes, width: int, mode: str) -> bytes: return b"".join(new_data) -def _toqclass_helper(im): +def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]: data = None colortable = None exclusive_fp = False @@ -135,30 +141,32 @@ def _toqclass_helper(im): if is_path(im): im = Image.open(im) exclusive_fp = True + assert isinstance(im, Image.Image) - qt_format = QImage.Format if qt_version == "6" else QImage + qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage if im.mode == "1": - format = qt_format.Format_Mono + format = getattr(qt_format, "Format_Mono") elif im.mode == "L": - format = qt_format.Format_Indexed8 + format = getattr(qt_format, "Format_Indexed8") colortable = [rgb(i, i, i) for i in range(256)] elif im.mode == "P": - format = qt_format.Format_Indexed8 + format = getattr(qt_format, "Format_Indexed8") palette = im.getpalette() + assert palette is not None colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)] elif im.mode == "RGB": # Populate the 4th channel with 255 im = im.convert("RGBA") data = im.tobytes("raw", "BGRA") - format = qt_format.Format_RGB32 + format = getattr(qt_format, "Format_RGB32") elif im.mode == "RGBA": data = im.tobytes("raw", "BGRA") - format = qt_format.Format_ARGB32 + format = getattr(qt_format, "Format_ARGB32") elif im.mode == "I;16": im = im.point(lambda i: i * 256) - format = qt_format.Format_Grayscale16 + format = getattr(qt_format, "Format_Grayscale16") else: if exclusive_fp: im.close() @@ -174,8 +182,8 @@ def _toqclass_helper(im): if qt_is_installed: - class ImageQt(QImage): - def __init__(self, im) -> None: + class ImageQt(QImage): # type: ignore[misc] + def __init__(self, im: Image.Image | str | QByteArray) -> None: """ An PIL image wrapper for Qt. This is a subclass of PyQt's QImage class. @@ -199,10 +207,10 @@ if qt_is_installed: self.setColorTable(im_data["colortable"]) -def toqimage(im) -> ImageQt: +def toqimage(im: Image.Image | str | QByteArray) -> ImageQt: return ImageQt(im) -def toqpixmap(im): +def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap: qimage = toqimage(im) - return QPixmap.fromImage(qimage) + return getattr(QPixmap, "fromImage")(qimage) diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index 03712d24c..a8cd38218 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -217,8 +217,8 @@ def getiptcinfo( # get raw data from the IPTC/NAA tag (PhotoShop tags the data # as 4-byte integers, so we cannot use the get method...) try: - data = im.tag.tagdata[TiffImagePlugin.IPTC_NAA_CHUNK] - except (AttributeError, KeyError): + data = im.tag_v2[TiffImagePlugin.IPTC_NAA_CHUNK] + except KeyError: pass if data is None: diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index ef9107f00..02c2e48cf 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -419,7 +419,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: plt, ) - ImageFile._save(im, fp, [("jpeg2k", (0, 0) + im.size, 0, kind)]) + ImageFile._save(im, fp, [ImageFile._Tile("jpeg2k", (0, 0) + im.size, 0, kind)]) # ------------------------------------------------------------ diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index fc897e2b9..bd4539be4 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -826,7 +826,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Ensure that our buffer is big enough. Same with the icc_profile block. bufsize = max(bufsize, len(exif) + 5, len(extra) + 1) - ImageFile._save(im, fp, [("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize) + ImageFile._save( + im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize + ) def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index 0a75c868b..40e5fa435 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -188,7 +188,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(o16(h)) # image body - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 32, ("1", 0, 1))]) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 32, ("1", 0, 1))]) # diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index 673eae1d1..02939d26b 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -17,7 +17,7 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING +from typing import IO, TYPE_CHECKING from . import EpsImagePlugin @@ -28,15 +28,12 @@ from . import EpsImagePlugin class PSDraw: """ Sets up printing to the given file. If ``fp`` is omitted, - ``sys.stdout.buffer`` or ``sys.stdout`` is assumed. + ``sys.stdout.buffer`` is assumed. """ - def __init__(self, fp=None): + def __init__(self, fp: IO[bytes] | None = None) -> None: if not fp: - try: - fp = sys.stdout.buffer - except AttributeError: - fp = sys.stdout + fp = sys.stdout.buffer self.fp = fp def begin_document(self, id: str | None = None) -> None: diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 9ca93c531..b33245376 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -214,7 +214,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ) # now convert data to raw form - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))] + ) if hasattr(fp, "flush"): fp.flush() diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 2b8ced8af..c3c9dd3f1 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -198,7 +198,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: assert fp.tell() == 128 - ImageFile._save(im, fp, [("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))]) + ImageFile._save( + im, fp, [ImageFile._Tile("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))] + ) if im.mode == "P": # colour palette diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 7fc1108bb..e9c20ddc1 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -138,7 +138,7 @@ def _write_image( op = io.BytesIO() if decode_filter == "ASCIIHexDecode": - ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)]) + ImageFile._save(im, op, [ImageFile._Tile("hex", (0, 0) + im.size, 0, im.mode)]) elif decode_filter == "CCITTFaxDecode": im.save( op, diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index a0ac24571..dc7face2f 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1229,7 +1229,7 @@ def _write_multiple_frames( ImageFile._save( im, cast(IO[bytes], _idat(fp, chunk)), - [("zip", (0, 0) + im.size, 0, rawmode)], + [ImageFile._Tile("zip", (0, 0) + im.size, 0, rawmode)], ) seq_num = 0 @@ -1266,14 +1266,14 @@ def _write_multiple_frames( ImageFile._save( im_frame, cast(IO[bytes], _idat(fp, chunk)), - [("zip", (0, 0) + im_frame.size, 0, rawmode)], + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], ) else: fdat_chunks = _fdat(fp, chunk, seq_num) ImageFile._save( im_frame, cast(IO[bytes], fdat_chunks), - [("zip", (0, 0) + im_frame.size, 0, rawmode)], + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], ) seq_num = fdat_chunks.seq_num return None @@ -1474,7 +1474,7 @@ def _save( ImageFile._save( single_im, cast(IO[bytes], _idat(fp, chunk)), - [("zip", (0, 0) + single_im.size, 0, rawmode)], + [ImageFile._Tile("zip", (0, 0) + single_im.size, 0, rawmode)], ) if info: diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 16c9ccbba..7bdaa9fe7 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -353,7 +353,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: elif head == b"Pf": fp.write(b"-1.0\n") row_order = -1 if im.mode == "F" else 1 - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))] + ) # diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index a07101e54..7045ab566 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -278,7 +278,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.writelines(hdr) rawmode = "F;32NF" # 32-bit native floating point - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))] + ) def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index c2a0ee4e9..5aea363c0 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -236,11 +236,15 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if rle: ImageFile._save( - im, fp, [("tga_rle", (0, 0) + im.size, 0, (rawmode, orientation))] + im, + fp, + [ImageFile._Tile("tga_rle", (0, 0) + im.size, 0, (rawmode, orientation))], ) else: ImageFile._save( - im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, orientation))] + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, orientation))], ) # write targa version 2 footer diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index d9d1bab5a..ab23356d4 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -259,6 +259,7 @@ OPEN_INFO = { (II, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), (MM, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), (II, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16L"), + (MM, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16B"), (II, 6, (1,), 1, (8,), ()): ("L", "L"), (MM, 6, (1,), 1, (8,), ()): ("L", "L"), # JPEG compressed images handled by LibTiff and auto-converted to RGBX @@ -455,8 +456,11 @@ class IFDRational(Rational): __int__ = _delegate("__int__") -def _register_loader(idx: int, size: int): - def decorator(func): +_LoaderFunc = Callable[["ImageFileDirectory_v2", bytes, bool], Any] + + +def _register_loader(idx: int, size: int) -> Callable[[_LoaderFunc], _LoaderFunc]: + def decorator(func: _LoaderFunc) -> _LoaderFunc: from .TiffTags import TYPES if func.__name__.startswith("load_"): @@ -481,12 +485,13 @@ def _register_basic(idx_fmt_name: tuple[int, str, str]) -> None: idx, fmt, name = idx_fmt_name TYPES[idx] = name size = struct.calcsize(f"={fmt}") - _load_dispatch[idx] = ( # noqa: F821 - size, - lambda self, data, legacy_api=True: ( - self._unpack(f"{len(data) // size}{fmt}", data) - ), - ) + + def basic_handler( + self: ImageFileDirectory_v2, data: bytes, legacy_api: bool = True + ) -> tuple[Any, ...]: + return self._unpack(f"{len(data) // size}{fmt}", data) + + _load_dispatch[idx] = size, basic_handler # noqa: F821 _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 b"".join(self._pack(fmt, value) for value in values) ) @@ -559,7 +564,7 @@ class ImageFileDirectory_v2(_IFDv2Base): """ - _load_dispatch: dict[int, Callable[[ImageFileDirectory_v2, bytes, bool], Any]] = {} + _load_dispatch: dict[int, tuple[int, _LoaderFunc]] = {} _write_dispatch: dict[int, Callable[..., Any]] = {} def __init__( @@ -619,12 +624,12 @@ class ImageFileDirectory_v2(_IFDv2Base): self._tagdata: dict[int, bytes] = {} self.tagtype = {} # added 2008-06-05 by Florian Hoech self._next = None - self._offset = None + self._offset: int | None = None def __str__(self) -> str: return str(dict(self)) - def named(self): + def named(self) -> dict[str, Any]: """ :returns: dict of name|key: value @@ -638,7 +643,7 @@ class ImageFileDirectory_v2(_IFDv2Base): def __len__(self) -> int: return len(set(self._tagdata) | set(self._tags_v2)) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if tag not in self._tags_v2: # unpack on the fly data = self._tagdata[tag] typ = self.tagtype[tag] @@ -652,10 +657,10 @@ class ImageFileDirectory_v2(_IFDv2Base): def __contains__(self, tag: object) -> bool: return tag in self._tags_v2 or tag in self._tagdata - def __setitem__(self, tag: int, value) -> None: + def __setitem__(self, tag: int, value: Any) -> None: self._setitem(tag, value, self.legacy_api) - def _setitem(self, tag: int, value, legacy_api: bool) -> None: + def _setitem(self, tag: int, value: Any, legacy_api: bool) -> None: basetypes = (Number, bytes, str) info = TiffTags.lookup(tag, self.group) @@ -743,10 +748,10 @@ class ImageFileDirectory_v2(_IFDv2Base): def __iter__(self) -> Iterator[int]: return iter(set(self._tagdata) | set(self._tags_v2)) - def _unpack(self, fmt: str, data: bytes): + def _unpack(self, fmt: str, data: bytes) -> tuple[Any, ...]: return struct.unpack(self._endian + fmt, data) - def _pack(self, fmt: str, *values) -> bytes: + def _pack(self, fmt: str, *values: Any) -> bytes: return struct.pack(self._endian + fmt, *values) list( @@ -823,7 +828,9 @@ class ImageFileDirectory_v2(_IFDv2Base): return value @_register_loader(10, 8) - def load_signed_rational(self, data: bytes, legacy_api: bool = True): + def load_signed_rational( + self, data: bytes, legacy_api: bool = True + ) -> tuple[tuple[int, int] | IFDRational, ...]: vals = self._unpack(f"{len(data) // 4}l", data) def combine(a: int, b: int) -> tuple[int, int] | IFDRational: @@ -848,7 +855,7 @@ class ImageFileDirectory_v2(_IFDv2Base): raise OSError(msg) return ret - def load(self, fp): + def load(self, fp: IO[bytes]) -> None: self.reset() self._offset = fp.tell() @@ -1087,11 +1094,11 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): def __iter__(self) -> Iterator[int]: return iter(set(self._tagdata) | set(self._tags_v1)) - def __setitem__(self, tag: int, value) -> None: + def __setitem__(self, tag: int, value: Any) -> None: for legacy_api in (False, True): self._setitem(tag, value, legacy_api) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if tag not in self._tags_v1: # unpack on the fly data = self._tagdata[tag] typ = self.tagtype[tag] @@ -1117,11 +1124,15 @@ class TiffImageFile(ImageFile.ImageFile): format_description = "Adobe TIFF" _close_exclusive_fp_after_loading = False - def __init__(self, fp=None, filename=None): - self.tag_v2 = None + def __init__( + self, + fp: StrOrBytesPath | IO[bytes] | None = None, + filename: str | bytes | None = None, + ) -> None: + self.tag_v2: ImageFileDirectory_v2 """ Image file directory (tag dictionary) """ - self.tag = None + self.tag: ImageFileDirectory_v1 """ Legacy tag entries """ super().__init__(fp, filename) @@ -1136,9 +1147,6 @@ class TiffImageFile(ImageFile.ImageFile): self.tag_v2 = ImageFileDirectory_v2(ifh) - # legacy IFD entries will be filled in later - self.ifd: ImageFileDirectory_v1 | None = None - # setup frame pointers self.__first = self.__next = self.tag_v2.next self.__frame = -1 @@ -1386,11 +1394,14 @@ class TiffImageFile(ImageFile.ImageFile): logger.debug("- photometric_interpretation: %s", photo) logger.debug("- planar_configuration: %s", self._planar_configuration) logger.debug("- fill_order: %s", fillorder) - logger.debug("- YCbCr subsampling: %s", self.tag.get(YCBCRSUBSAMPLING)) + logger.debug("- YCbCr subsampling: %s", self.tag_v2.get(YCBCRSUBSAMPLING)) # size - xsize = int(self.tag_v2.get(IMAGEWIDTH)) - ysize = int(self.tag_v2.get(IMAGELENGTH)) + xsize = self.tag_v2.get(IMAGEWIDTH) + ysize = self.tag_v2.get(IMAGELENGTH) + if not isinstance(xsize, int) or not isinstance(ysize, int): + msg = "Invalid dimensions" + raise ValueError(msg) self._size = xsize, ysize logger.debug("- size: %s", self.size) @@ -1538,8 +1549,12 @@ class TiffImageFile(ImageFile.ImageFile): else: # tiled image offsets = self.tag_v2[TILEOFFSETS] - w = self.tag_v2.get(TILEWIDTH) + tilewidth = self.tag_v2.get(TILEWIDTH) h = self.tag_v2.get(TILELENGTH) + if not isinstance(tilewidth, int) or not isinstance(h, int): + msg = "Invalid tile dimensions" + raise ValueError(msg) + w = tilewidth for offset in offsets: if x + w > xsize: @@ -1617,7 +1632,7 @@ SAVE_INFO = { } -def _save(im, fp, filename): +def _save(im: Image.Image, fp, filename: str | bytes) -> None: try: rawmode, prefix, photo, format, bits, extra = SAVE_INFO[im.mode] except KeyError as e: @@ -1753,10 +1768,11 @@ def _save(im, fp, filename): if im.mode == "1": inverted_im = im.copy() px = inverted_im.load() - for y in range(inverted_im.height): - for x in range(inverted_im.width): - px[x, y] = 0 if px[x, y] == 255 else 255 - im = inverted_im + if px is not None: + for y in range(inverted_im.height): + for x in range(inverted_im.width): + px[x, y] = 0 if px[x, y] == 255 else 255 + im = inverted_im else: im = ImageOps.invert(im) @@ -1798,11 +1814,11 @@ def _save(im, fp, filename): ifd[COMPRESSION] = COMPRESSION_INFO_REV.get(compression, 1) if im.mode == "YCbCr": - for tag, value in { + for tag, default_value in { YCBCRSUBSAMPLING: (1, 1), REFERENCEBLACKWHITE: (0, 255, 128, 255, 128, 255), }.items(): - ifd.setdefault(tag, value) + ifd.setdefault(tag, default_value) blocklist = [TILEWIDTH, TILELENGTH, TILEOFFSETS, TILEBYTECOUNTS] if libtiff: @@ -1845,7 +1861,7 @@ def _save(im, fp, filename): ] # bits per sample is a single short in the tiff directory, not a list. - atts = {BITSPERSAMPLE: bits[0]} + atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]} # Merge the ones that we have with (optional) more bits from # the original file, e.g x,y resolution so that we can # save(load('')) == original file. @@ -1916,13 +1932,15 @@ def _save(im, fp, filename): offset = ifd.save(fp) ImageFile._save( - im, fp, [("raw", (0, 0) + im.size, offset, (rawmode, stride, 1))] + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, offset, (rawmode, stride, 1))], ) # -- helper for multi-page save -- if "_debug_multipage" in encoderinfo: # just to access o32 and o16 (using correct byte order) - im._debug_multipage = ifd + setattr(im, "_debug_multipage", ifd) class AppendingTiffWriter: @@ -2079,38 +2097,34 @@ class AppendingTiffWriter: (value,) = struct.unpack(self.longFmt, self.f.read(4)) return value + @staticmethod + def _verify_bytes_written(bytes_written: int | None, expected: int) -> None: + if bytes_written is not None and bytes_written != expected: + msg = f"wrote only {bytes_written} bytes but wanted {expected}" + raise RuntimeError(msg) + def rewriteLastShortToLong(self, value: int) -> None: self.f.seek(-2, os.SEEK_CUR) bytes_written = self.f.write(struct.pack(self.longFmt, value)) - if bytes_written is not None and bytes_written != 4: - msg = f"wrote only {bytes_written} bytes but wanted 4" - raise RuntimeError(msg) + self._verify_bytes_written(bytes_written, 4) def rewriteLastShort(self, value: int) -> None: self.f.seek(-2, os.SEEK_CUR) bytes_written = self.f.write(struct.pack(self.shortFmt, value)) - if bytes_written is not None and bytes_written != 2: - msg = f"wrote only {bytes_written} bytes but wanted 2" - raise RuntimeError(msg) + self._verify_bytes_written(bytes_written, 2) def rewriteLastLong(self, value: int) -> None: self.f.seek(-4, os.SEEK_CUR) bytes_written = self.f.write(struct.pack(self.longFmt, value)) - if bytes_written is not None and bytes_written != 4: - msg = f"wrote only {bytes_written} bytes but wanted 4" - raise RuntimeError(msg) + self._verify_bytes_written(bytes_written, 4) def writeShort(self, value: int) -> None: bytes_written = self.f.write(struct.pack(self.shortFmt, value)) - if bytes_written is not None and bytes_written != 2: - msg = f"wrote only {bytes_written} bytes but wanted 2" - raise RuntimeError(msg) + self._verify_bytes_written(bytes_written, 2) def writeLong(self, value: int) -> None: bytes_written = self.f.write(struct.pack(self.longFmt, value)) - if bytes_written is not None and bytes_written != 4: - msg = f"wrote only {bytes_written} bytes but wanted 4" - raise RuntimeError(msg) + self._verify_bytes_written(bytes_written, 4) def close(self) -> None: self.finalize() diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index cec796340..fc8f4fc2e 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -45,22 +45,6 @@ class WebPImageFile(ImageFile.ImageFile): __logical_frame = 0 def _open(self) -> None: - if not _webp.HAVE_WEBPANIM: - # Legacy mode - data, width, height, self._mode, icc_profile, exif = _webp.WebPDecode( - self.fp.read() - ) - if icc_profile: - self.info["icc_profile"] = icc_profile - if exif: - self.info["exif"] = exif - self._size = width, height - self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.mode)] - self.n_frames = 1 - self.is_animated = False - return - # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. self._decoder = _webp.WebPAnimDecoder(self.fp.read()) @@ -145,21 +129,20 @@ class WebPImageFile(ImageFile.ImageFile): self._get_next() # Advance to the requested frame def load(self) -> Image.core.PixelAccess | None: - if _webp.HAVE_WEBPANIM: - if self.__loaded != self.__logical_frame: - self._seek(self.__logical_frame) + if self.__loaded != self.__logical_frame: + self._seek(self.__logical_frame) - # We need to load the image data for this frame - data, timestamp, duration = self._get_next() - self.info["timestamp"] = timestamp - self.info["duration"] = duration - self.__loaded = self.__logical_frame + # We need to load the image data for this frame + data, timestamp, duration = self._get_next() + self.info["timestamp"] = timestamp + self.info["duration"] = duration + self.__loaded = self.__logical_frame - # Set tile - if self.fp and self._exclusive_fp: - self.fp.close() - self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.rawmode)] + # Set tile + if self.fp and self._exclusive_fp: + self.fp.close() + self.fp = BytesIO(data) + self.tile = [("raw", (0, 0) + self.size, 0, self.rawmode)] return super().load() @@ -167,9 +150,6 @@ class WebPImageFile(ImageFile.ImageFile): pass def tell(self) -> int: - if not _webp.HAVE_WEBPANIM: - return super().tell() - return self.__logical_frame @@ -357,7 +337,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: Image.register_open(WebPImageFile.format, WebPImageFile, _accept) if SUPPORTED: Image.register_save(WebPImageFile.format, _save) - if _webp.HAVE_WEBPANIM: - Image.register_save_all(WebPImageFile.format, _save_all) + Image.register_save_all(WebPImageFile.format, _save_all) Image.register_extension(WebPImageFile.format, ".webp") Image.register_mime(WebPImageFile.format, "image/webp") diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index 6d11bbfcf..6c2e32804 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -85,7 +85,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(b"static char im_bits[] = {\n") - ImageFile._save(im, fp, [("xbm", (0, 0) + im.size, 0, None)]) + ImageFile._save(im, fp, [ImageFile._Tile("xbm", (0, 0) + im.size, 0, None)]) fp.write(b"};\n") diff --git a/src/PIL/features.py b/src/PIL/features.py index 13908c4eb..24c5ee978 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -9,6 +9,7 @@ from typing import IO import PIL from . import Image +from ._deprecate import deprecate modules = { "pil": ("PIL._imaging", "PILLOW_VERSION"), @@ -118,10 +119,10 @@ def get_supported_codecs() -> list[str]: return [f for f in codecs if check_codec(f)] -features = { - "webp_anim": ("PIL._webp", "HAVE_WEBPANIM", None), - "webp_mux": ("PIL._webp", "HAVE_WEBPMUX", None), - "transp_webp": ("PIL._webp", "HAVE_TRANSPARENCY", None), +features: dict[str, tuple[str, str | bool, str | None]] = { + "webp_anim": ("PIL._webp", True, None), + "webp_mux": ("PIL._webp", True, None), + "transp_webp": ("PIL._webp", True, None), "raqm": ("PIL._imagingft", "HAVE_RAQM", "raqm_version"), "fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"), "harfbuzz": ("PIL._imagingft", "HAVE_HARFBUZZ", "harfbuzz_version"), @@ -147,6 +148,9 @@ def check_feature(feature: str) -> bool | None: try: imported_module = __import__(module, fromlist=["PIL"]) + if isinstance(flag, bool): + deprecate(f'check_feature("{feature}")', 12) + return flag return getattr(imported_module, flag) except ModuleNotFoundError: return None @@ -176,7 +180,17 @@ def get_supported_features() -> list[str]: """ :returns: A list of all supported features. """ - return [f for f in features if check_feature(f)] + supported_features = [] + for f, (module, flag, _) in features.items(): + if flag is True: + for feature, (feature_module, _) in modules.items(): + if feature_module == module: + if check_module(feature): + supported_features.append(f) + break + elif check_feature(f): + supported_features.append(f) + return supported_features def check(feature: str) -> bool | None: @@ -271,9 +285,6 @@ def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: ("freetype2", "FREETYPE2"), ("littlecms2", "LITTLECMS2"), ("webp", "WEBP"), - ("transp_webp", "WEBP Transparency"), - ("webp_mux", "WEBPMUX"), - ("webp_anim", "WEBP Animation"), ("jpg", "JPEG"), ("jpg_2000", "OPENJPEG (JPEG2000)"), ("zlib", "ZLIB (PNG/ZIP)"), diff --git a/src/_webp.c b/src/_webp.c index e686ec820..f59ad3036 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -4,8 +4,6 @@ #include #include #include - -#ifdef HAVE_WEBPMUX #include #include @@ -13,12 +11,10 @@ * Check the versions from mux.h and demux.h, to ensure the WebPAnimEncoder and * WebPAnimDecoder APIs are present (initial support was added in 0.5.0). The * very early versions had some significant differences, so we require later - * versions, before enabling animation support. + * versions. */ -#if WEBP_MUX_ABI_VERSION >= 0x0104 && WEBP_DEMUX_ABI_VERSION >= 0x0105 -#define HAVE_WEBPANIM -#endif - +#if WEBP_MUX_ABI_VERSION < 0x0106 || WEBP_DEMUX_ABI_VERSION < 0x0107 +#error libwebp 0.5.0 and above is required. Upgrade libwebp or build Pillow with --disable-webp flag #endif void @@ -35,8 +31,6 @@ ImagingSectionLeave(ImagingSectionCookie *cookie) { /* WebP Muxer Error Handling */ /* -------------------------------------------------------------------- */ -#ifdef HAVE_WEBPMUX - static const char *const kErrorMessages[-WEBP_MUX_NOT_ENOUGH_DATA + 1] = { "WEBP_MUX_NOT_FOUND", "WEBP_MUX_INVALID_ARGUMENT", @@ -89,14 +83,10 @@ HandleMuxError(WebPMuxError err, char *chunk) { return NULL; } -#endif - /* -------------------------------------------------------------------- */ /* WebP Animation Support */ /* -------------------------------------------------------------------- */ -#ifdef HAVE_WEBPANIM - // Encoder type typedef struct { PyObject_HEAD WebPAnimEncoder *enc; @@ -576,8 +566,6 @@ static PyTypeObject WebPAnimDecoder_Type = { 0, /*tp_getset*/ }; -#endif - /* -------------------------------------------------------------------- */ /* Legacy WebP Support */ /* -------------------------------------------------------------------- */ @@ -652,10 +640,7 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { config.quality = quality_factor; config.alpha_quality = alpha_quality_factor; config.method = method; -#if WEBP_ENCODER_ABI_VERSION >= 0x0209 - // the "exact" flag is only available in libwebp 0.5.0 and later config.exact = exact; -#endif // Validate the config if (!WebPValidateConfig(&config)) { @@ -687,19 +672,21 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { WebPPictureFree(&pic); if (!ok) { - PyErr_Format(PyExc_ValueError, "encoding error %d", (&pic)->error_code); + int error_code = (&pic)->error_code; + char message[50] = ""; + if (error_code == VP8_ENC_ERROR_BAD_DIMENSION) { + sprintf( + message, + ": Image size exceeds WebP limit of %d pixels", + WEBP_MAX_DIMENSION + ); + } + PyErr_Format(PyExc_ValueError, "encoding error %d%s", error_code, message); return NULL; } output = writer.mem; ret_size = writer.size; -#ifndef HAVE_WEBPMUX - if (ret_size > 0) { - PyObject *ret = PyBytes_FromStringAndSize((char *)output, ret_size); - free(output); - return ret; - } -#else { /* I want to truncate the *_size items that get passed into WebP data. Pypy2.1.0 had some issues where the Py_ssize_t items had @@ -775,132 +762,9 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { return ret; } } -#endif Py_RETURN_NONE; } -PyObject * -WebPDecode_wrapper(PyObject *self, PyObject *args) { - PyBytesObject *webp_string; - const uint8_t *webp; - Py_ssize_t size; - PyObject *ret = Py_None, *bytes = NULL, *pymode = NULL, *icc_profile = NULL, - *exif = NULL; - WebPDecoderConfig config; - VP8StatusCode vp8_status_code = VP8_STATUS_OK; - char *mode = "RGB"; - - if (!PyArg_ParseTuple(args, "S", &webp_string)) { - return NULL; - } - - if (!WebPInitDecoderConfig(&config)) { - Py_RETURN_NONE; - } - - PyBytes_AsStringAndSize((PyObject *)webp_string, (char **)&webp, &size); - - vp8_status_code = WebPGetFeatures(webp, size, &config.input); - if (vp8_status_code == VP8_STATUS_OK) { - // If we don't set it, we don't get alpha. - // Initialized to MODE_RGB - if (config.input.has_alpha) { - config.output.colorspace = MODE_RGBA; - mode = "RGBA"; - } - -#ifndef HAVE_WEBPMUX - vp8_status_code = WebPDecode(webp, size, &config); -#else - { - int copy_data = 0; - WebPData data = {webp, size}; - WebPMuxFrameInfo image; - WebPData icc_profile_data = {0}; - WebPData exif_data = {0}; - - WebPMux *mux = WebPMuxCreate(&data, copy_data); - if (NULL == mux) { - goto end; - } - - if (WEBP_MUX_OK != WebPMuxGetFrame(mux, 1, &image)) { - WebPMuxDelete(mux); - goto end; - } - - webp = image.bitstream.bytes; - size = image.bitstream.size; - - vp8_status_code = WebPDecode(webp, size, &config); - - if (WEBP_MUX_OK == WebPMuxGetChunk(mux, "ICCP", &icc_profile_data)) { - icc_profile = PyBytes_FromStringAndSize( - (const char *)icc_profile_data.bytes, icc_profile_data.size - ); - } - - if (WEBP_MUX_OK == WebPMuxGetChunk(mux, "EXIF", &exif_data)) { - exif = PyBytes_FromStringAndSize( - (const char *)exif_data.bytes, exif_data.size - ); - } - - WebPDataClear(&image.bitstream); - WebPMuxDelete(mux); - } -#endif - } - - if (vp8_status_code != VP8_STATUS_OK) { - goto end; - } - - if (config.output.colorspace < MODE_YUV) { - bytes = PyBytes_FromStringAndSize( - (char *)config.output.u.RGBA.rgba, config.output.u.RGBA.size - ); - } else { - // Skipping YUV for now. Need Test Images. - // UNDONE -- unclear if we'll ever get here if we set mode_rgb* - bytes = PyBytes_FromStringAndSize( - (char *)config.output.u.YUVA.y, config.output.u.YUVA.y_size - ); - } - - pymode = PyUnicode_FromString(mode); - ret = Py_BuildValue( - "SiiSSS", - bytes, - config.output.width, - config.output.height, - pymode, - NULL == icc_profile ? Py_None : icc_profile, - NULL == exif ? Py_None : exif - ); - -end: - WebPFreeDecBuffer(&config.output); - - Py_XDECREF(bytes); - Py_XDECREF(pymode); - Py_XDECREF(icc_profile); - Py_XDECREF(exif); - - if (Py_None == ret) { - Py_RETURN_NONE; - } - - return ret; -} - -// Return the decoder's version number, packed in hexadecimal using 8bits for -// each of major/minor/revision. E.g: v2.5.7 is 0x020507. -PyObject * -WebPDecoderVersion_wrapper() { - return Py_BuildValue("i", WebPGetDecoderVersion()); -} - // Version as string const char * WebPDecoderVersion_str(void) { @@ -916,85 +780,26 @@ WebPDecoderVersion_str(void) { return version; } -/* - * The version of webp that ships with (0.1.3) Ubuntu 12.04 doesn't handle alpha well. - * Files that are valid with 0.3 are reported as being invalid. - */ -int -WebPDecoderBuggyAlpha(void) { - return WebPGetDecoderVersion() == 0x0103; -} - -PyObject * -WebPDecoderBuggyAlpha_wrapper() { - return Py_BuildValue("i", WebPDecoderBuggyAlpha()); -} - /* -------------------------------------------------------------------- */ /* Module Setup */ /* -------------------------------------------------------------------- */ static PyMethodDef webpMethods[] = { -#ifdef HAVE_WEBPANIM {"WebPAnimDecoder", _anim_decoder_new, METH_VARARGS, "WebPAnimDecoder"}, {"WebPAnimEncoder", _anim_encoder_new, METH_VARARGS, "WebPAnimEncoder"}, -#endif {"WebPEncode", WebPEncode_wrapper, METH_VARARGS, "WebPEncode"}, - {"WebPDecode", WebPDecode_wrapper, METH_VARARGS, "WebPDecode"}, - {"WebPDecoderVersion", WebPDecoderVersion_wrapper, METH_NOARGS, "WebPVersion"}, - {"WebPDecoderBuggyAlpha", - WebPDecoderBuggyAlpha_wrapper, - METH_NOARGS, - "WebPDecoderBuggyAlpha"}, {NULL, NULL} }; -void -addMuxFlagToModule(PyObject *m) { - PyObject *have_webpmux; -#ifdef HAVE_WEBPMUX - have_webpmux = Py_True; -#else - have_webpmux = Py_False; -#endif - Py_INCREF(have_webpmux); - PyModule_AddObject(m, "HAVE_WEBPMUX", have_webpmux); -} - -void -addAnimFlagToModule(PyObject *m) { - PyObject *have_webpanim; -#ifdef HAVE_WEBPANIM - have_webpanim = Py_True; -#else - have_webpanim = Py_False; -#endif - Py_INCREF(have_webpanim); - PyModule_AddObject(m, "HAVE_WEBPANIM", have_webpanim); -} - -void -addTransparencyFlagToModule(PyObject *m) { - PyObject *have_transparency = PyBool_FromLong(!WebPDecoderBuggyAlpha()); - if (PyModule_AddObject(m, "HAVE_TRANSPARENCY", have_transparency)) { - Py_DECREF(have_transparency); - } -} - static int setup_module(PyObject *m) { -#ifdef HAVE_WEBPANIM /* Ready object types */ if (PyType_Ready(&WebPAnimDecoder_Type) < 0 || PyType_Ready(&WebPAnimEncoder_Type) < 0) { return -1; } -#endif - PyObject *d = PyModule_GetDict(m); - addMuxFlagToModule(m); - addAnimFlagToModule(m); - addTransparencyFlagToModule(m); + PyObject *d = PyModule_GetDict(m); PyObject *v = PyUnicode_FromString(WebPDecoderVersion_str()); PyDict_SetItemString(d, "webpdecoder_version", v ? v : Py_None); Py_XDECREF(v); diff --git a/src/libImaging/Quant.c b/src/libImaging/Quant.c index 55b5e7a55..a489a882d 100644 --- a/src/libImaging/Quant.c +++ b/src/libImaging/Quant.c @@ -314,8 +314,9 @@ test_sorted(PixelList *pl[3]) { for (i = 0; i < 3; i++) { l = 256; for (t = pl[i]; t; t = t->next[i]) { - if (l < t->p.a.v[i]) + if (l < t->p.a.v[i]) { return 0; + } l = t->p.a.v[i]; } } @@ -1009,7 +1010,8 @@ compute_palette_from_median_cut( uint32_t nPixels, HashTable *medianBoxHash, Pixel **palette, - uint32_t nPaletteEntries + uint32_t nPaletteEntries, + BoxNode *root ) { uint32_t i; uint32_t paletteEntry; @@ -1382,7 +1384,9 @@ quantize( fflush(stdout); timer = clock(); #endif - if (!compute_palette_from_median_cut(pixelData, nPixels, h, &p, nPaletteEntries)) { + if (!compute_palette_from_median_cut( + pixelData, nPixels, h, &p, nPaletteEntries, root + )) { goto error_3; } #ifdef DEBUG diff --git a/tox.ini b/tox.ini index 4b4059455..70b8bf145 100644 --- a/tox.ini +++ b/tox.ini @@ -36,4 +36,4 @@ deps = extras = typing commands = - mypy src Tests {posargs} + mypy docs src winbuild Tests {posargs} diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 9837589b2..1021e4f22 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -7,6 +7,7 @@ import re import shutil import struct import subprocess +from typing import Any def cmd_cd(path: str) -> str: @@ -43,21 +44,19 @@ def cmd_nmake( target: str = "", params: list[str] | None = None, ) -> str: - params = "" if params is None else " ".join(params) - return " ".join( [ "{nmake}", "-nologo", f'-f "{makefile}"' if makefile is not None else "", - f"{params}", + f'{" ".join(params)}' if params is not None else "", f'"{target}"', ] ) def cmds_cmake( - target: str | tuple[str, ...] | list[str], *params, build_dir: str = "." + target: str | tuple[str, ...] | list[str], *params: str, build_dir: str = "." ) -> list[str]: if not isinstance(target, str): target = " ".join(target) @@ -129,7 +128,7 @@ V["ZLIB_DOTLESS"] = V["ZLIB"].replace(".", "") # dependencies, listed in order of compilation -DEPS = { +DEPS: dict[str, dict[str, Any]] = { "libjpeg": { "url": f"{SF_PROJECTS}/libjpeg-turbo/files/{V['JPEGTURBO']}/" f"libjpeg-turbo-{V['JPEGTURBO']}.tar.gz/download", @@ -201,7 +200,7 @@ DEPS = { }, "build": [ *cmds_cmake( - "webp webpdemux webpmux", + "webp webpmux webpdemux", "-DBUILD_SHARED_LIBS:BOOL=OFF", "-DWEBP_LINK_STATIC:BOOL=OFF", ), @@ -538,7 +537,7 @@ def write_script( print(" " + line) -def get_footer(dep: dict) -> list[str]: +def get_footer(dep: dict[str, Any]) -> list[str]: lines = [] for out in dep.get("headers", []): lines.append(cmd_copy(out, "{inc_dir}")) @@ -583,6 +582,7 @@ def build_dep(name: str, prefs: dict[str, str], verbose: bool) -> str: license_text += f.read() if "license_pattern" in dep: match = re.search(dep["license_pattern"], license_text, re.DOTALL) + assert match is not None license_text = "\n".join(match.groups()) assert len(license_text) > 50 with open(os.path.join(license_dir, f"{directory}.txt"), "w") as f: