diff --git a/.appveyor.yml b/.appveyor.yml index 57a8fa5a0..6ce5200b6 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -32,10 +32,10 @@ install: - curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip - 7z x pillow-test-images.zip -oc:\ - xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images -- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.01-win64.zip +- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip - 7z x nasm-win64.zip -oc:\ -- choco install ghostscript --version=10.3.0 -- path c:\nasm-2.16.01;C:\Program Files\gs\gs10.00.0\bin;%PATH% +- choco install ghostscript --version=10.3.1 +- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.03.1\bin;%PATH% - cd c:\pillow\winbuild\ - ps: | c:\python38\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 7e257b75c..0d0f81fbf 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.18.1 +cibuildwheel==2.19.1 diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index 28124d7f7..f8f191d38 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -7,11 +7,15 @@ brew install \ ghostscript \ libimagequant \ libjpeg \ - libraqm \ libtiff \ little-cms2 \ openjpeg \ webp +if [[ "$ImageOS" == "macos13" ]]; then + brew install --ignore-dependencies libraqm +else + brew install libraqm +fi export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" # TODO Update condition when cffi supports 3.13 diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 9edc15173..ee265774b 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -86,7 +86,7 @@ jobs: choco install nasm --no-progress echo "C:\Program Files\NASM" >> $env:GITHUB_PATH - choco install ghostscript --version=10.3.0 --no-progress + choco install ghostscript --version=10.3.1 --no-progress echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH # Install extra test images diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 0d45d5a20..97f70ed84 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -16,9 +16,9 @@ ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds FREETYPE_VERSION=2.13.2 -HARFBUZZ_VERSION=8.4.0 +HARFBUZZ_VERSION=8.5.0 LIBPNG_VERSION=1.6.43 -JPEGTURBO_VERSION=3.0.2 +JPEGTURBO_VERSION=3.0.3 OPENJPEG_VERSION=2.5.2 XZ_VERSION=5.4.5 TIFF_VERSION=4.6.0 @@ -33,9 +33,9 @@ if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then else ZLIB_VERSION=1.2.8 fi -LIBWEBP_VERSION=1.3.2 +LIBWEBP_VERSION=1.4.0 BZIP2_VERSION=1.0.8 -LIBXCB_VERSION=1.16.1 +LIBXCB_VERSION=1.17.0 BROTLI_VERSION=1.1.0 if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then @@ -70,7 +70,7 @@ function build { fi build_new_zlib - build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto + build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto if [ -n "$IS_MACOS" ]; then build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib diff --git a/.readthedocs.yml b/.readthedocs.yml index b83ba05b1..def6282dd 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 formats: [pdf] build: - os: ubuntu-22.04 + os: ubuntu-lts-latest tools: python: "3" jobs: diff --git a/CHANGES.rst b/CHANGES.rst index dc4016d76..d7231ebea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ Changelog (Pillow) 10.4.0 (unreleased) ------------------- +- Accept 't' suffix for libtiff version #8126, #8129 + [radarhere] + +- Deprecate ImageDraw.getdraw hints parameter #8124 + [radarhere, hugovk] + - Added ImageDraw circle() #8085 [void4, hugovk, radarhere] diff --git a/Tests/bench_cffi_access.py b/Tests/bench_cffi_access.py index d2a08c07b..c7d105836 100644 --- a/Tests/bench_cffi_access.py +++ b/Tests/bench_cffi_access.py @@ -44,6 +44,7 @@ def test_direct() -> None: caccess = im.im.pixel_access(False) access = PyAccess.new(im, False) + assert access is not None assert caccess[(0, 0)] == access[(0, 0)] print(f"Size: {im.width}x{im.height}") diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index 9c21efa45..c140156f9 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -12,7 +12,7 @@ ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS class TestDecompressionBomb: - def teardown_method(self, method) -> None: + def teardown_method(self) -> None: Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT def test_no_warning_small_file(self) -> None: diff --git a/Tests/test_features.py b/Tests/test_features.py index 59fb49809..b7eefa09a 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -38,7 +38,9 @@ def test_version() -> None: assert function(name) == version if name != "PIL": if name == "zlib" and version is not None: - version = version.replace(".zlib-ng", "") + version = re.sub(".zlib-ng$", "", version) + elif name == "libtiff" and version is not None: + version = re.sub("t$", "", version) assert version is None or re.search(r"\d+(\.\d+)*$", version) for module in features.modules: @@ -124,7 +126,7 @@ def test_unsupported_module() -> None: @pytest.mark.parametrize("supported_formats", (True, False)) -def test_pilinfo(supported_formats) -> None: +def test_pilinfo(supported_formats: bool) -> None: buf = io.StringIO() features.pilinfo(buf, supported_formats=supported_formats) out = buf.getvalue() diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index c7c9b24e7..2ff4160bd 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -140,7 +140,7 @@ def test_load_dib() -> None: (124, "g/pal8v5.bmp"), ), ) -def test_dib_header_size(header_size, path): +def test_dib_header_size(header_size: int, path: str) -> None: image_path = "Tests/images/bmp/" + path with open(image_path, "rb") as fp: data = fp.read()[14:] diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 3dd24533a..939e82e77 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -1,10 +1,11 @@ from __future__ import annotations from pathlib import Path +from typing import IO import pytest -from PIL import BufrStubImagePlugin, Image +from PIL import BufrStubImagePlugin, Image, ImageFile from .helper import hopper @@ -50,20 +51,20 @@ def test_save(tmp_path: Path) -> None: def test_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): opened = False loaded = False saved = False - def open(self, im) -> None: + def open(self, im: ImageFile.StubImageFile) -> None: self.opened = True - def load(self, im): + def load(self, im: ImageFile.StubImageFile) -> Image.Image: self.loaded = True im.fp.close() return Image.new("RGB", (1, 1)) - def save(self, im, fp, filename) -> None: + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True handler = TestHandler() diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 4e790926b..e19c88a47 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -53,6 +53,7 @@ def test_closed_file() -> None: def test_seek_after_close() -> None: im = Image.open("Tests/images/iss634.gif") + assert isinstance(im, GifImagePlugin.GifImageFile) im.load() im.close() @@ -377,7 +378,8 @@ def test_save_netpbm_bmp_mode(tmp_path: Path) -> None: img = img.convert("RGB") tempfile = str(tmp_path / "temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) + b = BytesIO() + GifImagePlugin._save_netpbm(img, b, tempfile) with Image.open(tempfile) as reloaded: assert_image_similar(img, reloaded.convert("RGB"), 0) @@ -388,7 +390,8 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None: img = img.convert("L") tempfile = str(tmp_path / "temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) + b = BytesIO() + GifImagePlugin._save_netpbm(img, b, tempfile) with Image.open(tempfile) as reloaded: assert_image_similar(img, reloaded.convert("L"), 0) @@ -648,7 +651,7 @@ def test_dispose2_palette(tmp_path: Path) -> None: assert rgb_img.getpixel((50, 50)) == circle # Check that frame transparency wasn't added unnecessarily - assert img._frame_transparency is None + assert getattr(img, "_frame_transparency") is None def test_dispose2_diff(tmp_path: Path) -> None: diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index 096a5b88b..86a9064fc 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -5,7 +5,7 @@ from typing import IO import pytest -from PIL import GribStubImagePlugin, Image +from PIL import GribStubImagePlugin, Image, ImageFile from .helper import hopper @@ -51,7 +51,7 @@ def test_save(tmp_path: Path) -> None: def test_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): opened = False loaded = False saved = False diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index f871e2eff..ee1544c51 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -1,11 +1,12 @@ from __future__ import annotations +from io import BytesIO from pathlib import Path from typing import IO import pytest -from PIL import Hdf5StubImagePlugin, Image +from PIL import Hdf5StubImagePlugin, Image, ImageFile TEST_FILE = "Tests/images/hdf5.h5" @@ -41,7 +42,7 @@ def test_load() -> None: def test_save() -> None: # Arrange with Image.open(TEST_FILE) as im: - dummy_fp = None + dummy_fp = BytesIO() dummy_filename = "dummy.filename" # Act / Assert: stub cannot save without an implemented handler @@ -52,7 +53,7 @@ def test_save() -> None: def test_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): opened = False loaded = False saved = False diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 79e351eea..02d0b29d8 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -171,7 +171,7 @@ class TestFileJpeg: [TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"], ) def test_dpi(self, test_image_path: str) -> None: - def test(xdpi: int, ydpi: int | None = None): + def test(xdpi: int, ydpi: int | None = None) -> tuple[int, int] | None: with Image.open(test_image_path) as im: im = self.roundtrip(im, dpi=(xdpi, ydpi or xdpi)) return im.info.get("dpi") @@ -443,7 +443,9 @@ class TestFileJpeg: assert_image(im1, im2.mode, im2.size) def test_subsampling(self) -> None: - def getsampling(im: JpegImagePlugin.JpegImageFile): + def getsampling( + im: JpegImagePlugin.JpegImageFile, + ) -> tuple[int, int, int, int, int, int]: layer = im.layer return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] @@ -699,7 +701,7 @@ class TestFileJpeg: def test_save_cjpeg(self, tmp_path: Path) -> None: with Image.open(TEST_FILE) as img: tempfile = str(tmp_path / "temp.jpg") - JpegImagePlugin._save_cjpeg(img, 0, tempfile) + JpegImagePlugin._save_cjpeg(img, BytesIO(), tempfile) # Default save quality is 75%, so a tiny bit of difference is alright assert_image_similar_tofile(img, tempfile, 17) @@ -917,24 +919,25 @@ class TestFileJpeg: with Image.open("Tests/images/icc-after-SOF.jpg") as im: assert im.info["icc_profile"] == b"profile" - def test_jpeg_magic_number(self) -> None: + def test_jpeg_magic_number(self, monkeypatch: pytest.MonkeyPatch) -> None: size = 4097 buffer = BytesIO(b"\xFF" * size) # Many xFF bytes - buffer.max_pos = 0 + max_pos = 0 orig_read = buffer.read - def read(n=-1): + def read(n: int | None = -1) -> bytes: + nonlocal max_pos res = orig_read(n) - buffer.max_pos = max(buffer.max_pos, buffer.tell()) + max_pos = max(max_pos, buffer.tell()) return res - buffer.read = read + monkeypatch.setattr(buffer, "read", read) with pytest.raises(UnidentifiedImageError): with Image.open(buffer): pass # Assert the entire file has not been read - assert 0 < buffer.max_pos < size + assert 0 < max_pos < size def test_getxmp(self) -> None: with Image.open("Tests/images/xmp_test.jpg") as im: diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 5a208739f..ed7ea4fcf 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -460,7 +460,7 @@ def test_plt_marker() -> None: out.seek(length - 2, os.SEEK_CUR) -def test_9bit(): +def test_9bit() -> None: with Image.open("Tests/images/9bit.j2k") as im: assert im.mode == "I;16" assert im.size == (128, 128) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 22bcd2856..fe9d017c0 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -54,7 +54,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_version(self) -> None: version = features.version_codec("libtiff") assert version is not None - assert re.search(r"\d+\.\d+\.\d+$", version) + assert re.search(r"\d+\.\d+\.\d+t?$", version) def test_g4_tiff(self, tmp_path: Path) -> None: """Test the ordinary file path load path""" diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 56b9148d1..17dab25e9 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -113,7 +113,7 @@ class TestFileTiff: outfile = str(tmp_path / "temp.tif") im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2) - def test_seek_too_large(self): + def test_seek_too_large(self) -> None: with pytest.raises(ValueError, match="Unable to seek to frame"): Image.open("Tests/images/seek_too_large.tif") diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index e2de84c71..1caf032f6 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -198,7 +198,9 @@ class TestFileWebp: (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), ) @skip_unless_feature("webp_anim") - def test_invalid_background(self, background, tmp_path: Path) -> None: + def test_invalid_background( + self, background: int | tuple[int, ...], tmp_path: Path + ) -> None: temp_file = str(tmp_path / "temp.webp") im = hopper() with pytest.raises(OSError): diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index ba931f864..882dccb32 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -69,7 +69,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None: are visually similar to the originals. """ - def check(temp_file) -> None: + def check(temp_file: str) -> None: with Image.open(temp_file) as im: assert im.n_frames == 2 diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index b43e3f296..79e707263 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,10 +1,11 @@ from __future__ import annotations from pathlib import Path +from typing import IO import pytest -from PIL import Image, WmfImagePlugin +from PIL import Image, ImageFile, WmfImagePlugin from .helper import assert_image_similar_tofile, hopper @@ -34,10 +35,13 @@ def test_load() -> None: def test_register_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): methodCalled = False - def save(self, im, fp, filename) -> None: + def load(self, im: ImageFile.StubImageFile) -> Image.Image: + return Image.new("RGB", (1, 1)) + + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.methodCalled = True handler = TestHandler() @@ -70,7 +74,7 @@ def test_load_set_dpi() -> None: @pytest.mark.parametrize("ext", (".wmf", ".emf")) -def test_save(ext, tmp_path: Path) -> None: +def test_save(ext: str, tmp_path: Path) -> None: im = hopper() tmpfile = str(tmp_path / ("temp" + ext)) diff --git a/Tests/test_image.py b/Tests/test_image.py index f9537cfd6..35a7a49a8 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -152,7 +152,7 @@ class TestImage: def test_stringio(self) -> None: with pytest.raises(ValueError): - with Image.open(io.StringIO()): + with Image.open(io.StringIO()): # type: ignore[arg-type] pass def test_pathlib(self, tmp_path: Path) -> None: diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 9d6006679..8abb1f69f 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -259,6 +259,7 @@ class TestCffi(AccessTest): caccess = im.im.pixel_access(False) with pytest.warns(DeprecationWarning): access = PyAccess.new(im, False) + assert access is not None w, h = im.size for x in range(0, w, 10): @@ -289,6 +290,7 @@ class TestCffi(AccessTest): caccess = im.im.pixel_access(False) with pytest.warns(DeprecationWarning): access = PyAccess.new(im, False) + assert access is not None w, h = im.size for x in range(0, w, 10): @@ -299,6 +301,8 @@ class TestCffi(AccessTest): # Attempt to set the value on a read-only image with pytest.warns(DeprecationWarning): access = PyAccess.new(im, True) + assert access is not None + with pytest.raises(ValueError): access[(0, 0)] = color @@ -341,6 +345,8 @@ class TestCffi(AccessTest): im = Image.new(mode, (1, 1)) with pytest.warns(DeprecationWarning): access = PyAccess.new(im, False) + assert access is not None + access.putpixel((0, 0), color) if len(color) == 3: diff --git a/Tests/test_image_mode.py b/Tests/test_image_mode.py index 8e94aafc5..20d3a160e 100644 --- a/Tests/test_image_mode.py +++ b/Tests/test_image_mode.py @@ -68,7 +68,11 @@ def test_sanity() -> None: ), ) def test_properties( - mode, expected_base, expected_type, expected_bands, expected_band_names + mode: str, + expected_base: str, + expected_type: str, + expected_bands: int, + expected_band_names: tuple[str, ...], ) -> None: assert Image.getmodebase(mode) == expected_base assert Image.getmodetype(mode) == expected_type diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index dad26ef14..5e57e4c4c 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -113,13 +113,13 @@ def test_array_F() -> None: def test_not_flattened() -> None: im = Image.new("L", (1, 1)) with pytest.raises(TypeError): - im.putdata([[0]]) + im.putdata([[0]]) # type: ignore[list-item] with pytest.raises(TypeError): - im.putdata([[0]], 2) + im.putdata([[0]], 2) # type: ignore[list-item] with pytest.raises(TypeError): im = Image.new("I", (1, 1)) - im.putdata([[0]]) + im.putdata([[0]]) # type: ignore[list-item] with pytest.raises(TypeError): im = Image.new("F", (1, 1)) - im.putdata([[0]]) + im.putdata([[0]]) # type: ignore[list-item] diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 2daaf5c3c..2d461d985 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -98,7 +98,7 @@ def test_quantize_dither_diff() -> None: @pytest.mark.parametrize( "method", (Image.Quantize.MEDIANCUT, Image.Quantize.MAXCOVERAGE) ) -def test_quantize_kmeans(method) -> None: +def test_quantize_kmeans(method: Image.Quantize) -> None: im = hopper() no_kmeans = im.quantize(kmeans=0, method=method) kmeans = im.quantize(kmeans=1, method=method) diff --git a/Tests/test_image_reduce.py b/Tests/test_image_reduce.py index f6609a1a0..6771b46b0 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -56,10 +56,12 @@ def test_args_factor(size: int | tuple[int, int], expected: tuple[int, int]) -> @pytest.mark.parametrize( "size, expected_error", ((0, ValueError), (2.0, TypeError), ((0, 10), ValueError)) ) -def test_args_factor_error(size: float | tuple[int, int], expected_error) -> None: +def test_args_factor_error( + size: float | tuple[int, int], expected_error: type[Exception] +) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): - im.reduce(size) + im.reduce(size) # type: ignore[arg-type] @pytest.mark.parametrize( @@ -86,10 +88,12 @@ def test_args_box(size: tuple[int, int, int, int], expected: tuple[int, int]) -> ((5, 0, 5, 10), ValueError), ), ) -def test_args_box_error(size: str | tuple[int, int, int, int], expected_error) -> None: +def test_args_box_error( + size: str | tuple[int, int, int, int], expected_error: type[Exception] +) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): - im.reduce(2, size).size + im.reduce(2, size).size # type: ignore[arg-type] @pytest.mark.parametrize("mode", ("P", "1", "I;16")) diff --git a/Tests/test_image_rotate.py b/Tests/test_image_rotate.py index c10c96da6..252a15db7 100644 --- a/Tests/test_image_rotate.py +++ b/Tests/test_image_rotate.py @@ -124,8 +124,8 @@ def test_fastpath_translate() -> None: def test_center() -> None: im = hopper() rotate(im, im.mode, 45, center=(0, 0)) - rotate(im, im.mode, 45, translate=(im.size[0] / 2, 0)) - rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] / 2, 0)) + rotate(im, im.mode, 45, translate=(im.size[0] // 2, 0)) + rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] // 2, 0)) def test_rotate_no_fill() -> None: diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 2ca1d2cfc..bdbf09c40 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -16,7 +16,7 @@ from .helper import ( def test_sanity() -> None: im = hopper() - assert im.thumbnail((100, 100)) is None + im.thumbnail((100, 100)) assert im.size == (100, 100) @@ -111,7 +111,9 @@ def test_load_first_unless_jpeg() -> None: with Image.open("Tests/images/hopper.jpg") as im: draft = im.draft - def im_draft(mode: str, size: tuple[int, int]): + def im_draft( + mode: str, size: tuple[int, int] + ) -> tuple[str, tuple[int, int, float, float]] | None: result = draft(mode, size) assert result is not None diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index c221fe008..d9593c60f 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1562,7 +1562,11 @@ def test_compute_regular_polygon_vertices( ], ) def test_compute_regular_polygon_vertices_input_error_handling( - n_sides, bounding_circle, rotation, expected_error, error_message + n_sides: int, + bounding_circle: int | tuple[int | tuple[int] | str, ...], + rotation: int | str, + expected_error: type[Exception], + error_message: str, ) -> None: with pytest.raises(expected_error) as e: ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) @@ -1624,3 +1628,8 @@ def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None: draw.rectangle(xy) with pytest.raises(ValueError): draw.rounded_rectangle(xy) + + +def test_getdraw(): + with pytest.warns(DeprecationWarning): + ImageDraw.getdraw(None, []) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 4398f8a30..73cad513e 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -224,7 +224,7 @@ def test_render_multiline(font: ImageFont.FreeTypeFont) -> None: draw = ImageDraw.Draw(im) line_spacing = font.getbbox("A")[3] + 4 lines = TEST_TEXT.split("\n") - y = 0 + y: float = 0 for line in lines: draw.text((0, y), line, font=font) y += line_spacing diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index d6bdaf450..27a6090c5 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -454,7 +454,7 @@ def test_autocontrast_cutoff() -> None: # Test the cutoff argument of autocontrast with Image.open("Tests/images/bw_gradient.png") as img: - def autocontrast(cutoff: int | tuple[int, int]): + def autocontrast(cutoff: int | tuple[int, int]) -> list[int]: return ImageOps.autocontrast(img, cutoff).histogram() assert autocontrast(10) == autocontrast((10, 10)) diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index 104c620de..dbdd5b317 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -58,7 +58,6 @@ def test_blur_formats(test_images: dict[str, ImageFile.ImageFile]) -> None: blur = ImageFilter.GaussianBlur with pytest.raises(ValueError): im.convert("1").filter(blur) - blur(im.convert("L")) with pytest.raises(ValueError): im.convert("I").filter(blur) with pytest.raises(ValueError): diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index f59ee7284..e6c312a0c 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -70,7 +70,7 @@ if is_win32(): ] CreateDIBSection.restype = ctypes.wintypes.HBITMAP - def serialize_dib(bi, pixels) -> bytearray: + def serialize_dib(bi: BITMAPINFOHEADER, pixels: ctypes.c_void_p) -> bytearray: bf = BITMAPFILEHEADER() bf.bfType = 0x4D42 bf.bfOffBits = ctypes.sizeof(bf) + bi.biSize diff --git a/Tests/test_main.py b/Tests/test_main.py index e9e12b24a..2582dbee3 100644 --- a/Tests/test_main.py +++ b/Tests/test_main.py @@ -11,7 +11,7 @@ import pytest "args, report", ((["PIL"], False), (["PIL", "--report"], True), (["PIL.report"], True)), ) -def test_main(args, report) -> None: +def test_main(args: list[str], report: bool) -> None: args = [sys.executable, "-m"] + args out = subprocess.check_output(args).decode("utf-8") lines = out.splitlines() diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 9f4e6534e..36cdb3682 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from typing import TYPE_CHECKING, Any import pytest @@ -8,13 +9,19 @@ from PIL import Image from .helper import assert_deep_equal, assert_image, hopper, skip_unless_feature -numpy = pytest.importorskip("numpy", reason="NumPy not installed") +if TYPE_CHECKING: + import numpy + import numpy.typing +else: + numpy = pytest.importorskip("numpy", reason="NumPy not installed") TEST_IMAGE_SIZE = (10, 10) def test_numpy_to_image() -> None: - def to_image(dtype, bands: int = 1, boolean: int = 0) -> Image.Image: + def to_image( + dtype: numpy.typing.DTypeLike, bands: int = 1, boolean: int = 0 + ) -> Image.Image: if bands == 1: if boolean: data = [0, 255] * 50 @@ -99,14 +106,16 @@ def test_1d_array() -> None: assert_image(Image.fromarray(a), "L", (1, 5)) -def _test_img_equals_nparray(img: Image.Image, np) -> None: - assert len(np.shape) >= 2 - np_size = np.shape[1], np.shape[0] +def _test_img_equals_nparray( + img: Image.Image, np_img: numpy.typing.NDArray[Any] +) -> None: + assert len(np_img.shape) >= 2 + np_size = np_img.shape[1], np_img.shape[0] assert img.size == np_size px = img.load() for x in range(0, img.size[0], int(img.size[0] / 10)): for y in range(0, img.size[1], int(img.size[1] / 10)): - assert_deep_equal(px[x, y], np[y, x]) + assert_deep_equal(px[x, y], np_img[y, x]) def test_16bit() -> None: @@ -157,7 +166,7 @@ def test_save_tiff_uint16() -> None: ("HSV", numpy.uint8), ), ) -def test_to_array(mode: str, dtype) -> None: +def test_to_array(mode: str, dtype: numpy.typing.DTypeLike) -> None: img = hopper(mode) # Resize to non-square @@ -207,7 +216,7 @@ def test_putdata() -> None: numpy.float64, ), ) -def test_roundtrip_eye(dtype) -> None: +def test_roundtrip_eye(dtype: numpy.typing.DTypeLike) -> None: arr = numpy.eye(10, dtype=dtype) numpy.testing.assert_array_equal(arr, numpy.array(Image.fromarray(arr))) diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py index 3cd323553..28f66891c 100644 --- a/Tests/test_qt_image_qapplication.py +++ b/Tests/test_qt_image_qapplication.py @@ -46,7 +46,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([]) + app: QApplication | None = QApplication([]) ex = Example() assert app # Silence warning assert ex # Silence warning diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index 2a072fd44..dd4fc46c3 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -1,8 +1,9 @@ from __future__ import annotations import shutil +from io import BytesIO from pathlib import Path -from typing import Callable +from typing import IO, Callable import pytest @@ -22,11 +23,11 @@ class TestShellInjection: self, tmp_path: Path, src_img: Image.Image, - save_func: Callable[[Image.Image, int, str], None], + save_func: Callable[[Image.Image, IO[bytes], str | bytes], None], ) -> None: for filename in test_filenames: dest_file = str(tmp_path / filename) - save_func(src_img, 0, dest_file) + save_func(src_img, BytesIO(), dest_file) # If file can't be opened, shell injection probably occurred with Image.open(dest_file) as im: im.load() diff --git a/depends/install_imagequant.sh b/depends/install_imagequant.sh index 973b4374f..9dd7742ed 100755 --- a/depends/install_imagequant.sh +++ b/depends/install_imagequant.sh @@ -2,7 +2,7 @@ # install libimagequant archive_name=libimagequant -archive_version=4.3.0 +archive_version=4.3.1 archive=$archive_name-$archive_version diff --git a/depends/install_webp.sh b/depends/install_webp.sh index 6f867ab37..c47fb35f1 100755 --- a/depends/install_webp.sh +++ b/depends/install_webp.sh @@ -1,7 +1,7 @@ #!/bin/bash # install webp -archive=libwebp-1.3.2 +archive=libwebp-1.4.0 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz diff --git a/docs/Makefile b/docs/Makefile index 6495e5866..8f13f1aea 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -9,9 +9,9 @@ PAPER = BUILDDIR = _build # Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +PAPEROPT_a4 = --define latex_paper_size=a4 +PAPEROPT_letter = --define latex_paper_size=letter +ALLSPHINXOPTS = --doctree-dir $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . @@ -51,42 +51,42 @@ install-sphinx: .PHONY: html html: $(MAKE) install-sphinx - $(SPHINXBUILD) -b html -W --keep-going $(ALLSPHINXOPTS) $(BUILDDIR)/html + $(SPHINXBUILD) --builder html --fail-on-warning --keep-going $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." .PHONY: dirhtml dirhtml: $(MAKE) install-sphinx - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + $(SPHINXBUILD) --builder dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." .PHONY: singlehtml singlehtml: $(MAKE) install-sphinx - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + $(SPHINXBUILD) --builder singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." .PHONY: pickle pickle: $(MAKE) install-sphinx - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + $(SPHINXBUILD) --builder pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." .PHONY: json json: $(MAKE) install-sphinx - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + $(SPHINXBUILD) --builder json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." .PHONY: htmlhelp htmlhelp: $(MAKE) install-sphinx - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + $(SPHINXBUILD) --builder htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." @@ -94,7 +94,7 @@ htmlhelp: .PHONY: qthelp qthelp: $(MAKE) install-sphinx - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + $(SPHINXBUILD) --builder qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @@ -105,7 +105,7 @@ qthelp: .PHONY: devhelp devhelp: $(MAKE) install-sphinx - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + $(SPHINXBUILD) --builder devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @@ -116,14 +116,14 @@ devhelp: .PHONY: epub epub: $(MAKE) install-sphinx - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + $(SPHINXBUILD) --builder epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." .PHONY: latex latex: $(MAKE) install-sphinx - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + $(SPHINXBUILD) --builder latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ @@ -132,7 +132,7 @@ latex: .PHONY: latexpdf latexpdf: $(MAKE) install-sphinx - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + $(SPHINXBUILD) --builder latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." @@ -140,21 +140,21 @@ latexpdf: .PHONY: text text: $(MAKE) install-sphinx - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + $(SPHINXBUILD) --builder text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." .PHONY: man man: $(MAKE) install-sphinx - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + $(SPHINXBUILD) --builder man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." .PHONY: texinfo texinfo: $(MAKE) install-sphinx - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + $(SPHINXBUILD) --builder texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ @@ -163,7 +163,7 @@ texinfo: .PHONY: info info: $(MAKE) install-sphinx - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + $(SPHINXBUILD) --builder texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." @@ -171,21 +171,21 @@ info: .PHONY: gettext gettext: $(MAKE) install-sphinx - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + $(SPHINXBUILD) --builder gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." .PHONY: changes changes: $(MAKE) install-sphinx - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + $(SPHINXBUILD) --builder changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." .PHONY: linkcheck linkcheck: $(MAKE) install-sphinx - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck -j auto + $(SPHINXBUILD) --builder linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck -j auto @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." @@ -193,7 +193,7 @@ linkcheck: .PHONY: doctest doctest: $(MAKE) install-sphinx - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + $(SPHINXBUILD) --builder doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/deprecations.rst b/docs/deprecations.rst index b2cd968fe..627672e1f 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -115,6 +115,13 @@ Support for LibTIFF earlier than 4 Support for LibTIFF earlier than version 4 has been deprecated. Upgrade to a newer version of LibTIFF instead. +ImageDraw.getdraw hints parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 10.4.0 + +The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. + Removed features ---------------- diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst index 5094dbf3f..7da1078c1 100644 --- a/docs/handbook/concepts.rst +++ b/docs/handbook/concepts.rst @@ -144,10 +144,12 @@ pixel, the Python Imaging Library provides different resampling *filters*. .. py:currentmodule:: PIL.Image .. data:: Resampling.NEAREST + :noindex: Pick one nearest pixel from the input image. Ignore all other input pixels. .. data:: Resampling.BOX + :noindex: Each pixel of source image contributes to one pixel of the destination image with identical weights. @@ -158,6 +160,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. .. versionadded:: 3.4.0 .. data:: Resampling.BILINEAR + :noindex: For resize calculate the output pixel value using linear interpolation on all pixels that may contribute to the output value. @@ -165,6 +168,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. in the input image is used. .. data:: Resampling.HAMMING + :noindex: Produces a sharper image than :data:`Resampling.BILINEAR`, doesn't have dislocations on local level like with :data:`Resampling.BOX`. @@ -174,6 +178,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. .. versionadded:: 3.4.0 .. data:: Resampling.BICUBIC + :noindex: For resize calculate the output pixel value using cubic interpolation on all pixels that may contribute to the output value. @@ -181,6 +186,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. in the input image is used. .. data:: Resampling.LANCZOS + :noindex: Calculate the output pixel value using a high-quality Lanczos filter (a truncated sinc) on all pixels that may contribute to the output value. diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 961312b14..7f7dfa6ff 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -68,7 +68,7 @@ Many of Pillow's features require external libraries: * **libimagequant** provides improved color quantization - * Pillow has been tested with libimagequant **2.6-4.3** + * Pillow has been tested with libimagequant **2.6-4.3.1** * Libimagequant is licensed GPLv3, which is more restrictive than the Pillow license, therefore we will not be distributing binaries with libimagequant support enabled. diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 767c833b3..e83c8787f 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -78,8 +78,6 @@ Constructing images ^^^^^^^^^^^^^^^^^^^ .. autofunction:: new -.. autoclass:: SupportsArrayInterface - :show-inheritance: .. autofunction:: fromarray .. autofunction:: frombytes .. autofunction:: frombuffer @@ -366,6 +364,14 @@ Classes .. autoclass:: PIL.Image.ImagePointHandler .. autoclass:: PIL.Image.ImageTransformHandler +Protocols +--------- + +.. autoclass:: SupportsArrayInterface + :show-inheritance: +.. autoclass:: SupportsGetData + :show-inheritance: + Constants --------- @@ -419,7 +425,6 @@ See :ref:`concept-filters` for details. .. autoclass:: Resampling :members: :undoc-members: - :noindex: Dither modes ^^^^^^^^^^^^ diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index e0d695a8b..44727efd4 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -34,6 +34,11 @@ Support for LibTIFF earlier than 4 Support for LibTIFF earlier than version 4 has been deprecated. Upgrade to a newer version of LibTIFF instead. +ImageDraw.getdraw hints parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. + API Changes =========== diff --git a/setup.py b/setup.py index abdd87ea2..0abfaaddc 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,9 @@ IMAGEQUANT_ROOT = None JPEG2K_ROOT = None JPEG_ROOT = None LCMS_ROOT = None +RAQM_ROOT = None TIFF_ROOT = None +WEBP_ROOT = None ZLIB_ROOT = None FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ @@ -459,6 +461,8 @@ class pil_build_ext(build_ext): "FREETYPE_ROOT": "freetype2", "HARFBUZZ_ROOT": "harfbuzz", "FRIBIDI_ROOT": "fribidi", + "RAQM_ROOT": "raqm", + "WEBP_ROOT": "libwebp", "LCMS_ROOT": "lcms2", "IMAGEQUANT_ROOT": "libimagequant", }.items(): diff --git a/src/PIL/BdfFontFile.py b/src/PIL/BdfFontFile.py index e3eda4fe9..bc1416c74 100644 --- a/src/PIL/BdfFontFile.py +++ b/src/PIL/BdfFontFile.py @@ -103,7 +103,7 @@ def bdf_char( class BdfFontFile(FontFile.FontFile): """Font file plugin for the X11 BDF format.""" - def __init__(self, fp: BinaryIO): + def __init__(self, fp: BinaryIO) -> None: super().__init__() s = fp.readline() diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 2db115ccc..59246c6e2 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -31,6 +31,7 @@ BLP files come in many different flavours: from __future__ import annotations +import abc import os import struct from enum import IntEnum @@ -60,7 +61,9 @@ def unpack_565(i: int) -> tuple[int, int, int]: return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3 -def decode_dxt1(data, alpha=False): +def decode_dxt1( + data: bytes, alpha: bool = False +) -> tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4*width pixels) """ @@ -68,9 +71,9 @@ def decode_dxt1(data, alpha=False): blocks = len(data) // 8 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): + for block_index in range(blocks): # Decode next 8-byte block. - idx = block * 8 + idx = block_index * 8 color0, color1, bits = struct.unpack_from(" tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4*width pixels) """ @@ -123,8 +126,8 @@ def decode_dxt3(data): blocks = len(data) // 16 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): - idx = block * 16 + for block_index in range(blocks): + idx = block_index * 16 block = data[idx : idx + 16] # Decode next 16-byte block. bits = struct.unpack_from("<8B", block) @@ -168,7 +171,7 @@ def decode_dxt3(data): return ret -def decode_dxt5(data): +def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4 * width pixels) """ @@ -176,8 +179,8 @@ def decode_dxt5(data): blocks = len(data) // 16 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): - idx = block * 16 + for block_index in range(blocks): + idx = block_index * 16 block = data[idx : idx + 16] # Decode next 16-byte block. a0, a1 = struct.unpack_from(" tuple[int, int]: try: self._read_blp_header() self._load() @@ -285,6 +288,10 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): raise OSError(msg) from e return -1, 0 + @abc.abstractmethod + def _load(self) -> None: + pass + def _read_blp_header(self) -> None: assert self.fd is not None self.fd.seek(4) @@ -318,7 +325,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): ret.append((b, g, r, a)) return ret - def _read_bgra(self, palette): + def _read_bgra(self, palette: list[tuple[int, int, int, int]]) -> bytearray: data = bytearray() _data = BytesIO(self._safe_read(self._blp_lengths[0])) while True: @@ -327,7 +334,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): except struct.error: break b, g, r, a = palette[offset] - d = (r, g, b) + d: tuple[int, ...] = (r, g, b) if self._blp_alpha_depth: d += (a,) data.extend(d) @@ -431,7 +438,7 @@ class BLPEncoder(ImageFile.PyEncoder): data += b"\x00" * 4 return data - def encode(self, bufsize): + def encode(self, bufsize: int) -> tuple[int, int, bytes]: palette_data = self._write_palette() offset = 20 + 16 * 4 * 2 + len(palette_data) @@ -449,7 +456,7 @@ class BLPEncoder(ImageFile.PyEncoder): return len(data), 0, data -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "P": msg = "Unsupported BLP image mode" raise ValueError(msg) diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index c5d1cd40d..ef2045337 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -25,6 +25,7 @@ from __future__ import annotations import os +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -52,7 +53,7 @@ def _accept(prefix: bytes) -> bool: return prefix[:2] == b"BM" -def _dib_accept(prefix): +def _dib_accept(prefix: bytes) -> bool: return i32(prefix) in [12, 40, 52, 56, 64, 108, 124] @@ -300,7 +301,8 @@ class BmpImageFile(ImageFile.ImageFile): class BmpRleDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None rle4 = self.args[1] data = bytearray() x = 0 @@ -394,11 +396,13 @@ SAVE = { } -def _dib_save(im, fp, filename): +def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, False) -def _save(im, fp, filename, bitmap_header=True): +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True +) -> None: try: rawmode, bits, colors = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 826e89daf..0ee2f653b 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -10,12 +10,14 @@ # from __future__ import annotations +from typing import IO + from . import Image, ImageFile _handler = None -def register_handler(handler: ImageFile.StubHandler) -> None: +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific BUFR image handler. @@ -58,7 +60,7 @@ class BufrStubImageFile(ImageFile.StubImageFile): return _handler -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): msg = "BUFR save handler not installed" raise OSError(msg) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 1575f2d88..e74727007 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -16,6 +16,7 @@ import io import struct import sys from enum import IntEnum, IntFlag +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i32le as i32 @@ -479,7 +480,8 @@ class DdsImageFile(ImageFile.ImageFile): class DdsRgbDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None bitcount, masks = self.args # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100 @@ -510,7 +512,7 @@ class DdsRgbDecoder(ImageFile.PyDecoder): return -1, 0 -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode not in ("RGB", "RGBA", "L", "LA"): msg = f"cannot write mode {im.mode} as DDS" raise OSError(msg) diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index d24a2ba80..380b1cf0e 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -27,6 +27,7 @@ import re import subprocess import sys import tempfile +from typing import IO from . import Image, ImageFile from ._binary import i32le as i32 @@ -236,7 +237,7 @@ class EpsImageFile(ImageFile.ImageFile): msg = 'EPS header missing "%%BoundingBox" comment' raise SyntaxError(msg) - def _read_comment(s): + def _read_comment(s: str) -> bool: nonlocal reading_trailer_comments try: m = split.match(s) @@ -244,27 +245,25 @@ class EpsImageFile(ImageFile.ImageFile): msg = "not an EPS file" raise SyntaxError(msg) from e - if m: - k, v = m.group(1, 2) - self.info[k] = v - if k == "BoundingBox": - if v == "(atend)": - reading_trailer_comments = True - elif not self._size or ( - trailer_reached and reading_trailer_comments - ): - try: - # Note: The DSC spec says that BoundingBox - # fields should be integers, but some drivers - # put floating point values there anyway. - box = [int(float(i)) for i in v.split()] - self._size = box[2] - box[0], box[3] - box[1] - self.tile = [ - ("eps", (0, 0) + self.size, offset, (length, box)) - ] - except Exception: - pass - return True + if not m: + return False + + k, v = m.group(1, 2) + self.info[k] = v + if k == "BoundingBox": + if v == "(atend)": + reading_trailer_comments = True + elif not self._size or (trailer_reached and reading_trailer_comments): + try: + # Note: The DSC spec says that BoundingBox + # fields should be integers, but some drivers + # put floating point values there anyway. + box = [int(float(i)) for i in v.split()] + self._size = box[2] - box[0], box[3] - box[1] + self.tile = [("eps", (0, 0) + self.size, offset, (length, box))] + except Exception: + pass + return True while True: byte = self.fp.read(1) @@ -413,7 +412,7 @@ class EpsImageFile(ImageFile.ImageFile): # -------------------------------------------------------------------- -def _save(im, fp, filename, eps=1): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None: """EPS Writer for the Python Imaging Library.""" # make sure image data is available diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index 071918925..4846054b1 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -115,14 +115,18 @@ class FitsImageFile(ImageFile.ImageFile): elif number_of_bits in (-32, -64): self._mode = "F" - args = (self.mode, 0, -1) if decoder_name == "raw" else (number_of_bits,) + args: tuple[str | int, ...] + if decoder_name == "raw": + args = (self.mode, 0, -1) + else: + args = (number_of_bits,) return decoder_name, offset, args class FitsGzipDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: assert self.fd is not None value = gzip.decompress(self.fd.read()) diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index b3e6c6e36..c1927bd26 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -241,7 +241,7 @@ class FpxImageFile(ImageFile.ImageFile): self.ole.close() super().close() - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.ole.close() super().__exit__() diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 962a92834..541d97f8c 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -29,8 +29,10 @@ import itertools import math import os import subprocess +import sys from enum import IntEnum from functools import cached_property +from typing import IO, TYPE_CHECKING, Any, List, Literal, NamedTuple, Union from . import ( Image, @@ -45,6 +47,9 @@ from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 +if TYPE_CHECKING: + from . import _imaging + class LoadingStrategy(IntEnum): """.. versionadded:: 9.1.0""" @@ -117,7 +122,7 @@ class GifImageFile(ImageFile.ImageFile): self._seek(0) # get ready to read first frame @property - def n_frames(self): + def n_frames(self) -> int: if self._n_frames is None: current = self.tell() try: @@ -162,11 +167,11 @@ class GifImageFile(ImageFile.ImageFile): msg = "no more images in GIF file" raise EOFError(msg) from e - def _seek(self, frame, update_image=True): + def _seek(self, frame: int, update_image: bool = True) -> None: if frame == 0: # rewind self.__offset = 0 - self.dispose = None + self.dispose: _imaging.ImagingCore | None = None self.__frame = -1 self._fp.seek(self.__rewind) self.disposal_method = 0 @@ -194,9 +199,9 @@ class GifImageFile(ImageFile.ImageFile): msg = "no more images in GIF file" raise EOFError(msg) - palette = None + palette: ImagePalette.ImagePalette | Literal[False] | None = None - info = {} + info: dict[str, Any] = {} frame_transparency = None interlace = None frame_dispose_extent = None @@ -212,7 +217,7 @@ class GifImageFile(ImageFile.ImageFile): # s = self.fp.read(1) block = self.data() - if s[0] == 249: + if s[0] == 249 and block is not None: # # graphic control extension # @@ -248,14 +253,14 @@ class GifImageFile(ImageFile.ImageFile): info["comment"] = comment s = None continue - elif s[0] == 255 and frame == 0: + elif s[0] == 255 and frame == 0 and block is not None: # # application extension # info["extension"] = block, self.fp.tell() if block[:11] == b"NETSCAPE2.0": block = self.data() - if len(block) >= 3 and block[0] == 1: + if block and len(block) >= 3 and block[0] == 1: self.info["loop"] = i16(block, 1) while self.data(): pass @@ -336,60 +341,60 @@ class GifImageFile(ImageFile.ImageFile): self._mode = "RGB" self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) - def _rgb(color): + def _rgb(color: int) -> tuple[int, int, int]: if self._frame_palette: if color * 3 + 3 > len(self._frame_palette.palette): color = 0 - color = tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) + return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) else: - color = (color, color, color) - return color + return (color, color, color) + self.dispose = None self.dispose_extent = frame_dispose_extent - try: - if self.disposal_method < 2: - # do not dispose or none specified - self.dispose = None - elif self.disposal_method == 2: - # replace with background colour + if self.dispose_extent and self.disposal_method >= 2: + try: + if self.disposal_method == 2: + # replace with background colour - # only dispose the extent in this frame - x0, y0, x1, y1 = self.dispose_extent - dispose_size = (x1 - x0, y1 - y0) - - Image._decompression_bomb_check(dispose_size) - - # by convention, attempt to use transparency first - dispose_mode = "P" - color = self.info.get("transparency", frame_transparency) - if color is not None: - if self.mode in ("RGB", "RGBA"): - dispose_mode = "RGBA" - color = _rgb(color) + (0,) - else: - color = self.info.get("background", 0) - if self.mode in ("RGB", "RGBA"): - dispose_mode = "RGB" - color = _rgb(color) - self.dispose = Image.core.fill(dispose_mode, dispose_size, color) - else: - # replace with previous contents - if self.im is not None: # only dispose the extent in this frame - self.dispose = self._crop(self.im, self.dispose_extent) - elif frame_transparency is not None: x0, y0, x1, y1 = self.dispose_extent dispose_size = (x1 - x0, y1 - y0) Image._decompression_bomb_check(dispose_size) + + # by convention, attempt to use transparency first dispose_mode = "P" - color = frame_transparency - if self.mode in ("RGB", "RGBA"): - dispose_mode = "RGBA" - color = _rgb(frame_transparency) + (0,) + color = self.info.get("transparency", frame_transparency) + if color is not None: + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(color) + (0,) + else: + color = self.info.get("background", 0) + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGB" + color = _rgb(color) self.dispose = Image.core.fill(dispose_mode, dispose_size, color) - except AttributeError: - pass + else: + # replace with previous contents + if self.im is not None: + # only dispose the extent in this frame + self.dispose = self._crop(self.im, self.dispose_extent) + elif frame_transparency is not None: + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + dispose_mode = "P" + color = frame_transparency + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(frame_transparency) + (0,) + self.dispose = Image.core.fill( + dispose_mode, dispose_size, color + ) + except AttributeError: + pass if interlace is not None: transparency = -1 @@ -453,6 +458,8 @@ class GifImageFile(ImageFile.ImageFile): frame_im = self.im.convert("RGBA") else: frame_im = self.im.convert("RGB") + + assert self.dispose_extent is not None frame_im = self._crop(frame_im, self.dispose_extent) self.im = self._prev_im @@ -498,7 +505,12 @@ def _normalize_mode(im: Image.Image) -> Image.Image: return im.convert("L") -def _normalize_palette(im, palette, info): +_Palette = Union[bytes, bytearray, List[int], ImagePalette.ImagePalette] + + +def _normalize_palette( + im: Image.Image, palette: _Palette | None, info: dict[str, Any] +) -> Image.Image: """ Normalizes the palette for image. - Sets the palette to the incoming palette, if provided. @@ -526,8 +538,10 @@ def _normalize_palette(im, palette, info): source_palette = bytearray(i // 3 for i in range(768)) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) + used_palette_colors: list[int] | None if palette: used_palette_colors = [] + assert source_palette is not None for i in range(0, len(source_palette), 3): source_color = tuple(source_palette[i : i + 3]) index = im.palette.colors.get(source_color) @@ -558,7 +572,11 @@ def _normalize_palette(im, palette, info): return im -def _write_single_frame(im, fp, palette): +def _write_single_frame( + im: Image.Image, + fp: IO[bytes], + palette: _Palette | None, +) -> None: im_out = _normalize_mode(im) for k, v in im_out.info.items(): im.encoderinfo.setdefault(k, v) @@ -579,7 +597,9 @@ def _write_single_frame(im, fp, palette): fp.write(b"\0") # end of image data -def _getbbox(base_im, im_frame): +def _getbbox( + base_im: Image.Image, im_frame: Image.Image +) -> tuple[Image.Image, tuple[int, int, int, int] | None]: if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): im_frame = im_frame.convert("RGBA") base_im = base_im.convert("RGBA") @@ -587,12 +607,20 @@ def _getbbox(base_im, im_frame): return delta, delta.getbbox(alpha_only=False) -def _write_multiple_frames(im, fp, palette): +class _Frame(NamedTuple): + im: Image.Image + bbox: tuple[int, int, int, int] | None + encoderinfo: dict[str, Any] + + +def _write_multiple_frames( + im: Image.Image, fp: IO[bytes], palette: _Palette | None +) -> bool: duration = im.encoderinfo.get("duration") disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) - im_frames = [] - previous_im = None + im_frames: list[_Frame] = [] + previous_im: Image.Image | None = None frame_count = 0 background_im = None for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): @@ -618,24 +646,22 @@ def _write_multiple_frames(im, fp, palette): frame_count += 1 diff_frame = None - if im_frames: + if im_frames and previous_im: # delta frame delta, bbox = _getbbox(previous_im, im_frame) if not bbox: # This frame is identical to the previous frame if encoderinfo.get("duration"): - im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[ - "duration" - ] + im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"] continue - if im_frames[-1]["encoderinfo"].get("disposal") == 2: + if im_frames[-1].encoderinfo.get("disposal") == 2: if background_im is None: color = im.encoderinfo.get( "transparency", im.info.get("transparency", (0, 0, 0)) ) background = _get_background(im_frame, color) background_im = Image.new("P", im_frame.size, background) - background_im.putpalette(im_frames[0]["im"].palette) + background_im.putpalette(im_frames[0].im.palette) bbox = _getbbox(background_im, im_frame)[1] elif encoderinfo.get("optimize") and im_frame.mode != "1": if "transparency" not in encoderinfo: @@ -681,39 +707,39 @@ def _write_multiple_frames(im, fp, palette): else: bbox = None previous_im = im_frame - im_frames.append( - {"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo} - ) + im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo)) if len(im_frames) == 1: if "duration" in im.encoderinfo: # Since multiple frames will not be written, use the combined duration - im.encoderinfo["duration"] = im_frames[0]["encoderinfo"]["duration"] - return + im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"] + return False for frame_data in im_frames: - im_frame = frame_data["im"] - if not frame_data["bbox"]: + im_frame = frame_data.im + if not frame_data.bbox: # global header - for s in _get_global_header(im_frame, frame_data["encoderinfo"]): + for s in _get_global_header(im_frame, frame_data.encoderinfo): fp.write(s) offset = (0, 0) else: # compress difference if not palette: - frame_data["encoderinfo"]["include_color_table"] = True + frame_data.encoderinfo["include_color_table"] = True - im_frame = im_frame.crop(frame_data["bbox"]) - offset = frame_data["bbox"][:2] - _write_frame_data(fp, im_frame, offset, frame_data["encoderinfo"]) + im_frame = im_frame.crop(frame_data.bbox) + offset = frame_data.bbox[:2] + _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo) return True -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) -def _save(im, fp, filename, save_all=False): +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: # header if "palette" in im.encoderinfo or "palette" in im.info: palette = im.encoderinfo.get("palette", im.info.get("palette")) @@ -730,7 +756,7 @@ def _save(im, fp, filename, save_all=False): fp.flush() -def get_interlace(im): +def get_interlace(im: Image.Image) -> int: interlace = im.encoderinfo.get("interlace", 1) # workaround for @PIL153 @@ -740,7 +766,9 @@ def get_interlace(im): return interlace -def _write_local_header(fp, im, offset, flags): +def _write_local_header( + fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int +) -> None: try: transparency = im.encoderinfo["transparency"] except KeyError: @@ -788,7 +816,7 @@ def _write_local_header(fp, im, offset, flags): fp.write(o8(8)) # bits -def _save_netpbm(im, fp, filename): +def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Unused by default. # To use, uncomment the register_save call at the end of the file. # @@ -819,6 +847,7 @@ def _save_netpbm(im, fp, filename): ) # Allow ppmquant to receive SIGPIPE if ppmtogif exits + assert quant_proc.stdout is not None quant_proc.stdout.close() retcode = quant_proc.wait() @@ -840,7 +869,7 @@ def _save_netpbm(im, fp, filename): _FORCE_OPTIMIZE = False -def _get_optimize(im, info): +def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None: """ Palette optimization is a potentially expensive operation. @@ -884,6 +913,7 @@ def _get_optimize(im, info): and current_palette_size > 2 ): return used_palette_colors + return None def _get_color_table_size(palette_bytes: bytes) -> int: @@ -924,7 +954,10 @@ def _get_palette_bytes(im: Image.Image) -> bytes: return im.palette.palette if im.palette else b"" -def _get_background(im, info_background): +def _get_background( + im: Image.Image, + info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None, +) -> int: background = 0 if info_background: if isinstance(info_background, tuple): @@ -947,7 +980,7 @@ def _get_background(im, info_background): return background -def _get_global_header(im, info): +def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]: """Return a list of strings representing a GIF header""" # Header Block @@ -1009,7 +1042,12 @@ def _get_global_header(im, info): return header -def _write_frame_data(fp, im_frame, offset, params): +def _write_frame_data( + fp: IO[bytes], + im_frame: Image.Image, + offset: tuple[int, int], + params: dict[str, Any], +) -> None: try: im_frame.encoderinfo = params @@ -1029,7 +1067,9 @@ def _write_frame_data(fp, im_frame, offset, params): # Legacy GIF utilities -def getheader(im, palette=None, info=None): +def getheader( + im: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None +) -> tuple[list[bytes], list[int] | None]: """ Legacy Method to get Gif data from image. @@ -1041,11 +1081,11 @@ def getheader(im, palette=None, info=None): :returns: tuple of(list of header items, optimized palette) """ - used_palette_colors = _get_optimize(im, info) - if info is None: info = {} + used_palette_colors = _get_optimize(im, info) + if "background" not in info and "background" in im.info: info["background"] = im.info["background"] @@ -1057,7 +1097,9 @@ def getheader(im, palette=None, info=None): return header, used_palette_colors -def getdata(im, offset=(0, 0), **params): +def getdata( + im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any +) -> list[bytes]: """ Legacy Method @@ -1074,12 +1116,23 @@ def getdata(im, offset=(0, 0), **params): :returns: List of bytes containing GIF encoded frame data """ + from io import BytesIO - class Collector: + class Collector(BytesIO): data = [] - def write(self, data): - self.data.append(data) + if sys.version_info >= (3, 12): + from collections.abc import Buffer + + def write(self, data: Buffer) -> int: + self.data.append(data) + return len(data) + + else: + + def write(self, data: Any) -> int: + self.data.append(data) + return len(data) im.load() # make sure raster data is available diff --git a/src/PIL/GimpGradientFile.py b/src/PIL/GimpGradientFile.py index 2d8c78ea9..220eac57e 100644 --- a/src/PIL/GimpGradientFile.py +++ b/src/PIL/GimpGradientFile.py @@ -21,6 +21,7 @@ See the GIMP distribution for more information.) from __future__ import annotations from math import log, pi, sin, sqrt +from typing import IO, Callable from ._binary import o8 @@ -28,7 +29,7 @@ EPSILON = 1e-10 """""" # Enable auto-doc for data member -def linear(middle, pos): +def linear(middle: float, pos: float) -> float: if pos <= middle: if middle < EPSILON: return 0.0 @@ -43,19 +44,19 @@ def linear(middle, pos): return 0.5 + 0.5 * pos / middle -def curved(middle, pos): +def curved(middle: float, pos: float) -> float: return pos ** (log(0.5) / log(max(middle, EPSILON))) -def sine(middle, pos): +def sine(middle: float, pos: float) -> float: return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0 -def sphere_increasing(middle, pos): +def sphere_increasing(middle: float, pos: float) -> float: return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2) -def sphere_decreasing(middle, pos): +def sphere_decreasing(middle: float, pos: float) -> float: return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2) @@ -64,9 +65,22 @@ SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing] class GradientFile: - gradient = None + gradient: ( + list[ + tuple[ + float, + float, + float, + list[float], + list[float], + Callable[[float, float], float], + ] + ] + | None + ) = None - def getpalette(self, entries=256): + def getpalette(self, entries: int = 256) -> tuple[bytes, str]: + assert self.gradient is not None palette = [] ix = 0 @@ -101,7 +115,7 @@ class GradientFile: class GimpGradientFile(GradientFile): """File handler for GIMP's gradient format.""" - def __init__(self, fp): + def __init__(self, fp: IO[bytes]) -> None: if fp.readline()[:13] != b"GIMP Gradient": msg = "not a GIMP gradient file" raise SyntaxError(msg) @@ -114,7 +128,7 @@ class GimpGradientFile(GradientFile): count = int(line) - gradient = [] + self.gradient = [] for i in range(count): s = fp.readline().split() @@ -132,6 +146,4 @@ class GimpGradientFile(GradientFile): msg = "cannot handle HSV colour space" raise OSError(msg) - gradient.append((x0, x1, xm, rgb0, rgb1, segment)) - - self.gradient = gradient + self.gradient.append((x0, x1, xm, rgb0, rgb1, segment)) diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index c27cffab6..e9aa084b2 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -10,12 +10,14 @@ # from __future__ import annotations +from typing import IO + from . import Image, ImageFile _handler = None -def register_handler(handler: ImageFile.StubHandler) -> None: +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific GRIB image handler. @@ -58,7 +60,7 @@ class GribStubImageFile(ImageFile.StubImageFile): return _handler -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): msg = "GRIB save handler not installed" raise OSError(msg) diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index c8d7866a3..cc9e73deb 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -17,7 +17,7 @@ from . import Image, ImageFile _handler = None -def register_handler(handler: ImageFile.StubHandler) -> None: +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific HDF5 image handler. @@ -60,7 +60,7 @@ class HDF5StubImageFile(ImageFile.StubImageFile): return _handler -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): msg = "HDF5 save handler not installed" raise OSError(msg) diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 0a86ba883..2a89d498c 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -22,6 +22,7 @@ import io import os import struct import sys +from typing import IO from . import Image, ImageFile, PngImagePlugin, features @@ -312,7 +313,7 @@ class IcnsImageFile(ImageFile.ImageFile): return px -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: """ Saves the image as a series of PNG files, that are then combined into a .icns file. @@ -346,29 +347,27 @@ def _save(im, fp, filename): entries = [] for type, size in sizes.items(): stream = size_streams[size] - entries.append( - {"type": type, "size": HEADERSIZE + len(stream), "stream": stream} - ) + entries.append((type, HEADERSIZE + len(stream), stream)) # Header fp.write(MAGIC) file_length = HEADERSIZE # Header file_length += HEADERSIZE + 8 * len(entries) # TOC - file_length += sum(entry["size"] for entry in entries) + file_length += sum(entry[1] for entry in entries) fp.write(struct.pack(">i", file_length)) # TOC fp.write(b"TOC ") fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE)) for entry in entries: - fp.write(entry["type"]) - fp.write(struct.pack(">i", entry["size"])) + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) # Data for entry in entries: - fp.write(entry["type"]) - fp.write(struct.pack(">i", entry["size"])) - fp.write(entry["stream"]) + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) + fp.write(entry[2]) if hasattr(fp, "flush"): fp.flush() diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index cea093f9c..227fcf35c 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -25,6 +25,7 @@ from __future__ import annotations import warnings from io import BytesIO from math import ceil, log +from typing import IO from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin from ._binary import i16le as i16 @@ -39,7 +40,7 @@ from ._binary import o32le as o32 _MAGIC = b"\0\0\1\0" -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(_MAGIC) # (2+2) bmp = im.encoderinfo.get("bitmap_format") == "bmp" sizes = im.encoderinfo.get( @@ -194,7 +195,7 @@ class IcoFile: """ return self.frame(self.getentryindex(size, bpp)) - def frame(self, idx): + def frame(self, idx: int) -> Image.Image: """ Get an image from frame idx """ @@ -205,6 +206,7 @@ class IcoFile: data = self.buf.read(8) self.buf.seek(header["offset"]) + im: Image.Image if data[:8] == PngImagePlugin._MAGIC: # png frame im = PngImagePlugin.PngImageFile(self.buf) diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 8e949ebaf..015c2febe 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -28,6 +28,7 @@ from __future__ import annotations import os import re +from typing import IO, Any from . import Image, ImageFile, ImagePalette @@ -103,7 +104,7 @@ for j in range(2, 33): split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$") -def number(s): +def number(s: Any) -> float: try: return int(s) except ValueError: @@ -325,7 +326,7 @@ SAVE = { } -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: image_type, rawmode = SAVE[im.mode] except KeyError as e: @@ -340,6 +341,8 @@ def _save(im, fp, filename): # or: SyntaxError("not an IM file") # 8 characters are used for "Name: " and "\r\n" # Keep just the filename, ditch the potentially overlong path + if isinstance(filename, bytes): + filename = filename.decode("ascii") name, ext = os.path.splitext(os.path.basename(filename)) name = "".join([name[: 92 - len(ext)], ext]) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index c8d1017f2..57da58522 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -41,7 +41,7 @@ import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast +from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, Tuple, cast # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -410,7 +410,9 @@ def init() -> bool: # Codec factories (used by tobytes/frombytes and ImageFile.load) -def _getdecoder(mode, decoder_name, args, extra=()): +def _getdecoder( + mode: str, decoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingDecoder | ImageFile.PyDecoder: # tweak arguments if args is None: args = () @@ -433,7 +435,9 @@ def _getdecoder(mode, decoder_name, args, extra=()): return decoder(mode, *args + extra) -def _getencoder(mode, encoder_name, args, extra=()): +def _getencoder( + mode: str, encoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingEncoder | ImageFile.PyEncoder: # tweak arguments if args is None: args = () @@ -503,6 +507,12 @@ def _getscaleoffset(expr): # Implementation wrapper +class SupportsGetData(Protocol): + def getdata( + self, + ) -> tuple[Transform, Sequence[int]]: ... + + class Image: """ This class represents an image object. To create @@ -544,10 +554,10 @@ class Image: return self._size @property - def mode(self): + def mode(self) -> str: return self._mode - def _new(self, im) -> Image: + def _new(self, im: core.ImagingCore) -> Image: new = Image() new.im = im new._mode = im.mode @@ -620,7 +630,7 @@ class Image: self.load() def _dump( - self, file: str | None = None, format: str | None = None, **options + self, file: str | None = None, format: str | None = None, **options: Any ) -> str: suffix = "" if format: @@ -643,10 +653,12 @@ class Image: return filename - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, Image) return ( - self.__class__ is other.__class__ - and self.mode == other.mode + self.mode == other.mode and self.size == other.size and self.info == other.info and self.getpalette() == other.getpalette() @@ -679,7 +691,7 @@ class Image: ) ) - def _repr_image(self, image_format, **kwargs): + def _repr_image(self, image_format: str, **kwargs: Any) -> bytes | None: """Helper function for iPython display hook. :param image_format: Image format. @@ -692,14 +704,14 @@ class Image: return None return b.getvalue() - def _repr_png_(self): + def _repr_png_(self) -> bytes | None: """iPython display hook support for PNG format. :returns: PNG version of the image as bytes """ return self._repr_image("PNG", compress_level=1) - def _repr_jpeg_(self): + def _repr_jpeg_(self) -> bytes | None: """iPython display hook support for JPEG format. :returns: JPEG version of the image as bytes @@ -746,7 +758,7 @@ class Image: self.putpalette(palette) self.frombytes(data) - def tobytes(self, encoder_name: str = "raw", *args) -> bytes: + def tobytes(self, encoder_name: str = "raw", *args: Any) -> bytes: """ Return image as a bytes object. @@ -768,12 +780,13 @@ class Image: :returns: A :py:class:`bytes` object. """ - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + encoder_args: Any = args + if len(encoder_args) == 1 and isinstance(encoder_args[0], tuple): + # may pass tuple instead of argument list + encoder_args = encoder_args[0] - if encoder_name == "raw" and args == (): - args = self.mode + if encoder_name == "raw" and encoder_args == (): + encoder_args = self.mode self.load() @@ -781,7 +794,7 @@ class Image: return b"" # unpack data - e = _getencoder(self.mode, encoder_name, args) + e = _getencoder(self.mode, encoder_name, encoder_args) e.setimage(self.im) bufsize = max(65536, self.size[0] * 4) # see RawEncode.c @@ -824,7 +837,9 @@ class Image: ] ) - def frombytes(self, data: bytes, decoder_name: str = "raw", *args) -> None: + def frombytes( + self, data: bytes | bytearray, decoder_name: str = "raw", *args: Any + ) -> None: """ Loads this image with pixel data from a bytes object. @@ -835,16 +850,17 @@ class Image: if self.width == 0 or self.height == 0: return - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] # default format - if decoder_name == "raw" and args == (): - args = self.mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = self.mode # unpack data - d = _getdecoder(self.mode, decoder_name, args) + d = _getdecoder(self.mode, decoder_name, decoder_args) d.setimage(self.im) s = d.decode(data) @@ -988,9 +1004,11 @@ class Image: if has_transparency and self.im.bands == 3: transparency = new_im.info["transparency"] - def convert_transparency(m, v): - v = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 - return max(0, min(255, int(v))) + def convert_transparency( + m: tuple[float, ...], v: tuple[int, int, int] + ) -> int: + value = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 + return max(0, min(255, int(value))) if mode == "L": transparency = convert_transparency(matrix, transparency) @@ -1242,7 +1260,7 @@ class Image: __copy__ = copy - def crop(self, box: tuple[int, int, int, int] | None = None) -> Image: + def crop(self, box: tuple[float, float, float, float] | None = None) -> Image: """ Returns a rectangular region from this image. The box is a 4-tuple defining the left, upper, right, and lower pixel @@ -1268,7 +1286,9 @@ class Image: self.load() return self._new(self._crop(self.im, box)) - def _crop(self, im, box): + def _crop( + self, im: core.ImagingCore, box: tuple[float, float, float, float] + ) -> core.ImagingCore: """ Returns a rectangular region from the core image object im. @@ -1289,7 +1309,7 @@ class Image: return im.crop((x0, y0, x1, y1)) def draft( - self, mode: str, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] ) -> tuple[str, tuple[int, int, float, float]] | None: """ Configures the image file loader so it returns a version of the @@ -1359,7 +1379,7 @@ class Image: """ return ImageMode.getmode(self.mode).bands - def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int]: + def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None: """ Calculates the bounding box of the non-zero regions in the image. @@ -1447,7 +1467,7 @@ class Image: :returns: XMP tags in a dictionary. """ - def get_name(tag): + def get_name(tag: str) -> str: return re.sub("^{[^}]+}", "", tag) def get_value(element): @@ -1549,7 +1569,11 @@ class Image: fp = io.BytesIO(data) with open(fp) as im: - if thumbnail_offset is None: + from . import TiffImagePlugin + + if thumbnail_offset is None and isinstance( + im, TiffImagePlugin.TiffImageFile + ): im._frame_pos = [ifd_offset] im._seek(0) im.load() @@ -1717,7 +1741,12 @@ class Image: return self.im.entropy(extrema) return self.im.entropy() - def paste(self, im, box=None, mask=None) -> None: + def paste( + self, + im: Image | str | float | tuple[float, ...], + box: tuple[int, int, int, int] | tuple[int, int] | None = None, + mask: Image | None = None, + ) -> None: """ Pastes another image into this image. The box argument is either a 2-tuple giving the upper left corner, a 4-tuple defining the @@ -1745,7 +1774,7 @@ class Image: See :py:meth:`~PIL.Image.Image.alpha_composite` if you want to combine images with respect to their alpha channels. - :param im: Source image or pixel value (integer or tuple). + :param im: Source image or pixel value (integer, float or tuple). :param box: An optional 4-tuple giving the region to paste into. If a 2-tuple is used instead, it's treated as the upper left corner. If omitted or None, the source is pasted into the @@ -1798,7 +1827,9 @@ class Image: else: self.im.paste(im, box) - def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): + def alpha_composite( + self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0) + ) -> None: """'In-place' analog of Image.alpha_composite. Composites an image onto this image. @@ -1813,32 +1844,35 @@ class Image: """ if not isinstance(source, (list, tuple)): - msg = "Source must be a tuple" + msg = "Source must be a list or tuple" raise ValueError(msg) if not isinstance(dest, (list, tuple)): - msg = "Destination must be a tuple" + msg = "Destination must be a list or tuple" raise ValueError(msg) - if len(source) not in (2, 4): - msg = "Source must be a 2 or 4-tuple" + + if len(source) == 4: + overlay_crop_box = tuple(source) + elif len(source) == 2: + overlay_crop_box = tuple(source) + im.size + else: + msg = "Source must be a sequence of length 2 or 4" raise ValueError(msg) + if not len(dest) == 2: - msg = "Destination must be a 2-tuple" + msg = "Destination must be a sequence of length 2" raise ValueError(msg) if min(source) < 0: msg = "Source must be non-negative" raise ValueError(msg) - if len(source) == 2: - source = source + im.size - - # over image, crop if it's not the whole thing. - if source == (0, 0) + im.size: + # over image, crop if it's not the whole image. + if overlay_crop_box == (0, 0) + im.size: overlay = im else: - overlay = im.crop(source) + overlay = im.crop(overlay_crop_box) # target for the paste - box = dest + (dest[0] + overlay.width, dest[1] + overlay.height) + box = tuple(dest) + (dest[0] + overlay.width, dest[1] + overlay.height) # destination image. don't copy if we're using the whole image. if box == (0, 0) + self.size: @@ -1849,7 +1883,11 @@ class Image: result = alpha_composite(background, overlay) self.paste(result, box) - def point(self, lut, mode: str | None = None) -> Image: + def point( + self, + lut: Sequence[float] | Callable[[int], float] | ImagePointHandler, + mode: str | None = None, + ) -> Image: """ Maps this image through a lookup table or function. @@ -1886,7 +1924,9 @@ class Image: scale, offset = _getscaleoffset(lut) return self._new(self.im.point_transform(scale, offset)) # for other modes, convert the function to a table - lut = [lut(i) for i in range(256)] * self.im.bands + flatLut = [lut(i) for i in range(256)] * self.im.bands + else: + flatLut = lut if self.mode == "F": # FIXME: _imaging returns a confusing error message for this case @@ -1894,8 +1934,8 @@ class Image: raise ValueError(msg) if mode != "F": - lut = [round(i) for i in lut] - return self._new(self.im.point(lut, mode)) + flatLut = [round(i) for i in flatLut] + return self._new(self.im.point(flatLut, mode)) def putalpha(self, alpha): """ @@ -2154,7 +2194,13 @@ class Image: min(self.size[1], math.ceil(box[3] + support_y)), ) - def resize(self, size, resample=None, box=None, reducing_gap=None) -> Image: + def resize( + self, + size: tuple[int, int], + resample: int | None = None, + box: tuple[float, float, float, float] | None = None, + reducing_gap: float | None = None, + ) -> Image: """ Returns a resized copy of this image. @@ -2219,13 +2265,9 @@ class Image: msg = "reducing_gap must be 1.0 or greater" raise ValueError(msg) - size = tuple(size) - self.load() if box is None: box = (0, 0) + self.size - else: - box = tuple(box) if self.size == size and box == (0, 0) + self.size: return self.copy() @@ -2260,7 +2302,11 @@ class Image: return self._new(self.im.resize(size, resample, box)) - def reduce(self, factor, box=None): + def reduce( + self, + factor: int | tuple[int, int], + box: tuple[int, int, int, int] | None = None, + ) -> Image: """ Returns a copy of the image reduced ``factor`` times. If the size of the image is not dividable by ``factor``, @@ -2278,8 +2324,6 @@ class Image: if box is None: box = (0, 0) + self.size - else: - box = tuple(box) if factor == (1, 1) and box == (0, 0) + self.size: return self.copy() @@ -2295,13 +2339,13 @@ class Image: def rotate( self, - angle, - resample=Resampling.NEAREST, - expand=0, - center=None, - translate=None, - fillcolor=None, - ): + angle: float, + resample: Resampling = Resampling.NEAREST, + expand: int | bool = False, + center: tuple[int, int] | None = None, + translate: tuple[int, int] | None = None, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: """ Returns a rotated copy of this image. This method returns a copy of this image, rotated the given number of degrees counter @@ -2463,7 +2507,7 @@ class Image: save_all = params.pop("save_all", False) self.encoderinfo = params - self.encoderconfig = () + self.encoderconfig: tuple[Any, ...] = () preinit() @@ -2608,7 +2652,12 @@ class Image: """ return 0 - def thumbnail(self, size, resample=Resampling.BICUBIC, reducing_gap=2.0): + def thumbnail( + self, + size: tuple[float, float], + resample: Resampling = Resampling.BICUBIC, + reducing_gap: float = 2.0, + ) -> None: """ Make this image into a thumbnail. This method modifies the image to contain a thumbnail version of itself, no larger than @@ -2669,20 +2718,24 @@ class Image: box = None if reducing_gap is not None: - size = preserve_aspect_ratio() - if size is None: + preserved_size = preserve_aspect_ratio() + if preserved_size is None: return + size = preserved_size - res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap)) + res = self.draft( + None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) + ) if res is not None: box = res[1] if box is None: self.load() # load() may have changed the size of the image - size = preserve_aspect_ratio() - if size is None: + preserved_size = preserve_aspect_ratio() + if preserved_size is None: return + size = preserved_size if self.size != size: im = self.resize(size, resample, box=box, reducing_gap=reducing_gap) @@ -2698,12 +2751,12 @@ class Image: # instead of bloating the method docs, add a separate chapter. def transform( self, - size, - method, - data=None, - resample=Resampling.NEAREST, - fill=1, - fillcolor=None, + size: tuple[int, int], + method: Transform | ImageTransformHandler | SupportsGetData, + data: Sequence[Any] | None = None, + resample: int = Resampling.NEAREST, + fill: int = 1, + fillcolor: float | tuple[float, ...] | str | None = None, ) -> Image: """ Transforms this image. This method creates a new image with the @@ -2867,7 +2920,7 @@ class Image: if image.mode in ("1", "P"): resample = Resampling.NEAREST - self.im.transform2(box, image.im, method, data, resample, fill) + self.im.transform(box, image.im, method, data, resample, fill) def transpose(self, method: Transpose) -> Image: """ @@ -2883,7 +2936,7 @@ class Image: self.load() return self._new(self.im.transpose(method)) - def effect_spread(self, distance): + def effect_spread(self, distance: int) -> Image: """ Randomly spread pixels in an image. @@ -2937,7 +2990,7 @@ class ImageTransformHandler: self, size: tuple[int, int], image: Image, - **options: dict[str, str | int | tuple[int, ...] | list[int]], + **options: Any, ) -> Image: pass @@ -2949,35 +3002,35 @@ class ImageTransformHandler: # Debugging -def _wedge(): +def _wedge() -> Image: """Create grayscale wedge (for debugging only)""" return Image()._new(core.wedge("L")) -def _check_size(size): +def _check_size(size: Any) -> None: """ Common check to enforce type and sanity check on size tuples :param size: Should be a 2 tuple of (width, height) - :returns: True, or raises a ValueError + :returns: None, or raises a ValueError """ if not isinstance(size, (list, tuple)): - msg = "Size must be a tuple" + msg = "Size must be a list or tuple" raise ValueError(msg) if len(size) != 2: - msg = "Size must be a tuple of length 2" + msg = "Size must be a sequence of length 2" raise ValueError(msg) if size[0] < 0 or size[1] < 0: msg = "Width and height must be >= 0" raise ValueError(msg) - return True - def new( - mode: str, size: tuple[int, int], color: float | tuple[float, ...] | str | None = 0 + mode: str, + size: tuple[int, int] | list[int], + color: float | tuple[float, ...] | str | None = 0, ) -> Image: """ Creates a new image with the given mode and size. @@ -3011,16 +3064,28 @@ def new( color = ImageColor.getcolor(color, mode) im = Image() - if mode == "P" and isinstance(color, (list, tuple)) and len(color) in [3, 4]: - # RGB or RGBA value for a P image - from . import ImagePalette + if ( + mode == "P" + and isinstance(color, (list, tuple)) + and all(isinstance(i, int) for i in color) + ): + color_ints: tuple[int, ...] = cast(Tuple[int, ...], tuple(color)) + if len(color_ints) == 3 or len(color_ints) == 4: + # RGB or RGBA value for a P image + from . import ImagePalette - im.palette = ImagePalette.ImagePalette() - color = im.palette.getcolor(color) + im.palette = ImagePalette.ImagePalette() + color = im.palette.getcolor(color_ints) return im._new(core.fill(mode, size, color)) -def frombytes(mode, size, data, decoder_name="raw", *args) -> Image: +def frombytes( + mode: str, + size: tuple[int, int], + data: bytes | bytearray, + decoder_name: str = "raw", + *args: Any, +) -> Image: """ Creates a copy of an image memory from pixel data in a buffer. @@ -3048,18 +3113,21 @@ def frombytes(mode, size, data, decoder_name="raw", *args) -> Image: im = new(mode, size) if im.width != 0 and im.height != 0: - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] - if decoder_name == "raw" and args == (): - args = mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = mode - im.frombytes(data, decoder_name, args) + im.frombytes(data, decoder_name, decoder_args) return im -def frombuffer(mode, size, data, decoder_name="raw", *args) -> Image: +def frombuffer( + mode: str, size: tuple[int, int], data, decoder_name: str = "raw", *args: Any +) -> Image: """ Creates an image memory referencing pixel data in a byte buffer. @@ -3516,7 +3584,7 @@ def merge(mode: str, bands: Sequence[Image]) -> Image: def register_open( - id, + id: str, factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], accept: Callable[[bytes], bool | str] | None = None, ) -> None: @@ -3550,7 +3618,9 @@ def register_mime(id: str, mimetype: str) -> None: MIME[id.upper()] = mimetype -def register_save(id: str, driver) -> None: +def register_save( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image save function. This function should not be used in application code. @@ -3561,7 +3631,9 @@ def register_save(id: str, driver) -> None: SAVE[id.upper()] = driver -def register_save_all(id, driver) -> None: +def register_save_all( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image function to save all the frames of a multiframe format. This function should not be @@ -3573,7 +3645,7 @@ def register_save_all(id, driver) -> None: SAVE_ALL[id.upper()] = driver -def register_extension(id, extension) -> None: +def register_extension(id: str, extension: str) -> None: """ Registers an image extension. This function should not be used in application code. @@ -3584,7 +3656,7 @@ def register_extension(id, extension) -> None: EXTENSION[extension.lower()] = id.upper() -def register_extensions(id, extensions) -> None: +def register_extensions(id: str, extensions: list[str]) -> None: """ Registers image extensions. This function should not be used in application code. @@ -3596,7 +3668,7 @@ def register_extensions(id, extensions) -> None: register_extension(id, extension) -def registered_extensions(): +def registered_extensions() -> dict[str, str]: """ Returns a dictionary containing all file extensions belonging to registered plugins @@ -3635,7 +3707,7 @@ def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None: # Simple display support. -def _show(image, **options) -> None: +def _show(image: Image, **options: Any) -> None: from . import ImageShow ImageShow.show(image, **options) @@ -3645,7 +3717,9 @@ def _show(image, **options) -> None: # Effects -def effect_mandelbrot(size, extent, quality): +def effect_mandelbrot( + size: tuple[int, int], extent: tuple[float, float, float, float], quality: int +) -> Image: """ Generate a Mandelbrot set covering the given extent. @@ -3658,7 +3732,7 @@ def effect_mandelbrot(size, extent, quality): return Image()._new(core.effect_mandelbrot(size, extent, quality)) -def effect_noise(size, sigma): +def effect_noise(size: tuple[int, int], sigma: float) -> Image: """ Generate Gaussian noise centered around 128. @@ -3669,7 +3743,7 @@ def effect_noise(size, sigma): return Image()._new(core.effect_noise(size, sigma)) -def linear_gradient(mode): +def linear_gradient(mode: str) -> Image: """ Generate 256x256 linear gradient from black to white, top to bottom. @@ -3678,7 +3752,7 @@ def linear_gradient(mode): return Image()._new(core.linear_gradient(mode)) -def radial_gradient(mode): +def radial_gradient(mode: str) -> Image: """ Generate 256x256 radial gradient from black to white, centre to edge. @@ -3691,19 +3765,18 @@ def radial_gradient(mode): # Resources -def _apply_env_variables(env=None) -> None: - if env is None: - env = os.environ +def _apply_env_variables(env: dict[str, str] | None = None) -> None: + env_dict = env if env is not None else os.environ for var_name, setter in [ ("PILLOW_ALIGNMENT", core.set_alignment), ("PILLOW_BLOCK_SIZE", core.set_block_size), ("PILLOW_BLOCKS_MAX", core.set_blocks_max), ]: - if var_name not in env: + if var_name not in env_dict: continue - var = env[var_name].lower() + var = env_dict[var_name].lower() units = 1 for postfix, mul in [("k", 1024), ("m", 1024 * 1024)]: @@ -3712,13 +3785,13 @@ def _apply_env_variables(env=None) -> None: var = var[: -len(postfix)] try: - var = int(var) * units + var_int = int(var) * units except ValueError: warnings.warn(f"{var_name} is not int") continue try: - setter(var) + setter(var_int) except ValueError as e: warnings.warn(f"{var_name}: {e}") diff --git a/src/PIL/ImageColor.py b/src/PIL/ImageColor.py index 5fb80b753..9a15a8eb7 100644 --- a/src/PIL/ImageColor.py +++ b/src/PIL/ImageColor.py @@ -25,7 +25,7 @@ from . import Image @lru_cache -def getrgb(color): +def getrgb(color: str) -> tuple[int, int, int] | tuple[int, int, int, int]: """ Convert a color string to an RGB or RGBA tuple. If the string cannot be parsed, this function raises a :py:exc:`ValueError` exception. @@ -44,8 +44,10 @@ def getrgb(color): if rgb: if isinstance(rgb, tuple): return rgb - colormap[color] = rgb = getrgb(rgb) - return rgb + rgb_tuple = getrgb(rgb) + assert len(rgb_tuple) == 3 + colormap[color] = rgb_tuple + return rgb_tuple # check for known string formats if re.match("#[a-f0-9]{3}$", color): @@ -88,15 +90,15 @@ def getrgb(color): if m: from colorsys import hls_to_rgb - rgb = hls_to_rgb( + rgb_floats = hls_to_rgb( float(m.group(1)) / 360.0, float(m.group(3)) / 100.0, float(m.group(2)) / 100.0, ) return ( - int(rgb[0] * 255 + 0.5), - int(rgb[1] * 255 + 0.5), - int(rgb[2] * 255 + 0.5), + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), ) m = re.match( @@ -105,15 +107,15 @@ def getrgb(color): if m: from colorsys import hsv_to_rgb - rgb = hsv_to_rgb( + rgb_floats = hsv_to_rgb( float(m.group(1)) / 360.0, float(m.group(2)) / 100.0, float(m.group(3)) / 100.0, ) return ( - int(rgb[0] * 255 + 0.5), - int(rgb[1] * 255 + 0.5), - int(rgb[2] * 255 + 0.5), + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), ) m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) @@ -124,7 +126,7 @@ def getrgb(color): @lru_cache -def getcolor(color, mode: str) -> tuple[int, ...]: +def getcolor(color: str, mode: str) -> int | tuple[int, ...]: """ Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if ``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is @@ -136,33 +138,34 @@ def getcolor(color, mode: str) -> tuple[int, ...]: :param color: A color string :param mode: Convert result to this mode - :return: ``(graylevel[, alpha]) or (red, green, blue[, alpha])`` + :return: ``graylevel, (graylevel, alpha) or (red, green, blue[, alpha])`` """ # same as getrgb, but converts the result to the given mode - color, alpha = getrgb(color), 255 - if len(color) == 4: - color, alpha = color[:3], color[3] + rgb, alpha = getrgb(color), 255 + if len(rgb) == 4: + alpha = rgb[3] + rgb = rgb[:3] if mode == "HSV": from colorsys import rgb_to_hsv - r, g, b = color + r, g, b = rgb h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255) return int(h * 255), int(s * 255), int(v * 255) elif Image.getmodebase(mode) == "L": - r, g, b = color + r, g, b = rgb # ITU-R Recommendation 601-2 for nonlinear RGB # scaled to 24 bits to match the convert's implementation. - color = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 + graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 if mode[-1] == "A": - return color, alpha - else: - if mode[-1] == "A": - return color + (alpha,) - return color + return graylevel, alpha + return graylevel + elif mode[-1] == "A": + return rgb + (alpha,) + return rgb -colormap = { +colormap: dict[str, str | tuple[int, int, int]] = { # X11 colour table from https://drafts.csswg.org/css-color-4/, with # gray/grey spelling issues fixed. This is a superset of HTML 4.0 # colour names used in CSS 1. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 8fe179dd5..41a3eb0cb 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,11 +34,16 @@ from __future__ import annotations import math import numbers import struct -from typing import TYPE_CHECKING, Sequence, cast +from types import ModuleType +from typing import TYPE_CHECKING, AnyStr, Sequence, cast from . import Image, ImageColor +from ._deprecate import deprecate from ._typing import Coords +if TYPE_CHECKING: + from . import ImageDraw2, ImageFont + """ A simple 2D drawing interface for PIL images.

@@ -92,10 +97,9 @@ class ImageDraw: self.fontmode = "L" # aliasing is okay for other modes self.fill = False - if TYPE_CHECKING: - from . import ImageFont - - def getfont(self) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + def getfont( + self, + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: """ Get the current default font. @@ -120,14 +124,15 @@ class ImageDraw: self.font = ImageFont.load_default() return self.font - def _getfont(self, font_size: float | None): + def _getfont( + self, font_size: float | None + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: if font_size is not None: from . import ImageFont - font = ImageFont.load_default(font_size) + return ImageFont.load_default(font_size) else: - font = self.getfont() - return font + return self.getfont() def _getink(self, ink, fill=None) -> tuple[int | None, int | None]: if ink is None and fill is None: @@ -216,7 +221,9 @@ class ImageDraw: # This is a straight line, so no joint is required continue - def coord_at_angle(coord, angle): + def coord_at_angle( + coord: Sequence[float], angle: float + ) -> tuple[float, float]: x, y = coord angle -= 90 distance = width / 2 - 1 @@ -460,15 +467,13 @@ class ImageDraw: right[3] -= r + 1 self.draw.draw_rectangle(right, ink, 1) - def _multiline_check(self, text) -> bool: + def _multiline_check(self, text: AnyStr) -> bool: split_character = "\n" if isinstance(text, str) else b"\n" return split_character in text - def _multiline_split(self, text) -> list[str | bytes]: - split_character = "\n" if isinstance(text, str) else b"\n" - - return text.split(split_character) + def _multiline_split(self, text: AnyStr) -> list[AnyStr]: + return text.split("\n" if isinstance(text, str) else b"\n") def _multiline_spacing(self, font, spacing, stroke_width): return ( @@ -479,10 +484,15 @@ class ImageDraw: def text( self, - xy, - text, + xy: tuple[float, float], + text: str, fill=None, - font=None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, anchor=None, spacing=4, align="left", @@ -536,7 +546,7 @@ class ImageDraw: coord.append(int(xy[i])) start.append(math.modf(xy[i])[0]) try: - mask, offset = font.getmask2( + mask, offset = font.getmask2( # type: ignore[union-attr,misc] text, mode, direction=direction, @@ -552,7 +562,7 @@ class ImageDraw: coord = [coord[0] + offset[0], coord[1] + offset[1]] except AttributeError: try: - mask = font.getmask( + mask = font.getmask( # type: ignore[misc] text, mode, direction, @@ -601,10 +611,15 @@ class ImageDraw: def multiline_text( self, - xy, - text, + xy: tuple[float, float], + text: str, fill=None, - font=None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, anchor=None, spacing=4, align="left", @@ -634,7 +649,7 @@ class ImageDraw: font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: @@ -688,15 +703,20 @@ class ImageDraw: def textlength( self, - text, - font=None, + text: str, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, direction=None, features=None, language=None, embedded_color=False, *, font_size=None, - ): + ) -> float: """Get the length of a given string, in pixels with 1/64 precision.""" if self._multiline_check(text): msg = "can't measure length of multiline text" @@ -788,7 +808,7 @@ class ImageDraw: font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: @@ -860,7 +880,7 @@ class ImageDraw: return bbox -def Draw(im, mode: str | None = None) -> ImageDraw: +def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: """ A simple 2D drawing interface for PIL images. @@ -872,7 +892,7 @@ def Draw(im, mode: str | None = None) -> ImageDraw: defaults to the mode of the image. """ try: - return im.getdraw(mode) + return getattr(im, "getdraw")(mode) except AttributeError: return ImageDraw(im, mode) @@ -884,28 +904,20 @@ except AttributeError: Outline = None -def getdraw(im=None, hints=None): +def getdraw( + im: Image.Image | None = None, hints: list[str] | None = None +) -> tuple[ImageDraw2.Draw | None, ModuleType]: """ - (Experimental) A more advanced 2D drawing interface for PIL images, - based on the WCK interface. - :param im: The image to draw in. - :param hints: An optional list of hints. + :param hints: An optional list of hints. Deprecated. :returns: A (drawing context, drawing resource factory) tuple. """ - # FIXME: this needs more work! - # FIXME: come up with a better 'hints' scheme. - handler = None - if not hints or "nicest" in hints: - try: - from . import _imagingagg as handler - except ImportError: - pass - if handler is None: - from . import ImageDraw2 as handler - if im: - im = handler.Draw(im) - return im, handler + if hints is not None: + deprecate("'hints' parameter", 12) + from . import ImageDraw2 + + draw = ImageDraw2.Draw(im) if im is not None else None + return draw, ImageDraw2 def floodfill( @@ -1093,11 +1105,13 @@ def _compute_regular_polygon_vertices( return [_compute_polygon_vertex(angle) for angle in angles] -def _color_diff(color1, color2: float | tuple[int, ...]) -> float: +def _color_diff( + color1: float | tuple[int, ...], color2: float | tuple[int, ...] +) -> float: """ Uses 1-norm distance to calculate difference between two values. """ - if isinstance(color2, tuple): - return sum(abs(color1[i] - color2[i]) for i in range(0, len(color2))) - else: - return abs(color1 - color2) + first = color1 if isinstance(color1, tuple) else (color1,) + second = color2 if isinstance(color2, tuple) else (color2,) + + return sum(abs(first[i] - second[i]) for i in range(0, len(second))) diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py index 35ee5834e..b42f5d9ea 100644 --- a/src/PIL/ImageDraw2.py +++ b/src/PIL/ImageDraw2.py @@ -30,7 +30,7 @@ from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath class Pen: """Stores an outline color and width.""" - def __init__(self, color, width=1, opacity=255): + def __init__(self, color: str, width: int = 1, opacity: int = 255) -> None: self.color = ImageColor.getrgb(color) self.width = width @@ -38,7 +38,7 @@ class Pen: class Brush: """Stores a fill color""" - def __init__(self, color, opacity=255): + def __init__(self, color: str, opacity: int = 255) -> None: self.color = ImageColor.getrgb(color) @@ -63,7 +63,7 @@ class Draw: self.image = image self.transform = None - def flush(self): + def flush(self) -> Image.Image: return self.image def render(self, op, xy, pen, brush=None): diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index f0e492387..5d67409ea 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -487,7 +487,7 @@ class Parser: def __enter__(self): return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() def close(self): @@ -763,7 +763,7 @@ class PyEncoder(PyCodec): def pushes_fd(self): return self._pushes_fd - def encode(self, bufsize): + def encode(self, bufsize: int) -> tuple[int, int, bytes]: """ Override to perform the encoding process. diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 03c8f81df..fa5608e6c 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -33,11 +33,16 @@ import sys import warnings from enum import IntEnum from io import BytesIO -from typing import BinaryIO +from typing import IO, TYPE_CHECKING, Any, BinaryIO from . import Image from ._typing import StrOrBytesPath -from ._util import is_directory, is_path +from ._util import is_path + +if TYPE_CHECKING: + from . import ImageFile + from ._imaging import ImagingFont + from ._imagingft import Font class Layout(IntEnum): @@ -56,7 +61,7 @@ except ImportError as ex: core = DeferredError.new(ex) -def _string_length_check(text): +def _string_length_check(text: str | bytes | bytearray) -> None: if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: msg = "too many characters in string" raise ValueError(msg) @@ -81,9 +86,11 @@ def _string_length_check(text): class ImageFont: """PIL font wrapper""" - def _load_pilfont(self, filename): + font: ImagingFont + + def _load_pilfont(self, filename: str) -> None: with open(filename, "rb") as fp: - image = None + image: ImageFile.ImageFile | None = None for ext in (".png", ".gif", ".pbm"): if image: image.close() @@ -106,7 +113,7 @@ class ImageFont: self._load_pilfont_data(fp, image) image.close() - def _load_pilfont_data(self, file, image): + def _load_pilfont_data(self, file: IO[bytes], image: Image.Image) -> None: # read PILfont header if file.readline() != b"PILfont\n": msg = "Not a PILfont file" @@ -153,7 +160,9 @@ class ImageFont: Image._decompression_bomb_check(self.font.getsize(text)) return self.font.getmask(text, mode) - def getbbox(self, text, *args, **kwargs): + def getbbox( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> tuple[int, int, int, int]: """ Returns bounding box (in pixels) of given text. @@ -167,7 +176,9 @@ class ImageFont: width, height = self.font.getsize(text) return 0, 0, width, height - def getlength(self, text, *args, **kwargs): + def getlength( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> int: """ Returns length (in pixels) of given text. This is the amount by which following text should be offset. @@ -187,6 +198,8 @@ class ImageFont: class FreeTypeFont: """FreeType font wrapper (requires _imagingft service)""" + font: Font + def __init__( self, font: StrOrBytesPath | BinaryIO | None = None, @@ -250,7 +263,7 @@ class FreeTypeFont: path, size, index, encoding, layout_engine = state self.__init__(path, size, index, encoding, layout_engine) - def getname(self): + def getname(self) -> tuple[str | None, str | None]: """ :return: A tuple of the font family (e.g. Helvetica) and the font style (e.g. Bold) @@ -265,7 +278,9 @@ class FreeTypeFont: """ return self.font.ascent, self.font.descent - def getlength(self, text, mode="", direction=None, features=None, language=None): + def getlength( + self, text: str, mode="", direction=None, features=None, language=None + ) -> float: """ Returns length (in pixels with 1/64 precision) of given text when rendered in font with provided direction, features, and language. @@ -339,14 +354,14 @@ class FreeTypeFont: def getbbox( self, - text, - mode="", - direction=None, - features=None, - language=None, - stroke_width=0, - anchor=None, - ): + text: str, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ) -> tuple[float, float, float, float]: """ Returns bounding box (in pixels) of given text relative to given anchor when rendered in font with provided direction, features, and language. @@ -496,7 +511,7 @@ class FreeTypeFont: def getmask2( self, - text, + text: str, mode="", direction=None, features=None, @@ -666,10 +681,11 @@ class FreeTypeFont: msg = "FreeType 2.9.1 or greater is required" raise NotImplementedError(msg) from e for axis in axes: - axis["name"] = axis["name"].replace(b"\x00", b"") + if axis["name"]: + axis["name"] = axis["name"].replace(b"\x00", b"") return axes - def set_variation_by_axes(self, axes): + def set_variation_by_axes(self, axes: list[float]) -> None: """ :param axes: A list of values for each axis. :exception OSError: If the font is not a variation font. @@ -714,14 +730,14 @@ class TransposedFont: return 0, 0, height, width return 0, 0, width, height - def getlength(self, text, *args, **kwargs): + def getlength(self, text: str, *args, **kwargs) -> float: if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): msg = "text length is undefined for text rotated by 90 or 270 degrees" raise ValueError(msg) return self.font.getlength(text, *args, **kwargs) -def load(filename): +def load(filename: str) -> ImageFont: """ Load a font file. This function loads a font object from the given bitmap font file, and returns the corresponding font object. @@ -735,7 +751,13 @@ def load(filename): return f -def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): +def truetype( + font: StrOrBytesPath | BinaryIO | None = None, + size: float = 10, + index: int = 0, + encoding: str = "", + layout_engine: Layout | None = None, +) -> FreeTypeFont: """ Load a TrueType or OpenType font from a file or file-like object, and create a font object. @@ -796,7 +818,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): :exception ValueError: If the font size is not greater than zero. """ - def freetype(font): + def freetype(font: StrOrBytesPath | BinaryIO | None) -> FreeTypeFont: return FreeTypeFont(font, size, index, encoding, layout_engine) try: @@ -846,7 +868,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): raise -def load_path(filename): +def load_path(filename: str | bytes) -> ImageFont: """ Load font file. Same as :py:func:`~PIL.ImageFont.load`, but searches for a bitmap font along the Python path. @@ -855,14 +877,13 @@ def load_path(filename): :return: A font object. :exception OSError: If the file could not be read. """ + if not isinstance(filename, str): + filename = filename.decode("utf-8") for directory in sys.path: - if is_directory(directory): - if not isinstance(filename, str): - filename = filename.decode("utf-8") - try: - return load(os.path.join(directory, filename)) - except OSError: - pass + try: + return load(os.path.join(directory, filename)) + except OSError: + pass msg = "cannot find font file" raise OSError(msg) @@ -881,6 +902,7 @@ def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: :return: A font object. """ + f: FreeTypeFont | ImageFont if core.__class__.__name__ == "module" or size is not None: f = truetype( BytesIO( diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 48bde39fe..6d2814345 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -497,7 +497,7 @@ def expand( color = _color(fill, image.mode) if image.palette: palette = ImagePalette.ImagePalette(palette=image.getpalette()) - if isinstance(color, tuple): + if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4): color = palette.getcolor(color) else: palette = None diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 057ccd1d7..6473c4577 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -18,10 +18,13 @@ from __future__ import annotations import array -from typing import IO, Sequence +from typing import IO, TYPE_CHECKING, Sequence from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile +if TYPE_CHECKING: + from . import Image + class ImagePalette: """ @@ -51,7 +54,7 @@ class ImagePalette: self._palette = palette @property - def colors(self): + def colors(self) -> dict[tuple[int, int, int] | tuple[int, int, int, int], int]: if self._colors is None: mode_len = len(self.mode) self._colors = {} @@ -63,7 +66,9 @@ class ImagePalette: return self._colors @colors.setter - def colors(self, colors): + def colors( + self, colors: dict[tuple[int, int, int] | tuple[int, int, int, int], int] + ) -> None: self._colors = colors def copy(self) -> ImagePalette: @@ -104,11 +109,13 @@ class ImagePalette: # Declare tostring as an alias for tobytes tostring = tobytes - def _new_color_index(self, image=None, e=None): + def _new_color_index( + self, image: Image.Image | None = None, e: Exception | None = None + ) -> int: if not isinstance(self.palette, bytearray): self._palette = bytearray(self.palette) index = len(self.palette) // 3 - special_colors = () + special_colors: tuple[int | tuple[int, ...] | None, ...] = () if image: special_colors = ( image.info.get("background"), @@ -128,7 +135,11 @@ class ImagePalette: raise ValueError(msg) from e return index - def getcolor(self, color, image=None) -> int: + def getcolor( + self, + color: tuple[int, int, int] | tuple[int, int, int, int], + image: Image.Image | None = None, + ) -> int: """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. @@ -163,7 +174,7 @@ class ImagePalette: self.dirty = 1 return index else: - msg = f"unknown color specifier: {repr(color)}" + msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable] raise ValueError(msg) def save(self, fp: str | IO[str]) -> None: diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index 6e2e7db1e..90defdbbc 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -37,7 +37,7 @@ from . import Image _pilbitmap_ok = None -def _pilbitmap_check(): +def _pilbitmap_check() -> int: global _pilbitmap_ok if _pilbitmap_ok is None: try: @@ -162,7 +162,7 @@ class PhotoImage: """ return self.__size[1] - def paste(self, im): + def paste(self, im: Image.Image) -> None: """ Paste a PIL image into the photo image. Note that this can be very slow if the photo image is displayed. @@ -254,7 +254,7 @@ class BitmapImage: return str(self.__photo) -def getimage(photo): +def getimage(photo: PhotoImage) -> Image.Image: """Copies the contents of a PhotoImage to a PIL image memory.""" im = Image.new("RGBA", (photo.width(), photo.height())) block = im.im diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py index 6aa82dadd..80a6116b7 100644 --- a/src/PIL/ImageTransform.py +++ b/src/PIL/ImageTransform.py @@ -14,7 +14,7 @@ # from __future__ import annotations -from typing import Sequence +from typing import Any, Sequence from . import Image @@ -34,7 +34,7 @@ class Transform(Image.ImageTransformHandler): self, size: tuple[int, int], image: Image.Image, - **options: dict[str, str | int | tuple[int, ...] | list[int]], + **options: Any, ) -> Image.Image: """Perform the transform. Called from :py:meth:`.Image.transform`.""" # can be overridden diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index e6395b1cb..39eb1c203 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -18,6 +18,7 @@ from __future__ import annotations import io import os import struct +from typing import IO, Tuple, cast from . import Image, ImageFile, ImagePalette, _binary @@ -58,7 +59,7 @@ class BoxReader: self.remaining_in_box -= num_bytes return data - def read_fields(self, field_format): + def read_fields(self, field_format: str) -> tuple[int | bytes, ...]: size = struct.calcsize(field_format) data = self._read_bytes(size) return struct.unpack(field_format, data) @@ -81,9 +82,9 @@ class BoxReader: self.remaining_in_box = -1 # Read the length and type of the next box - lbox, tbox = self.read_fields(">I4s") + lbox, tbox = cast(Tuple[int, bytes], self.read_fields(">I4s")) if lbox == 1: - lbox = self.read_fields(">Q")[0] + lbox = cast(int, self.read_fields(">Q")[0]) hlen = 16 else: hlen = 8 @@ -121,17 +122,18 @@ def _parse_codestream(fp): elif csiz == 4: mode = "RGBA" else: - mode = None + mode = "" return size, mode -def _res_to_dpi(num, denom, exp): +def _res_to_dpi(num: int, denom: int, exp: int) -> float | None: """Convert JPEG2000's (numerator, denominator, exponent-base-10) resolution, calculated as (num / denom) * 10^exp and stored in dots per meter, to floating-point dots per inch.""" - if denom != 0: - return (254 * num * (10**exp)) / (10000 * denom) + if denom == 0: + return None + return (254 * num * (10**exp)) / (10000 * denom) def _parse_jp2_header(fp): @@ -235,7 +237,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): msg = "not a JPEG 2000 file" raise SyntaxError(msg) - if self.size is None or self.mode is None: + if self.size is None or not self.mode: msg = "unable to determine size/mode" raise SyntaxError(msg) @@ -328,11 +330,13 @@ def _accept(prefix: bytes) -> bool: # Save support -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Get the keyword arguments info = im.encoderinfo - if filename.endswith(".j2k") or info.get("no_jp2", False): + if isinstance(filename, str): + filename = filename.encode() + if filename.endswith(b".j2k") or info.get("no_jp2", False): kind = "j2k" else: kind = "jp2" diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 0e0b2823b..7a32f1cde 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -42,7 +42,7 @@ import subprocess import sys import tempfile import warnings -from typing import Any +from typing import IO, Any from . import Image, ImageFile from ._binary import i16be as i16 @@ -428,7 +428,7 @@ class JpegImageFile(ImageFile.ImageFile): return s def draft( - self, mode: str, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] ) -> tuple[str, tuple[int, int, float, float]] | None: if len(self.tile) != 1: return None @@ -631,7 +631,7 @@ def get_sampling(im): return samplings.get(sampling, -1) -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.width == 0 or im.height == 0: msg = "cannot write empty image as JPEG" raise ValueError(msg) @@ -814,7 +814,7 @@ def _save(im, fp, filename): ImageFile._save(im, fp, [("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize) -def _save_cjpeg(im, fp, filename): +def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # ALTERNATIVE: handle JPEGs via the IJG command line utilities. tempfile = im._dump() subprocess.check_call(["cjpeg", "-outfile", filename, tempfile]) diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index 5aef94dfb..07239887f 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -63,7 +63,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): msg = "not an MIC file; no image entries" raise SyntaxError(msg) - self.frame = None + self.frame = -1 self._n_frames = len(self.images) self.is_animated = self._n_frames > 1 @@ -85,7 +85,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self.frame = frame - def tell(self): + def tell(self) -> int: return self.frame def close(self) -> None: @@ -93,7 +93,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self.ole.close() super().close() - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.__fp.close() self.ole.close() super().__exit__() diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 766e1290c..f21570661 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -22,6 +22,7 @@ from __future__ import annotations import itertools import os import struct +from typing import IO from . import ( Image, @@ -32,23 +33,18 @@ from . import ( from ._binary import o32le -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: JpegImagePlugin._save(im, fp, filename) -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: append_images = im.encoderinfo.get("append_images", []) - if not append_images: - try: - animated = im.is_animated - except AttributeError: - animated = False - if not animated: - _save(im, fp, filename) - return + if not append_images and not getattr(im, "is_animated", False): + _save(im, fp, filename) + return mpf_offset = 28 - offsets = [] + offsets: list[int] = [] for imSequence in itertools.chain([im], append_images): for im_frame in ImageSequence.Iterator(imSequence): if not offsets: diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index 65cc70624..0a75c868b 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -164,7 +164,7 @@ Image.register_decoder("MSP", MspDecoder) # write MSP files (uncompressed only) -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "1": msg = f"cannot write mode {im.mode} as MSP" raise OSError(msg) diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index 4e2b9788e..673eae1d1 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -138,7 +138,7 @@ class PSDraw: sx = x / im.size[0] sy = y / im.size[1] self.fp.write(b"%f %f scale\n" % (sx, sy)) - EpsImagePlugin._save(im, self.fp, None, 0) + EpsImagePlugin._save(im, self.fp, "", 0) self.fp.write(b"\ngrestore\n") diff --git a/src/PIL/PaletteFile.py b/src/PIL/PaletteFile.py index eaed5367c..81652e5ee 100644 --- a/src/PIL/PaletteFile.py +++ b/src/PIL/PaletteFile.py @@ -14,6 +14,8 @@ # from __future__ import annotations +from typing import IO + from ._binary import o8 @@ -22,8 +24,8 @@ class PaletteFile: rawmode = "RGB" - def __init__(self, fp): - self.palette = [(i, i, i) for i in range(256)] + def __init__(self, fp: IO[bytes]) -> None: + palette = [o8(i) * 3 for i in range(256)] while True: s = fp.readline() @@ -44,9 +46,9 @@ class PaletteFile: g = b = r if 0 <= i <= 255: - self.palette[i] = o8(r) + o8(g) + o8(b) + palette[i] = o8(r) + o8(g) + o8(b) - self.palette = b"".join(self.palette) + self.palette = b"".join(palette) def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 85f9fe1bf..1735070f8 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -8,6 +8,8 @@ ## from __future__ import annotations +from typing import IO + from . import Image, ImageFile from ._binary import o8 from ._binary import o16be as o16b @@ -82,10 +84,10 @@ _Palm8BitColormapValues = ( # so build a prototype image to be used for palette resampling -def build_prototype_image(): +def build_prototype_image() -> Image.Image: image = Image.new("L", (1, len(_Palm8BitColormapValues))) image.putdata(list(range(len(_Palm8BitColormapValues)))) - palettedata = () + palettedata: tuple[int, ...] = () for colormapValue in _Palm8BitColormapValues: palettedata += colormapValue palettedata += (0, 0, 0) * (256 - len(_Palm8BitColormapValues)) @@ -112,7 +114,7 @@ _COMPRESSION_TYPES = {"none": 0xFF, "rle": 0x01, "scanline": 0x00} # (Internal) Image save plugin for the Palm format. -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "P": # we assume this is a color Palm image with the standard colormap, # unless the "info" dict has a "custom-colormap" field @@ -127,21 +129,22 @@ def _save(im, fp, filename): # and invert it because # Palm does grayscale from white (0) to black (1) bpp = im.encoderinfo["bpp"] - im = im.point( - lambda x, shift=8 - bpp, maxval=(1 << bpp) - 1: maxval - (x >> shift) - ) + maxval = (1 << bpp) - 1 + shift = 8 - bpp + im = im.point(lambda x: maxval - (x >> shift)) elif im.info.get("bpp") in (1, 2, 4): # here we assume that even though the inherent mode is 8-bit grayscale, # only the lower bpp bits are significant. # We invert them to match the Palm. bpp = im.info["bpp"] - im = im.point(lambda x, maxval=(1 << bpp) - 1: maxval - (x & maxval)) + maxval = (1 << bpp) - 1 + im = im.point(lambda x: maxval - (x & maxval)) else: msg = f"cannot write mode {im.mode} as Palm" raise OSError(msg) # we ignore the palette here - im.mode = "P" + im._mode = "P" rawmode = f"P;{bpp}" version = 1 diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 026bfd9a0..dd42003b5 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -144,7 +144,7 @@ SAVE = { } -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: version, bits, planes, rawmode = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 1777f1f20..f0da1e047 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -25,6 +25,7 @@ import io import math import os import time +from typing import IO from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features @@ -39,7 +40,7 @@ from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features # 5. page contents -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index a6c24e671..9e2231347 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -76,7 +76,7 @@ class PdfFormatError(RuntimeError): pass -def check_format_condition(condition, error_message): +def check_format_condition(condition: bool, error_message: str) -> None: if not condition: raise PdfFormatError(error_message) @@ -93,12 +93,11 @@ class IndirectReference(IndirectReferenceTuple): def __bytes__(self) -> bytes: return self.__str__().encode("us-ascii") - def __eq__(self, other): - return ( - other.__class__ is self.__class__ - and other.object_id == self.object_id - and other.generation == self.generation - ) + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, IndirectReference) + return other.object_id == self.object_id and other.generation == self.generation def __ne__(self, other): return not (self == other) @@ -405,9 +404,8 @@ class PdfParser: def __enter__(self) -> PdfParser: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *args: object) -> None: self.close() - return False # do not suppress exceptions def start_writing(self) -> None: self.close_buf() diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 28d1a295e..a2ca9399a 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -39,7 +39,7 @@ import struct import warnings import zlib from enum import IntEnum -from typing import IO, Any +from typing import IO, TYPE_CHECKING, Any, NoReturn from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from ._binary import i16be as i16 @@ -48,6 +48,9 @@ from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 +if TYPE_CHECKING: + from . import _imaging + logger = logging.getLogger(__name__) is_cid = re.compile(rb"\w\w\w\w").match @@ -178,7 +181,7 @@ class ChunkStream: def __enter__(self) -> ChunkStream: return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() def close(self) -> None: @@ -249,6 +252,9 @@ class iTXt(str): """ + lang: str | bytes | None + tkey: str | bytes | None + @staticmethod def __new__(cls, text, lang=None, tkey=None): """ @@ -270,10 +276,10 @@ class PngInfo: """ - def __init__(self): - self.chunks = [] + def __init__(self) -> None: + self.chunks: list[tuple[bytes, bytes, bool]] = [] - def add(self, cid, data, after_idat=False): + def add(self, cid: bytes, data: bytes, after_idat: bool = False) -> None: """Appends an arbitrary chunk. Use with caution. :param cid: a byte string, 4 bytes long. @@ -283,12 +289,16 @@ class PngInfo: """ - chunk = [cid, data] - if after_idat: - chunk.append(True) - self.chunks.append(tuple(chunk)) + self.chunks.append((cid, data, after_idat)) - def add_itxt(self, key, value, lang="", tkey="", zip=False): + def add_itxt( + self, + key: str | bytes, + value: str | bytes, + lang: str | bytes = "", + tkey: str | bytes = "", + zip: bool = False, + ) -> None: """Appends an iTXt chunk. :param key: latin-1 encodable text key name @@ -316,7 +326,9 @@ class PngInfo: else: self.add(b"iTXt", key + b"\0\0\0" + lang + b"\0" + tkey + b"\0" + value) - def add_text(self, key, value, zip=False): + def add_text( + self, key: str | bytes, value: str | bytes | iTXt, zip: bool = False + ) -> None: """Appends a text chunk. :param key: latin-1 encodable text key name @@ -326,7 +338,13 @@ class PngInfo: """ if isinstance(value, iTXt): - return self.add_itxt(key, value, value.lang, value.tkey, zip=zip) + return self.add_itxt( + key, + value, + value.lang if value.lang is not None else b"", + value.tkey if value.tkey is not None else b"", + zip=zip, + ) # The tEXt chunk stores latin-1 text if not isinstance(value, bytes): @@ -434,7 +452,7 @@ class PngStream(ChunkStream): raise SyntaxError(msg) return s - def chunk_IDAT(self, pos, length): + def chunk_IDAT(self, pos: int, length: int) -> NoReturn: # image data if "bbox" in self.im_info: tile = [("zip", self.im_info["bbox"], pos, self.im_rawmode)] @@ -447,7 +465,7 @@ class PngStream(ChunkStream): msg = "image data found" raise EOFError(msg) - def chunk_IEND(self, pos, length): + def chunk_IEND(self, pos: int, length: int) -> NoReturn: msg = "end of PNG image" raise EOFError(msg) @@ -823,7 +841,10 @@ class PngImageFile(ImageFile.ImageFile): msg = "no more images in APNG file" raise EOFError(msg) from e - def _seek(self, frame, rewind=False): + def _seek(self, frame: int, rewind: bool = False) -> None: + assert self.png is not None + + self.dispose: _imaging.ImagingCore | None if frame == 0: if rewind: self._fp.seek(self.__rewind) @@ -908,14 +929,14 @@ class PngImageFile(ImageFile.ImageFile): if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: self.dispose_op = Disposal.OP_BACKGROUND + self.dispose = None if self.dispose_op == Disposal.OP_PREVIOUS: - self.dispose = self._prev_im.copy() - self.dispose = self._crop(self.dispose, self.dispose_extent) + if self._prev_im: + self.dispose = self._prev_im.copy() + self.dispose = self._crop(self.dispose, self.dispose_extent) elif self.dispose_op == Disposal.OP_BACKGROUND: self.dispose = Image.core.fill(self.mode, self.size) self.dispose = self._crop(self.dispose, self.dispose_extent) - else: - self.dispose = None def tell(self) -> int: return self.__frame @@ -1028,7 +1049,7 @@ class PngImageFile(ImageFile.ImageFile): return None return self.getexif()._get_merged_dict() - def getexif(self): + def getexif(self) -> Image.Exif: if "exif" not in self.info: self.load() @@ -1223,7 +1244,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) seq_num = fdat_chunks.seq_num -def _save_all(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) @@ -1335,7 +1356,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk(fp, cid, data) elif cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if not after_idat: chunk(fp, cid, data) @@ -1414,7 +1435,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): cid, data = info_chunk[:2] if cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if after_idat: chunk(fp, cid, data) diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 94bf430b8..16c9ccbba 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -328,7 +328,7 @@ class PpmDecoder(ImageFile.PyDecoder): # -------------------------------------------------------------------- -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "1": rawmode, head = "1;I", b"P4" elif im.mode == "L": diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index f2cf06d0d..202ef52d0 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -37,6 +37,8 @@ class QoiImageFile(ImageFile.ImageFile): class QoiDecoder(ImageFile.PyDecoder): _pulls_fd = True + _previous_pixel: bytes | bytearray | None = None + _previously_seen_pixels: dict[int, bytes | bytearray] = {} def _add_to_previous_pixels(self, value: bytes | bytearray) -> None: self._previous_pixel = value @@ -45,9 +47,10 @@ class QoiDecoder(ImageFile.PyDecoder): hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 self._previously_seen_pixels[hash_value] = value - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None + self._previously_seen_pixels = {} - self._previous_pixel = None self._add_to_previous_pixels(bytearray((0, 0, 0, 255))) data = bytearray() @@ -55,7 +58,8 @@ class QoiDecoder(ImageFile.PyDecoder): dest_length = self.state.xsize * self.state.ysize * bands while len(data) < dest_length: byte = self.fd.read(1)[0] - if byte == 0b11111110: # QOI_OP_RGB + value: bytes | bytearray + if byte == 0b11111110 and self._previous_pixel: # QOI_OP_RGB value = bytearray(self.fd.read(3)) + self._previous_pixel[3:] elif byte == 0b11111111: # QOI_OP_RGBA value = self.fd.read(4) @@ -66,7 +70,7 @@ class QoiDecoder(ImageFile.PyDecoder): value = self._previously_seen_pixels.get( op_index, bytearray((0, 0, 0, 0)) ) - elif op == 1: # QOI_OP_DIFF + elif op == 1 and self._previous_pixel: # QOI_OP_DIFF value = bytearray( ( (self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2) @@ -77,7 +81,7 @@ class QoiDecoder(ImageFile.PyDecoder): self._previous_pixel[3], ) ) - elif op == 2: # QOI_OP_LUMA + elif op == 2 and self._previous_pixel: # QOI_OP_LUMA second_byte = self.fd.read(1)[0] diff_green = (byte & 0b00111111) - 32 diff_red = ((second_byte & 0b11110000) >> 4) - 8 @@ -90,7 +94,7 @@ class QoiDecoder(ImageFile.PyDecoder): ) ) value += self._previous_pixel[3:] - elif op == 3: # QOI_OP_RUN + elif op == 3 and self._previous_pixel: # QOI_OP_RUN run_length = (byte & 0b00111111) + 1 value = self._previous_pixel if bands == 3: diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index 7bd84ebd4..50d979109 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -125,7 +125,7 @@ class SgiImageFile(ImageFile.ImageFile): ] -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode not in {"RGB", "RGBA", "L"}: msg = "Unsupported SGI image mode" raise ValueError(msg) @@ -171,8 +171,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: # Maximum Byte value (255 = 8bits per pixel) pinmax = 255 # Image name (79 characters max, truncated below in write) - filename = os.path.basename(filename) - img_name = os.path.splitext(filename)[0].encode("ascii", "ignore") + img_name = os.path.splitext(os.path.basename(filename))[0] + if isinstance(img_name, str): + img_name = img_name.encode("ascii", "ignore") # Standard representation of pixel in the file colormap = 0 fp.write(struct.pack(">h", magic_number)) diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index e5242395f..a6cc00019 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -37,7 +37,7 @@ from __future__ import annotations import os import struct import sys -from typing import TYPE_CHECKING +from typing import IO, TYPE_CHECKING from . import Image, ImageFile @@ -263,7 +263,7 @@ def makeSpiderHeader(im: Image.Image) -> list[bytes]: return [struct.pack("f", v) for v in hdr] -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode[0] != "F": im = im.convert("F") @@ -279,9 +279,10 @@ def _save(im, fp, filename): ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) -def _save_spider(im, fp, filename): +def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # get the filename extension and register it with Image - ext = os.path.splitext(filename)[1] + filename_ext = os.path.splitext(filename)[1] + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext Image.register_extension(SpiderImageFile.format, ext) _save(im, fp, filename) diff --git a/src/PIL/TarIO.py b/src/PIL/TarIO.py index 7470663b4..cba26d4b0 100644 --- a/src/PIL/TarIO.py +++ b/src/PIL/TarIO.py @@ -16,7 +16,6 @@ from __future__ import annotations import io -from types import TracebackType from . import ContainerIO @@ -61,12 +60,7 @@ class TarIO(ContainerIO.ContainerIO[bytes]): def __enter__(self) -> TarIO: return self - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: + def __exit__(self, *args: object) -> None: self.close() def close(self) -> None: diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 401a83f9f..f16f075df 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -178,7 +178,7 @@ SAVE = { } -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: rawmode, bits, colormaptype, imagetype = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 7ba71b58e..b8e486929 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -50,7 +50,7 @@ import warnings from collections.abc import MutableMapping from fractions import Fraction from numbers import Number, Rational -from typing import TYPE_CHECKING, Any, Callable +from typing import IO, TYPE_CHECKING, Any, Callable, NoReturn from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 @@ -384,10 +384,10 @@ class IFDRational(Rational): def __repr__(self) -> str: return str(float(self._val)) - def __hash__(self): + def __hash__(self) -> int: return self._val.__hash__() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: val = self._val if isinstance(other, IFDRational): other = other._val @@ -551,7 +551,12 @@ class ImageFileDirectory_v2(_IFDv2Base): _load_dispatch: dict[int, Callable[[ImageFileDirectory_v2, bytes, bool], Any]] = {} _write_dispatch: dict[int, Callable[..., Any]] = {} - def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None, group=None): + def __init__( + self, + ifh: bytes = b"II\052\0\0\0\0\0", + prefix: bytes | None = None, + group: int | None = None, + ) -> None: """Initialize an ImageFileDirectory. To construct an ImageFileDirectory from a real file, pass the 8-byte @@ -575,7 +580,7 @@ class ImageFileDirectory_v2(_IFDv2Base): raise SyntaxError(msg) self._bigtiff = ifh[2] == 43 self.group = group - self.tagtype = {} + self.tagtype: dict[int, int] = {} """ Dictionary of tag types """ self.reset() (self.next,) = ( @@ -587,18 +592,18 @@ class ImageFileDirectory_v2(_IFDv2Base): offset = property(lambda self: self._offset) @property - def legacy_api(self): + def legacy_api(self) -> bool: return self._legacy_api @legacy_api.setter - def legacy_api(self, value): + def legacy_api(self, value: bool) -> NoReturn: msg = "Not allowing setting of legacy api" raise Exception(msg) - def reset(self): - self._tags_v1 = {} # will remain empty if legacy_api is false - self._tags_v2 = {} # main tag storage - self._tagdata = {} + def reset(self) -> None: + self._tags_v1: dict[int, Any] = {} # will remain empty if legacy_api is false + self._tags_v2: dict[int, Any] = {} # main tag storage + self._tagdata: dict[int, bytes] = {} self.tagtype = {} # added 2008-06-05 by Florian Hoech self._next = None self._offset = None @@ -717,7 +722,7 @@ class ImageFileDirectory_v2(_IFDv2Base): # Unspec'd, and length > 1 dest[tag] = values - def __delitem__(self, tag): + def __delitem__(self, tag: int) -> None: self._tags_v2.pop(tag, None) self._tags_v1.pop(tag, None) self._tagdata.pop(tag, None) @@ -1106,7 +1111,7 @@ class TiffImageFile(ImageFile.ImageFile): super().__init__(fp, filename) - def _open(self): + def _open(self) -> None: """Open the first image in a TIFF file""" # Header @@ -1123,8 +1128,8 @@ class TiffImageFile(ImageFile.ImageFile): self.__first = self.__next = self.tag_v2.next self.__frame = -1 self._fp = self.fp - self._frame_pos = [] - self._n_frames = None + self._frame_pos: list[int] = [] + self._n_frames: int | None = None logger.debug("*** TiffImageFile._open ***") logger.debug("- __first: %s", self.__first) @@ -1990,13 +1995,12 @@ class AppendingTiffWriter: self.finalize() self.setup() - def __enter__(self): + def __enter__(self) -> AppendingTiffWriter: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *args: object) -> None: if self.close_fp: self.close() - return False def tell(self) -> int: return self.f.tell() - self.offsetOfNewPage @@ -2018,7 +2022,7 @@ class AppendingTiffWriter: self.f.write(bytes(pad_bytes)) self.offsetOfNewPage = self.f.tell() - def setEndian(self, endian): + def setEndian(self, endian: str) -> None: self.endian = endian self.longFmt = f"{self.endian}L" self.shortFmt = f"{self.endian}H" @@ -2035,45 +2039,45 @@ class AppendingTiffWriter: num_tags = self.readShort() self.f.seek(num_tags * 12, os.SEEK_CUR) - def write(self, data): + def write(self, data: bytes) -> int | None: return self.f.write(data) - def readShort(self): + def readShort(self) -> int: (value,) = struct.unpack(self.shortFmt, self.f.read(2)) return value - def readLong(self): + def readLong(self) -> int: (value,) = struct.unpack(self.longFmt, self.f.read(4)) return value - def rewriteLastShortToLong(self, value): + 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) - def rewriteLastShort(self, value): + 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) - def rewriteLastLong(self, value): + 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) - def writeShort(self, value): + 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) - def writeLong(self, value): + 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" @@ -2092,9 +2096,9 @@ class AppendingTiffWriter: field_size = self.fieldSizes[field_type] total_size = field_size * count is_local = total_size <= 4 + offset: int | None if not is_local: - offset = self.readLong() - offset += self.offsetOfNewPage + offset = self.readLong() + self.offsetOfNewPage self.rewriteLastLong(offset) if tag in self.Tags: @@ -2118,7 +2122,9 @@ class AppendingTiffWriter: # skip the locally stored value that is not an offset self.f.seek(4, os.SEEK_CUR) - def fixOffsets(self, count, isShort=False, isLong=False): + def fixOffsets( + self, count: int, isShort: bool = False, isLong: bool = False + ) -> None: if not isShort and not isLong: msg = "offset is neither short nor long" raise RuntimeError(msg) @@ -2144,7 +2150,7 @@ class AppendingTiffWriter: self.rewriteLastLong(offset) -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() encoderconfig = im.encoderconfig append_images = list(encoderinfo.get("append_images", [])) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 29ce13eb7..59be5bf9d 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -1,7 +1,7 @@ from __future__ import annotations from io import BytesIO -from typing import Any +from typing import IO, Any from . import Image, ImageFile @@ -173,7 +173,7 @@ class WebPImageFile(ImageFile.ImageFile): return self.__logical_frame -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() append_images = list(encoderinfo.get("append_images", [])) @@ -186,7 +186,7 @@ def _save_all(im, fp, filename): _save(im, fp, filename) return - background = (0, 0, 0, 0) + background: int | tuple[int, ...] = (0, 0, 0, 0) if "background" in encoderinfo: background = encoderinfo["background"] elif "background" in im.info: @@ -316,7 +316,7 @@ def _save_all(im, fp, filename): fp.write(data) -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) alpha_quality = im.encoderinfo.get("alpha_quality", 100) diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index fab3e26c5..3d5cddcc8 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -20,6 +20,8 @@ # http://wvware.sourceforge.net/caolan/ora-wmf.html from __future__ import annotations +from typing import IO + from . import Image, ImageFile from ._binary import i16le as word from ._binary import si16le as short @@ -28,7 +30,7 @@ from ._binary import si32le as _long _handler = None -def register_handler(handler: ImageFile.StubHandler) -> None: +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific WMF image handler. @@ -161,7 +163,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): return super().load() -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): msg = "WMF save handler not installed" raise OSError(msg) diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index eee727436..6d11bbfcf 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -70,7 +70,7 @@ class XbmImageFile(ImageFile.ImageFile): self.tile = [("xbm", (0, 0) + self.size, m.end(), None)] -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "1": msg = f"cannot write mode {im.mode} as XBM" raise OSError(msg) diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index e27843e53..dda06b5a1 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -1,3 +1,22 @@ from typing import Any +class ImagingCore: + def __getattr__(self, name: str) -> Any: ... + +class ImagingFont: + def __getattr__(self, name: str) -> Any: ... + +class ImagingDraw: + def __getattr__(self, name: str) -> Any: ... + +class PixelAccess: + def __getattr__(self, name: str) -> Any: ... + +class ImagingDecoder: + def __getattr__(self, name: str) -> Any: ... + +class ImagingEncoder: + def __getattr__(self, name: str) -> Any: ... + +def font(image: ImagingCore, glyphdata: bytes) -> ImagingFont: ... def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index e27843e53..6e0ddd2f1 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -1,3 +1,69 @@ -from typing import Any +from typing import Any, TypedDict +from . import _imaging + +class _Axis(TypedDict): + minimum: int | None + default: int | None + maximum: int | None + name: bytes | None + +class Font: + @property + def family(self) -> str | None: ... + @property + def style(self) -> str | None: ... + @property + def ascent(self) -> int: ... + @property + def descent(self) -> int: ... + @property + def height(self) -> int: ... + @property + def x_ppem(self) -> int: ... + @property + def y_ppem(self) -> int: ... + @property + def glyphs(self) -> int: ... + def render( + self, + string: str, + fill, + mode=..., + dir=..., + features=..., + lang=..., + stroke_width=..., + anchor=..., + foreground_ink_long=..., + x_start=..., + y_start=..., + /, + ) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ... + def getsize( + self, + string: str | bytes | bytearray, + mode=..., + dir=..., + features=..., + lang=..., + anchor=..., + /, + ) -> tuple[tuple[int, int], tuple[int, int]]: ... + def getlength( + self, string: str, mode=..., dir=..., features=..., lang=..., / + ) -> float: ... + def getvarnames(self) -> list[bytes]: ... + def getvaraxes(self) -> list[_Axis] | None: ... + def setvarname(self, instance_index: int, /) -> None: ... + def setvaraxes(self, axes: list[float], /) -> None: ... + +def getfont( + filename: str | bytes, + size: float, + index=..., + encoding=..., + font_bytes=..., + layout_engine=..., +) -> Font: ... def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/features.py b/src/PIL/features.py index 16c749f14..13908c4eb 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -4,6 +4,7 @@ import collections import os import sys import warnings +from typing import IO import PIL @@ -223,7 +224,7 @@ def get_supported() -> list[str]: return ret -def pilinfo(out=None, supported_formats=True): +def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: """ Prints information about this installation of Pillow. This function can be called with ``python3 -m PIL``. @@ -244,9 +245,9 @@ def pilinfo(out=None, supported_formats=True): print("-" * 68, file=out) print(f"Pillow {PIL.__version__}", file=out) - py_version = sys.version.splitlines() - print(f"Python {py_version[0].strip()}", file=out) - for py_version in py_version[1:]: + py_version_lines = sys.version.splitlines() + print(f"Python {py_version_lines[0].strip()}", file=out) + for py_version in py_version_lines[1:]: print(f" {py_version.strip()}", file=out) print("-" * 68, file=out) print(f"Python executable is {sys.executable or 'unknown'}", file=out) @@ -282,9 +283,12 @@ def pilinfo(out=None, supported_formats=True): ("xcb", "XCB (X protocol)"), ]: if check(name): - if name == "jpg" and check_feature("libjpeg_turbo"): - v = "libjpeg-turbo " + version_feature("libjpeg_turbo") - else: + v: str | None = None + if name == "jpg": + libjpeg_turbo_version = version_feature("libjpeg_turbo") + if libjpeg_turbo_version is not None: + v = "libjpeg-turbo " + libjpeg_turbo_version + if v is None: v = version(name) if v is not None: version_static = name in ("pil", "jpg") diff --git a/src/_imaging.c b/src/_imaging.c index c565c21bb..f398c6c7c 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2028,7 +2028,7 @@ im_setmode(ImagingObject *self, PyObject *args) { } static PyObject * -_transform2(ImagingObject *self, PyObject *args) { +_transform(ImagingObject *self, PyObject *args) { static const char *wrong_number = "wrong number of matrix entries"; Imaging imOut; @@ -3647,7 +3647,7 @@ static struct PyMethodDef methods[] = { {"resize", (PyCFunction)_resize, METH_VARARGS}, {"reduce", (PyCFunction)_reduce, METH_VARARGS}, {"transpose", (PyCFunction)_transpose, METH_VARARGS}, - {"transform2", (PyCFunction)_transform2, METH_VARARGS}, + {"transform", (PyCFunction)_transform, METH_VARARGS}, {"isblock", (PyCFunction)_isblock, METH_NOARGS}, diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0d6da7754..9837589b2 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -112,12 +112,12 @@ ARCHITECTURES = { V = { "BROTLI": "1.1.0", "FREETYPE": "2.13.2", - "FRIBIDI": "1.0.13", - "HARFBUZZ": "8.4.0", - "JPEGTURBO": "3.0.2", + "FRIBIDI": "1.0.15", + "HARFBUZZ": "8.5.0", + "JPEGTURBO": "3.0.3", "LCMS2": "2.16", "LIBPNG": "1.6.43", - "LIBWEBP": "1.3.2", + "LIBWEBP": "1.4.0", "OPENJPEG": "2.5.2", "TIFF": "4.6.0", "XZ": "5.4.5",