diff --git a/.appveyor.yml b/.appveyor.yml index 6ce5200b6..e12987a5f 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -21,9 +21,9 @@ environment: - PYTHON: C:/Python312 ARCHITECTURE: x86 APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 - - PYTHON: C:/Python38-x64 + - PYTHON: C:/Python39-x64 ARCHITECTURE: AMD64 - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 + APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 install: @@ -38,7 +38,7 @@ install: - 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\ + c:\python39\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ c:\pillow\winbuild\build\build_dep_all.cmd $host.SetShouldExit(0) - path C:\pillow\winbuild\build\bin;%PATH% diff --git a/.ci/install.sh b/.ci/install.sh index 30b64349d..1eb098be9 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -28,8 +28,6 @@ fi python3 -m pip install --upgrade pip python3 -m pip install --upgrade wheel -# TODO Update condition when cffi supports 3.13 -if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then PYTHONOPTIMIZE=0 python3 -m pip install cffi ; fi python3 -m pip install coverage python3 -m pip install defusedxml python3 -m pip install olefile @@ -39,8 +37,7 @@ python3 -m pip install -U pytest-timeout python3 -m pip install pyroma if [[ $(uname) != CYGWIN* ]]; then - # TODO Update condition when NumPy supports 3.13 - if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then python3 -m pip install numpy ; fi + python3 -m pip install numpy # PyQt6 doesn't support PyPy3 if [[ $GHA_PYTHON_VERSION == 3.* ]]; then @@ -51,7 +48,6 @@ if [[ $(uname) != CYGWIN* ]]; then # Pyroma uses non-isolated build and fails with old setuptools if [[ $GHA_PYTHON_VERSION == pypy3.9 - || $GHA_PYTHON_VERSION == 3.8 || $GHA_PYTHON_VERSION == 3.9 ]]; then # To match pyproject.toml diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 0d0f81fbf..a2bf2a7b0 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.19.1 +cibuildwheel==2.19.2 diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index a0dcb92d2..6dd432488 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1 +1 @@ -mypy==1.10.0 +mypy==1.10.1 diff --git a/.coveragerc b/.coveragerc index 018cc1cbf..a94a25678 100644 --- a/.coveragerc +++ b/.coveragerc @@ -19,6 +19,5 @@ exclude_also = [run] omit = Tests/32bit_segfault_check.py - Tests/bench_cffi_access.py Tests/check_*.py Tests/createfontdatachunk.py diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index f8f191d38..d35cfcd31 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -18,9 +18,6 @@ else fi export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" -# TODO Update condition when cffi supports 3.13 -if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then PYTHONOPTIMIZE=0 python3 -m pip install cffi ; fi - python3 -m pip install coverage python3 -m pip install defusedxml python3 -m pip install olefile @@ -28,9 +25,7 @@ python3 -m pip install -U pytest python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-timeout python3 -m pip install pyroma - -# TODO Update condition when NumPy supports 3.13 -if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then python3 -m pip install numpy ; fi +python3 -m pip install numpy # extra test images pushd depends && ./install_extra_test_images.sh && popd diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 1269ef8cb..8e2827099 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - python-minor-version: [8, 9] + python-minor-version: [9] timeout-minutes: 40 @@ -72,7 +72,6 @@ jobs: make netpbm perl - python3${{ matrix.python-minor-version }}-cffi python3${{ matrix.python-minor-version }}-cython python3${{ matrix.python-minor-version }}-devel python3${{ matrix.python-minor-version }}-numpy diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 6afed74db..21e1275e7 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -44,13 +44,11 @@ jobs: amazon-2023-amd64, arch, centos-stream-9-amd64, - debian-11-bullseye-amd64, debian-12-bookworm-x86, debian-12-bookworm-amd64, fedora-39-amd64, fedora-40-amd64, gentoo, - ubuntu-20.04-focal-amd64, ubuntu-22.04-jammy-amd64, ubuntu-24.04-noble-amd64, ] diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index a773ca453..e5e1ec32e 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -64,7 +64,6 @@ jobs: mingw-w64-x86_64-libtiff \ mingw-w64-x86_64-libwebp \ mingw-w64-x86_64-openjpeg2 \ - mingw-w64-x86_64-python3-cffi \ mingw-w64-x86_64-python3-numpy \ mingw-w64-x86_64-python3-olefile \ mingw-w64-x86_64-python3-setuptools \ diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index ee265774b..5b34d6703 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3.10", "pypy3.9", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["pypy3.10", "pypy3.9", "3.9", "3.10", "3.11", "3.12", "3.13"] timeout-minutes: 30 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa5646caf..0972459b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,6 @@ jobs: "3.11", "3.10", "3.9", - "3.8", ] include: - python-version: "3.11" @@ -59,13 +58,9 @@ jobs: # M1 only available for 3.10+ - os: "macos-13" python-version: "3.9" - - os: "macos-13" - python-version: "3.8" exclude: - os: "macos-14" python-version: "3.9" - - os: "macos-14" - python-version: "3.8" runs-on: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/.github/workflows/wheels-test.sh b/.github/workflows/wheels-test.sh index a3376ac92..3fbf3be69 100755 --- a/.github/workflows/wheels-test.sh +++ b/.github/workflows/wheels-test.sh @@ -12,7 +12,7 @@ elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then else yum install -y fribidi fi -if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ] && !([[ "$OSTYPE" == "darwin"* ]] && [[ $(python3 --version) == *"3.13."* ]]); then +if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then python3 -m pip install numpy fi diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3d6099c1c..f8dff3a3e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -41,12 +41,8 @@ jobs: python-version: - pp39 - pp310 - - cp38 - - cp39 - - cp310 - - cp311 - - cp312 - - cp313 + - cp3{9,10,11} + - cp3{12,13} spec: - manylinux2014 - manylinux_2_28 @@ -136,8 +132,6 @@ jobs: CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_PRERELEASE_PYTHONS: True - CIBW_SKIP: pp38-* - CIBW_TEST_SKIP: cp38-macosx_arm64 MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} - uses: actions/upload-artifact@v4 @@ -208,7 +202,6 @@ jobs: CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_CACHE_PATH: "C:\\cibw" CIBW_PRERELEASE_PYTHONS: True - CIBW_SKIP: pp38-* CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_COMMAND: 'docker run --rm -v {project}:C:\pillow diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6a76e8c00..659409de4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.7 + rev: v0.5.0 hooks: - id: ruff args: [--exit-non-zero-on-fix] @@ -11,7 +11,7 @@ repos: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.7.8 + rev: 1.7.9 hooks: - id: bandit args: [--severity-level=high] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v18.1.5 + rev: v18.1.8 hooks: - id: clang-format types: [c] @@ -50,7 +50,7 @@ repos: exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.4 + rev: 0.28.6 hooks: - id: check-github-workflows - id: check-readthedocs @@ -62,7 +62,7 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: 1.8.0 + rev: 2.1.3 hooks: - id: pyproject-fmt diff --git a/CHANGES.rst b/CHANGES.rst index d7231ebea..856458aa9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,9 +2,99 @@ Changelog (Pillow) ================== -10.4.0 (unreleased) +11.0.0 (unreleased) ------------------- +- Drop support for Python 3.8 #8183 + [hugovk, radarhere] + +- Add support for Python 3.13 #8181 + [hugovk, radarhere] + +- Fix incompatibility with NumPy 1.20 #8187 + [neutrinoceros, radarhere] + +- Remove PSFile, PyAccess and USE_CFFI_ACCESS #8182 + [hugovk, radarhere] + +10.4.0 (2024-07-01) +------------------- + +- Raise FileNotFoundError if show_file() path does not exist #8178 + [radarhere] + +- Improved reading 16-bit TGA images with colour #7965 + [Yay295, radarhere] + +- Deprecate non-image ImageCms modes #8031 + [radarhere] + +- Fixed processing multiple JPEG EXIF markers #8127 + [radarhere] + +- Do not preserve EXIFIFD tag by default when saving TIFF images #8110 + [radarhere] + +- Added ImageFont.load_default_imagefont() #8086 + [radarhere] + +- Added Image.WARN_POSSIBLE_FORMATS #8063 + [radarhere] + +- Remove zero-byte end padding when parsing any XMP data #8171 + [radarhere] + +- Do not detect Ultra HDR images as MPO #8056 + [radarhere] + +- Raise SyntaxError specific to JP2 #8146 + [Yay295, radarhere] + +- Do not use first frame duration for other frames when saving APNG images #8104 + [radarhere] + +- Consider I;16 pixel size when using a 1 mode mask #8112 + [radarhere] + +- When saving multiple PNG frames, convert to mode rather than raw mode #8087 + [radarhere] + +- Added byte support to FreeTypeFont #8141 + [radarhere] + +- Allow float center for rotate operations #8114 + [radarhere] + +- Do not read layers immediately when opening PSD images #8039 + [radarhere] + +- Restore original thread state #8065 + [radarhere] + +- Read IM and TIFF images as RGB, rather than RGBX #7997 + [radarhere] + +- Only preserve TIFF IPTC_NAA_CHUNK tag if type is BYTE or UNDEFINED #7948 + [radarhere] + +- Clarify ImageDraw2 error message when size is missing #8165 + [radarhere] + +- Support unpacking more rawmodes to RGBA palettes #7966 + [radarhere] + +- Removed support for Qt 5 #8159 + [radarhere] + +- Improve ``ImageFont.freetype`` support for XDG directories on Linux #8135 + [mamg22, radarhere] + +- Improved consistency of XMP handling #8069 + [radarhere] + +- Use pkg-config to help find libwebp and raqm #8142 + [radarhere] + - Accept 't' suffix for libtiff version #8126, #8129 [radarhere] diff --git a/Tests/bench_cffi_access.py b/Tests/bench_cffi_access.py deleted file mode 100644 index c7d105836..000000000 --- a/Tests/bench_cffi_access.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import time - -from PIL import PyAccess - -from .helper import hopper - -# Not running this test by default. No DOS against CI. - - -def iterate_get(size, access) -> None: - (w, h) = size - for x in range(w): - for y in range(h): - access[(x, y)] - - -def iterate_set(size, access) -> None: - (w, h) = size - for x in range(w): - for y in range(h): - access[(x, y)] = (x % 256, y % 256, 0) - - -def timer(func, label, *args) -> None: - iterations = 5000 - starttime = time.time() - for x in range(iterations): - func(*args) - if time.time() - starttime > 10: - break - endtime = time.time() - print( - f"{label}: completed {x + 1} iterations in {endtime - starttime:.4f}s, " - f"{(endtime - starttime) / (x + 1.0):.6f}s per iteration" - ) - - -def test_direct() -> None: - im = hopper() - im.load() - # im = Image.new("RGB", (2000, 2000), (1, 3, 2)) - 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}") - timer(iterate_get, "PyAccess - get", im.size, access) - timer(iterate_set, "PyAccess - set", im.size, access) - timer(iterate_get, "C-api - get", im.size, caccess) - timer(iterate_set, "C-api - set", im.size, caccess) diff --git a/Tests/helper.py b/Tests/helper.py index fe337c09f..d6a93a803 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -11,14 +11,15 @@ import subprocess import sys import sysconfig import tempfile +from collections.abc import Sequence from functools import lru_cache from io import BytesIO -from typing import Any, Callable, Sequence +from typing import Any, Callable import pytest from packaging.version import parse as parse_version -from PIL import Image, ImageMath, features +from PIL import Image, ImageFile, ImageMath, features logger = logging.getLogger(__name__) @@ -59,9 +60,7 @@ def convert_to_comparable( return new_a, new_b -def assert_deep_equal( - a: Sequence[Any], b: Sequence[Any], msg: str | None = None -) -> None: +def assert_deep_equal(a: Any, b: Any, msg: str | None = None) -> None: try: assert len(a) == len(b), msg or f"got length {len(a)}, expected {len(b)}" except Exception: @@ -240,7 +239,7 @@ class PillowLeakTestCase: # helpers -def fromstring(data: bytes) -> Image.Image: +def fromstring(data: bytes) -> ImageFile.ImageFile: return Image.open(BytesIO(data)) diff --git a/Tests/images/imagedraw_polygon_width_I.tiff b/Tests/images/imagedraw_polygon_width_I.tiff new file mode 100644 index 000000000..ce1917d61 Binary files /dev/null and b/Tests/images/imagedraw_polygon_width_I.tiff differ diff --git a/Tests/images/multiple_exif.jpg b/Tests/images/multiple_exif.jpg index 32e0aa301..f9f343507 100644 Binary files a/Tests/images/multiple_exif.jpg and b/Tests/images/multiple_exif.jpg differ diff --git a/Tests/images/p_16.png b/Tests/images/p_16.png index e35886412..458f7138e 100644 Binary files a/Tests/images/p_16.png and b/Tests/images/p_16.png differ diff --git a/Tests/images/rgba16.tga b/Tests/images/rgba16.tga new file mode 100644 index 000000000..3918647a2 Binary files /dev/null and b/Tests/images/rgba16.tga differ diff --git a/Tests/images/ultrahdr.jpg b/Tests/images/ultrahdr.jpg new file mode 100644 index 000000000..34f615b61 Binary files /dev/null and b/Tests/images/ultrahdr.jpg differ diff --git a/Tests/images/unknown_mode.j2k b/Tests/images/unknown_mode.j2k new file mode 100644 index 000000000..38719fe25 Binary files /dev/null and b/Tests/images/unknown_mode.j2k differ diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index c8886a779..0d9c0b419 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -321,6 +321,7 @@ class TestColorLut3DCoreAPI: -1, 2, 2, 2, 2, 2, ])).load() # fmt: on + assert transformed is not None assert transformed[0, 0] == (0, 0, 255) assert transformed[50, 50] == (0, 0, 255) assert transformed[255, 0] == (0, 255, 255) @@ -341,6 +342,7 @@ class TestColorLut3DCoreAPI: -3, 5, 5, 5, 5, 5, ])).load() # fmt: on + assert transformed is not None assert transformed[0, 0] == (0, 0, 255) assert transformed[50, 50] == (0, 0, 255) assert transformed[255, 0] == (0, 255, 255) @@ -354,10 +356,10 @@ class TestColorLut3DCoreAPI: class TestColorLut3DFilter: def test_wrong_args(self) -> None: with pytest.raises(ValueError, match="should be either an integer"): - ImageFilter.Color3DLUT("small", [1]) + ImageFilter.Color3DLUT("small", [1]) # type: ignore[arg-type] with pytest.raises(ValueError, match="should be either an integer"): - ImageFilter.Color3DLUT((11, 11), [1]) + ImageFilter.Color3DLUT((11, 11), [1]) # type: ignore[arg-type] with pytest.raises(ValueError, match=r"in \[2, 65\] range"): ImageFilter.Color3DLUT((11, 11, 1), [1]) diff --git a/Tests/test_deprecate.py b/Tests/test_deprecate.py index 584d8f91d..82ff14181 100644 --- a/Tests/test_deprecate.py +++ b/Tests/test_deprecate.py @@ -9,9 +9,9 @@ from PIL import _deprecate "version, expected", [ ( - 11, - "Old thing is deprecated and will be removed in Pillow 11 " - r"\(2024-10-15\)\. Use new thing instead\.", + 12, + "Old thing is deprecated and will be removed in Pillow 12 " + r"\(2025-10-15\)\. Use new thing instead\.", ), ( None, @@ -54,18 +54,18 @@ def test_old_version(deprecated: str, plural: bool, expected: str) -> None: def test_plural() -> None: expected = ( - r"Old things are deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " + r"Old things are deprecated and will be removed in Pillow 12 \(2025-10-15\)\. " r"Use new thing instead\." ) with pytest.warns(DeprecationWarning, match=expected): - _deprecate.deprecate("Old things", 11, "new thing", plural=True) + _deprecate.deprecate("Old things", 12, "new thing", plural=True) def test_replacement_and_action() -> None: expected = "Use only one of 'replacement' and 'action'" with pytest.raises(ValueError, match=expected): _deprecate.deprecate( - "Old thing", 11, replacement="new thing", action="Upgrade to new thing" + "Old thing", 12, replacement="new thing", action="Upgrade to new thing" ) @@ -78,16 +78,16 @@ def test_replacement_and_action() -> None: ) def test_action(action: str) -> None: expected = ( - r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " + r"Old thing is deprecated and will be removed in Pillow 12 \(2025-10-15\)\. " r"Upgrade to new thing\." ) with pytest.warns(DeprecationWarning, match=expected): - _deprecate.deprecate("Old thing", 11, action=action) + _deprecate.deprecate("Old thing", 12, action=action) def test_no_replacement_or_action() -> None: expected = ( - r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)" + r"Old thing is deprecated and will be removed in Pillow 12 \(2025-10-15\)" ) with pytest.warns(DeprecationWarning, match=expected): - _deprecate.deprecate("Old thing", 11) + _deprecate.deprecate("Old thing", 12) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 1b393a3ff..e95850212 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -706,10 +706,21 @@ def test_different_modes_in_later_frames( assert reloaded.mode == mode -def test_apng_repeated_seeks_give_correct_info() -> None: +def test_different_durations(tmp_path: Path) -> None: + test_file = str(tmp_path / "temp.png") + with Image.open("Tests/images/apng/different_durations.png") as im: - for i in range(3): + for _ in range(3): im.seek(0) assert im.info["duration"] == 4000 + im.seek(1) assert im.info["duration"] == 1000 + + im.save(test_file, save_all=True) + + with Image.open(test_file) as reloaded: + assert reloaded.info["duration"] == 4000 + + reloaded.seek(1) + assert reloaded.info["duration"] == 1000 diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 939e82e77..77ee5b0ea 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -64,6 +64,9 @@ def test_handler(tmp_path: Path) -> None: im.fp.close() return Image.new("RGB", (1, 1)) + def is_loaded(self) -> bool: + return self.loaded + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True @@ -71,10 +74,10 @@ def test_handler(tmp_path: Path) -> None: BufrStubImagePlugin.register_handler(handler) with Image.open(TEST_FILE) as im: assert handler.opened - assert not handler.loaded + assert not handler.is_loaded() im.load() - assert handler.loaded + assert handler.is_loaded() temp_file = str(tmp_path / "temp.bufr") im.save(temp_file) diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 1c21aa8ca..b54238132 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -329,46 +329,6 @@ def test_read_binary_preview() -> None: pass -def test_readline_psfile(tmp_path: Path) -> None: - # check all the freaking line endings possible from the spec - # test_string = u'something\r\nelse\n\rbaz\rbif\n' - line_endings = ["\r\n", "\n", "\n\r", "\r"] - strings = ["something", "else", "baz", "bif"] - - def _test_readline(t: EpsImagePlugin.PSFile, ending: str) -> None: - ending = f"Failure with line ending: {''.join(str(ord(s)) for s in ending)}" - assert t.readline().strip("\r\n") == "something", ending - assert t.readline().strip("\r\n") == "else", ending - assert t.readline().strip("\r\n") == "baz", ending - assert t.readline().strip("\r\n") == "bif", ending - - def _test_readline_io_psfile(test_string: str, ending: str) -> None: - f = io.BytesIO(test_string.encode("latin-1")) - with pytest.warns(DeprecationWarning): - t = EpsImagePlugin.PSFile(f) - _test_readline(t, ending) - - def _test_readline_file_psfile(test_string: str, ending: str) -> None: - f = str(tmp_path / "temp.txt") - with open(f, "wb") as w: - w.write(test_string.encode("latin-1")) - - with open(f, "rb") as r: - with pytest.warns(DeprecationWarning): - t = EpsImagePlugin.PSFile(r) - _test_readline(t, ending) - - for ending in line_endings: - s = ending.join(strings) - _test_readline_io_psfile(s, ending) - _test_readline_file_psfile(s, ending) - - -def test_psfile_deprecation() -> None: - with pytest.warns(DeprecationWarning): - EpsImagePlugin.PSFile(None) - - @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) @pytest.mark.parametrize( "line_ending", @@ -425,9 +385,10 @@ def test_timeout(test_file: str) -> None: def test_bounding_box_in_trailer() -> None: # Check bounding boxes are parsed in the same way # when specified in the header and the trailer - with Image.open("Tests/images/zero_bb_trailer.eps") as trailer_image, Image.open( - FILE1 - ) as header_image: + with ( + Image.open("Tests/images/zero_bb_trailer.eps") as trailer_image, + Image.open(FILE1) as header_image, + ): assert trailer_image.size == header_image.size diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index e19c88a47..85b017d29 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1,9 +1,9 @@ from __future__ import annotations import warnings +from collections.abc import Generator from io import BytesIO from pathlib import Path -from typing import Generator import pytest @@ -353,7 +353,7 @@ def test_palette_434(tmp_path: Path) -> None: def roundtrip(im: Image.Image, **kwargs: bool) -> Image.Image: out = str(tmp_path / "temp.gif") - im.copy().save(out, **kwargs) + im.copy().save(out, "GIF", **kwargs) reloaded = Image.open(out) return reloaded diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index 86a9064fc..aba473d24 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -64,6 +64,9 @@ def test_handler(tmp_path: Path) -> None: im.fp.close() return Image.new("RGB", (1, 1)) + def is_loaded(self) -> bool: + return self.loaded + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True @@ -71,10 +74,10 @@ def test_handler(tmp_path: Path) -> None: GribStubImagePlugin.register_handler(handler) with Image.open(TEST_FILE) as im: assert handler.opened - assert not handler.loaded + assert not handler.is_loaded() im.load() - assert handler.loaded + assert handler.is_loaded() temp_file = str(tmp_path / "temp.grib") im.save(temp_file) diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index ee1544c51..8275bd0d8 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -66,6 +66,9 @@ def test_handler(tmp_path: Path) -> None: im.fp.close() return Image.new("RGB", (1, 1)) + def is_loaded(self) -> bool: + return self.loaded + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True @@ -73,10 +76,10 @@ def test_handler(tmp_path: Path) -> None: Hdf5StubImagePlugin.register_handler(handler) with Image.open(TEST_FILE) as im: assert handler.opened - assert not handler.loaded + assert not handler.is_loaded() im.load() - assert handler.loaded + assert handler.is_loaded() temp_file = str(tmp_path / "temp.h5") im.save(temp_file) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 1459a87eb..68705094b 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -872,7 +872,7 @@ class TestFileJpeg: def test_multiple_exif(self) -> None: with Image.open("Tests/images/multiple_exif.jpg") as im: - assert im.info["exif"] == b"Exif\x00\x00firstsecond" + assert im.getexif()[270] == "firstsecond" @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" @@ -948,6 +948,7 @@ class TestFileJpeg: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] @@ -1032,8 +1033,10 @@ class TestFileJpeg: def test_repr_jpeg(self) -> None: im = hopper() + b = im._repr_jpeg_() + assert b is not None - with Image.open(BytesIO(im._repr_jpeg_())) as repr_jpeg: + with Image.open(BytesIO(b)) as repr_jpeg: assert repr_jpeg.format == "JPEG" assert_image_similar(im, repr_jpeg, 17) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index ed7ea4fcf..a5cfa7c6c 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -335,9 +335,15 @@ def test_issue_6194() -> None: assert im.getpixel((5, 5)) == 31 +def test_unknown_j2k_mode() -> None: + with pytest.raises(UnidentifiedImageError): + with Image.open("Tests/images/unknown_mode.j2k"): + pass + + def test_unbound_local() -> None: # prepatch, a malformed jp2 file could cause an UnboundLocalError exception. - with pytest.raises(OSError): + with pytest.raises(UnidentifiedImageError): with Image.open("Tests/images/unbound_variable.jp2"): pass diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index fe9d017c0..d5dbeeb6f 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -92,11 +92,22 @@ class TestFileLibTiff(LibTiffTestCase): def test_g4_non_disk_file_object(self, tmp_path: Path) -> None: """Testing loading from non-disk non-BytesIO file object""" test_file = "Tests/images/hopper_g4_500.tif" - s = io.BytesIO() with open(test_file, "rb") as f: - s.write(f.read()) - s.seek(0) - r = io.BufferedReader(s) + data = f.read() + + class NonBytesIO(io.RawIOBase): + def read(self, size: int = -1) -> bytes: + nonlocal data + if size == -1: + size = len(data) + result = data[:size] + data = data[size:] + return result + + def readable(self) -> bool: + return True + + r = io.BufferedReader(NonBytesIO()) with Image.open(r) as im: assert im.size == (500, 500) self._assert_noerr(tmp_path, im) @@ -685,13 +696,18 @@ class TestFileLibTiff(LibTiffTestCase): assert reloaded.tag_v2[530] == (1, 1) assert reloaded.tag_v2[532] == (0, 255, 128, 255, 128, 255) - def test_exif_ifd(self, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.tif") + def test_exif_ifd(self) -> None: + out = io.BytesIO() with Image.open("Tests/images/tiff_adobe_deflate.tif") as im: assert im.tag_v2[34665] == 125456 - im.save(outfile) + im.save(out, "TIFF") - with Image.open(outfile) as reloaded: + with Image.open(out) as reloaded: + assert 34665 not in reloaded.tag_v2 + + im.save(out, "TIFF", tiffinfo={34665: 125456}) + + with Image.open(out) as reloaded: if Image.core.libtiff_support_custom_tags: assert reloaded.tag_v2[34665] == 125456 @@ -1043,7 +1059,11 @@ class TestFileLibTiff(LibTiffTestCase): ], ) def test_wrong_bits_per_sample( - self, file_name: str, mode: str, size: tuple[int, int], tile + self, + file_name: str, + mode: str, + size: tuple[int, int], + tile: list[tuple[str, tuple[int, int, int, int], int, tuple[Any, ...]]], ) -> None: with Image.open("Tests/images/" + file_name) as im: assert im.mode == mode @@ -1130,7 +1150,7 @@ class TestFileLibTiff(LibTiffTestCase): arguments: dict[str, str | int] = {"compression": "tiff_adobe_deflate"} if argument: arguments["strip_size"] = 2**18 - im.save(out, **arguments) + im.save(out, "TIFF", **arguments) with Image.open(out) as im: assert isinstance(im, TiffImagePlugin.TiffImageFile) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index a50188700..39b9c60b7 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -226,6 +226,11 @@ def test_eoferror() -> None: im.seek(n_frames - 1) +def test_ultra_hdr() -> None: + with Image.open("Tests/images/ultrahdr.jpg") as im: + assert im.format == "JPEG" + + @pytest.mark.parametrize("test_file", test_files) def test_image_grab(test_file: str) -> None: with Image.open(test_file) as im: diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index ab9f9663e..b3f38c3e5 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -76,6 +76,7 @@ def test_pil184() -> None: def test_1px_width(tmp_path: Path) -> None: im = Image.new("L", (1, 256)) px = im.load() + assert px is not None for y in range(256): px[0, y] = y _roundtrip(tmp_path, im) @@ -84,6 +85,7 @@ def test_1px_width(tmp_path: Path) -> None: def test_large_count(tmp_path: Path) -> None: im = Image.new("L", (256, 1)) px = im.load() + assert px is not None for x in range(256): px[x, 0] = x // 67 * 67 _roundtrip(tmp_path, im) @@ -101,6 +103,7 @@ def _test_buffer_overflow(tmp_path: Path, im: Image.Image, size: int = 1024) -> def test_break_in_count_overflow(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() + assert px is not None for y in range(4): for x in range(256): px[x, y] = x % 128 @@ -110,6 +113,7 @@ def test_break_in_count_overflow(tmp_path: Path) -> None: def test_break_one_in_loop(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() + assert px is not None for y in range(5): for x in range(256): px[x, y] = x % 128 @@ -119,6 +123,7 @@ def test_break_one_in_loop(tmp_path: Path) -> None: def test_break_many_in_loop(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() + assert px is not None for y in range(4): for x in range(256): px[x, y] = x % 128 @@ -130,6 +135,7 @@ def test_break_many_in_loop(tmp_path: Path) -> None: def test_break_one_at_end(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() + assert px is not None for y in range(5): for x in range(256): px[x, y] = x % 128 @@ -140,6 +146,7 @@ def test_break_one_at_end(tmp_path: Path) -> None: def test_break_many_at_end(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() + assert px is not None for y in range(5): for x in range(256): px[x, y] = x % 128 @@ -152,6 +159,7 @@ def test_break_many_at_end(tmp_path: Path) -> None: def test_break_padding(tmp_path: Path) -> None: im = Image.new("L", (257, 5)) px = im.load() + assert px is not None for y in range(5): for x in range(257): px[x, y] = x % 128 diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index d39a86565..3729ca58b 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -5,8 +5,9 @@ import os import os.path import tempfile import time +from collections.abc import Generator from pathlib import Path -from typing import Any, Generator +from typing import Any import pytest @@ -117,7 +118,7 @@ def test_dpi(params: dict[str, int | tuple[int, int]], tmp_path: Path) -> None: im = hopper() outfile = str(tmp_path / "temp.pdf") - im.save(outfile, **params) + im.save(outfile, "PDF", **params) with open(outfile, "rb") as fp: contents = fp.read() diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index c7c9f6fab..dfe8f9e99 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -535,8 +535,10 @@ class TestFilePng: def test_repr_png(self) -> None: im = hopper() + b = im._repr_png_() + assert b is not None - with Image.open(BytesIO(im._repr_png_())) as repr_png: + with Image.open(BytesIO(b)) as repr_png: assert repr_png.format == "PNG" assert_image_equal(im, repr_png) @@ -655,11 +657,12 @@ class TestFilePng: png.call(cid, 0, 0) ImageFile.LOAD_TRUNCATED_IMAGES = False - def test_specify_bits(self, tmp_path: Path) -> None: + @pytest.mark.parametrize("save_all", (True, False)) + def test_specify_bits(self, save_all: bool, tmp_path: Path) -> None: im = hopper("P") out = str(tmp_path / "temp.png") - im.save(out, bits=4) + im.save(out, bits=4, save_all=save_all) with Image.open(out) as reloaded: assert len(reloaded.png.im_palette[1]) == 48 @@ -683,6 +686,7 @@ class TestFilePng: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] @@ -767,16 +771,12 @@ class TestFilePng: def test_save_stdout(self, buffer: bool) -> None: old_stdout = sys.stdout - if buffer: + class MyStdOut: + buffer = BytesIO() - class MyStdOut: - buffer = BytesIO() + mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() - mystdout = MyStdOut() - else: - mystdout = BytesIO() - - sys.stdout = mystdout + sys.stdout = mystdout # type: ignore[assignment] with Image.open(TEST_PNG_FILE) as im: im.save(sys.stdout, "PNG") @@ -784,7 +784,7 @@ class TestFilePng: # Reset stdout sys.stdout = old_stdout - if buffer: + if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 1bfd0434e..d6451ec18 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -368,16 +368,12 @@ def test_mimetypes(tmp_path: Path) -> None: def test_save_stdout(buffer: bool) -> None: old_stdout = sys.stdout - if buffer: + class MyStdOut: + buffer = BytesIO() - class MyStdOut: - buffer = BytesIO() + mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() - mystdout = MyStdOut() - else: - mystdout = BytesIO() - - sys.stdout = mystdout + sys.stdout = mystdout # type: ignore[assignment] with Image.open(TEST_FILE) as im: im.save(sys.stdout, "PPM") @@ -385,7 +381,7 @@ def test_save_stdout(buffer: bool) -> None: # Reset stdout sys.stdout = old_stdout - if buffer: + if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_FILE) diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 484a1be8f..e6c79e40b 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -4,7 +4,7 @@ import warnings import pytest -from PIL import Image, PsdImagePlugin, UnidentifiedImageError +from PIL import Image, PsdImagePlugin from .helper import assert_image_equal_tofile, assert_image_similar, hopper, is_pypy @@ -150,20 +150,26 @@ def test_combined_larger_than_size() -> None: @pytest.mark.parametrize( "test_file,raises", [ - ( - "Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd", - UnidentifiedImageError, - ), - ( - "Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd", - UnidentifiedImageError, - ), ("Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd", OSError), ("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError), ], ) -def test_crashes(test_file: str, raises) -> None: +def test_crashes(test_file: str, raises: type[Exception]) -> None: with open(test_file, "rb") as f: with pytest.raises(raises): with Image.open(f): pass + + +@pytest.mark.parametrize( + "test_file", + [ + "Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd", + "Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd", + ], +) +def test_layer_crashes(test_file: str) -> None: + with open(test_file, "rb") as f: + with Image.open(f) as im: + with pytest.raises(SyntaxError): + im.layers diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 9b82a962a..66c88e9d8 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -105,6 +105,7 @@ def test_load_image_series() -> None: img_list = SpiderImagePlugin.loadImageSeries(file_list) # Assert + assert img_list is not None assert len(img_list) == 1 assert isinstance(img_list[0], Image.Image) assert img_list[0].size == (128, 128) diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index ff6dab00d..a03a6a6e1 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -72,12 +72,21 @@ def test_palette_depth_8(tmp_path: Path) -> None: def test_palette_depth_16(tmp_path: Path) -> None: with Image.open("Tests/images/p_16.tga") as im: - assert_image_equal_tofile(im.convert("RGB"), "Tests/images/p_16.png") + assert im.palette.mode == "RGBA" + assert_image_equal_tofile(im.convert("RGBA"), "Tests/images/p_16.png") out = str(tmp_path / "temp.png") im.save(out) with Image.open(out) as reloaded: - assert_image_equal_tofile(reloaded.convert("RGB"), "Tests/images/p_16.png") + assert_image_equal_tofile(reloaded.convert("RGBA"), "Tests/images/p_16.png") + + +def test_rgba_16() -> None: + with Image.open("Tests/images/rgba16.tga") as im: + assert im.mode == "RGBA" + + assert im.getpixel((0, 0)) == (172, 0, 255, 255) + assert im.getpixel((1, 0)) == (0, 255, 82, 0) def test_id_field() -> None: diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 06591a29a..8cad25272 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -2,10 +2,10 @@ from __future__ import annotations import os import warnings +from collections.abc import Generator from io import BytesIO from pathlib import Path from types import ModuleType -from typing import Generator import pytest @@ -78,6 +78,7 @@ class TestFileTiff: def test_seek_after_close(self) -> None: im = Image.open("Tests/images/multipage.tiff") + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.close() with pytest.raises(ValueError): @@ -120,7 +121,7 @@ class TestFileTiff: def test_set_legacy_api(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() with pytest.raises(Exception) as e: - ifd.legacy_api = None + ifd.legacy_api = False assert str(e.value) == "Not allowing setting of legacy api" def test_xyres_tiff(self) -> None: @@ -424,13 +425,13 @@ class TestFileTiff: def test_load_float(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() data = b"abcdabcd" - ret = ifd.load_float(data, False) + ret = getattr(ifd, "load_float")(data, False) assert ret == (1.6777999408082104e22, 1.6777999408082104e22) def test_load_double(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() data = b"abcdefghabcdefgh" - ret = ifd.load_double(data, False) + ret = getattr(ifd, "load_double")(data, False) assert ret == (8.540883223036124e194, 8.540883223036124e194) def test_ifd_tag_type(self) -> None: @@ -599,7 +600,7 @@ class TestFileTiff: def test_with_underscores(self, tmp_path: Path) -> None: kwargs = {"resolution_unit": "inch", "x_resolution": 72, "y_resolution": 36} filename = str(tmp_path / "temp.tif") - hopper("RGB").save(filename, **kwargs) + hopper("RGB").save(filename, "TIFF", **kwargs) with Image.open(filename) as im: # legacy interface assert im.tag[X_RESOLUTION][0][0] == 72 @@ -621,6 +622,22 @@ class TestFileTiff: assert_image_equal_tofile(im, tmpfile) + def test_iptc(self, tmp_path: Path) -> None: + # Do not preserve IPTC_NAA_CHUNK by default if type is LONG + outfile = str(tmp_path / "temp.tif") + with Image.open("Tests/images/hopper.tif") as im: + im.load() + assert isinstance(im, TiffImagePlugin.TiffImageFile) + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[33723] = 1 + ifd.tagtype[33723] = 4 + im.tag_v2 = ifd + im.save(outfile) + + with Image.open(outfile) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + assert 33723 not in im.tag_v2 + def test_rowsperstrip(self, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") im = hopper() @@ -759,6 +776,7 @@ class TestFileTiff: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index 8b816aa4f..1e0310001 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -11,7 +11,11 @@ from PIL.TiffImagePlugin import IFDRational from .helper import assert_deep_equal, hopper -TAG_IDS = {info.name: info.value for info in TiffTags.TAGS_V2.values()} +TAG_IDS: dict[str, int] = { + info.name: info.value + for info in TiffTags.TAGS_V2.values() + if info.value is not None +} def test_rt_metadata(tmp_path: Path) -> None: @@ -411,8 +415,8 @@ def test_empty_values() -> None: info = TiffImagePlugin.ImageFileDirectory_v2(head) info.load(data) # Should not raise ValueError. - info = dict(info) - assert 33432 in info + info_dict = dict(info) + assert 33432 in info_dict def test_photoshop_info(tmp_path: Path) -> None: diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 1caf032f6..cbc905de4 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -5,6 +5,7 @@ import re import sys import warnings from pathlib import Path +from typing import Any import pytest @@ -70,7 +71,9 @@ class TestFileWebp: # dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm assert_image_similar_tofile(image, "Tests/images/hopper_webp_bits.ppm", 1.0) - def _roundtrip(self, tmp_path: Path, mode, epsilon, args={}) -> None: + def _roundtrip( + self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {} + ) -> None: temp_file = str(tmp_path / "temp.webp") hopper(mode).save(temp_file, **args) diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index 882dccb32..e0d7999e3 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Generator from pathlib import Path import pytest @@ -96,7 +97,9 @@ def test_write_animation_RGB(tmp_path: Path) -> None: check(temp_file1) # Tests appending using a generator - def im_generator(ims): + def im_generator( + ims: list[Image.Image], + ) -> Generator[Image.Image, None, None]: yield from ims temp_file2 = str(tmp_path / "temp_generator.webp") diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index 875941240..c3df4ad7b 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -129,6 +129,7 @@ def test_getxmp() -> None: ): assert im.getxmp() == {} else: + assert "xmp" in im.info assert ( im.getxmp()["xmpmeta"]["xmptk"] == "Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 " diff --git a/Tests/test_font_leaks.py b/Tests/test_font_leaks.py index 3fb92a62e..ab8a7f9ec 100644 --- a/Tests/test_font_leaks.py +++ b/Tests/test_font_leaks.py @@ -34,7 +34,7 @@ class TestDefaultFontLeak(TestTTypeFontLeak): def test_leak(self) -> None: if features.check_module("freetype2"): - ImageFont.core = _util.DeferredError(ImportError) + ImageFont.core = _util.DeferredError(ImportError("Disabled for testing")) try: default_font = ImageFont.load_default() finally: diff --git a/Tests/test_image.py b/Tests/test_image.py index 3ad4d1305..dde7a8d8b 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -8,7 +8,8 @@ import sys import tempfile import warnings from pathlib import Path -from typing import IO +from types import ModuleType +from typing import IO, Any import pytest @@ -35,6 +36,12 @@ from .helper import ( skip_unless_feature, ) +ElementTree: ModuleType | None +try: + from defusedxml import ElementTree +except ImportError: + ElementTree = None + # Deprecation helper def helper_image_new(mode: str, size: tuple[int, int]) -> Image.Image: @@ -129,6 +136,15 @@ class TestImage: assert im.mode == "RGB" assert im.size == (128, 128) + def test_open_verbose_failure(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(Image, "WARN_POSSIBLE_FORMATS", True) + + im = io.BytesIO(b"") + with pytest.warns(UserWarning): + with pytest.raises(UnidentifiedImageError): + with Image.open(im): + pass + def test_width_height(self) -> None: im = Image.new("RGB", (1, 2)) assert im.width == 1 @@ -179,11 +195,19 @@ class TestImage: def test_fp_name(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.jpg") - class FP: + class FP(io.BytesIO): name: str - def write(self, b: bytes) -> None: - pass + if sys.version_info >= (3, 12): + from collections.abc import Buffer + + def write(self, data: Buffer) -> int: + return len(data) + + else: + + def write(self, data: Any) -> int: + return len(data) fp = FP() fp.name = temp_file @@ -352,8 +376,9 @@ class TestImage: img = Image.alpha_composite(dst, src) # Assert - img_colors = sorted(img.getcolors()) - assert img_colors == expected_colors + img_colors = img.getcolors() + assert img_colors is not None + assert sorted(img_colors) == expected_colors def test_alpha_inplace(self) -> None: src = Image.new("RGBA", (128, 128), "blue") @@ -397,13 +422,13 @@ class TestImage: # errors with pytest.raises(ValueError): - source.alpha_composite(over, "invalid source") + source.alpha_composite(over, "invalid destination") # type: ignore[arg-type] with pytest.raises(ValueError): - source.alpha_composite(over, (0, 0), "invalid destination") + source.alpha_composite(over, (0, 0), "invalid source") # type: ignore[arg-type] with pytest.raises(ValueError): - source.alpha_composite(over, 0) + source.alpha_composite(over, 0) # type: ignore[arg-type] with pytest.raises(ValueError): - source.alpha_composite(over, (0, 0), 0) + source.alpha_composite(over, (0, 0), 0) # type: ignore[arg-type] with pytest.raises(ValueError): source.alpha_composite(over, (0, 0), (0, -1)) @@ -555,6 +580,7 @@ class TestImage: for mode in ("I", "F", "L"): im = Image.new(mode, (100, 100), (5,)) px = im.load() + assert px is not None assert px[0, 0] == 5 def test_linear_gradient_wrong_mode(self) -> None: @@ -649,7 +675,9 @@ class TestImage: im_remapped = im.remap_palette([1, 0]) assert im_remapped.info["transparency"] == 1 - assert len(im_remapped.getpalette()) == 6 + palette = im_remapped.getpalette() + assert palette is not None + assert len(palette) == 6 # Test unused transparency im.info["transparency"] = 2 @@ -680,7 +708,7 @@ class TestImage: else: assert new_image.palette is None - _make_new(im, im_p, ImagePalette.ImagePalette(list(range(256)) * 3)) + _make_new(im, im_p, ImagePalette.ImagePalette("RGB")) _make_new(im_p, im, None) _make_new(im, blank_p, ImagePalette.ImagePalette()) _make_new(im, blank_pa, ImagePalette.ImagePalette()) @@ -913,6 +941,25 @@ class TestImage: assert tag not in exif.get_ifd(0x8769) assert exif.get_ifd(0xA005) + def test_empty_xmp(self) -> None: + with Image.open("Tests/images/hopper.gif") as im: + assert im.getxmp() == {} + + def test_getxmp_padded(self) -> None: + im = Image.new("RGB", (1, 1)) + im.info["xmp"] = ( + b'\n' + b'\n\x00\x00' + ) + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + assert im.getxmp() == {} + else: + assert im.getxmp() == {"xmpmeta": None} + @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) def test_zero_tobytes(self, size: tuple[int, int]) -> None: im = Image.new("RGB", size) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 8abb1f69f..854c79dae 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -12,19 +12,6 @@ from PIL import Image from .helper import assert_image_equal, hopper, is_win32 -# CFFI imports pycparser which doesn't support PYTHONOPTIMIZE=2 -# https://github.com/eliben/pycparser/pull/198#issuecomment-317001670 -cffi: ModuleType | None -if os.environ.get("PYTHONOPTIMIZE") == "2": - cffi = None -else: - try: - import cffi - - from PIL import PyAccess - except ImportError: - cffi = None - numpy: ModuleType | None try: import numpy @@ -32,21 +19,7 @@ except ImportError: numpy = None -class AccessTest: - # Initial value - _init_cffi_access = Image.USE_CFFI_ACCESS - _need_cffi_access = False - - @classmethod - def setup_class(cls) -> None: - Image.USE_CFFI_ACCESS = cls._need_cffi_access - - @classmethod - def teardown_class(cls) -> None: - Image.USE_CFFI_ACCESS = cls._init_cffi_access - - -class TestImagePutPixel(AccessTest): +class TestImagePutPixel: def test_sanity(self) -> None: im1 = hopper() im2 = Image.new(im1.mode, im1.size, 0) @@ -54,7 +27,9 @@ class TestImagePutPixel(AccessTest): for y in range(im1.size[1]): for x in range(im1.size[0]): pos = x, y - im2.putpixel(pos, im1.getpixel(pos)) + value = im1.getpixel(pos) + assert value is not None + im2.putpixel(pos, value) assert_image_equal(im1, im2) @@ -64,7 +39,9 @@ class TestImagePutPixel(AccessTest): for y in range(im1.size[1]): for x in range(im1.size[0]): pos = x, y - im2.putpixel(pos, im1.getpixel(pos)) + value = im1.getpixel(pos) + assert value is not None + im2.putpixel(pos, value) assert not im2.readonly assert_image_equal(im1, im2) @@ -74,10 +51,12 @@ class TestImagePutPixel(AccessTest): pix1 = im1.load() pix2 = im2.load() + assert pix1 is not None + assert pix2 is not None with pytest.raises(TypeError): - pix1[0, "0"] + pix1[0, "0"] # type: ignore[index] with pytest.raises(TypeError): - pix1["0", 0] + pix1["0", 0] # type: ignore[index] for y in range(im1.size[1]): for x in range(im1.size[0]): @@ -96,7 +75,9 @@ class TestImagePutPixel(AccessTest): for y in range(-1, -im1.size[1] - 1, -1): for x in range(-1, -im1.size[0] - 1, -1): pos = x, y - im2.putpixel(pos, im1.getpixel(pos)) + value = im1.getpixel(pos) + assert value is not None + im2.putpixel(pos, value) assert_image_equal(im1, im2) @@ -106,7 +87,9 @@ class TestImagePutPixel(AccessTest): for y in range(-1, -im1.size[1] - 1, -1): for x in range(-1, -im1.size[0] - 1, -1): pos = x, y - im2.putpixel(pos, im1.getpixel(pos)) + value = im1.getpixel(pos) + assert value is not None + im2.putpixel(pos, value) assert not im2.readonly assert_image_equal(im1, im2) @@ -116,6 +99,8 @@ class TestImagePutPixel(AccessTest): pix1 = im1.load() pix2 = im2.load() + assert pix1 is not None + assert pix2 is not None for y in range(-1, -im1.size[1] - 1, -1): for x in range(-1, -im1.size[0] - 1, -1): pix2[x, y] = pix1[x, y] @@ -125,13 +110,14 @@ class TestImagePutPixel(AccessTest): @pytest.mark.skipif(numpy is None, reason="NumPy not installed") def test_numpy(self) -> None: im = hopper() - pix = im.load() + px = im.load() + assert px is not None assert numpy is not None - assert pix[numpy.int32(1), numpy.int32(2)] == (18, 20, 59) + assert px[numpy.int32(1), numpy.int32(2)] == (18, 20, 59) -class TestImageGetPixel(AccessTest): +class TestImageGetPixel: @staticmethod def color(mode: str) -> int | tuple[int, ...]: bands = Image.getmodebands(mode) @@ -144,9 +130,6 @@ class TestImageGetPixel(AccessTest): return tuple(range(1, bands + 1)) def check(self, mode: str, expected_color_int: int | None = None) -> None: - if self._need_cffi_access and mode.startswith("BGR;"): - pytest.skip("Support not added to deprecated module for BGR;* modes") - expected_color = ( self.color(mode) if expected_color_int is None else expected_color_int ) @@ -171,15 +154,14 @@ class TestImageGetPixel(AccessTest): # Check 0x0 image with None initial color im = Image.new(mode, (0, 0), None) assert im.load() is not None - error = ValueError if self._need_cffi_access else IndexError - with pytest.raises(error): + with pytest.raises(IndexError): im.putpixel((0, 0), expected_color) - with pytest.raises(error): + with pytest.raises(IndexError): im.getpixel((0, 0)) # Check negative index - with pytest.raises(error): + with pytest.raises(IndexError): im.putpixel((-1, -1), expected_color) - with pytest.raises(error): + with pytest.raises(IndexError): im.getpixel((-1, -1)) # Check initial color @@ -199,10 +181,10 @@ class TestImageGetPixel(AccessTest): # Check 0x0 image with initial color im = Image.new(mode, (0, 0), expected_color) - with pytest.raises(error): + with pytest.raises(IndexError): im.getpixel((0, 0)) # Check negative index - with pytest.raises(error): + with pytest.raises(IndexError): im.getpixel((-1, -1)) @pytest.mark.parametrize("mode", Image.MODES) @@ -235,126 +217,7 @@ class TestImageGetPixel(AccessTest): assert im.convert("RGBA").getpixel((0, 0)) == (255, 0, 0, alpha) -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -@pytest.mark.skipif(cffi is None, reason="No CFFI") -class TestCffiPutPixel(TestImagePutPixel): - _need_cffi_access = True - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -@pytest.mark.skipif(cffi is None, reason="No CFFI") -class TestCffiGetPixel(TestImageGetPixel): - _need_cffi_access = True - - -@pytest.mark.skipif(cffi is None, reason="No CFFI") -class TestCffi(AccessTest): - _need_cffi_access = True - - def _test_get_access(self, im: Image.Image) -> None: - """Do we get the same thing as the old pixel access - - Using private interfaces, forcing a capi access and - a pyaccess for the same image""" - 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): - for y in range(0, h, 10): - assert access[(x, y)] == caccess[(x, y)] - - # Access an out-of-range pixel - with pytest.raises(ValueError): - access[(access.xsize + 1, access.ysize + 1)] - - def test_get_vs_c(self) -> None: - with pytest.warns(DeprecationWarning): - rgb = hopper("RGB") - rgb.load() - self._test_get_access(rgb) - for mode in ("RGBA", "L", "LA", "1", "P", "F"): - self._test_get_access(hopper(mode)) - - for mode in ("I;16", "I;16L", "I;16B", "I;16N", "I"): - im = Image.new(mode, (10, 10), 40000) - self._test_get_access(im) - - def _test_set_access(self, im: Image.Image, color: tuple[int, ...] | float) -> None: - """Are we writing the correct bits into the image? - - Using private interfaces, forcing a capi access and - a pyaccess for the same image""" - 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): - for y in range(0, h, 10): - access[(x, y)] = color - assert color == caccess[(x, y)] - - # 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 - - def test_set_vs_c(self) -> None: - rgb = hopper("RGB") - with pytest.warns(DeprecationWarning): - rgb.load() - self._test_set_access(rgb, (255, 128, 0)) - self._test_set_access(hopper("RGBA"), (255, 192, 128, 0)) - self._test_set_access(hopper("L"), 128) - self._test_set_access(hopper("LA"), (128, 128)) - self._test_set_access(hopper("1"), 255) - self._test_set_access(hopper("P"), 128) - self._test_set_access(hopper("PA"), (128, 128)) - self._test_set_access(hopper("F"), 1024.0) - - for mode in ("I;16", "I;16L", "I;16B", "I;16N", "I"): - im = Image.new(mode, (10, 10), 40000) - self._test_set_access(im, 45000) - - @pytest.mark.filterwarnings("ignore::DeprecationWarning") - def test_not_implemented(self) -> None: - assert PyAccess.new(hopper("BGR;15")) is None - - # Ref https://github.com/python-pillow/Pillow/pull/2009 - def test_reference_counting(self) -> None: - size = 10 - - for _ in range(10): - # Do not save references to the image, only to the access object - with pytest.warns(DeprecationWarning): - px = Image.new("L", (size, 1), 0).load() - for i in range(size): - # Pixels can contain garbage if image is released - assert px[i, 0] == 0 - - @pytest.mark.parametrize("mode", ("P", "PA")) - def test_p_putpixel_rgb_rgba(self, mode: str) -> None: - for color in ((255, 0, 0), (255, 0, 0, 127 if mode == "PA" else 255)): - 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: - color += (255,) - assert im.convert("RGBA").getpixel((0, 0)) == color - - -class TestImagePutPixelError(AccessTest): +class TestImagePutPixelError: IMAGE_MODES1 = ["LA", "RGB", "RGBA", "BGR;15"] IMAGE_MODES2 = ["L", "I", "I;16"] INVALID_TYPES = ["foo", 1.0, None] @@ -364,7 +227,7 @@ class TestImagePutPixelError(AccessTest): im = hopper(mode) for v in self.INVALID_TYPES: with pytest.raises(TypeError, match="color must be int or tuple"): - im.putpixel((0, 0), v) + im.putpixel((0, 0), v) # type: ignore[arg-type] @pytest.mark.parametrize( ("mode", "band_numbers", "match"), @@ -398,7 +261,7 @@ class TestImagePutPixelError(AccessTest): with pytest.raises( TypeError, match="color must be int or single-element tuple" ): - im.putpixel((0, 0), v) + im.putpixel((0, 0), v) # type: ignore[arg-type] @pytest.mark.parametrize("mode", IMAGE_MODES1 + IMAGE_MODES2) def test_putpixel_overflow_error(self, mode: str) -> None: diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index d7e6c562c..bb6064882 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import pytest from packaging.version import parse as parse_version @@ -13,13 +13,16 @@ numpy = pytest.importorskip("numpy", reason="NumPy not installed") im = hopper().resize((128, 100)) +if TYPE_CHECKING: + import numpy.typing as npt + def test_toarray() -> None: def test(mode: str) -> tuple[tuple[int, ...], str, int]: ai = numpy.array(im.convert(mode)) return ai.shape, ai.dtype.str, ai.nbytes - def test_with_dtype(dtype) -> None: + def test_with_dtype(dtype: npt.DTypeLike) -> None: ai = numpy.array(im, dtype=dtype) assert ai.dtype == dtype diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 2fb45854a..ebb7f2822 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -222,8 +222,10 @@ def test_l_macro_rounding(convert_mode: str) -> None: converted_im = im.convert(convert_mode) px = converted_im.load() + assert px is not None converted_color = px[0, 0] if convert_mode == "LA": + assert isinstance(converted_color, tuple) converted_color = converted_color[0] assert converted_color == 1 diff --git a/Tests/test_image_draft.py b/Tests/test_image_draft.py index 1ce1a7cd8..c9d8c93f3 100644 --- a/Tests/test_image_draft.py +++ b/Tests/test_image_draft.py @@ -16,7 +16,9 @@ def draft_roundtrip( im = Image.new(in_mode, in_size) data = tostring(im, "JPEG") im = fromstring(data) - mode, box = im.draft(req_mode, req_size) + result = im.draft(req_mode, req_size) + assert result is not None + box = result[1] scale, _ = im.decoderconfig assert box[:2] == (0, 0) assert (im.width - scale) < box[2] <= im.width diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py index 1f0644471..412ab44c3 100644 --- a/Tests/test_image_filter.py +++ b/Tests/test_image_filter.py @@ -137,7 +137,7 @@ def test_builtinfilter_p() -> None: builtin_filter = ImageFilter.BuiltinFilter() with pytest.raises(ValueError): - builtin_filter.filter(hopper("P")) + builtin_filter.filter(hopper("P").im) def test_kernel_not_enough_coefficients() -> None: diff --git a/Tests/test_image_getcolors.py b/Tests/test_image_getcolors.py index 8f8870f4f..8dbe82b29 100644 --- a/Tests/test_image_getcolors.py +++ b/Tests/test_image_getcolors.py @@ -54,17 +54,21 @@ def test_pack() -> None: assert A is None A = im.getcolors(maxcolors=3) + assert A is not None A.sort() assert A == expected A = im.getcolors(maxcolors=4) + assert A is not None A.sort() assert A == expected A = im.getcolors(maxcolors=8) + assert A is not None A.sort() assert A == expected A = im.getcolors(maxcolors=16) + assert A is not None A.sort() assert A == expected diff --git a/Tests/test_image_load.py b/Tests/test_image_load.py index 0605821e0..4f1d63b8f 100644 --- a/Tests/test_image_load.py +++ b/Tests/test_image_load.py @@ -12,9 +12,10 @@ from .helper import hopper def test_sanity() -> None: im = hopper() - pix = im.load() + px = im.load() - assert pix[0, 0] == (20, 20, 70) + assert px is not None + assert px[0, 0] == (20, 20, 70) def test_close() -> None: diff --git a/Tests/test_image_paste.py b/Tests/test_image_paste.py index d8f6b65e0..2cff6c893 100644 --- a/Tests/test_image_paste.py +++ b/Tests/test_image_paste.py @@ -14,6 +14,7 @@ class TestImagingPaste: self, im: Image.Image, expected: list[tuple[int, int, int, int]] ) -> None: px = im.load() + assert px is not None actual = [ px[0, 0], px[self.size // 2, 0], @@ -48,6 +49,7 @@ class TestImagingPaste: def mask_1(self) -> Image.Image: mask = Image.new("1", (self.size, self.size)) px = mask.load() + assert px is not None for y in range(mask.height): for x in range(mask.width): px[y, x] = (x + y) % 2 @@ -61,6 +63,7 @@ class TestImagingPaste: def gradient_L(self) -> Image.Image: gradient = Image.new("L", (self.size, self.size)) px = gradient.load() + assert px is not None for y in range(gradient.height): for x in range(gradient.width): px[y, x] = (x + y) % 255 @@ -338,3 +341,8 @@ class TestImagingPaste: im.copy().paste(im2) im.copy().paste(im2, (0, 0)) + + def test_incorrect_abbreviated_form(self) -> None: + im = Image.new("L", (1, 1)) + with pytest.raises(ValueError): + im.paste(im, im, im) diff --git a/Tests/test_image_point.py b/Tests/test_image_point.py index 05f209351..a5d5a15db 100644 --- a/Tests/test_image_point.py +++ b/Tests/test_image_point.py @@ -61,4 +61,4 @@ def test_f_lut() -> None: def test_f_mode() -> None: im = hopper("F") with pytest.raises(ValueError): - im.point(None) + im.point([]) diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index 5e57e4c4c..27cb7c59d 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -31,7 +31,7 @@ def test_sanity() -> None: def test_long_integers() -> None: # see bug-200802-systemerror - def put(value: int) -> tuple[int, int, int, int]: + def put(value: int) -> float | tuple[int, ...] | None: im = Image.new("RGBA", (1, 1)) im.putdata([value]) return im.getpixel((0, 0)) @@ -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]]) # type: ignore[list-item] + im.putdata([[0]]) with pytest.raises(TypeError): - im.putdata([[0]], 2) # type: ignore[list-item] + im.putdata([[0]], 2) with pytest.raises(TypeError): im = Image.new("I", (1, 1)) - im.putdata([[0]]) # type: ignore[list-item] + im.putdata([[0]]) with pytest.raises(TypeError): im = Image.new("F", (1, 1)) - im.putdata([[0]]) # type: ignore[list-item] + im.putdata([[0]]) diff --git a/Tests/test_image_putpalette.py b/Tests/test_image_putpalette.py index cc7cf58f0..75d9d2fc1 100644 --- a/Tests/test_image_putpalette.py +++ b/Tests/test_image_putpalette.py @@ -79,6 +79,7 @@ def test_putpalette_with_alpha_values() -> None: ( ("RGBA", (1, 2, 3, 4)), ("RGBAX", (1, 2, 3, 4, 0)), + ("ARGB", (4, 1, 2, 3)), ), ) def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None: diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 2d461d985..903cd8550 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -31,7 +31,9 @@ def test_libimagequant_quantize() -> None: converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT) assert converted.mode == "P" assert_image_similar(converted.convert("RGB"), image, 15) - assert len(converted.getcolors()) == 100 + colors = converted.getcolors() + assert colors is not None + assert len(colors) == 100 def test_octree_quantize() -> None: @@ -39,7 +41,9 @@ def test_octree_quantize() -> None: converted = image.quantize(100, Image.Quantize.FASTOCTREE) assert converted.mode == "P" assert_image_similar(converted.convert("RGB"), image, 20) - assert len(converted.getcolors()) == 100 + colors = converted.getcolors() + assert colors is not None + assert len(colors) == 100 def test_rgba_quantize() -> None: @@ -80,6 +84,7 @@ def test_quantize_no_dither2() -> None: assert tuple(quantized.palette.palette) == data px = quantized.load() + assert px is not None for x in range(9): assert px[x, 0] == (0 if x < 5 else 1) @@ -118,10 +123,12 @@ def test_colors() -> None: def test_transparent_colors_equal() -> None: im = Image.new("RGBA", (1, 2), (0, 0, 0, 0)) px = im.load() + assert px is not None px[0, 1] = (255, 255, 255, 0) converted = im.quantize() converted_px = converted.load() + assert converted_px is not None assert converted_px[0, 0] == converted_px[0, 1] @@ -139,6 +146,7 @@ def test_palette(method: Image.Quantize, color: tuple[int, ...]) -> None: converted = im.quantize(method=method) converted_px = converted.load() + assert converted_px is not None assert converted_px[0, 0] == converted.palette.colors[color] @@ -154,4 +162,6 @@ def test_small_palette() -> None: im = im.quantize(palette=p) # Assert - assert len(im.getcolors()) == 2 + quantized_colors = im.getcolors() + assert quantized_colors is not None + assert len(quantized_colors) == 2 diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 9b3bdf330..ce6209c0d 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -1,7 +1,7 @@ from __future__ import annotations +from collections.abc import Generator from contextlib import contextmanager -from typing import Generator import pytest @@ -74,6 +74,7 @@ class TestImagingCoreResampleAccuracy: data = data.replace(" ", "") sample = Image.new("L", size) s_px = sample.load() + assert s_px is not None w, h = size[0] // 2, size[1] // 2 for y in range(h): for x in range(w): @@ -87,6 +88,8 @@ class TestImagingCoreResampleAccuracy: def check_case(self, case: Image.Image, sample: Image.Image) -> None: s_px = sample.load() c_px = case.load() + assert s_px is not None + assert c_px is not None for y in range(case.size[1]): for x in range(case.size[0]): if c_px[x, y] != s_px[x, y]: @@ -98,6 +101,7 @@ class TestImagingCoreResampleAccuracy: def serialize_image(self, image: Image.Image) -> str: s_px = image.load() + assert s_px is not None return "\n".join( " ".join(f"{s_px[x, y]:02x}" for x in range(image.size[0])) for y in range(image.size[1]) @@ -233,13 +237,16 @@ class TestImagingCoreResampleAccuracy: class TestCoreResampleConsistency: def make_case( self, mode: str, fill: tuple[int, int, int] | float - ) -> tuple[Image.Image, tuple[int, ...]]: + ) -> tuple[Image.Image, float | tuple[int, ...]]: im = Image.new(mode, (512, 9), fill) - return im.resize((9, 512), Image.Resampling.LANCZOS), im.load()[0, 0] + px = im.load() + assert px is not None + return im.resize((9, 512), Image.Resampling.LANCZOS), px[0, 0] - def run_case(self, case: tuple[Image.Image, int | tuple[int, ...]]) -> None: + def run_case(self, case: tuple[Image.Image, float | tuple[int, ...]]) -> None: channel, color = case px = channel.load() + assert px is not None for x in range(channel.size[0]): for y in range(channel.size[1]): if px[x, y] != color: @@ -249,6 +256,7 @@ class TestCoreResampleConsistency: def test_8u(self) -> None: im, color = self.make_case("RGB", (0, 64, 255)) r, g, b = im.split() + assert isinstance(color, tuple) self.run_case((r, color[0])) self.run_case((g, color[1])) self.run_case((b, color[2])) @@ -271,6 +279,7 @@ class TestCoreResampleAlphaCorrect: def make_levels_case(self, mode: str) -> Image.Image: i = Image.new(mode, (256, 16)) px = i.load() + assert px is not None for y in range(i.size[1]): for x in range(i.size[0]): pix = [x] * len(mode) @@ -280,8 +289,13 @@ class TestCoreResampleAlphaCorrect: def run_levels_case(self, i: Image.Image) -> None: px = i.load() + assert px is not None for y in range(i.size[1]): - used_colors = {px[x, y][0] for x in range(i.size[0])} + used_colors = set() + for x in range(i.size[0]): + value = px[x, y] + assert isinstance(value, tuple) + used_colors.add(value[0]) assert 256 == len(used_colors), ( "All colors should be present in resized image. " f"Only {len(used_colors)} on line {y}." @@ -310,6 +324,7 @@ class TestCoreResampleAlphaCorrect: ) -> Image.Image: i = Image.new(mode, (64, 64), dirty_pixel) px = i.load() + assert px is not None xdiv4 = i.size[0] // 4 ydiv4 = i.size[1] // 4 for y in range(ydiv4 * 2): @@ -319,14 +334,16 @@ class TestCoreResampleAlphaCorrect: def run_dirty_case(self, i: Image.Image, clean_pixel: tuple[int, ...]) -> None: px = i.load() + assert px is not None for y in range(i.size[1]): for x in range(i.size[0]): - if px[x, y][-1] != 0 and px[x, y][:-1] != clean_pixel: + value = px[x, y] + assert isinstance(value, tuple) + if value[-1] != 0 and value[:-1] != clean_pixel: message = ( - f"pixel at ({x}, {y}) is different:\n" - f"{px[x, y]}\n{clean_pixel}" + f"pixel at ({x}, {y}) is different:\n{value}\n{clean_pixel}" ) - assert px[x, y][:3] == clean_pixel, message + assert value[:3] == clean_pixel, message def test_dirty_pixels_rgba(self) -> None: case = self.make_dirty_case("RGBA", (255, 255, 0, 128), (0, 0, 255, 0)) @@ -406,6 +423,7 @@ class TestCoreResampleCoefficients: draw.rectangle((0, 0, i.size[0] // 2 - 1, 0), test_color) px = i.resize((5, i.size[1]), Image.Resampling.BICUBIC).load() + assert px is not None if px[2, 0] != test_color // 2: assert test_color // 2 == px[2, 0] @@ -445,7 +463,7 @@ class TestCoreResampleBox: im.resize((32, 32), resample, (20, 20, 100, 20)) with pytest.raises(TypeError, match="must be sequence of length 4"): - im.resize((32, 32), resample, (im.width, im.height)) + im.resize((32, 32), resample, (im.width, im.height)) # type: ignore[arg-type] with pytest.raises(ValueError, match="can't be negative"): im.resize((32, 32), resample, (-20, 20, 100, 100)) diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 64098f80f..c9e304512 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -4,9 +4,9 @@ Tests for resize functionality. from __future__ import annotations +from collections.abc import Generator from itertools import permutations from pathlib import Path -from typing import Generator import pytest @@ -285,14 +285,14 @@ class TestReducingGapResize: class TestImageResize: def test_resize(self) -> None: - def resize(mode: str, size: tuple[int, int]) -> None: + def resize(mode: str, size: tuple[int, int] | list[int]) -> None: out = hopper(mode).resize(size) assert out.mode == mode - assert out.size == size + assert out.size == tuple(size) for mode in "1", "P", "L", "RGB", "I", "F": resize(mode, (112, 103)) - resize(mode, (188, 214)) + resize(mode, [188, 214]) # Test unknown resampling filter with hopper() as im: diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 638d12247..7e83396de 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -192,8 +192,9 @@ class TestImageTransform: im = op(im, (40, 10)) - colors = sorted(im.getcolors()) - assert colors == sorted( + colors = im.getcolors() + assert colors is not None + assert sorted(colors) == sorted( ( (20 * 10, opaque), (20 * 10, transparent), diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 7e2290c15..4fc28cdb9 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -391,23 +391,25 @@ def test_overlay() -> None: def test_logical() -> None: def table( op: Callable[[Image.Image, Image.Image], Image.Image], a: int, b: int - ) -> tuple[int, int, int, int]: + ) -> list[float]: out = [] for x in (a, b): imx = Image.new("1", (1, 1), x) for y in (a, b): imy = Image.new("1", (1, 1), y) - out.append(op(imx, imy).getpixel((0, 0))) - return tuple(out) + value = op(imx, imy).getpixel((0, 0)) + assert not isinstance(value, tuple) and value is not None + out.append(value) + return out - assert table(ImageChops.logical_and, 0, 1) == (0, 0, 0, 255) - assert table(ImageChops.logical_or, 0, 1) == (0, 255, 255, 255) - assert table(ImageChops.logical_xor, 0, 1) == (0, 255, 255, 0) + assert table(ImageChops.logical_and, 0, 1) == [0, 0, 0, 255] + assert table(ImageChops.logical_or, 0, 1) == [0, 255, 255, 255] + assert table(ImageChops.logical_xor, 0, 1) == [0, 255, 255, 0] - assert table(ImageChops.logical_and, 0, 128) == (0, 0, 0, 255) - assert table(ImageChops.logical_or, 0, 128) == (0, 255, 255, 255) - assert table(ImageChops.logical_xor, 0, 128) == (0, 255, 255, 0) + assert table(ImageChops.logical_and, 0, 128) == [0, 0, 0, 255] + assert table(ImageChops.logical_or, 0, 128) == [0, 255, 255, 255] + assert table(ImageChops.logical_xor, 0, 128) == [0, 255, 255, 0] - assert table(ImageChops.logical_and, 0, 255) == (0, 0, 0, 255) - assert table(ImageChops.logical_or, 0, 255) == (0, 255, 255, 255) - assert table(ImageChops.logical_xor, 0, 255) == (0, 255, 255, 0) + assert table(ImageChops.logical_and, 0, 255) == [0, 0, 0, 255] + assert table(ImageChops.logical_or, 0, 255) == [0, 255, 255, 255] + assert table(ImageChops.logical_xor, 0, 255) == [0, 255, 255, 0] diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 55f72c3b9..5ee5fcedf 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -103,7 +103,7 @@ def test_sanity() -> None: def test_flags() -> None: - assert ImageCms.Flags.NONE == 0 + assert ImageCms.Flags.NONE.value == 0 assert ImageCms.Flags.GRIDPOINTS(0) == ImageCms.Flags.NONE assert ImageCms.Flags.GRIDPOINTS(256) == ImageCms.Flags.NONE @@ -569,9 +569,9 @@ def assert_aux_channel_preserved( for delta in nine_grid_deltas: channel_data.paste( channel_pattern, - tuple( - paste_offset[c] + delta[c] * channel_pattern.size[c] - for c in range(2) + ( + paste_offset[0] + delta[0] * channel_pattern.size[0], + paste_offset[1] + delta[1] * channel_pattern.size[1], ), ) chans.append(channel_data) @@ -642,7 +642,8 @@ def test_auxiliary_channels_isolated() -> None: # convert with and without AUX data, test colors are equal src_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], src_format[1]) source_profile = ImageCms.createProfile(src_colorSpace) - destination_profile = ImageCms.createProfile(dst_format[1]) + dst_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], dst_format[1]) + destination_profile = ImageCms.createProfile(dst_colorSpace) source_image = src_format[3] test_transform = ImageCms.buildTransform( source_profile, @@ -678,7 +679,8 @@ def test_auxiliary_channels_isolated() -> None: def test_long_modes() -> None: p = ImageCms.getOpenProfile("Tests/icc/sGrey-v2-nano.icc") - ImageCms.buildTransform(p, p, "ABCDEFGHI", "ABCDEFGHI") + with pytest.warns(DeprecationWarning): + ImageCms.buildTransform(p, p, "ABCDEFGHI", "ABCDEFGHI") @pytest.mark.parametrize("mode", ("RGB", "RGBA", "RGBX")) @@ -689,7 +691,9 @@ def test_rgb_lab(mode: str) -> None: im = Image.new("LAB", (1, 1), (255, 0, 0)) converted_im = im.convert(mode) - assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255) + value = converted_im.getpixel((0, 0)) + assert isinstance(value, tuple) + assert value[:3] == (0, 255, 255) def test_deprecation() -> None: @@ -699,3 +703,9 @@ def test_deprecation() -> None: assert ImageCms.VERSION == "1.0.0 pil" with pytest.warns(DeprecationWarning): assert isinstance(ImageCms.FLAGS, dict) + + profile = ImageCmsProfile(ImageCms.createProfile("sRGB")) + with pytest.warns(DeprecationWarning): + ImageCms.ImageCmsTransform(profile, profile, "RGBA;16B", "RGB") + with pytest.warns(DeprecationWarning): + ImageCms.ImageCmsTransform(profile, profile, "RGB", "RGBA;16B") diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index d9593c60f..e397978cb 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1,8 +1,8 @@ from __future__ import annotations -import contextlib import os.path -from typing import Sequence +from collections.abc import Sequence +from typing import Callable import pytest @@ -448,6 +448,7 @@ def test_shape1() -> None: x3, y3 = 95, 5 # Act + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -469,6 +470,7 @@ def test_shape2() -> None: x3, y3 = 5, 95 # Act + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -487,6 +489,7 @@ def test_transform() -> None: draw = ImageDraw.Draw(im) # Act + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.line(0, 0) s.transform((0, 0, 0, 0, 0, 0)) @@ -631,6 +634,19 @@ def test_polygon(points: Coords) -> None: assert_image_equal_tofile(im, "Tests/images/imagedraw_polygon.png") +@pytest.mark.parametrize("points", POINTS) +def test_polygon_width_I16(points: Coords) -> None: + # Arrange + im = Image.new("I;16", (W, H)) + draw = ImageDraw.Draw(im) + + # Act + draw.polygon(points, outline=0xFFFF, width=2) + + # Assert + assert_image_equal_tofile(im, "Tests/images/imagedraw_polygon_width_I.tiff") + + @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("kite_points", KITE_POINTS) def test_polygon_kite( @@ -913,7 +929,12 @@ def test_rounded_rectangle_translucent( def test_floodfill(bbox: Coords) -> None: red = ImageColor.getrgb("red") - for mode, value in [("L", 1), ("RGBA", (255, 0, 0, 0)), ("RGB", red)]: + mode_values: list[tuple[str, int | tuple[int, ...]]] = [ + ("L", 1), + ("RGBA", (255, 0, 0, 0)), + ("RGB", red), + ] + for mode, value in mode_values: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -1401,25 +1422,44 @@ def test_default_font_size() -> None: im = Image.new("RGB", (220, 25)) draw = ImageDraw.Draw(im) - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + + def check(func: Callable[[], None]) -> None: + if freetype_support: + func() + else: + with pytest.raises(ImportError): + func() + + def draw_text() -> None: draw.text((0, 0), text, font_size=16) assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + check(draw_text) + + def draw_textlength() -> None: assert draw.textlength(text, font_size=16) == 216 - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + check(draw_textlength) + + def draw_textbbox() -> None: assert draw.textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) + check(draw_textbbox) + im = Image.new("RGB", (220, 25)) draw = ImageDraw.Draw(im) - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + + def draw_multiline_text() -> None: draw.multiline_text((0, 0), text, font_size=16) assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") - with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + check(draw_multiline_text) + + def draw_multiline_textbbox() -> None: assert draw.multiline_textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) + check(draw_multiline_textbbox) + @pytest.mark.parametrize("bbox", BBOX) def test_same_color_outline(bbox: Coords) -> None: @@ -1429,6 +1469,7 @@ def test_same_color_outline(bbox: Coords) -> None: x2, y2 = 95, 50 x3, y3 = 95, 5 + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -1467,7 +1508,7 @@ def test_same_color_outline(bbox: Coords) -> None: (4, "square", {}), (8, "regular_octagon", {}), (4, "square_rotate_45", {"rotation": 45}), - (3, "triangle_width", {"width": 5, "outline": "yellow"}), + (3, "triangle_width", {"outline": "yellow", "width": 5}), ], ) def test_draw_regular_polygon( @@ -1477,7 +1518,10 @@ def test_draw_regular_polygon( filename = f"Tests/images/imagedraw_{polygon_name}.png" draw = ImageDraw.Draw(im) bounding_circle = ((W // 2, H // 2), 25) - draw.regular_polygon(bounding_circle, n_sides, fill="red", **args) + rotation = int(args.get("rotation", 0)) + outline = args.get("outline") + width = int(args.get("width", 1)) + draw.regular_polygon(bounding_circle, n_sides, rotation, "red", outline, width) assert_image_equal_tofile(im, filename) @@ -1569,7 +1613,7 @@ def test_compute_regular_polygon_vertices_input_error_handling( error_message: str, ) -> None: with pytest.raises(expected_error) as e: - ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) + ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) # type: ignore[arg-type] assert str(e.value) == error_message @@ -1630,6 +1674,6 @@ def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None: draw.rounded_rectangle(xy) -def test_getdraw(): +def test_getdraw() -> None: with pytest.warns(DeprecationWarning): ImageDraw.getdraw(None, []) diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index 3171eb9ae..c80aa739c 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -51,9 +51,18 @@ def test_sanity() -> None: pen = ImageDraw2.Pen("blue", width=7) draw.line(list(range(10)), pen) - draw, handler = ImageDraw.getdraw(im) + draw2, handler = ImageDraw.getdraw(im) + assert draw2 is not None pen = ImageDraw2.Pen("blue", width=7) - draw.line(list(range(10)), pen) + draw2.line(list(range(10)), pen) + + +def test_mode() -> None: + draw = ImageDraw2.Draw("L", (1, 1)) + assert draw.image.mode == "L" + + with pytest.raises(ValueError): + ImageDraw2.Draw("L") @pytest.mark.parametrize("bbox", BBOX) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index c9dba2943..bb686bb3b 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -209,7 +209,7 @@ class MockPyDecoder(ImageFile.PyDecoder): super().__init__(mode, *args) - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: # eof return -1, 0 @@ -222,7 +222,7 @@ class MockPyEncoder(ImageFile.PyEncoder): super().__init__(mode, *args) - def encode(self, buffer): + def encode(self, bufsize: int) -> tuple[int, int, bytes]: return 1, 1, b"" def cleanup(self) -> None: @@ -305,7 +305,7 @@ class TestPyDecoder(CodecsTest): def test_decode(self) -> None: decoder = ImageFile.PyDecoder(None) with pytest.raises(NotImplementedError): - decoder.decode(None) + decoder.decode(b"") class TestPyEncoder(CodecsTest): @@ -351,7 +351,9 @@ class TestPyEncoder(CodecsTest): ImageFile._save( im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")] ) - assert MockPyEncoder.last.cleanup_called + last: MockPyEncoder | None = MockPyEncoder.last + assert last + assert last.cleanup_called with pytest.raises(ValueError): ImageFile._save( @@ -381,7 +383,7 @@ class TestPyEncoder(CodecsTest): def test_encode(self) -> None: encoder = ImageFile.PyEncoder(None) with pytest.raises(NotImplementedError): - encoder.encode(None) + encoder.encode(0) bytes_consumed, errcode = encoder.encode_to_pyfd() assert bytes_consumed == 0 diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 73cad513e..9cb420371 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -209,7 +209,7 @@ def test_getlength( assert length == length_raqm -def test_float_size() -> None: +def test_float_size(layout_engine: ImageFont.Layout) -> None: lengths = [] for size in (48, 48.5, 49): f = ImageFont.truetype( @@ -494,8 +494,8 @@ def test_default_font() -> None: assert_image_equal_tofile(im, "Tests/images/default_font_freetype.png") -@pytest.mark.parametrize("mode", (None, "1", "RGBA")) -def test_getbbox(font: ImageFont.FreeTypeFont, mode: str | None) -> None: +@pytest.mark.parametrize("mode", ("", "1", "RGBA")) +def test_getbbox(font: ImageFont.FreeTypeFont, mode: str) -> None: assert (0, 4, 12, 16) == font.getbbox("A", mode) @@ -548,7 +548,7 @@ def test_find_font( def loadable_font( filepath: str, size: int, index: int, encoding: str, *args: Any - ): + ) -> ImageFont.FreeTypeFont: _freeTypeFont = getattr(ImageFont, "_FreeTypeFont") if filepath == path_to_fake: return _freeTypeFont(FONT_PATH, size, index, encoding, *args) @@ -564,6 +564,7 @@ def test_find_font( # catching syntax like errors monkeypatch.setattr(sys, "platform", platform) if platform == "linux": + monkeypatch.setenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") def fake_walker(path: str) -> list[tuple[str, list[str], list[str]]]: @@ -1096,6 +1097,23 @@ def test_too_many_characters(font: ImageFont.FreeTypeFont) -> None: imagefont.getmask("A" * 1_000_001) +def test_bytes(font: ImageFont.FreeTypeFont) -> None: + assert font.getlength(b"test") == font.getlength("test") + + assert font.getbbox(b"test") == font.getbbox("test") + + assert_image_equal( + Image.Image()._new(font.getmask(b"test")), + Image.Image()._new(font.getmask("test")), + ) + + assert_image_equal( + Image.Image()._new(font.getmask2(b"test")[0]), + Image.Image()._new(font.getmask2("test")[0]), + ) + assert font.getmask2(b"test")[1] == font.getmask2("test")[1] + + @pytest.mark.parametrize( "test_file", [ diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py index 3b1c14b4e..695aecbde 100644 --- a/Tests/test_imagefontpil.py +++ b/Tests/test_imagefontpil.py @@ -9,51 +9,57 @@ from PIL import Image, ImageDraw, ImageFont, _util, features from .helper import assert_image_equal_tofile -original_core = ImageFont.core +fonts = [ImageFont.load_default_imagefont()] +if not features.check_module("freetype2"): + default_font = ImageFont.load_default() + if isinstance(default_font, ImageFont.ImageFont): + fonts.append(default_font) -def setup_module() -> None: - if features.check_module("freetype2"): - ImageFont.core = _util.DeferredError(ImportError) - - -def teardown_module() -> None: - ImageFont.core = original_core - - -def test_default_font() -> None: +@pytest.mark.parametrize("font", fonts) +def test_default_font(font: ImageFont.ImageFont) -> None: # Arrange txt = 'This is a "better than nothing" default font.' im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) # Act - default_font = ImageFont.load_default() - draw.text((10, 10), txt, font=default_font) + draw.text((10, 10), txt, font=font) # Assert assert_image_equal_tofile(im, "Tests/images/default_font.png") -def test_size_without_freetype() -> None: - with pytest.raises(ImportError): - ImageFont.load_default(size=14) +def test_without_freetype() -> None: + original_core = ImageFont.core + if features.check_module("freetype2"): + ImageFont.core = _util.DeferredError(ImportError("Disabled for testing")) + try: + with pytest.raises(ImportError): + ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + assert isinstance(ImageFont.load_default(), ImageFont.ImageFont) + + with pytest.raises(ImportError): + ImageFont.load_default(size=14) + finally: + ImageFont.core = original_core -def test_unicode() -> None: +@pytest.mark.parametrize("font", fonts) +def test_unicode(font: ImageFont.ImageFont) -> None: # should not segfault, should return UnicodeDecodeError # issue #2826 - font = ImageFont.load_default() with pytest.raises(UnicodeEncodeError): font.getbbox("’") -def test_textbbox() -> None: +@pytest.mark.parametrize("font", fonts) +def test_textbbox(font: ImageFont.ImageFont) -> None: im = Image.new("RGB", (200, 200)) d = ImageDraw.Draw(im) - default_font = ImageFont.load_default() - assert d.textlength("test", font=default_font) == 24 - assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, 24, 11) + assert d.textlength("test", font=font) == 24 + assert d.textbbox((0, 0), "test", font=font) == (0, 0, 24, 11) def test_decompression_bomb() -> None: diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index e23adeb70..5cd510751 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -60,6 +60,8 @@ class TestImageGrab: def test_grabclipboard(self) -> None: if sys.platform == "darwin": subprocess.call(["screencapture", "-cx"]) + + ImageGrab.grabclipboard() elif sys.platform == "win32": p = subprocess.Popen(["powershell", "-command", "-"], stdin=subprocess.PIPE) p.stdin.write( @@ -69,6 +71,8 @@ $bmp = New-Object Drawing.Bitmap 200, 200 [Windows.Forms.Clipboard]::SetImage($bmp)""" ) p.communicate() + + ImageGrab.grabclipboard() else: if not shutil.which("wl-paste") and not shutil.which("xclip"): with pytest.raises( @@ -77,9 +81,6 @@ $bmp = New-Object Drawing.Bitmap 200, 200 r" ImageGrab.grabclipboard\(\) on Linux", ): ImageGrab.grabclipboard() - return - - ImageGrab.grabclipboard() @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_grabclipboard_file(self) -> None: @@ -89,6 +90,7 @@ $bmp = New-Object Drawing.Bitmap 200, 200 p.communicate() im = ImageGrab.grabclipboard() + assert isinstance(im, list) assert len(im) == 1 assert os.path.samefile(im[0], "Tests/images/hopper.gif") @@ -105,6 +107,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes) p.communicate() im = ImageGrab.grabclipboard() + assert isinstance(im, Image.Image) assert_image_equal_tofile(im, "Tests/images/hopper.png") @pytest.mark.skipif( @@ -120,6 +123,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes) with open(image_path, "rb") as fp: subprocess.call(["wl-copy"], stdin=fp) im = ImageGrab.grabclipboard() + assert isinstance(im, Image.Image) assert_image_equal_tofile(im, image_path) @pytest.mark.skipif( diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index 32615cf0e..4363f456e 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -41,11 +41,15 @@ A = string_to_img( def img_to_string(im: Image.Image) -> str: """Turn a (small) binary image into a string representation""" chars = ".1" - width, height = im.size - return "\n".join( - "".join(chars[im.getpixel((c, r)) > 0] for c in range(width)) - for r in range(height) - ) + result = [] + for r in range(im.height): + line = "" + for c in range(im.width): + value = im.getpixel((c, r)) + assert not isinstance(value, tuple) and value is not None + line += chars[value > 0] + result.append(line) + return "\n".join(result) def img_string_normalize(im: str) -> str: diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 27a6090c5..e33e6d4c8 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -165,10 +165,14 @@ def test_pad() -> None: def test_pad_round() -> None: im = Image.new("1", (1, 1), 1) new_im = ImageOps.pad(im, (4, 1)) - assert new_im.load()[2, 0] == 1 + px = new_im.load() + assert px is not None + assert px[2, 0] == 1 new_im = ImageOps.pad(im, (1, 4)) - assert new_im.load()[0, 2] == 1 + px = new_im.load() + assert px is not None + assert px[0, 2] == 1 @pytest.mark.parametrize("mode", ("P", "PA")) @@ -223,6 +227,7 @@ def test_expand_palette(border: int | tuple[int, int, int, int]) -> None: else: left, top, right, bottom = border px = im_expanded.convert("RGB").load() + assert px is not None for x in range(im_expanded.width): for b in range(top): assert px[x, b] == (255, 0, 0) @@ -254,20 +259,26 @@ def test_colorize_2color() -> None: left = (0, 1) middle = (127, 1) right = (255, 1) + value = im_test.getpixel(left) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(left), + value, (255, 0, 0), threshold=1, msg="black test pixel incorrect", ) + value = im_test.getpixel(middle) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(middle), + value, (127, 63, 0), threshold=1, msg="mid test pixel incorrect", ) + value = im_test.getpixel(right) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(right), + value, (0, 127, 0), threshold=1, msg="white test pixel incorrect", @@ -290,20 +301,26 @@ def test_colorize_2color_offset() -> None: left = (25, 1) middle = (75, 1) right = (125, 1) + value = im_test.getpixel(left) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(left), + value, (255, 0, 0), threshold=1, msg="black test pixel incorrect", ) + value = im_test.getpixel(middle) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(middle), + value, (127, 63, 0), threshold=1, msg="mid test pixel incorrect", ) + value = im_test.getpixel(right) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(right), + value, (0, 127, 0), threshold=1, msg="white test pixel incorrect", @@ -334,29 +351,37 @@ def test_colorize_3color_offset() -> None: middle = (100, 1) right_middle = (150, 1) right = (225, 1) + value = im_test.getpixel(left) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(left), + value, (255, 0, 0), threshold=1, msg="black test pixel incorrect", ) + value = im_test.getpixel(left_middle) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(left_middle), + value, (127, 0, 127), threshold=1, msg="low-mid test pixel incorrect", ) + value = im_test.getpixel(middle) + assert isinstance(value, tuple) + assert_tuple_approx_equal(value, (0, 0, 255), threshold=1, msg="mid incorrect") + value = im_test.getpixel(right_middle) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(middle), (0, 0, 255), threshold=1, msg="mid incorrect" - ) - assert_tuple_approx_equal( - im_test.getpixel(right_middle), + value, (0, 63, 127), threshold=1, msg="high-mid test pixel incorrect", ) + value = im_test.getpixel(right) + assert isinstance(value, tuple) assert_tuple_approx_equal( - im_test.getpixel(right), + value, (0, 127, 0), threshold=1, msg="white test pixel incorrect", @@ -432,6 +457,17 @@ def test_exif_transpose() -> None: assert 0x0112 not in transposed_im.getexif() +def test_exif_transpose_xml_without_xmp() -> None: + with Image.open("Tests/images/xmp_tags_orientation.png") as im: + assert im.getexif()[0x0112] == 3 + assert "XML:com.adobe.xmp" in im.info + + del im.info["xmp"] + transposed_im = ImageOps.exif_transpose(im) + assert transposed_im is not None + assert 0x0112 not in transposed_im.getexif() + + def test_exif_transpose_in_place() -> None: with Image.open("Tests/images/orientation_rectangle.jpg") as im: assert im.size == (2, 1) diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index dbdd5b317..920012d86 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Generator +from collections.abc import Generator import pytest @@ -101,7 +101,7 @@ def test_blur_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None: assert i.im.getpixel((x, y))[c] >= 250 # Fuzzy match. - def gp(x, y): + def gp(x: int, y: int) -> tuple[int, ...]: return i.im.getpixel((x, y)) assert 236 <= gp(7, 4)[0] <= 239 diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 8e2db15aa..6cf0079dd 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -45,7 +45,7 @@ def test_getcolor() -> None: # Test unknown color specifier with pytest.raises(ValueError): - palette.getcolor("unknown") + palette.getcolor("unknown") # type: ignore[arg-type] def test_getcolor_rgba_color_rgb_palette() -> None: @@ -88,13 +88,13 @@ def test_file(tmp_path: Path) -> None: palette.save(f) - p = ImagePalette.load(f) + lut = ImagePalette.load(f) # load returns raw palette information - assert len(p[0]) == 768 - assert p[1] == "RGB" + assert len(lut[0]) == 768 + assert lut[1] == "RGB" - p = ImagePalette.raw(p[1], p[0]) + p = ImagePalette.raw(lut[1], lut[0]) assert isinstance(p, ImagePalette.ImagePalette) assert p.palette == palette.tobytes() diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index 9487560af..cda2584e7 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -3,7 +3,7 @@ from __future__ import annotations import array import math import struct -from typing import Sequence +from collections.abc import Sequence import pytest diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index 88ad1f9ee..22cd674ce 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -41,18 +41,13 @@ def test_rgb() -> None: checkrgb(0, 0, 255) -def test_image() -> None: - modes = ["1", "RGB", "RGBA", "L", "P"] - qt_format = ImageQt.QImage.Format if ImageQt.qt_version == "6" else ImageQt.QImage - if hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+ - modes.append("I;16") - - for mode in modes: - im = hopper(mode) - roundtripped_im = ImageQt.fromqimage(ImageQt.ImageQt(im)) - if mode not in ("RGB", "RGBA"): - im = im.convert("RGB") - assert_image_similar(roundtripped_im, im, 1) +@pytest.mark.parametrize("mode", ("1", "RGB", "RGBA", "L", "P", "I;16")) +def test_image(mode: str) -> None: + im = hopper(mode) + roundtripped_im = ImageQt.fromqimage(ImageQt.ImageQt(im)) + if mode not in ("RGB", "RGBA"): + im = im.convert("RGB") + assert_image_similar(roundtripped_im, im, 1) def test_closed_file() -> None: diff --git a/Tests/test_imageshow.py b/Tests/test_imageshow.py index 4e9291fbb..a4f7e5cc5 100644 --- a/Tests/test_imageshow.py +++ b/Tests/test_imageshow.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from typing import Any import pytest @@ -15,8 +16,11 @@ def test_sanity() -> None: def test_register() -> None: - # Test registering a viewer that is not a class - ImageShow.register("not a class") + # Test registering a viewer that is an instance + class TestViewer(ImageShow.Viewer): + pass + + ImageShow.register(TestViewer()) # Restore original state ImageShow._viewers.pop() @@ -65,6 +69,27 @@ def test_show_without_viewers() -> None: ImageShow._viewers = viewers +@pytest.mark.parametrize( + "viewer", + ( + ImageShow.Viewer(), + ImageShow.WindowsViewer(), + ImageShow.MacViewer(), + ImageShow.XDGViewer(), + ImageShow.DisplayViewer(), + ImageShow.GmDisplayViewer(), + ImageShow.EogViewer(), + ImageShow.XVViewer(), + ImageShow.IPythonViewer(), + ), +) +def test_show_file(viewer: ImageShow.Viewer) -> None: + assert not os.path.exists("missing.png") + + with pytest.raises(FileNotFoundError): + viewer.show_file("missing.png") + + def test_viewer() -> None: viewer = ImageShow.Viewer() diff --git a/Tests/test_imagetk.py b/Tests/test_imagetk.py index b607b8c43..f84c6c03a 100644 --- a/Tests/test_imagetk.py +++ b/Tests/test_imagetk.py @@ -45,10 +45,12 @@ def test_kw() -> None: # Test "file" im = ImageTk._get_image_from_kw(kw) + assert im is not None assert_image_equal(im, im1) # Test "data" im = ImageTk._get_image_from_kw(kw) + assert im is not None assert_image_equal(im, im2) # Test no relevant entry @@ -70,6 +72,11 @@ def test_photoimage(mode: str) -> None: reloaded = ImageTk.getimage(im_tk) assert_image_equal(reloaded, im.convert("RGBA")) + with pytest.raises(ValueError): + ImageTk.PhotoImage() + with pytest.raises(ValueError): + ImageTk.PhotoImage(mode) + def test_photoimage_apply_transparency() -> None: with Image.open("Tests/images/pil123p.png") as im: diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index e6c312a0c..c23a5c690 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -45,21 +45,22 @@ if is_win32(): memcpy = ctypes.cdll.msvcrt.memcpy memcpy.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t] - CreateCompatibleDC = ctypes.windll.gdi32.CreateCompatibleDC + windll = getattr(ctypes, "windll") + CreateCompatibleDC = windll.gdi32.CreateCompatibleDC CreateCompatibleDC.argtypes = [ctypes.wintypes.HDC] CreateCompatibleDC.restype = ctypes.wintypes.HDC - DeleteDC = ctypes.windll.gdi32.DeleteDC + DeleteDC = windll.gdi32.DeleteDC DeleteDC.argtypes = [ctypes.wintypes.HDC] - SelectObject = ctypes.windll.gdi32.SelectObject + SelectObject = windll.gdi32.SelectObject SelectObject.argtypes = [ctypes.wintypes.HDC, ctypes.wintypes.HGDIOBJ] SelectObject.restype = ctypes.wintypes.HGDIOBJ - DeleteObject = ctypes.windll.gdi32.DeleteObject + DeleteObject = windll.gdi32.DeleteObject DeleteObject.argtypes = [ctypes.wintypes.HGDIOBJ] - CreateDIBSection = ctypes.windll.gdi32.CreateDIBSection + CreateDIBSection = windll.gdi32.CreateDIBSection CreateDIBSection.argtypes = [ ctypes.wintypes.HDC, ctypes.c_void_p, diff --git a/Tests/test_mode_i16.py b/Tests/test_mode_i16.py index 1b01f95ce..e26f5d283 100644 --- a/Tests/test_mode_i16.py +++ b/Tests/test_mode_i16.py @@ -16,6 +16,8 @@ def verify(im1: Image.Image) -> None: assert im1.size == im2.size pix1 = im1.load() pix2 = im2.load() + assert pix1 is not None + assert pix2 is not None for y in range(im1.size[1]): for x in range(im1.size[0]): xy = x, y diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 36cdb3682..312e32e0c 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -1,17 +1,17 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import pytest -from PIL import Image +from PIL import Image, _typing from .helper import assert_deep_equal, assert_image, hopper, skip_unless_feature if TYPE_CHECKING: import numpy - import numpy.typing + import numpy.typing as npt else: numpy = pytest.importorskip("numpy", reason="NumPy not installed") @@ -19,9 +19,7 @@ TEST_IMAGE_SIZE = (10, 10) def test_numpy_to_image() -> None: - def to_image( - dtype: numpy.typing.DTypeLike, bands: int = 1, boolean: int = 0 - ) -> Image.Image: + def to_image(dtype: npt.DTypeLike, bands: int = 1, boolean: int = 0) -> Image.Image: if bands == 1: if boolean: data = [0, 255] * 50 @@ -106,13 +104,12 @@ def test_1d_array() -> None: assert_image(Image.fromarray(a), "L", (1, 5)) -def _test_img_equals_nparray( - img: Image.Image, np_img: numpy.typing.NDArray[Any] -) -> None: +def _test_img_equals_nparray(img: Image.Image, np_img: _typing.NumpyArray) -> 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() + assert px is not None 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_img[y, x]) @@ -145,6 +142,7 @@ def test_save_tiff_uint16() -> None: img = Image.fromarray(a) img_px = img.load() + assert img_px is not None assert img_px[0, 0] == pixel_value @@ -166,7 +164,7 @@ def test_save_tiff_uint16() -> None: ("HSV", numpy.uint8), ), ) -def test_to_array(mode: str, dtype: numpy.typing.DTypeLike) -> None: +def test_to_array(mode: str, dtype: npt.DTypeLike) -> None: img = hopper(mode) # Resize to non-square @@ -200,6 +198,15 @@ def test_putdata() -> None: assert len(im.getdata()) == len(arr) +def test_resize() -> None: + im = hopper() + size = (64, 64) + + im_resized = im.resize(numpy.array(size)) + + assert im_resized.size == size + + @pytest.mark.parametrize( "dtype", ( @@ -216,7 +223,7 @@ def test_putdata() -> None: numpy.float64, ), ) -def test_roundtrip_eye(dtype: numpy.typing.DTypeLike) -> None: +def test_roundtrip_eye(dtype: npt.DTypeLike) -> None: arr = numpy.eye(10, dtype=dtype) numpy.testing.assert_array_equal(arr, numpy.array(Image.fromarray(arr))) diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index 64dfb2c95..130ffa863 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -54,16 +54,12 @@ def test_stdout(buffer: bool) -> None: # Temporarily redirect stdout old_stdout = sys.stdout - if buffer: + class MyStdOut: + buffer = BytesIO() - class MyStdOut: - buffer = BytesIO() + mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() - mystdout = MyStdOut() - else: - mystdout = BytesIO() - - sys.stdout = mystdout + sys.stdout = mystdout # type: ignore[assignment] ps = PSDraw.PSDraw() _create_document(ps) @@ -71,6 +67,6 @@ def test_stdout(buffer: bool) -> None: # Reset stdout sys.stdout = old_stdout - if buffer: + if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer assert mystdout.getvalue() != b"" diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index ae80b98b8..9d06a9332 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -11,7 +11,11 @@ from PIL.TiffImagePlugin import IFDRational from .helper import hopper, skip_unless_feature -def _test_equal(num, denom, target) -> None: +def _test_equal( + num: float | Fraction | IFDRational, + denom: int, + target: float | Fraction | IFDRational, +) -> None: t = IFDRational(num, denom) assert target == t diff --git a/codecov.yml b/codecov.yml index 1ea7974eb..8646576bb 100644 --- a/codecov.yml +++ b/codecov.yml @@ -17,6 +17,5 @@ coverage: # Matches 'omit:' in .coveragerc ignore: - "Tests/32bit_segfault_check.py" - - "Tests/bench_cffi_access.py" - "Tests/check_*.py" - "Tests/createfontdatachunk.py" diff --git a/docs/conf.py b/docs/conf.py index f12b30e65..41f9c0e38 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -338,7 +338,7 @@ linkcheck_allowed_redirects = { # https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html _repo = "https://github.com/python-pillow/Pillow/" extlinks = { - "cve": ("https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-%s", "CVE-%s"), + "cve": ("https://www.cve.org/CVERecord?id=CVE-%s", "CVE-%s"), "cwe": ("https://cwe.mitre.org/data/definitions/%s.html", "CWE-%s"), "issue": (_repo + "issues/%s", "#%s"), "pr": (_repo + "pull/%s", "#%s"), diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 627672e1f..792fd1c70 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -12,28 +12,6 @@ Deprecated features Below are features which are considered deprecated. Where appropriate, a :py:exc:`DeprecationWarning` is issued. -PSFile -~~~~~~ - -.. deprecated:: 9.5.0 - -The :py:class:`~PIL.EpsImagePlugin.PSFile` class has been deprecated and will -be removed in Pillow 11 (2024-10-15). This class was only made as a helper to -be used internally, so there is no replacement. If you need this functionality -though, it is a very short class that can easily be recreated in your own code. - -PyAccess and Image.USE_CFFI_ACCESS -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 10.0.0 - -Since Pillow's C API is now faster than PyAccess on PyPy, -:py:mod:`~PIL.PyAccess` has been deprecated and will be removed in Pillow -11.0.0 (2024-10-15). Pillow's C API will now be used by default on PyPy instead. - -``Image.USE_CFFI_ACCESS``, for switching from the C API to PyAccess, is -similarly deprecated. - ImageFile.raise_oserror ~~~~~~~~~~~~~~~~~~~~~~~ @@ -107,6 +85,15 @@ BGR;15, BGR 16 and BGR;24 The experimental BGR;15, BGR;16 and BGR;24 modes have been deprecated. +Non-image modes in ImageCms +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 10.4.0 + +The use in :py:mod:`.ImageCms` of input modes and output modes that are not Pillow +image modes has been deprecated. Defaulting to "L" or "1" if the mode cannot be mapped +is also deprecated. + Support for LibTIFF earlier than 4 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -128,6 +115,29 @@ Removed features Deprecated features are only removed in major releases after an appropriate period of deprecation has passed. +PSFile +~~~~~~ + +.. deprecated:: 9.5.0 +.. versionremoved:: 11.0.0 + +The :py:class:`!PSFile` class was removed in Pillow 11 (2024-10-15). +This class was only made as a helper to be used internally, +so there is no replacement. If you need this functionality though, +it is a very short class that can easily be recreated in your own code. + +PyAccess and Image.USE_CFFI_ACCESS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 10.0.0 +.. versionremoved:: 11.0.0 + +Since Pillow's C API is now faster than PyAccess on PyPy, ``PyAccess`` has been +removed. Pillow's C API will now be used on PyPy instead. + +``Image.USE_CFFI_ACCESS``, for switching from the C API to PyAccess, was +similarly removed. + Tk/Tcl 8.4 ~~~~~~~~~~ @@ -286,7 +296,7 @@ Previously, the ``size`` methods returned a ``height`` that included the vertica offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` offset. -.. image:: ./example/size_vs_bbox.png +.. image:: ./example/size_vs_bbox.webp :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. :align: center diff --git a/docs/example/anchors.png b/docs/example/anchors.png deleted file mode 100644 index 40476b092..000000000 Binary files a/docs/example/anchors.png and /dev/null differ diff --git a/docs/example/anchors.py b/docs/example/anchors.py index b5d76b4fe..2ee11103f 100644 --- a/docs/example/anchors.py +++ b/docs/example/anchors.py @@ -26,5 +26,5 @@ if __name__ == "__main__": d.line(((x * 200, y * 100), (x * 200, (y + 1) * 100)), "black", 3) if y != 0: d.line(((x * 200, y * 100), ((x + 1) * 200, y * 100)), "black", 3) - im.save("docs/example/anchors.png") + im.save("docs/example/anchors.webp") im.show() diff --git a/docs/example/anchors.webp b/docs/example/anchors.webp new file mode 100644 index 000000000..216b6c235 Binary files /dev/null and b/docs/example/anchors.webp differ diff --git a/docs/example/image_thumbnail.png b/docs/example/image_thumbnail.png deleted file mode 100644 index 293b05794..000000000 Binary files a/docs/example/image_thumbnail.png and /dev/null differ diff --git a/docs/example/image_thumbnail.webp b/docs/example/image_thumbnail.webp new file mode 100644 index 000000000..9780f2852 Binary files /dev/null and b/docs/example/image_thumbnail.webp differ diff --git a/docs/example/imageops_contain.png b/docs/example/imageops_contain.png deleted file mode 100644 index 293b05794..000000000 Binary files a/docs/example/imageops_contain.png and /dev/null differ diff --git a/docs/example/imageops_contain.webp b/docs/example/imageops_contain.webp new file mode 100644 index 000000000..9780f2852 Binary files /dev/null and b/docs/example/imageops_contain.webp differ diff --git a/docs/example/imageops_cover.png b/docs/example/imageops_cover.png deleted file mode 100644 index 929e1d874..000000000 Binary files a/docs/example/imageops_cover.png and /dev/null differ diff --git a/docs/example/imageops_cover.webp b/docs/example/imageops_cover.webp new file mode 100644 index 000000000..a0b6c10bf Binary files /dev/null and b/docs/example/imageops_cover.webp differ diff --git a/docs/example/imageops_fit.png b/docs/example/imageops_fit.png deleted file mode 100644 index 13a3d5e3f..000000000 Binary files a/docs/example/imageops_fit.png and /dev/null differ diff --git a/docs/example/imageops_fit.webp b/docs/example/imageops_fit.webp new file mode 100644 index 000000000..803ee66d2 Binary files /dev/null and b/docs/example/imageops_fit.webp differ diff --git a/docs/example/imageops_pad.png b/docs/example/imageops_pad.png deleted file mode 100644 index 69649d6e5..000000000 Binary files a/docs/example/imageops_pad.png and /dev/null differ diff --git a/docs/example/imageops_pad.webp b/docs/example/imageops_pad.webp new file mode 100644 index 000000000..0ab63ef42 Binary files /dev/null and b/docs/example/imageops_pad.webp differ diff --git a/docs/example/size_vs_bbox.png b/docs/example/size_vs_bbox.png deleted file mode 100644 index 11a05d2a8..000000000 Binary files a/docs/example/size_vs_bbox.png and /dev/null differ diff --git a/docs/example/size_vs_bbox.webp b/docs/example/size_vs_bbox.webp new file mode 100644 index 000000000..391162d2d Binary files /dev/null and b/docs/example/size_vs_bbox.webp differ diff --git a/docs/handbook/text-anchors.rst b/docs/handbook/text-anchors.rst index 3a9572ab2..48de4bc95 100644 --- a/docs/handbook/text-anchors.rst +++ b/docs/handbook/text-anchors.rst @@ -132,7 +132,7 @@ of the two lines. .. comment: Image generated with ../example/anchors.py -.. image:: ../example/anchors.png +.. image:: ../example/anchors.webp :alt: Text anchor examples :align: center diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst index 523e2ad74..6cb1e2639 100644 --- a/docs/handbook/tutorial.rst +++ b/docs/handbook/tutorial.rst @@ -278,26 +278,26 @@ choose to resize relative to a given size. from PIL import Image, ImageOps size = (100, 150) - with Image.open("Tests/images/hopper.png") as im: - ImageOps.contain(im, size).save("imageops_contain.png") - ImageOps.cover(im, size).save("imageops_cover.png") - ImageOps.fit(im, size).save("imageops_fit.png") - ImageOps.pad(im, size, color="#f00").save("imageops_pad.png") + with Image.open("Tests/images/hopper.webp") as im: + ImageOps.contain(im, size).save("imageops_contain.webp") + ImageOps.cover(im, size).save("imageops_cover.webp") + ImageOps.fit(im, size).save("imageops_fit.webp") + ImageOps.pad(im, size, color="#f00").save("imageops_pad.webp") # thumbnail() can also be used, # but will modify the image object in place im.thumbnail(size) - im.save("imageops_thumbnail.png") + im.save("image_thumbnail.webp") -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | -+================+===========================================+============================================+==========================================+========================================+========================================+ -|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -|Resulting image | .. image:: ../example/image_thumbnail.png | .. image:: ../example/imageops_contain.png | .. image:: ../example/imageops_cover.png | .. image:: ../example/imageops_fit.png | .. image:: ../example/imageops_pad.png | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | ++================+============================================+=============================================+===========================================+=========================================+=========================================+ +|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +|Resulting image | .. image:: ../example/image_thumbnail.webp | .. image:: ../example/imageops_contain.webp | .. image:: ../example/imageops_cover.webp | .. image:: ../example/imageops_fit.webp | .. image:: ../example/imageops_pad.webp | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ .. _color-transforms: diff --git a/docs/installation/basic-installation.rst b/docs/installation/basic-installation.rst index 01981aa4f..989f72ddd 100644 --- a/docs/installation/basic-installation.rst +++ b/docs/installation/basic-installation.rst @@ -68,8 +68,8 @@ and :pypi:`olefile` for Pillow to read FPX and MIC images:: .. tab:: Windows We provide Pillow binaries for Windows compiled for the matrix of supported - Pythons in the wheel format. These include x86, x86-64 and arm64 versions - (with the exception of Python 3.8 on arm64). These binaries include support + Pythons in the wheel format. These include x86, x86-64 and arm64 versions. + These binaries include support for all optional libraries except libimagequant and libxcb. Raqm support requires FriBiDi to be installed separately:: diff --git a/docs/installation/newer-versions.csv b/docs/installation/newer-versions.csv index e21caf520..19816af58 100644 --- a/docs/installation/newer-versions.csv +++ b/docs/installation/newer-versions.csv @@ -1,8 +1,9 @@ -Python,3.12,3.11,3.10,3.9,3.8,3.7,3.6,3.5 -Pillow >= 10.1,Yes,Yes,Yes,Yes,Yes,,, -Pillow 10.0,,Yes,Yes,Yes,Yes,,, -Pillow 9.3 - 9.5,,Yes,Yes,Yes,Yes,Yes,, -Pillow 9.0 - 9.2,,,Yes,Yes,Yes,Yes,, -Pillow 8.3.2 - 8.4,,,Yes,Yes,Yes,Yes,Yes, -Pillow 8.0 - 8.3.1,,,,Yes,Yes,Yes,Yes, -Pillow 7.0 - 7.2,,,,,Yes,Yes,Yes,Yes +Python,3.13,3.12,3.11,3.10,3.9,3.8,3.7,3.6,3.5 +Pillow >= 11,Yes,Yes,Yes,Yes,Yes,,,, +Pillow 10.1 - 10.4,,Yes,Yes,Yes,Yes,Yes,,, +Pillow 10.0,,,Yes,Yes,Yes,Yes,,, +Pillow 9.3 - 9.5,,,Yes,Yes,Yes,Yes,Yes,, +Pillow 9.0 - 9.2,,,,Yes,Yes,Yes,Yes,, +Pillow 8.3.2 - 8.4,,,,Yes,Yes,Yes,Yes,Yes, +Pillow 8.0 - 8.3.1,,,,,Yes,Yes,Yes,Yes, +Pillow 7.0 - 7.2,,,,,,Yes,Yes,Yes,Yes diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index ed25d33a6..f2ef9cacb 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -27,8 +27,6 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | CentOS Stream 9 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Debian 11 Bullseye | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ | Debian 12 Bookworm | 3.11 | x86, x86-64 | +----------------------------------+----------------------------+---------------------+ | Fedora 39 | 3.12 | x86-64 | @@ -37,14 +35,12 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Gentoo | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| macOS 13 Ventura | 3.8, 3.9 | x86-64 | +| macOS 13 Ventura | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ | macOS 14 Sonoma | 3.10, 3.11, 3.12, 3.13, | arm64 | | | PyPy3 | | +----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 20.04 LTS (Focal) | 3.8 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 22.04 LTS (Jammy) | 3.8, 3.9, 3.10, 3.11, | x86-64 | +| Ubuntu Linux 22.04 LTS (Jammy) | 3.9, 3.10, 3.11, | x86-64 | | | 3.12, 3.13, PyPy3 | | | +----------------------------+---------------------+ | | 3.10 | arm64v8 | @@ -52,16 +48,16 @@ These platforms are built and tested for every change. | Ubuntu Linux 24.04 LTS (Noble) | 3.12 | x86-64, ppc64le, | | | | s390x | +----------------------------------+----------------------------+---------------------+ -| Windows Server 2016 | 3.8 | x86-64 | +| Windows Server 2016 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Windows Server 2022 | 3.8, 3.9, 3.10, 3.11, | x86-64 | +| Windows Server 2022 | 3.9, 3.10, 3.11, | x86-64 | | | 3.12, 3.13, PyPy3 | | | +----------------------------+---------------------+ | | 3.12 | x86 | | +----------------------------+---------------------+ | | 3.9 (MinGW) | x86-64 | | +----------------------------+---------------------+ -| | 3.8, 3.9 (Cygwin) | x86-64 | +| | 3.9 (Cygwin) | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -79,7 +75,7 @@ These platforms have been reported to work at the versions mentioned. | Operating system | | Tested Python | | Latest tested | | Tested | | | | versions | | Pillow version | | processors | +==================================+============================+==================+==============+ -| macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.3.0 |arm | +| macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.4.0 |arm | +----------------------------------+----------------------------+------------------+--------------+ | macOS 13 Ventura | 3.8, 3.9, 3.10, 3.11 | 10.0.1 |arm | | +----------------------------+------------------+ | diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 1c095a114..66c5e5422 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -195,6 +195,7 @@ This helps to get the bounding box coordinates of the input image:: .. automethod:: PIL.Image.Image.getpalette .. automethod:: PIL.Image.Image.getpixel .. automethod:: PIL.Image.Image.getprojection +.. automethod:: PIL.Image.Image.getxmp .. automethod:: PIL.Image.Image.histogram .. automethod:: PIL.Image.Image.paste .. automethod:: PIL.Image.Image.point @@ -380,6 +381,11 @@ Constants Set to 89,478,485, approximately 0.25GB for a 24-bit (3 bpp) image. See :py:meth:`~PIL.Image.open` for more information about how this is used. +.. data:: WARN_POSSIBLE_FORMATS + + Set to false. If true, when an image cannot be identified, warnings will be raised + from formats that attempted to read the data. + Transpose methods ^^^^^^^^^^^^^^^^^ diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 1404869ca..3e9aa73f8 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -691,23 +691,7 @@ Methods :param hints: An optional list of hints. :returns: A (drawing context, drawing resource factory) tuple. -.. py:method:: floodfill(image, xy, value, border=None, thresh=0) - - .. warning:: This method is experimental. - - Fills a bounded region with a given color. - - :param image: Target image. - :param xy: Seed position (a 2-item coordinate tuple). - :param value: Fill color. - :param border: Optional border value. If given, the region consists of - pixels with a color different from the border color. If not given, - the region consists of pixels having the same color as the seed - pixel. - :param thresh: Optional threshold value which specifies a maximum - tolerable difference of a pixel value from the 'background' in - order for it to be replaced. Useful for filling regions of non- - homogeneous, but similar, colors. +.. autofunction:: PIL.ImageDraw.floodfill .. _BCP 47 language code: https://www.w3.org/International/articles/language-tags/ .. _OpenType docs: https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist diff --git a/docs/reference/ImageFont.rst b/docs/reference/ImageFont.rst index 6edf4b05c..edbdd9a32 100644 --- a/docs/reference/ImageFont.rst +++ b/docs/reference/ImageFont.rst @@ -53,6 +53,7 @@ Functions .. autofunction:: PIL.ImageFont.load_path .. autofunction:: PIL.ImageFont.truetype .. autofunction:: PIL.ImageFont.load_default +.. autofunction:: PIL.ImageFont.load_default_imagefont Methods ------- diff --git a/docs/reference/ImageOps.rst b/docs/reference/ImageOps.rst index 051fdcfc9..fcaa3c8f6 100644 --- a/docs/reference/ImageOps.rst +++ b/docs/reference/ImageOps.rst @@ -36,26 +36,26 @@ Resize relative to a given size from PIL import Image, ImageOps size = (100, 150) - with Image.open("Tests/images/hopper.png") as im: - ImageOps.contain(im, size).save("imageops_contain.png") - ImageOps.cover(im, size).save("imageops_cover.png") - ImageOps.fit(im, size).save("imageops_fit.png") - ImageOps.pad(im, size, color="#f00").save("imageops_pad.png") + with Image.open("Tests/images/hopper.webp") as im: + ImageOps.contain(im, size).save("imageops_contain.webp") + ImageOps.cover(im, size).save("imageops_cover.webp") + ImageOps.fit(im, size).save("imageops_fit.webp") + ImageOps.pad(im, size, color="#f00").save("imageops_pad.webp") # thumbnail() can also be used, # but will modify the image object in place im.thumbnail(size) - im.save("imageops_thumbnail.png") + im.save("image_thumbnail.webp") -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | -+================+===========================================+============================================+==========================================+========================================+========================================+ -|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -|Resulting image | .. image:: ../example/image_thumbnail.png | .. image:: ../example/imageops_contain.png | .. image:: ../example/imageops_cover.png | .. image:: ../example/imageops_fit.png | .. image:: ../example/imageops_pad.png | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | ++================+============================================+=============================================+===========================================+=========================================+=========================================+ +|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +|Resulting image | .. image:: ../example/image_thumbnail.webp | .. image:: ../example/imageops_contain.webp | .. image:: ../example/imageops_cover.webp | .. image:: ../example/imageops_fit.webp | .. image:: ../example/imageops_pad.webp | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ .. autofunction:: contain .. autofunction:: cover diff --git a/docs/reference/PixelAccess.rst b/docs/reference/PixelAccess.rst index 04d6f5dcd..1ac3d034b 100644 --- a/docs/reference/PixelAccess.rst +++ b/docs/reference/PixelAccess.rst @@ -44,42 +44,23 @@ Access using negative indexes is also possible. :: ----------------------------- .. class:: PixelAccess + :canonical: PIL.Image.core.PixelAccess - .. method:: __setitem__(self, xy, color): + .. method:: __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...] - Modifies the pixel at x,y. The color is given as a single - numerical value for single band images, and a tuple for - multi-band images - - :param xy: The pixel coordinate, given as (x, y). - :param color: The pixel value according to its mode. e.g. tuple (r, g, b) for RGB mode) - - .. method:: __getitem__(self, xy): - - Returns the pixel at x,y. The pixel is returned as a single - value for single band images or a tuple for multiple band - images + Returns the pixel at x,y. The pixel is returned as a single + value for single band images or a tuple for multi-band images. :param xy: The pixel coordinate, given as (x, y). :returns: a pixel value for single band images, a tuple of - pixel values for multiband images. + pixel values for multiband images. - .. method:: putpixel(self, xy, color): + .. method:: __setitem__(self, xy: tuple[int, int], color: float | tuple[int, ...]) -> None Modifies the pixel at x,y. The color is given as a single numerical value for single band images, and a tuple for - multi-band images. In addition to this, RGB and RGBA tuples - are accepted for P and PA images. + multi-band images. :param xy: The pixel coordinate, given as (x, y). - :param color: The pixel value according to its mode. e.g. tuple (r, g, b) for RGB mode) - - .. method:: getpixel(self, xy): - - Returns the pixel at x,y. The pixel is returned as a single - value for single band images or a tuple for multiple band - images - - :param xy: The pixel coordinate, given as (x, y). - :returns: a pixel value for single band images, a tuple of - pixel values for multiband images. + :param color: The pixel value according to its mode, + e.g. tuple (r, g, b) for RGB mode. diff --git a/docs/reference/PyAccess.rst b/docs/reference/PyAccess.rst deleted file mode 100644 index ed58ca3a5..000000000 --- a/docs/reference/PyAccess.rst +++ /dev/null @@ -1,46 +0,0 @@ -.. py:module:: PIL.PyAccess -.. py:currentmodule:: PIL.PyAccess - -:py:mod:`~PIL.PyAccess` Module -============================== - -The :py:mod:`~PIL.PyAccess` module provides a CFFI/Python implementation of the :ref:`PixelAccess`. This implementation is far faster on PyPy than the PixelAccess version. - -.. note:: Accessing individual pixels is fairly slow. If you are - looping over all of the pixels in an image, there is likely - a faster way using other parts of the Pillow API. - - :mod:`~PIL.Image`, :mod:`~PIL.ImageChops` and :mod:`~PIL.ImageOps` - have methods for many standard operations. If you wish to perform - a custom mapping, check out :py:meth:`~PIL.Image.Image.point`. - -Example -------- - -The following script loads an image, accesses one pixel from it, then changes it. :: - - from PIL import Image - - with Image.open("hopper.jpg") as im: - px = im.load() - print(px[4, 4]) - px[4, 4] = (0, 0, 0) - print(px[4, 4]) - -Results in the following:: - - (23, 24, 68) - (0, 0, 0) - -Access using negative indexes is also possible. :: - - px[-1, -1] = (0, 0, 0) - print(px[-1, -1]) - - - -:py:class:`PyAccess` Class --------------------------- - -.. autoclass:: PIL.PyAccess.PyAccess() - :members: diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 82c75e373..effcd3c46 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -32,7 +32,6 @@ Reference JpegPresets PSDraw PixelAccess - PyAccess features ../PIL plugins diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index 899e4966f..e4cb17c4d 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -33,6 +33,10 @@ Internal Modules Provides a convenient way to import type hints that are not available on some Python versions. +.. py:class:: NumpyArray + + Typing alias. + .. py:class:: StrOrBytesPath Typing alias. diff --git a/docs/releasenotes/10.0.0.rst b/docs/releasenotes/10.0.0.rst index adada6e01..2ea973c5c 100644 --- a/docs/releasenotes/10.0.0.rst +++ b/docs/releasenotes/10.0.0.rst @@ -158,7 +158,7 @@ PyAccess and Image.USE_CFFI_ACCESS ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Since Pillow's C API is now faster than PyAccess on PyPy, -:py:mod:`~PIL.PyAccess` has been deprecated and will be removed in Pillow +:py:mod:`!PyAccess` has been deprecated and will be removed in Pillow 11.0.0 (2024-10-15). Pillow's C API will now be used by default on PyPy instead. ``Image.USE_CFFI_ACCESS``, for switching from the C API to PyAccess, is diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index 44727efd4..8d3706be6 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -4,21 +4,16 @@ Security ======== -TODO -^^^^ +ImageShow.WindowsViewer.show_file +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +If an attacker has control over the ``path`` passed to +``ImageShow.WindowsViewer.show_file()``, they may be able to +execute arbitrary shell commands. -:cve:`YYYY-XXXXX`: TODO -^^^^^^^^^^^^^^^^^^^^^^^ - -TODO - -Backwards Incompatible Changes -============================== - -TODO -^^^^ +To prevent this, a :py:exc:`FileNotFoundError` will be raised if the ``path`` +does not exist as a file. To provide a consistent experience, the error has +been added to all :py:class:`~PIL.ImageShow` viewers. Deprecations ============ @@ -28,6 +23,13 @@ BGR;15, BGR 16 and BGR;24 The experimental BGR;15, BGR;16 and BGR;24 modes have been deprecated. +Non-image modes in ImageCms +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The use in :py:mod:`.ImageCms` of input modes and output modes that are not Pillow +image modes has been deprecated. Defaulting to "L" or "1" if the mode cannot be mapped +is also deprecated. + Support for LibTIFF earlier than 4 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -39,14 +41,6 @@ ImageDraw.getdraw hints parameter The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. -API Changes -=========== - -TODO -^^^^ - -TODO - API Additions ============= @@ -57,11 +51,6 @@ Added :py:meth:`~PIL.ImageDraw.ImageDraw.circle`. It provides the same functiona :py:meth:`~PIL.ImageDraw.ImageDraw.ellipse`, but instead of taking a bounding box, it takes a center point and radius. -TODO -^^^^ - -TODO - Other Changes ============= diff --git a/docs/releasenotes/11.0.0.rst b/docs/releasenotes/11.0.0.rst new file mode 100644 index 000000000..964423ae0 --- /dev/null +++ b/docs/releasenotes/11.0.0.rst @@ -0,0 +1,77 @@ +11.0.0 +------ + +Security +======== + +TODO +^^^^ + +TODO + +:cve:`YYYY-XXXXX`: TODO +^^^^^^^^^^^^^^^^^^^^^^^ + +TODO + +Backwards Incompatible Changes +============================== + +Python 3.8 +^^^^^^^^^^ + +Pillow has dropped support for Python 3.8, +which reached end-of-life in October 2024. + +PSFile +^^^^^^ + +The :py:class:`!PSFile` class was removed in Pillow 11 (2024-10-15). +This class was only made as a helper to be used internally, +so there is no replacement. If you need this functionality though, +it is a very short class that can easily be recreated in your own code. + +PyAccess and Image.USE_CFFI_ACCESS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Since Pillow's C API is now faster than PyAccess on PyPy, ``PyAccess`` has been +removed. Pillow's C API will now be used on PyPy instead. + +``Image.USE_CFFI_ACCESS``, for switching from the C API to PyAccess, was +similarly removed. + +Deprecations +============ + +TODO +^^^^ + +TODO + +API Changes +=========== + +TODO +^^^^ + +TODO + +API Additions +============= + +TODO +^^^^ + +TODO + +Other Changes +============= + +Python 3.13 +^^^^^^^^^^^ + +Pillow 10.4.0 had wheels built against Python 3.13 beta, available as a preview to help +others prepare for 3.13, and to ensure Pillow could be used immediately at the release +of 3.13.0 final (2024-10-01, :pep:`719`). + +Pillow 11.0.0 now officially supports Python 3.13. diff --git a/docs/releasenotes/8.3.0.rst b/docs/releasenotes/8.3.0.rst index 9f46cc1e9..4ef914f64 100644 --- a/docs/releasenotes/8.3.0.rst +++ b/docs/releasenotes/8.3.0.rst @@ -18,9 +18,9 @@ is not secure. - :py:meth:`~PIL.Image.Image.getexif` has used ``xml`` to potentially retrieve orientation data since Pillow 7.2.0. It has been refactored to use ``re`` instead. -- :py:meth:`~PIL.JpegImagePlugin.JpegImageFile.getxmp` was added in Pillow 8.2.0. It - will now use ``defusedxml`` instead. If the dependency is not present, an empty - dictionary will be returned and a warning raised. +- ``getxmp()`` was added to :py:class:`~PIL.JpegImagePlugin.JpegImageFile` in Pillow + 8.2.0. It will now use ``defusedxml`` instead. If the dependency is not present, an + empty dictionary will be returned and a warning raised. Deprecations ============ diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index fe29f2e4f..6e0647343 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -98,7 +98,7 @@ Previously, the ``size`` methods returned a ``height`` that included the vertica offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` offset. -.. image:: ../example/size_vs_bbox.png +.. image:: ../example/size_vs_bbox.webp :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. :align: center diff --git a/docs/releasenotes/9.5.0.rst b/docs/releasenotes/9.5.0.rst index 08e9ec2a4..501479bb6 100644 --- a/docs/releasenotes/9.5.0.rst +++ b/docs/releasenotes/9.5.0.rst @@ -32,7 +32,7 @@ Deprecations PSFile ^^^^^^ -The :py:class:`~PIL.EpsImagePlugin.PSFile` class has been deprecated and will +The :py:class:`!PSFile` class has been deprecated and will be removed in Pillow 11 (2024-10-15). This class was only made as a helper to be used internally, so there is no replacement. If you need this functionality though, it is a very short class that can easily be recreated in your own code. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index 6ee5fb6c8..641cda4ef 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -14,6 +14,7 @@ expected to be backported to earlier versions. .. toctree:: :maxdepth: 2 + 11.0.0 10.4.0 10.3.0 10.2.0 diff --git a/pyproject.toml b/pyproject.toml index 20e87ad32..cd7248669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,18 +14,20 @@ readme = "README.md" keywords = [ "Imaging", ] -license = {text = "HPND"} -authors = [{name = "Jeffrey A. Clark", email = "aclark@aclark.net"}] -requires-python = ">=3.8" +license = { text = "HPND" } +authors = [ + { name = "Jeffrey A. Clark", email = "aclark@aclark.net" }, +] +requires-python = ">=3.9" classifiers = [ "Development Status :: 6 - Mature", "License :: OSI Approved :: Historical Permission Notice and Disclaimer (HPND)", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Multimedia :: Graphics", @@ -38,8 +40,7 @@ classifiers = [ dynamic = [ "version", ] -[project.optional-dependencies] -docs = [ +optional-dependencies.docs = [ "furo", "olefile", "sphinx>=7.3", @@ -47,13 +48,13 @@ docs = [ "sphinx-inline-tabs", "sphinxext-opengraph", ] -fpx = [ +optional-dependencies.fpx = [ "olefile", ] -mic = [ +optional-dependencies.mic = [ "olefile", ] -tests = [ +optional-dependencies.tests = [ "check-manifest", "coverage", "defusedxml", @@ -65,28 +66,29 @@ tests = [ "pytest-cov", "pytest-timeout", ] -typing = [ - 'typing-extensions; python_version < "3.10"', +optional-dependencies.typing = [ + "typing-extensions; python_version<'3.10'", ] -xmp = [ +optional-dependencies.xmp = [ "defusedxml", ] -[project.urls] -Changelog = "https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst" -Documentation = "https://pillow.readthedocs.io" -Funding = "https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=pypi" -Homepage = "https://python-pillow.org" -Mastodon = "https://fosstodon.org/@pillow" -"Release notes" = "https://pillow.readthedocs.io/en/stable/releasenotes/index.html" -Source = "https://github.com/python-pillow/Pillow" +urls.Changelog = "https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst" +urls.Documentation = "https://pillow.readthedocs.io" +urls.Funding = "https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=pypi" +urls.Homepage = "https://python-pillow.org" +urls.Mastodon = "https://fosstodon.org/@pillow" +urls."Release notes" = "https://pillow.readthedocs.io/en/stable/releasenotes/index.html" +urls.Source = "https://github.com/python-pillow/Pillow" [tool.setuptools] -packages = ["PIL"] +packages = [ + "PIL", +] include-package-data = true -package-dir = {"" = "src"} +package-dir = { "" = "src" } [tool.setuptools.dynamic] -version = {attr = "PIL.__version__"} +version = { attr = "PIL.__version__" } [tool.cibuildwheel] before-all = ".github/workflows/wheels-dependencies.sh" @@ -98,45 +100,53 @@ test-extras = "tests" [tool.ruff] fix = true -[tool.ruff.lint] -select = [ - "C4", # flake8-comprehensions - "E", # pycodestyle errors - "EM", # flake8-errmsg - "F", # pyflakes errors - "I", # isort - "ISC", # flake8-implicit-str-concat - "LOG", # flake8-logging - "PGH", # pygrep-hooks - "PYI", # flake8-pyi +lint.select = [ + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "EM", # flake8-errmsg + "F", # pyflakes errors + "I", # isort + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "PGH", # pygrep-hooks + "PYI", # flake8-pyi "RUF100", # unused noqa (yesqa) - "UP", # pyupgrade - "W", # pycodestyle warnings - "YTT", # flake8-2020 + "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 ] -ignore = [ - "E203", # Whitespace before ':' - "E221", # Multiple spaces before operator - "E226", # Missing whitespace around arithmetic operator - "E241", # Multiple spaces after ',' +lint.ignore = [ + "E203", # Whitespace before ':' + "E221", # Multiple spaces before operator + "E226", # Missing whitespace around arithmetic operator + "E241", # Multiple spaces after ',' "PYI026", # flake8-pyi: typing.TypeAlias added in Python 3.10 "PYI034", # flake8-pyi: typing.Self added in Python 3.11 ] +lint.per-file-ignores."Tests/oss-fuzz/fuzz_font.py" = [ + "I002", +] +lint.per-file-ignores."Tests/oss-fuzz/fuzz_pillow.py" = [ + "I002", +] +lint.isort.known-first-party = [ + "PIL", +] +lint.isort.required-imports = [ + "from __future__ import annotations", +] -[tool.ruff.lint.per-file-ignores] -"Tests/oss-fuzz/fuzz_font.py" = ["I002"] -"Tests/oss-fuzz/fuzz_pillow.py" = ["I002"] - -[tool.ruff.lint.isort] -known-first-party = ["PIL"] -required-imports = ["from __future__ import annotations"] +[tool.pyproject-fmt] +max_supported_python = "3.13" [tool.pytest.ini_options] addopts = "-ra --color=yes" -testpaths = ["Tests"] +testpaths = [ + "Tests", +] [tool.mypy] -python_version = "3.8" +python_version = "3.9" pretty = true disallow_any_generics = true enable_error_code = "ignore-without-code" @@ -145,3 +155,11 @@ follow_imports = "silent" warn_redundant_casts = true warn_unreachable = true warn_unused_ignores = true +exclude = [ + '^Tests/oss-fuzz/fuzz_font.py$', + '^Tests/oss-fuzz/fuzz_pillow.py$', + '^Tests/test_qt_image_qapplication.py$', + '^Tests/test_font_pcf_charsets.py$', + '^Tests/test_font_pcf.py$', + '^Tests/test_file_tar.py$', +] diff --git a/setup.py b/setup.py index 0abfaaddc..b26852b0b 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ WEBP_ROOT = None ZLIB_ROOT = None FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ -if sys.platform == "win32" and sys.version_info >= (3, 13): +if sys.platform == "win32" and sys.version_info >= (3, 14): import atexit atexit.register( diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 59246c6e2..b9cefafdd 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -430,6 +430,7 @@ class BLPEncoder(ImageFile.PyEncoder): def _write_palette(self) -> bytes: data = b"" + assert self.im is not None palette = self.im.getpalette("RGBA", "RGBA") for i in range(len(palette) // 4): r, g, b, a = palette[i * 4 : (i + 1) * 4] @@ -444,6 +445,7 @@ class BLPEncoder(ImageFile.PyEncoder): offset = 20 + 16 * 4 * 2 + len(palette_data) data = struct.pack("<16I", offset, *((0,) * 15)) + assert self.im is not None w, h = self.im.size data += struct.pack("<16I", w * h, *((0,) * 15)) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index e74727007..a57e4aea2 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -380,6 +380,7 @@ class DdsImageFile(ImageFile.ImageFile): elif pfflags & DDPF.PALETTEINDEXED8: self._mode = "P" self.palette = ImagePalette.raw("RGBA", self.fp.read(1024)) + self.palette.mode = "RGBA" elif pfflags & DDPF.FOURCC: offset = header_size + 4 if fourcc == D3DFMT.DXT1: diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 380b1cf0e..59bb8594d 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -31,7 +31,6 @@ from typing import IO from . import Image, ImageFile from ._binary import i32le as i32 -from ._deprecate import deprecate # -------------------------------------------------------------------- @@ -159,43 +158,6 @@ def Ghostscript(tile, size, fp, scale=1, transparency=False): return im -class PSFile: - """ - Wrapper for bytesio object that treats either CR or LF as end of line. - This class is no longer used internally, but kept for backwards compatibility. - """ - - def __init__(self, fp): - deprecate( - "PSFile", - 11, - action="If you need the functionality of this class " - "you will need to implement it yourself.", - ) - self.fp = fp - self.char = None - - def seek(self, offset, whence=io.SEEK_SET): - self.char = None - self.fp.seek(offset, whence) - - def readline(self) -> str: - s = [self.char or b""] - self.char = None - - c = self.fp.read(1) - while (c not in b"\r\n") and len(c): - s.append(c) - c = self.fp.read(1) - - self.char = self.fp.read(1) - # line endings can be 1 or 2 of \r \n, in either order - if self.char in b"\r\n": - self.char = None - - return b"".join(s).decode("latin-1") - - def _accept(prefix: bytes) -> bool: return prefix[:4] == b"%!PS" or (len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5) @@ -230,6 +192,11 @@ class EpsImageFile(ImageFile.ImageFile): trailer_reached = False def check_required_header_comments() -> None: + """ + The EPS specification requires that some headers exist. + This should be checked when the header comments formally end, + when image data starts, or when the file ends, whichever comes first. + """ if "PS-Adobe" not in self.info: msg = 'EPS header missing "%!PS-Adobe" comment' raise SyntaxError(msg) @@ -270,6 +237,8 @@ class EpsImageFile(ImageFile.ImageFile): if byte == b"": # if we didn't read a byte we must be at the end of the file if bytes_read == 0: + if reading_header_comments: + check_required_header_comments() break elif byte in b"\r\n": # if we read a line ending character, ignore it and parse what @@ -365,13 +334,11 @@ class EpsImageFile(ImageFile.ImageFile): trailer_reached = True bytes_read = 0 - check_required_header_comments() - if not self._size: msg = "cannot determine EPS bounding box" raise OSError(msg) - def _find_offset(self, fp): + def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]: s = fp.read(4) if s == b"%!PS": @@ -394,7 +361,9 @@ class EpsImageFile(ImageFile.ImageFile): return length, offset - def load(self, scale=1, transparency=False): + def load( + self, scale: int = 1, transparency: bool = False + ) -> Image.core.PixelAccess | None: # Load EPS via Ghostscript if self.tile: self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index dceb83927..52d1fce31 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -45,7 +45,7 @@ class FliImageFile(ImageFile.ImageFile): format_description = "Autodesk FLI/FLC Animation" _close_exclusive_fp_after_loading = False - def _open(self): + def _open(self) -> None: # HEAD s = self.fp.read(128) if not (_accept(s) and s[20:22] == b"\x00\x00"): @@ -83,7 +83,7 @@ class FliImageFile(ImageFile.ImageFile): if i16(s, 4) == 0xF1FA: # look for palette chunk number_of_subchunks = i16(s, 6) - chunk_size = None + chunk_size: int | None = None for _ in range(number_of_subchunks): if chunk_size is not None: self.fp.seek(chunk_size - 6, os.SEEK_CUR) @@ -96,8 +96,9 @@ class FliImageFile(ImageFile.ImageFile): if not chunk_size: break - palette = [o8(r) + o8(g) + o8(b) for (r, g, b) in palette] - self.palette = ImagePalette.raw("RGB", b"".join(palette)) + self.palette = ImagePalette.raw( + "RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette) + ) # set things up to decode first frame self.__frame = -1 @@ -105,7 +106,7 @@ class FliImageFile(ImageFile.ImageFile): self.__rewind = self.fp.tell() self.seek(0) - def _palette(self, palette, shift): + def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None: # load palette i = 0 diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index c1927bd26..386e37233 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -53,7 +53,7 @@ class FpxImageFile(ImageFile.ImageFile): format = "FPX" format_description = "FlashPix" - def _open(self): + def _open(self) -> None: # # read the OLE directory and see if this is a likely # to be a FlashPix file @@ -64,7 +64,8 @@ class FpxImageFile(ImageFile.ImageFile): msg = "not an FPX file; invalid OLE file" raise SyntaxError(msg) from e - if self.ole.root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B": + root = self.ole.root + if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B": msg = "not an FPX file; bad root CLSID" raise SyntaxError(msg) @@ -99,8 +100,7 @@ class FpxImageFile(ImageFile.ImageFile): s = prop[0x2000002 | id] - bands = i32(s, 4) - if bands > 4: + if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4: msg = "Invalid number of bands" raise OSError(msg) @@ -231,7 +231,7 @@ class FpxImageFile(ImageFile.ImageFile): self._fp = self.fp self.fp = None - def load(self): + def load(self) -> Image.core.PixelAccess | None: if not self.fp: self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"]) diff --git a/src/PIL/GbrImagePlugin.py b/src/PIL/GbrImagePlugin.py index 93e89b1e6..3c8feea5f 100644 --- a/src/PIL/GbrImagePlugin.py +++ b/src/PIL/GbrImagePlugin.py @@ -88,7 +88,7 @@ class GbrImageFile(ImageFile.ImageFile): # Data is an uncompressed block of w * h * bytes/pixel self._data_size = width * height * color_depth - def load(self): + def load(self) -> Image.core.PixelAccess | None: if not self.im: self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self._data_size)) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 541d97f8c..bf74f9356 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -32,7 +32,7 @@ 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 typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, Union from . import ( Image, @@ -331,7 +331,6 @@ class GifImageFile(ImageFile.ImageFile): LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY or palette ): - self.pyaccess = None if "transparency" in self.info: self.im.putpalettealpha(self.info["transparency"], 0) self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG) @@ -433,7 +432,7 @@ class GifImageFile(ImageFile.ImageFile): self._prev_im = self.im if self._frame_palette: self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) - self.im.putpalette(*self._frame_palette.getdata()) + self.im.putpalette("RGB", *self._frame_palette.getdata()) else: self.im = None self._mode = temp_mode @@ -505,7 +504,7 @@ def _normalize_mode(im: Image.Image) -> Image.Image: return im.convert("L") -_Palette = Union[bytes, bytearray, List[int], ImagePalette.ImagePalette] +_Palette = Union[bytes, bytearray, list[int], ImagePalette.ImagePalette] def _normalize_palette( diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 2a89d498c..8729f7643 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -34,11 +34,13 @@ MAGIC = b"icns" HEADERSIZE = 8 -def nextheader(fobj): +def nextheader(fobj: IO[bytes]) -> tuple[bytes, int]: return struct.unpack(">4sI", fobj.read(HEADERSIZE)) -def read_32t(fobj, start_length, size): +def read_32t( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: # The 128x128 icon seems to have an extra header for some reason. (start, length) = start_length fobj.seek(start) @@ -49,7 +51,9 @@ def read_32t(fobj, start_length, size): return read_32(fobj, (start + 4, length - 4), size) -def read_32(fobj, start_length, size): +def read_32( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: """ Read a 32bit RGB icon resource. Seems to be either uncompressed or an RLE packbits-like scheme. @@ -72,14 +76,14 @@ def read_32(fobj, start_length, size): byte = fobj.read(1) if not byte: break - byte = byte[0] - if byte & 0x80: - blocksize = byte - 125 + byte_int = byte[0] + if byte_int & 0x80: + blocksize = byte_int - 125 byte = fobj.read(1) for i in range(blocksize): data.append(byte) else: - blocksize = byte + 1 + blocksize = byte_int + 1 data.append(fobj.read(blocksize)) bytesleft -= blocksize if bytesleft <= 0: @@ -92,7 +96,9 @@ def read_32(fobj, start_length, size): return {"RGB": im} -def read_mk(fobj, start_length, size): +def read_mk( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: # Alpha masks seem to be uncompressed start = start_length[0] fobj.seek(start) @@ -102,10 +108,14 @@ def read_mk(fobj, start_length, size): return {"A": band} -def read_png_or_jpeg2000(fobj, start_length, size): +def read_png_or_jpeg2000( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: (start, length) = start_length fobj.seek(start) sig = fobj.read(12) + + im: Image.Image if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a": fobj.seek(start) im = PngImagePlugin.PngImageFile(fobj) @@ -164,12 +174,12 @@ class IcnsFile: ], } - def __init__(self, fobj): + def __init__(self, fobj: IO[bytes]) -> None: """ fobj is a file-like object as an icns resource """ # signature : (start, length) - self.dct = dct = {} + self.dct = {} self.fobj = fobj sig, filesize = nextheader(fobj) if not _accept(sig): @@ -183,11 +193,11 @@ class IcnsFile: raise SyntaxError(msg) i += HEADERSIZE blocksize -= HEADERSIZE - dct[sig] = (i, blocksize) + self.dct[sig] = (i, blocksize) fobj.seek(blocksize, io.SEEK_CUR) i += blocksize - def itersizes(self): + def itersizes(self) -> list[tuple[int, int, int]]: sizes = [] for size, fmts in self.SIZES.items(): for fmt, reader in fmts: @@ -196,14 +206,14 @@ class IcnsFile: break return sizes - def bestsize(self): + def bestsize(self) -> tuple[int, int, int]: sizes = self.itersizes() if not sizes: msg = "No 32bit icon resources found" raise SyntaxError(msg) return max(sizes) - def dataforsize(self, size): + def dataforsize(self, size: tuple[int, int, int]) -> dict[str, Image.Image]: """ Get an icon resource as {channel: array}. Note that the arrays are bottom-up like windows bitmaps and will likely @@ -216,18 +226,20 @@ class IcnsFile: dct.update(reader(self.fobj, desc, size)) return dct - def getimage(self, size=None): + def getimage( + self, size: tuple[int, int] | tuple[int, int, int] | None = None + ) -> Image.Image: if size is None: size = self.bestsize() - if len(size) == 2: + elif len(size) == 2: size = (size[0], size[1], 1) channels = self.dataforsize(size) - im = channels.get("RGBA", None) + im = channels.get("RGBA") if im: return im - im = channels.get("RGB").copy() + im = channels["RGB"].copy() try: im.putalpha(channels["A"]) except KeyError: @@ -268,7 +280,7 @@ class IcnsImageFile(ImageFile.ImageFile): return self._size @size.setter - def size(self, value): + def size(self, value) -> None: info_size = value if info_size not in self.info["sizes"] and len(info_size) == 2: info_size = (info_size[0], info_size[1], 1) @@ -287,7 +299,7 @@ class IcnsImageFile(ImageFile.ImageFile): raise ValueError(msg) self._size = value - def load(self): + def load(self) -> Image.core.PixelAccess | None: if len(self.size) == 3: self.best_size = self.size self.size = ( diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 227fcf35c..650f5e4f1 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -120,7 +120,7 @@ def _accept(prefix: bytes) -> bool: class IcoFile: - def __init__(self, buf): + def __init__(self, buf) -> None: """ Parse image from file-like object containing ico file data """ @@ -177,19 +177,19 @@ class IcoFile: # ICO images are usually squares self.entry = sorted(self.entry, key=lambda x: x["square"], reverse=True) - def sizes(self): + def sizes(self) -> set[tuple[int, int]]: """ Get a list of all available icon sizes and color depths. """ return {(h["width"], h["height"]) for h in self.entry} - def getentryindex(self, size, bpp=False): + def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int: for i, h in enumerate(self.entry): if size == h["dim"] and (bpp is False or bpp == h["color_depth"]): return i return 0 - def getimage(self, size, bpp=False): + def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image: """ Get an image from the icon """ @@ -321,7 +321,7 @@ class IcoImageFile(ImageFile.ImageFile): raise ValueError(msg) self._size = value - def load(self): + def load(self) -> Image.core.PixelAccess | None: if self.im is not None and self.im.size == self.size: # Already loaded return Image.Image.load(self) @@ -329,7 +329,6 @@ class IcoImageFile(ImageFile.ImageFile): # if tile is PNG, it won't really be loaded yet im.load() self.im = im.im - self.pyaccess = None self._mode = im.mode if im.palette: self.palette = im.palette @@ -342,6 +341,7 @@ class IcoImageFile(ImageFile.ImageFile): self.info["sizes"] = set(sizes) self.size = im.size + return None def load_seek(self, pos: int) -> None: # Flag the ImageFile.Parser so that it diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 015c2febe..2fb7ecd52 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -79,7 +79,7 @@ OPEN = { "LA image": ("LA", "LA;L"), "PA image": ("LA", "PA;L"), "RGBA image": ("RGBA", "RGBA;L"), - "RGBX image": ("RGBX", "RGBX;L"), + "RGBX image": ("RGB", "RGBX;L"), "CMYK image": ("CMYK", "CMYK;L"), "YCC image": ("YCbCr", "YCbCr;L"), } diff --git a/src/PIL/Image.py b/src/PIL/Image.py index f4d64292c..8dcc1715e 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -38,10 +38,17 @@ import struct import sys import tempfile import warnings -from collections.abc import Callable, MutableMapping +from collections.abc import Callable, MutableMapping, Sequence from enum import IntEnum from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, Tuple, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Literal, + Protocol, + cast, +) # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -56,7 +63,6 @@ from . import ( ) from ._binary import i32le, o32be, o32le from ._deprecate import deprecate -from ._typing import StrOrBytesPath, TypeGuard from ._util import DeferredError, is_path ElementTree: ModuleType | None @@ -76,6 +82,8 @@ class DecompressionBombError(Exception): pass +WARN_POSSIBLE_FORMATS: bool = False + # Limit to around a quarter gigabyte for a 24-bit (3 bpp) image MAX_IMAGE_PIXELS: int | None = int(1024 * 1024 * 1024 // 4 // 3) @@ -114,14 +122,6 @@ except ImportError as v: raise -USE_CFFI_ACCESS = False -cffi: ModuleType | None -try: - import cffi -except ImportError: - cffi = None - - def isImageType(t: Any) -> TypeGuard[Image]: """ Checks if an object is an image object. @@ -218,7 +218,8 @@ if hasattr(core, "DEFAULT_STRATEGY"): # Registries if TYPE_CHECKING: - from . import ImageFile + from . import ImageFile, ImagePalette + from ._typing import NumpyArray, StrOrBytesPath, TypeGuard ID: list[str] = [] OPEN: dict[ str, @@ -559,7 +560,6 @@ class Image: self.palette = None self.info = {} self.readonly = 0 - self.pyaccess = None self._exif = None @classmethod @@ -630,7 +630,6 @@ class Image: def _copy(self) -> None: self.load() self.im = self.im.copy() - self.pyaccess = None self.readonly = 0 def _ensure_mutable(self) -> None: @@ -881,7 +880,7 @@ class Image: msg = "cannot decode image data" raise ValueError(msg) - def load(self): + def load(self) -> core.PixelAccess | None: """ Allocates storage for the image and loads the pixel data. In normal cases, you don't need to call this method, since the @@ -894,12 +893,12 @@ class Image: operations. See :ref:`file-handling` for more information. :returns: An image access object. - :rtype: :ref:`PixelAccess` or :py:class:`PIL.PyAccess` + :rtype: :py:class:`.PixelAccess` """ if self.im is not None and self.palette and self.palette.dirty: # realize palette mode, arr = self.palette.getdata() - self.im.putpalette(mode, arr) + self.im.putpalette(self.palette.mode, mode, arr) self.palette.dirty = 0 self.palette.rawmode = None if "transparency" in self.info and mode in ("LA", "PA"): @@ -909,20 +908,13 @@ class Image: self.im.putpalettealphas(self.info["transparency"]) self.palette.mode = "RGBA" else: - palette_mode = "RGBA" if mode.startswith("RGBA") else "RGB" - self.palette.mode = palette_mode - self.palette.palette = self.im.getpalette(palette_mode, palette_mode) + self.palette.palette = self.im.getpalette( + self.palette.mode, self.palette.mode + ) if self.im is not None: - if cffi and USE_CFFI_ACCESS: - if self.pyaccess: - return self.pyaccess - from . import PyAccess - - self.pyaccess = PyAccess.new(self, self.readonly) - if self.pyaccess: - return self.pyaccess return self.im.pixel_access(self.readonly) + return None def verify(self) -> None: """ @@ -1112,7 +1104,10 @@ class Image: del new_im.info["transparency"] if trns is not None: try: - new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) + new_im.info["transparency"] = new_im.palette.getcolor( + cast(tuple[int, ...], trns), # trns was converted to RGB + new_im, + ) except Exception: # if we can't make a transparent color, don't leave the old # transparency hanging around to mess us up. @@ -1160,7 +1155,7 @@ class Image: # crash fail if we leave a bytes transparency in an rgb/l mode. del new_im.info["transparency"] if trns is not None: - if new_im.mode == "P": + if new_im.mode == "P" and new_im.palette: try: new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) except ValueError as e: @@ -1178,9 +1173,9 @@ class Image: def quantize( self, colors: int = 256, - method: Quantize | None = None, + method: int | None = None, kmeans: int = 0, - palette=None, + palette: Image | None = None, dither: Dither = Dither.FLOYDSTEINBERG, ) -> Image: """ @@ -1252,8 +1247,8 @@ class Image: from . import ImagePalette mode = im.im.getpalettemode() - palette = im.im.getpalette(mode, mode)[: colors * len(mode)] - im.palette = ImagePalette.ImagePalette(mode, palette) + palette_data = im.im.getpalette(mode, mode)[: colors * len(mode)] + im.palette = ImagePalette.ImagePalette(mode, palette_data) return im @@ -1319,7 +1314,7 @@ class Image: return im.crop((x0, y0, x1, y1)) def draft( - self, mode: str | None, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] | None ) -> tuple[str, tuple[int, int, float, float]] | None: """ Configures the image file loader so it returns a version of the @@ -1408,7 +1403,9 @@ class Image: self.load() return self.im.getbbox(alpha_only) - def getcolors(self, maxcolors: int = 256): + def getcolors( + self, maxcolors: int = 256 + ) -> list[tuple[int, tuple[int, ...]]] | list[tuple[int, float]] | None: """ Returns a list of colors used in this image. @@ -1425,7 +1422,7 @@ class Image: self.load() if self.mode in ("1", "L", "P"): h = self.im.histogram() - out = [(h[i], i) for i in range(256) if h[i]] + out: list[tuple[int, float]] = [(h[i], i) for i in range(256) if h[i]] if len(out) > maxcolors: return None return out @@ -1469,7 +1466,14 @@ class Image: return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return self.im.getextrema() - def _getxmp(self, xmp_tags): + def getxmp(self) -> dict[str, Any]: + """ + Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. + + :returns: XMP tags in a dictionary. + """ + def get_name(tag: str) -> str: return re.sub("^{[^}]+}", "", tag) @@ -1496,9 +1500,10 @@ class Image: if ElementTree is None: warnings.warn("XMP data cannot be read without defusedxml dependency") return {} - else: - root = ElementTree.fromstring(xmp_tags) - return {get_name(root.tag): get_value(root)} + if "xmp" not in self.info: + return {} + root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00")) + return {get_name(root.tag): get_value(root)} def getexif(self) -> Exif: """ @@ -1659,7 +1664,9 @@ class Image: del self.info["transparency"] - def getpixel(self, xy): + def getpixel( + self, xy: tuple[int, int] | list[int] + ) -> float | tuple[int, ...] | None: """ Returns the pixel value at a given position. @@ -1670,8 +1677,6 @@ class Image: """ self.load() - if self.pyaccess: - return self.pyaccess.getpixel(xy) return self.im.getpixel(tuple(xy)) def getprojection(self) -> tuple[list[int], list[int]]: @@ -1746,7 +1751,7 @@ class Image: def paste( self, im: Image | str | float | tuple[float, ...], - box: tuple[int, int, int, int] | tuple[int, int] | None = None, + box: Image | tuple[int, int, int, int] | tuple[int, int] | None = None, mask: Image | None = None, ) -> None: """ @@ -1788,10 +1793,14 @@ class Image: :param mask: An optional mask image. """ - if isImageType(box) and mask is None: + if isImageType(box): + if mask is not None: + msg = "If using second argument as mask, third argument must be None" + raise ValueError(msg) # abbreviated paste(im, mask) syntax mask = box box = None + assert not isinstance(box, Image) if box is None: box = (0, 0) @@ -1887,7 +1896,7 @@ class Image: def point( self, - lut: Sequence[float] | Callable[[int], float] | ImagePointHandler, + lut: Sequence[float] | NumpyArray | Callable[[int], float] | ImagePointHandler, mode: str | None = None, ) -> Image: """ @@ -1939,15 +1948,14 @@ class Image: flatLut = [round(i) for i in flatLut] return self._new(self.im.point(flatLut, mode)) - def putalpha(self, alpha): + def putalpha(self, alpha: Image | int) -> None: """ Adds or replaces the alpha layer in this image. If the image does not have an alpha layer, it's converted to "LA" or "RGBA". The new layer must be either "L" or "1". :param alpha: The new alpha layer. This can either be an "L" or "1" - image having the same size as this image, or an integer or - other color value. + image having the same size as this image, or an integer. """ self._ensure_mutable() @@ -1965,7 +1973,6 @@ class Image: msg = "alpha channel could not be added" raise ValueError(msg) from e # sanity check self.im = im - self.pyaccess = None self._mode = self.im.mode except KeyError as e: msg = "illegal image mode" @@ -1986,6 +1993,7 @@ class Image: alpha = alpha.convert("L") else: # constant alpha + alpha = cast(int, alpha) # see python/typing#1013 try: self.im.fillband(band, alpha) except (AttributeError, ValueError): @@ -1997,7 +2005,10 @@ class Image: self.im.putband(alpha.im, band) def putdata( - self, data: Sequence[float], scale: float = 1.0, offset: float = 0.0 + self, + data: Sequence[float] | Sequence[Sequence[int]] | NumpyArray, + scale: float = 1.0, + offset: float = 0.0, ) -> None: """ Copies pixel data from a flattened sequence object into the image. The @@ -2016,7 +2027,11 @@ class Image: self.im.putdata(data, scale, offset) - def putpalette(self, data, rawmode="RGB") -> None: + def putpalette( + self, + data: ImagePalette.ImagePalette | bytes | Sequence[int], + rawmode: str = "RGB", + ) -> None: """ Attaches a palette to this image. The image must be a "P", "PA", "L" or "LA" image. @@ -2048,10 +2063,12 @@ class Image: palette = ImagePalette.raw(rawmode, data) self._mode = "PA" if "A" in self.mode else "P" self.palette = palette - self.palette.mode = "RGB" + self.palette.mode = "RGBA" if "A" in rawmode else "RGB" self.load() # install new palette - def putpixel(self, xy, value): + def putpixel( + self, xy: tuple[int, int], value: float | tuple[int, ...] | list[int] + ) -> None: """ Modifies the pixel at the given position. The color is given as a single numerical value for single-band images, and a tuple for @@ -2077,9 +2094,6 @@ class Image: self._copy() self.load() - if self.pyaccess: - return self.pyaccess.putpixel(xy, value) - if ( self.mode in ("P", "PA") and isinstance(value, (list, tuple)) @@ -2089,12 +2103,13 @@ class Image: if self.mode == "PA": alpha = value[3] if len(value) == 4 else 255 value = value[:3] - value = self.palette.getcolor(value, self) - if self.mode == "PA": - value = (value, alpha) + palette_index = self.palette.getcolor(value, self) + value = (palette_index, alpha) if self.mode == "PA" else palette_index return self.im.putpixel(xy, value) - def remap_palette(self, dest_map, source_palette=None): + def remap_palette( + self, dest_map: list[int], source_palette: bytes | bytearray | None = None + ) -> Image: """ Rewrites the image to reorder the palette. @@ -2163,7 +2178,7 @@ class Image: # m_im.putpalette(mapping_palette, 'L') # converts to 'P' # or just force it. # UNDONE -- this is part of the general issue with palettes - m_im.im.putpalette(palette_mode + ";L", m_im.palette.tobytes()) + m_im.im.putpalette(palette_mode, palette_mode + ";L", m_im.palette.tobytes()) m_im = m_im.convert("L") @@ -2198,7 +2213,7 @@ class Image: def resize( self, - size: tuple[int, int], + size: tuple[int, int] | list[int] | NumpyArray, resample: int | None = None, box: tuple[float, float, float, float] | None = None, reducing_gap: float | None = None, @@ -2206,7 +2221,7 @@ class Image: """ Returns a resized copy of this image. - :param size: The requested size in pixels, as a 2-tuple: + :param size: The requested size in pixels, as a tuple or array: (width, height). :param resample: An optional resampling filter. This can be one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, @@ -2271,6 +2286,7 @@ class Image: if box is None: box = (0, 0) + self.size + size = tuple(size) if self.size == size and box == (0, 0) + self.size: return self.copy() @@ -2344,7 +2360,7 @@ class Image: angle: float, resample: Resampling = Resampling.NEAREST, expand: int | bool = False, - center: tuple[int, int] | None = None, + center: tuple[float, float] | None = None, translate: tuple[int, int] | None = None, fillcolor: float | tuple[float, ...] | str | None = None, ) -> Image: @@ -2412,10 +2428,7 @@ class Image: else: post_trans = translate if center is None: - # FIXME These should be rounded to ints? - rotn_center = (w / 2.0, h / 2.0) - else: - rotn_center = center + center = (w / 2, h / 2) angle = -math.radians(angle) matrix = [ @@ -2432,10 +2445,10 @@ class Image: return a * x + b * y + c, d * x + e * y + f matrix[2], matrix[5] = transform( - -rotn_center[0] - post_trans[0], -rotn_center[1] - post_trans[1], matrix + -center[0] - post_trans[0], -center[1] - post_trans[1], matrix ) - matrix[2] += rotn_center[0] - matrix[5] += rotn_center[1] + matrix[2] += center[0] + matrix[5] += center[1] if expand: # calculate output size @@ -2658,7 +2671,7 @@ class Image: self, size: tuple[float, float], resample: Resampling = Resampling.BICUBIC, - reducing_gap: float = 2.0, + reducing_gap: float | None = 2.0, ) -> None: """ Make this image into a thumbnail. This method modifies the @@ -2719,11 +2732,12 @@ class Image: return x, y box = None + final_size: tuple[int, int] if reducing_gap is not None: preserved_size = preserve_aspect_ratio() if preserved_size is None: return - size = preserved_size + final_size = preserved_size res = self.draft( None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) @@ -2737,17 +2751,16 @@ class Image: preserved_size = preserve_aspect_ratio() if preserved_size is None: return - size = preserved_size + final_size = preserved_size - if self.size != size: - im = self.resize(size, resample, box=box, reducing_gap=reducing_gap) + if self.size != final_size: + im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap) self.im = im.im - self._size = size + self._size = final_size self._mode = self.im.mode self.readonly = 0 - self.pyaccess = None # FIXME: the different transform methods need further explanation # instead of bloating the method docs, add a separate chapter. @@ -3071,7 +3084,7 @@ def new( and isinstance(color, (list, tuple)) and all(isinstance(i, int) for i in color) ): - color_ints: tuple[int, ...] = cast(Tuple[int, ...], tuple(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 rgb_color = True @@ -3425,7 +3438,7 @@ def open( preinit() - accept_warnings: list[str] = [] + warning_messages: list[str] = [] def _open_core( fp: IO[bytes], @@ -3441,17 +3454,15 @@ def open( factory, accept = OPEN[i] result = not accept or accept(prefix) if isinstance(result, str): - accept_warnings.append(result) + warning_messages.append(result) elif result: fp.seek(0) im = factory(fp, filename) _decompression_bomb_check(im.size) return im - except (SyntaxError, IndexError, TypeError, struct.error): - # Leave disabled by default, spams the logs with image - # opening failures that are entirely expected. - # logger.debug("", exc_info=True) - continue + except (SyntaxError, IndexError, TypeError, struct.error) as e: + if WARN_POSSIBLE_FORMATS: + warning_messages.append(i + " opening failed. " + str(e)) except BaseException: if exclusive_fp: fp.close() @@ -3476,7 +3487,7 @@ def open( if exclusive_fp: fp.close() - for message in accept_warnings: + for message in warning_messages: warnings.warn(message) msg = "cannot identify image file %r" % (filename if filename else fp) raise UnidentifiedImageError(msg) @@ -3541,7 +3552,7 @@ def composite(image1: Image, image2: Image, mask: Image) -> Image: return image -def eval(image, *args): +def eval(image: Image, *args: Callable[[int], float]) -> Image: """ Applies the function (which should take one argument) to each pixel in the given image. If the image has more than one band, the same diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 19a79facc..ec10230f1 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -299,6 +299,31 @@ class ImageCmsTransform(Image.ImagePointHandler): proof_intent: Intent = Intent.ABSOLUTE_COLORIMETRIC, flags: Flags = Flags.NONE, ): + supported_modes = ( + "RGB", + "RGBA", + "RGBX", + "CMYK", + "I;16", + "I;16L", + "I;16B", + "YCbCr", + "LAB", + "L", + "1", + ) + for mode in (input_mode, output_mode): + if mode not in supported_modes: + deprecate( + mode, + 12, + { + "L;16": "I;16 or I;16L", + "L:16B": "I;16B", + "YCCA": "YCbCr", + "YCC": "YCbCr", + }.get(mode), + ) if proof is None: self.transform = core.buildTransform( input.profile, output.profile, input_mode, output_mode, intent, flags diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 41a3eb0cb..2b3620e71 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,16 +34,26 @@ from __future__ import annotations import math import numbers import struct +from collections.abc import Sequence from types import ModuleType -from typing import TYPE_CHECKING, AnyStr, Sequence, cast +from typing import TYPE_CHECKING, AnyStr, Callable, Union, cast from . import Image, ImageColor from ._deprecate import deprecate from ._typing import Coords +# experimental access to the outline API +Outline: Callable[[], Image.core._Outline] | None +try: + Outline = Image.core.outline +except AttributeError: + Outline = None + if TYPE_CHECKING: from . import ImageDraw2, ImageFont +_Ink = Union[float, tuple[int, ...], str] + """ A simple 2D drawing interface for PIL images.

@@ -53,7 +63,9 @@ directly. class ImageDraw: - font = None + font: ( + ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None + ) = None def __init__(self, im: Image.Image, mode: str | None = None) -> None: """ @@ -134,34 +146,47 @@ class ImageDraw: else: return self.getfont() - def _getink(self, ink, fill=None) -> tuple[int | None, int | None]: + def _getink( + self, ink: _Ink | None, fill: _Ink | None = None + ) -> tuple[int | None, int | None]: + result_ink = None + result_fill = None if ink is None and fill is None: if self.fill: - fill = self.ink + result_fill = self.ink else: - ink = self.ink + result_ink = self.ink else: if ink is not None: if isinstance(ink, str): ink = ImageColor.getcolor(ink, self.mode) if self.palette and not isinstance(ink, numbers.Number): ink = self.palette.getcolor(ink, self._image) - ink = self.draw.draw_ink(ink) + result_ink = self.draw.draw_ink(ink) if fill is not None: if isinstance(fill, str): fill = ImageColor.getcolor(fill, self.mode) if self.palette and not isinstance(fill, numbers.Number): fill = self.palette.getcolor(fill, self._image) - fill = self.draw.draw_ink(fill) - return ink, fill + result_fill = self.draw.draw_ink(fill) + return result_ink, result_fill - def arc(self, xy: Coords, start, end, fill=None, width=1) -> None: + def arc( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an arc.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_arc(xy, start, end, ink, width) - def bitmap(self, xy: Sequence[int], bitmap, fill=None) -> None: + def bitmap( + self, xy: Sequence[int], bitmap: Image.Image, fill: _Ink | None = None + ) -> None: """Draw a bitmap.""" bitmap.load() ink, fill = self._getink(fill) @@ -170,30 +195,55 @@ class ImageDraw: if ink is not None: self.draw.draw_bitmap(xy, bitmap.im, ink) - def chord(self, xy: Coords, start, end, fill=None, outline=None, width=1) -> None: + def chord( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a chord.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_chord(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_chord(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_chord(xy, start, end, ink, 0, width) - def ellipse(self, xy: Coords, fill=None, outline=None, width=1) -> None: + def ellipse( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an ellipse.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_ellipse(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_ellipse(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_ellipse(xy, ink, 0, width) def circle( - self, xy: Sequence[float], radius: float, fill=None, outline=None, width=1 + self, + xy: Sequence[float], + radius: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, ) -> None: """Draw a circle given center coordinates and a radius.""" ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius) self.ellipse(ellipse_xy, fill, outline, width) - def line(self, xy: Coords, fill=None, width=0, joint=None) -> None: + def line( + self, + xy: Coords, + fill: _Ink | None = None, + width: int = 0, + joint: str | None = None, + ) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] if ink is not None: @@ -223,7 +273,7 @@ class ImageDraw: def coord_at_angle( coord: Sequence[float], angle: float - ) -> tuple[float, float]: + ) -> tuple[float, ...]: x, y = coord angle -= 90 distance = width / 2 - 1 @@ -264,37 +314,54 @@ class ImageDraw: ] self.line(gap_coords, fill, width=3) - def shape(self, shape, fill=None, outline=None) -> None: + def shape( + self, + shape: Image.core._Outline, + fill: _Ink | None = None, + outline: _Ink | None = None, + ) -> None: """(Experimental) Draw a shape.""" shape.close() - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_outline(shape, fill, 1) - if ink is not None and ink != fill: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_outline(shape, fill_ink, 1) + if ink is not None and ink != fill_ink: self.draw.draw_outline(shape, ink, 0) def pieslice( - self, xy: Coords, start, end, fill=None, outline=None, width=1 + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, ) -> None: """Draw a pieslice.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_pieslice(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_pieslice(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_pieslice(xy, start, end, ink, 0, width) - def point(self, xy: Coords, fill=None) -> None: + def point(self, xy: Coords, fill: _Ink | None = None) -> None: """Draw one or more individual pixels.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_points(xy, ink) - def polygon(self, xy: Coords, fill=None, outline=None, width=1) -> None: + def polygon( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a polygon.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_polygon(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_polygon(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: if width == 1: self.draw.draw_polygon(xy, ink, 0, width) elif self.im is not None: @@ -320,22 +387,41 @@ class ImageDraw: self.im.paste(im.im, (0, 0) + im.size, mask.im) def regular_polygon( - self, bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1 + self, + bounding_circle: Sequence[Sequence[float] | float], + n_sides: int, + rotation: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, ) -> None: """Draw a regular polygon.""" xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) self.polygon(xy, fill, outline, width) - def rectangle(self, xy: Coords, fill=None, outline=None, width=1) -> None: + def rectangle( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a rectangle.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_rectangle(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_rectangle(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_rectangle(xy, ink, 0, width) def rounded_rectangle( - self, xy: Coords, radius=0, fill=None, outline=None, width=1, *, corners=None + self, + xy: Coords, + radius: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + *, + corners: tuple[bool, bool, bool, bool] | None = None, ) -> None: """Draw a rounded rectangle.""" if isinstance(xy[0], (list, tuple)): @@ -377,10 +463,10 @@ class ImageDraw: # that is a rectangle return self.rectangle(xy, fill, outline, width) - r = d // 2 - ink, fill = self._getink(outline, fill) + r = int(d // 2) + ink, fill_ink = self._getink(outline, fill) - def draw_corners(pieslice) -> None: + def draw_corners(pieslice: bool) -> None: parts: tuple[tuple[tuple[float, float, float, float], int, int], ...] if full_x: # Draw top and bottom halves @@ -410,32 +496,32 @@ class ImageDraw: ) for part in parts: if pieslice: - self.draw.draw_pieslice(*(part + (fill, 1))) + self.draw.draw_pieslice(*(part + (fill_ink, 1))) else: self.draw.draw_arc(*(part + (ink, width))) - if fill is not None: + if fill_ink is not None: draw_corners(True) if full_x: - self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill, 1) + self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1) else: - self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill, 1) + self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1) if not full_x and not full_y: left = [x0, y0, x0 + r, y1] if corners[0]: left[1] += r + 1 if corners[3]: left[3] -= r + 1 - self.draw.draw_rectangle(left, fill, 1) + self.draw.draw_rectangle(left, fill_ink, 1) right = [x1 - r, y0, x1, y1] if corners[1]: right[1] += r + 1 if corners[2]: right[3] -= r + 1 - self.draw.draw_rectangle(right, fill, 1) - if ink is not None and ink != fill and width != 0: + self.draw.draw_rectangle(right, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: draw_corners(False) if not full_x: @@ -530,10 +616,11 @@ class ImageDraw: embedded_color, ) - def getink(fill): - ink, fill = self._getink(fill) + def getink(fill: _Ink | None) -> int: + ink, fill_ink = self._getink(fill) if ink is None: - return fill + assert fill_ink is not None + return fill_ink return ink def draw_text(ink, stroke_width=0, stroke_offset=None) -> None: @@ -897,13 +984,6 @@ def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: return ImageDraw(im, mode) -# experimental access to the outline API -try: - Outline = Image.core.outline -except AttributeError: - Outline = None - - def getdraw( im: Image.Image | None = None, hints: list[str] | None = None ) -> tuple[ImageDraw2.Draw | None, ModuleType]: @@ -928,7 +1008,9 @@ def floodfill( thresh: float = 0, ) -> None: """ - (experimental) Fills a bounded region with a given color. + .. warning:: This method is experimental. + + Fills a bounded region with a given color. :param image: Target image. :param xy: Seed position (a 2-item coordinate tuple). See @@ -946,6 +1028,7 @@ def floodfill( # based on an implementation by Eric S. Raymond # amended by yo1995 @20180806 pixel = image.load() + assert pixel is not None x, y = xy try: background = pixel[x, y] @@ -983,12 +1066,12 @@ def floodfill( def _compute_regular_polygon_vertices( - bounding_circle, n_sides, rotation + bounding_circle: Sequence[Sequence[float] | float], n_sides: int, rotation: float ) -> list[tuple[float, float]]: """ Generate a list of vertices for a 2D regular polygon. - :param bounding_circle: The bounding circle is a tuple defined + :param bounding_circle: The bounding circle is a sequence defined by a point and radius. The polygon is inscribed in this circle. (e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``) :param n_sides: Number of sides @@ -1026,7 +1109,7 @@ def _compute_regular_polygon_vertices( # 1. Error Handling # 1.1 Check `n_sides` has an appropriate value if not isinstance(n_sides, int): - msg = "n_sides should be an int" + msg = "n_sides should be an int" # type: ignore[unreachable] raise TypeError(msg) if n_sides < 3: msg = "n_sides should be an int > 2" @@ -1038,9 +1121,24 @@ def _compute_regular_polygon_vertices( raise TypeError(msg) if len(bounding_circle) == 3: - *centroid, polygon_radius = bounding_circle - elif len(bounding_circle) == 2: - centroid, polygon_radius = bounding_circle + if not all(isinstance(i, (int, float)) for i in bounding_circle): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + *centroid, polygon_radius = cast(list[float], list(bounding_circle)) + elif len(bounding_circle) == 2 and isinstance(bounding_circle[0], (list, tuple)): + if not all( + isinstance(i, (int, float)) for i in bounding_circle[0] + ) or not isinstance(bounding_circle[1], (int, float)): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + if len(bounding_circle[0]) != 2: + msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" + raise ValueError(msg) + + centroid = cast(list[float], list(bounding_circle[0])) + polygon_radius = cast(float, bounding_circle[1]) else: msg = ( "bounding_circle should contain 2D coordinates " @@ -1048,25 +1146,17 @@ def _compute_regular_polygon_vertices( ) raise ValueError(msg) - if not all(isinstance(i, (int, float)) for i in (*centroid, polygon_radius)): - msg = "bounding_circle should only contain numeric data" - raise ValueError(msg) - - if not len(centroid) == 2: - msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" - raise ValueError(msg) - if polygon_radius <= 0: msg = "bounding_circle radius should be > 0" raise ValueError(msg) # 1.3 Check `rotation` has an appropriate value if not isinstance(rotation, (int, float)): - msg = "rotation should be an int or float" + msg = "rotation should be an int or float" # type: ignore[unreachable] raise ValueError(msg) # 2. Define Helper Functions - def _apply_rotation(point: list[float], degrees: float) -> tuple[int, int]: + def _apply_rotation(point: list[float], degrees: float) -> tuple[float, float]: return ( round( point[0] * math.cos(math.radians(360 - degrees)) @@ -1082,7 +1172,7 @@ def _compute_regular_polygon_vertices( ), ) - def _compute_polygon_vertex(angle: float) -> tuple[int, int]: + def _compute_polygon_vertex(angle: float) -> tuple[float, float]: start_point = [polygon_radius, 0] return _apply_rotation(start_point, angle) diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py index b42f5d9ea..e89a78be4 100644 --- a/src/PIL/ImageDraw2.py +++ b/src/PIL/ImageDraw2.py @@ -24,7 +24,10 @@ """ from __future__ import annotations +from typing import BinaryIO + from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath +from ._typing import StrOrBytesPath class Pen: @@ -45,7 +48,9 @@ class Brush: class Font: """Stores a TrueType font and color""" - def __init__(self, color, file, size=12): + def __init__( + self, color: str, file: StrOrBytesPath | BinaryIO, size: float = 12 + ) -> None: # FIXME: add support for bitmap fonts self.color = ImageColor.getrgb(color) self.font = ImageFont.truetype(file, size) @@ -56,8 +61,16 @@ class Draw: (Experimental) WCK-style drawing interface """ - def __init__(self, image, size=None, color=None): - if not hasattr(image, "im"): + def __init__( + self, + image: Image.Image | str, + size: tuple[int, int] | list[int] | None = None, + color: float | tuple[float, ...] | str | None = None, + ) -> None: + if isinstance(image, str): + if size is None: + msg = "If image argument is mode string, size must be a list or tuple" + raise ValueError(msg) image = Image.new(image, size, color) self.draw = ImageDraw.Draw(image) self.image = image diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 36bd004bf..746106fba 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -65,7 +65,7 @@ Dict of known error codes returned from :meth:`.PyDecoder.decode`, # Helpers -def _get_oserror(error, *, encoder): +def _get_oserror(error: int, *, encoder: bool) -> OSError: try: msg = Image.core.getcodecstatus(error) except AttributeError: @@ -76,7 +76,7 @@ def _get_oserror(error, *, encoder): return OSError(msg) -def raise_oserror(error): +def raise_oserror(error: int) -> OSError: deprecate( "raise_oserror", 12, @@ -154,11 +154,12 @@ class ImageFile(Image.Image): self.fp.close() raise - def get_format_mimetype(self): + def get_format_mimetype(self) -> str | None: if self.custom_mimetype: return self.custom_mimetype if self.format is not None: return Image.MIME.get(self.format.upper()) + return None def __setstate__(self, state): self.tile = [] @@ -365,7 +366,7 @@ class StubImageFile(ImageFile): certain format, but relies on external code to load the file. """ - def _open(self): + def _open(self) -> None: msg = "StubImageFile subclass must implement _open" raise NotImplementedError(msg) @@ -381,7 +382,7 @@ class StubImageFile(ImageFile): self.__dict__ = image.__dict__ return image.load() - def _load(self): + def _load(self) -> StubHandler | None: """(Hook) Find actual image loader.""" msg = "StubImageFile subclass must implement _load" raise NotImplementedError(msg) @@ -621,7 +622,7 @@ class PyCodecState: self.xoff = 0 self.yoff = 0 - def extents(self): + def extents(self) -> tuple[int, int, int, int]: return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize @@ -661,7 +662,7 @@ class PyCodec: """ self.fd = fd - def setimage(self, im, extents=None): + def setimage(self, im, extents: tuple[int, int, int, int] | None = None) -> None: """ Called from ImageFile to set the core output image for the codec @@ -710,10 +711,10 @@ class PyDecoder(PyCodec): _pulls_fd = False @property - def pulls_fd(self): + def pulls_fd(self) -> bool: return self._pulls_fd - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: """ Override to perform the decoding process. @@ -738,6 +739,7 @@ class PyDecoder(PyCodec): if not rawmode: rawmode = self.mode d = Image._getdecoder(self.mode, "raw", rawmode) + assert self.im is not None d.setimage(self.im, self.state.extents()) s = d.decode(data) @@ -760,7 +762,7 @@ class PyEncoder(PyCodec): _pushes_fd = False @property - def pushes_fd(self): + def pushes_fd(self) -> bool: return self._pushes_fd def encode(self, bufsize: int) -> tuple[int, int, bytes]: @@ -775,7 +777,7 @@ class PyEncoder(PyCodec): msg = "unavailable in base encoder" raise NotImplementedError(msg) - def encode_to_pyfd(self): + def encode_to_pyfd(self) -> tuple[int, int]: """ If ``pushes_fd`` is ``True``, then this method will be used, and ``encode()`` will only be called once. @@ -787,6 +789,7 @@ class PyEncoder(PyCodec): return 0, -8 # bad configuration bytes_consumed, errcode, data = self.encode(0) if data: + assert self.fd is not None self.fd.write(data) return bytes_consumed, errcode diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 02288e135..8b0974b2c 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -18,13 +18,18 @@ from __future__ import annotations import abc import functools +from collections.abc import Sequence from types import ModuleType -from typing import Any, Sequence +from typing import TYPE_CHECKING, Any, Callable, cast + +if TYPE_CHECKING: + from . import _imaging + from ._typing import NumpyArray class Filter: @abc.abstractmethod - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: pass @@ -33,7 +38,9 @@ class MultibandFilter(Filter): class BuiltinFilter(MultibandFilter): - def filter(self, image): + filterargs: tuple[Any, ...] + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: if image.mode == "P": msg = "cannot filter palette images" raise ValueError(msg) @@ -91,7 +98,7 @@ class RankFilter(Filter): self.size = size self.rank = rank - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: if image.mode == "P": msg = "cannot filter palette images" raise ValueError(msg) @@ -158,7 +165,7 @@ class ModeFilter(Filter): def __init__(self, size: int = 3) -> None: self.size = size - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: return image.modefilter(self.size) @@ -176,9 +183,9 @@ class GaussianBlur(MultibandFilter): def __init__(self, radius: float | Sequence[float] = 2) -> None: self.radius = radius - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: xy = self.radius - if not isinstance(xy, (tuple, list)): + if isinstance(xy, (int, float)): xy = (xy, xy) if xy == (0, 0): return image.copy() @@ -208,9 +215,9 @@ class BoxBlur(MultibandFilter): raise ValueError(msg) self.radius = radius - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: xy = self.radius - if not isinstance(xy, (tuple, list)): + if isinstance(xy, (int, float)): xy = (xy, xy) if xy == (0, 0): return image.copy() @@ -241,7 +248,7 @@ class UnsharpMask(MultibandFilter): self.percent = percent self.threshold = threshold - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: return image.unsharp_mask(self.radius, self.percent, self.threshold) @@ -387,8 +394,13 @@ class Color3DLUT(MultibandFilter): name = "Color 3D LUT" def __init__( - self, size, table, channels: int = 3, target_mode: str | None = None, **kwargs - ): + self, + size: int | tuple[int, int, int], + table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray, + channels: int = 3, + target_mode: str | None = None, + **kwargs: bool, + ) -> None: if channels not in (3, 4): msg = "Only 3 or 4 output channels are supported" raise ValueError(msg) @@ -410,15 +422,16 @@ class Color3DLUT(MultibandFilter): pass if numpy and isinstance(table, numpy.ndarray): + numpy_table: NumpyArray = table if copy_table: - table = table.copy() + numpy_table = numpy_table.copy() - if table.shape in [ + if numpy_table.shape in [ (items * channels,), (items, channels), (size[2], size[1], size[0], channels), ]: - table = table.reshape(items * channels) + table = numpy_table.reshape(items * channels) else: wrong_size = True @@ -428,7 +441,8 @@ class Color3DLUT(MultibandFilter): # Convert to a flat list if table and isinstance(table[0], (list, tuple)): - table, raw_table = [], table + raw_table = cast(Sequence[Sequence[int]], table) + flat_table: list[int] = [] for pixel in raw_table: if len(pixel) != channels: msg = ( @@ -436,7 +450,8 @@ class Color3DLUT(MultibandFilter): f"have a length of {channels}." ) raise ValueError(msg) - table.extend(pixel) + flat_table.extend(pixel) + table = flat_table if wrong_size or len(table) != items * channels: msg = ( @@ -449,7 +464,7 @@ class Color3DLUT(MultibandFilter): self.table = table @staticmethod - def _check_size(size: Any) -> list[int]: + def _check_size(size: Any) -> tuple[int, int, int]: try: _, _, _ = size except ValueError as e: @@ -457,7 +472,7 @@ class Color3DLUT(MultibandFilter): raise ValueError(msg) from e except TypeError: size = (size, size, size) - size = [int(x) for x in size] + size = tuple(int(x) for x in size) for size_1d in size: if not 2 <= size_1d <= 65: msg = "Size should be in [2, 65] range." @@ -465,7 +480,13 @@ class Color3DLUT(MultibandFilter): return size @classmethod - def generate(cls, size, callback, channels=3, target_mode=None): + def generate( + cls, + size: int | tuple[int, int, int], + callback: Callable[[float, float, float], tuple[float, ...]], + channels: int = 3, + target_mode: str | None = None, + ) -> Color3DLUT: """Generates new LUT using provided callback. :param size: Size of the table. Passed to the constructor. @@ -482,7 +503,7 @@ class Color3DLUT(MultibandFilter): msg = "Only 3 or 4 output channels are supported" raise ValueError(msg) - table = [0] * (size_1d * size_2d * size_3d * channels) + table: list[float] = [0] * (size_1d * size_2d * size_3d * channels) idx_out = 0 for b in range(size_3d): for g in range(size_2d): @@ -500,7 +521,13 @@ class Color3DLUT(MultibandFilter): _copy_table=False, ) - def transform(self, callback, with_normals=False, channels=None, target_mode=None): + def transform( + self, + callback: Callable[..., tuple[float, ...]], + with_normals: bool = False, + channels: int | None = None, + target_mode: str | None = None, + ) -> Color3DLUT: """Transforms the table values using provided callback and returns a new LUT with altered values. @@ -564,7 +591,7 @@ class Color3DLUT(MultibandFilter): r.append(f"target_mode={self.mode}") return "<{}>".format(" ".join(r)) - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: from . import Image return image.color_lut_3d( diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index fa5608e6c..d260eef69 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -33,11 +33,12 @@ import sys import warnings from enum import IntEnum from io import BytesIO +from types import ModuleType from typing import IO, TYPE_CHECKING, Any, BinaryIO from . import Image from ._typing import StrOrBytesPath -from ._util import is_path +from ._util import DeferredError, is_path if TYPE_CHECKING: from . import ImageFile @@ -53,11 +54,10 @@ class Layout(IntEnum): MAX_STRING_LENGTH = 1_000_000 +core: ModuleType | DeferredError try: from . import _imagingft as core except ImportError as ex: - from ._util import DeferredError - core = DeferredError.new(ex) @@ -199,6 +199,7 @@ class FreeTypeFont: """FreeType font wrapper (requires _imagingft service)""" font: Font + font_bytes: bytes def __init__( self, @@ -210,6 +211,9 @@ class FreeTypeFont: ) -> None: # FIXME: use service provider instead + if isinstance(core, DeferredError): + raise core.ex + if size <= 0: msg = "font size must be greater than 0" raise ValueError(msg) @@ -279,7 +283,7 @@ class FreeTypeFont: return self.font.ascent, self.font.descent def getlength( - self, text: str, mode="", direction=None, features=None, language=None + self, text: str | bytes, mode="", direction=None, features=None, language=None ) -> float: """ Returns length (in pixels with 1/64 precision) of given text when rendered @@ -354,7 +358,7 @@ class FreeTypeFont: def getbbox( self, - text: str, + text: str | bytes, mode: str = "", direction: str | None = None, features: list[str] | None = None, @@ -511,7 +515,7 @@ class FreeTypeFont: def getmask2( self, - text: str, + text: str | bytes, mode="", direction=None, features=None, @@ -730,7 +734,7 @@ class TransposedFont: return 0, 0, height, width return 0, 0, width, height - def getlength(self, text: str, *args, **kwargs) -> float: + def getlength(self, text: str | bytes, *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) @@ -775,10 +779,15 @@ def truetype( :param font: A filename or file-like object containing a TrueType font. If the file is not found in this filename, the loader may also - search in other directories, such as the :file:`fonts/` - directory on Windows or :file:`/Library/Fonts/`, - :file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` on - macOS. + search in other directories, such as: + + * The :file:`fonts/` directory on Windows, + * :file:`/Library/Fonts/`, :file:`/System/Library/Fonts/` + and :file:`~/Library/Fonts/` on macOS. + * :file:`~/.local/share/fonts`, :file:`/usr/local/share/fonts`, + and :file:`/usr/share/fonts` on Linux; or those specified by + the ``XDG_DATA_HOME`` and ``XDG_DATA_DIRS`` environment variables + for user-installed and system-wide fonts, respectively. :param size: The requested size, in pixels. :param index: Which font face to load (default is first available face). @@ -837,12 +846,21 @@ def truetype( if windir: dirs.append(os.path.join(windir, "fonts")) elif sys.platform in ("linux", "linux2"): - lindirs = os.environ.get("XDG_DATA_DIRS") - if not lindirs: - # According to the freedesktop spec, XDG_DATA_DIRS should - # default to /usr/share - lindirs = "/usr/share" - dirs += [os.path.join(lindir, "fonts") for lindir in lindirs.split(":")] + data_home = os.environ.get("XDG_DATA_HOME") + if not data_home: + # The freedesktop spec defines the following default directory for + # when XDG_DATA_HOME is unset or empty. This user-level directory + # takes precedence over system-level directories. + data_home = os.path.expanduser("~/.local/share") + xdg_dirs = [data_home] + + data_dirs = os.environ.get("XDG_DATA_DIRS") + if not data_dirs: + # Similarly, defaults are defined for the system-level directories + data_dirs = "/usr/local/share:/usr/share" + xdg_dirs += data_dirs.split(":") + + dirs += [os.path.join(xdg_dir, "fonts") for xdg_dir in xdg_dirs] elif sys.platform == "darwin": dirs += [ "/Library/Fonts", @@ -888,6 +906,142 @@ def load_path(filename: str | bytes) -> ImageFont: raise OSError(msg) +def load_default_imagefont() -> ImageFont: + f = ImageFont() + f._load_pilfont_data( + # courB08 + BytesIO( + base64.b64decode( + b""" +UElMZm9udAo7Ozs7OzsxMDsKREFUQQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAA//8AAQAAAAAAAAABAAEA +BgAAAAH/+gADAAAAAQAAAAMABgAGAAAAAf/6AAT//QADAAAABgADAAYAAAAA//kABQABAAYAAAAL +AAgABgAAAAD/+AAFAAEACwAAABAACQAGAAAAAP/5AAUAAAAQAAAAFQAHAAYAAP////oABQAAABUA +AAAbAAYABgAAAAH/+QAE//wAGwAAAB4AAwAGAAAAAf/5AAQAAQAeAAAAIQAIAAYAAAAB//kABAAB +ACEAAAAkAAgABgAAAAD/+QAE//0AJAAAACgABAAGAAAAAP/6AAX//wAoAAAALQAFAAYAAAAB//8A +BAACAC0AAAAwAAMABgAAAAD//AAF//0AMAAAADUAAQAGAAAAAf//AAMAAAA1AAAANwABAAYAAAAB +//kABQABADcAAAA7AAgABgAAAAD/+QAFAAAAOwAAAEAABwAGAAAAAP/5AAYAAABAAAAARgAHAAYA +AAAA//kABQAAAEYAAABLAAcABgAAAAD/+QAFAAAASwAAAFAABwAGAAAAAP/5AAYAAABQAAAAVgAH +AAYAAAAA//kABQAAAFYAAABbAAcABgAAAAD/+QAFAAAAWwAAAGAABwAGAAAAAP/5AAUAAABgAAAA +ZQAHAAYAAAAA//kABQAAAGUAAABqAAcABgAAAAD/+QAFAAAAagAAAG8ABwAGAAAAAf/8AAMAAABv +AAAAcQAEAAYAAAAA//wAAwACAHEAAAB0AAYABgAAAAD/+gAE//8AdAAAAHgABQAGAAAAAP/7AAT/ +/gB4AAAAfAADAAYAAAAB//oABf//AHwAAACAAAUABgAAAAD/+gAFAAAAgAAAAIUABgAGAAAAAP/5 +AAYAAQCFAAAAiwAIAAYAAP////oABgAAAIsAAACSAAYABgAA////+gAFAAAAkgAAAJgABgAGAAAA +AP/6AAUAAACYAAAAnQAGAAYAAP////oABQAAAJ0AAACjAAYABgAA////+gAFAAAAowAAAKkABgAG +AAD////6AAUAAACpAAAArwAGAAYAAAAA//oABQAAAK8AAAC0AAYABgAA////+gAGAAAAtAAAALsA +BgAGAAAAAP/6AAQAAAC7AAAAvwAGAAYAAP////oABQAAAL8AAADFAAYABgAA////+gAGAAAAxQAA +AMwABgAGAAD////6AAUAAADMAAAA0gAGAAYAAP////oABQAAANIAAADYAAYABgAA////+gAGAAAA +2AAAAN8ABgAGAAAAAP/6AAUAAADfAAAA5AAGAAYAAP////oABQAAAOQAAADqAAYABgAAAAD/+gAF +AAEA6gAAAO8ABwAGAAD////6AAYAAADvAAAA9gAGAAYAAAAA//oABQAAAPYAAAD7AAYABgAA//// ++gAFAAAA+wAAAQEABgAGAAD////6AAYAAAEBAAABCAAGAAYAAP////oABgAAAQgAAAEPAAYABgAA +////+gAGAAABDwAAARYABgAGAAAAAP/6AAYAAAEWAAABHAAGAAYAAP////oABgAAARwAAAEjAAYA +BgAAAAD/+gAFAAABIwAAASgABgAGAAAAAf/5AAQAAQEoAAABKwAIAAYAAAAA//kABAABASsAAAEv +AAgABgAAAAH/+QAEAAEBLwAAATIACAAGAAAAAP/5AAX//AEyAAABNwADAAYAAAAAAAEABgACATcA +AAE9AAEABgAAAAH/+QAE//wBPQAAAUAAAwAGAAAAAP/7AAYAAAFAAAABRgAFAAYAAP////kABQAA +AUYAAAFMAAcABgAAAAD/+wAFAAABTAAAAVEABQAGAAAAAP/5AAYAAAFRAAABVwAHAAYAAAAA//sA +BQAAAVcAAAFcAAUABgAAAAD/+QAFAAABXAAAAWEABwAGAAAAAP/7AAYAAgFhAAABZwAHAAYAAP// +//kABQAAAWcAAAFtAAcABgAAAAD/+QAGAAABbQAAAXMABwAGAAAAAP/5AAQAAgFzAAABdwAJAAYA +AP////kABgAAAXcAAAF+AAcABgAAAAD/+QAGAAABfgAAAYQABwAGAAD////7AAUAAAGEAAABigAF +AAYAAP////sABQAAAYoAAAGQAAUABgAAAAD/+wAFAAABkAAAAZUABQAGAAD////7AAUAAgGVAAAB +mwAHAAYAAAAA//sABgACAZsAAAGhAAcABgAAAAD/+wAGAAABoQAAAacABQAGAAAAAP/7AAYAAAGn +AAABrQAFAAYAAAAA//kABgAAAa0AAAGzAAcABgAA////+wAGAAABswAAAboABQAGAAD////7AAUA +AAG6AAABwAAFAAYAAP////sABgAAAcAAAAHHAAUABgAAAAD/+wAGAAABxwAAAc0ABQAGAAD////7 +AAYAAgHNAAAB1AAHAAYAAAAA//sABQAAAdQAAAHZAAUABgAAAAH/+QAFAAEB2QAAAd0ACAAGAAAA +Av/6AAMAAQHdAAAB3gAHAAYAAAAA//kABAABAd4AAAHiAAgABgAAAAD/+wAF//0B4gAAAecAAgAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAB +//sAAwACAecAAAHpAAcABgAAAAD/+QAFAAEB6QAAAe4ACAAGAAAAAP/5AAYAAAHuAAAB9AAHAAYA +AAAA//oABf//AfQAAAH5AAUABgAAAAD/+QAGAAAB+QAAAf8ABwAGAAAAAv/5AAMAAgH/AAACAAAJ +AAYAAAAA//kABQABAgAAAAIFAAgABgAAAAH/+gAE//sCBQAAAggAAQAGAAAAAP/5AAYAAAIIAAAC +DgAHAAYAAAAB//kABf/+Ag4AAAISAAUABgAA////+wAGAAACEgAAAhkABQAGAAAAAP/7AAX//gIZ +AAACHgADAAYAAAAA//wABf/9Ah4AAAIjAAEABgAAAAD/+QAHAAACIwAAAioABwAGAAAAAP/6AAT/ ++wIqAAACLgABAAYAAAAA//kABP/8Ai4AAAIyAAMABgAAAAD/+gAFAAACMgAAAjcABgAGAAAAAf/5 +AAT//QI3AAACOgAEAAYAAAAB//kABP/9AjoAAAI9AAQABgAAAAL/+QAE//sCPQAAAj8AAgAGAAD/ +///7AAYAAgI/AAACRgAHAAYAAAAA//kABgABAkYAAAJMAAgABgAAAAH//AAD//0CTAAAAk4AAQAG +AAAAAf//AAQAAgJOAAACUQADAAYAAAAB//kABP/9AlEAAAJUAAQABgAAAAH/+QAF//4CVAAAAlgA +BQAGAAD////7AAYAAAJYAAACXwAFAAYAAP////kABgAAAl8AAAJmAAcABgAA////+QAGAAACZgAA +Am0ABwAGAAD////5AAYAAAJtAAACdAAHAAYAAAAA//sABQACAnQAAAJ5AAcABgAA////9wAGAAAC +eQAAAoAACQAGAAD////3AAYAAAKAAAAChwAJAAYAAP////cABgAAAocAAAKOAAkABgAA////9wAG +AAACjgAAApUACQAGAAD////4AAYAAAKVAAACnAAIAAYAAP////cABgAAApwAAAKjAAkABgAA//// ++gAGAAACowAAAqoABgAGAAAAAP/6AAUAAgKqAAACrwAIAAYAAP////cABQAAAq8AAAK1AAkABgAA +////9wAFAAACtQAAArsACQAGAAD////3AAUAAAK7AAACwQAJAAYAAP////gABQAAAsEAAALHAAgA +BgAAAAD/9wAEAAACxwAAAssACQAGAAAAAP/3AAQAAALLAAACzwAJAAYAAAAA//cABAAAAs8AAALT +AAkABgAAAAD/+AAEAAAC0wAAAtcACAAGAAD////6AAUAAALXAAAC3QAGAAYAAP////cABgAAAt0A +AALkAAkABgAAAAD/9wAFAAAC5AAAAukACQAGAAAAAP/3AAUAAALpAAAC7gAJAAYAAAAA//cABQAA +Au4AAALzAAkABgAAAAD/9wAFAAAC8wAAAvgACQAGAAAAAP/4AAUAAAL4AAAC/QAIAAYAAAAA//oA +Bf//Av0AAAMCAAUABgAA////+gAGAAADAgAAAwkABgAGAAD////3AAYAAAMJAAADEAAJAAYAAP// +//cABgAAAxAAAAMXAAkABgAA////9wAGAAADFwAAAx4ACQAGAAD////4AAYAAAAAAAoABwASAAYA +AP////cABgAAAAcACgAOABMABgAA////+gAFAAAADgAKABQAEAAGAAD////6AAYAAAAUAAoAGwAQ +AAYAAAAA//gABgAAABsACgAhABIABgAAAAD/+AAGAAAAIQAKACcAEgAGAAAAAP/4AAYAAAAnAAoA +LQASAAYAAAAA//gABgAAAC0ACgAzABIABgAAAAD/+QAGAAAAMwAKADkAEQAGAAAAAP/3AAYAAAA5 +AAoAPwATAAYAAP////sABQAAAD8ACgBFAA8ABgAAAAD/+wAFAAIARQAKAEoAEQAGAAAAAP/4AAUA +AABKAAoATwASAAYAAAAA//gABQAAAE8ACgBUABIABgAAAAD/+AAFAAAAVAAKAFkAEgAGAAAAAP/5 +AAUAAABZAAoAXgARAAYAAAAA//gABgAAAF4ACgBkABIABgAAAAD/+AAGAAAAZAAKAGoAEgAGAAAA +AP/4AAYAAABqAAoAcAASAAYAAAAA//kABgAAAHAACgB2ABEABgAAAAD/+AAFAAAAdgAKAHsAEgAG +AAD////4AAYAAAB7AAoAggASAAYAAAAA//gABQAAAIIACgCHABIABgAAAAD/+AAFAAAAhwAKAIwA +EgAGAAAAAP/4AAUAAACMAAoAkQASAAYAAAAA//gABQAAAJEACgCWABIABgAAAAD/+QAFAAAAlgAK +AJsAEQAGAAAAAP/6AAX//wCbAAoAoAAPAAYAAAAA//oABQABAKAACgClABEABgAA////+AAGAAAA +pQAKAKwAEgAGAAD////4AAYAAACsAAoAswASAAYAAP////gABgAAALMACgC6ABIABgAA////+QAG +AAAAugAKAMEAEQAGAAD////4AAYAAgDBAAoAyAAUAAYAAP////kABQACAMgACgDOABMABgAA//// ++QAGAAIAzgAKANUAEw== +""" + ) + ), + Image.open( + BytesIO( + base64.b64decode( + b""" +iVBORw0KGgoAAAANSUhEUgAAAx4AAAAUAQAAAAArMtZoAAAEwElEQVR4nABlAJr/AHVE4czCI/4u +Mc4b7vuds/xzjz5/3/7u/n9vMe7vnfH/9++vPn/xyf5zhxzjt8GHw8+2d83u8x27199/nxuQ6Od9 +M43/5z2I+9n9ZtmDBwMQECDRQw/eQIQohJXxpBCNVE6QCCAAAAD//wBlAJr/AgALyj1t/wINwq0g +LeNZUworuN1cjTPIzrTX6ofHWeo3v336qPzfEwRmBnHTtf95/fglZK5N0PDgfRTslpGBvz7LFc4F +IUXBWQGjQ5MGCx34EDFPwXiY4YbYxavpnhHFrk14CDAAAAD//wBlAJr/AgKqRooH2gAgPeggvUAA +Bu2WfgPoAwzRAABAAAAAAACQgLz/3Uv4Gv+gX7BJgDeeGP6AAAD1NMDzKHD7ANWr3loYbxsAD791 +NAADfcoIDyP44K/jv4Y63/Z+t98Ovt+ub4T48LAAAAD//wBlAJr/AuplMlADJAAAAGuAphWpqhMx +in0A/fRvAYBABPgBwBUgABBQ/sYAyv9g0bCHgOLoGAAAAAAAREAAwI7nr0ArYpow7aX8//9LaP/9 +SjdavWA8ePHeBIKB//81/83ndznOaXx379wAAAD//wBlAJr/AqDxW+D3AABAAbUh/QMnbQag/gAY +AYDAAACgtgD/gOqAAAB5IA/8AAAk+n9w0AAA8AAAmFRJuPo27ciC0cD5oeW4E7KA/wD3ECMAn2tt +y8PgwH8AfAxFzC0JzeAMtratAsC/ffwAAAD//wBlAJr/BGKAyCAA4AAAAvgeYTAwHd1kmQF5chkG +ABoMIHcL5xVpTfQbUqzlAAAErwAQBgAAEOClA5D9il08AEh/tUzdCBsXkbgACED+woQg8Si9VeqY +lODCn7lmF6NhnAEYgAAA/NMIAAAAAAD//2JgjLZgVGBg5Pv/Tvpc8hwGBjYGJADjHDrAwPzAjv/H +/Wf3PzCwtzcwHmBgYGcwbZz8wHaCAQMDOwMDQ8MCBgYOC3W7mp+f0w+wHOYxO3OG+e376hsMZjk3 +AAAAAP//YmCMY2A4wMAIN5e5gQETPD6AZisDAwMDgzSDAAPjByiHcQMDAwMDg1nOze1lByRu5/47 +c4859311AYNZzg0AAAAA//9iYGDBYihOIIMuwIjGL39/fwffA8b//xv/P2BPtzzHwCBjUQAAAAD/ +/yLFBrIBAAAA//9i1HhcwdhizX7u8NZNzyLbvT97bfrMf/QHI8evOwcSqGUJAAAA//9iYBB81iSw +pEE170Qrg5MIYydHqwdDQRMrAwcVrQAAAAD//2J4x7j9AAMDn8Q/BgYLBoaiAwwMjPdvMDBYM1Tv +oJodAAAAAP//Yqo/83+dxePWlxl3npsel9lvLfPcqlE9725C+acfVLMEAAAA//9i+s9gwCoaaGMR +evta/58PTEWzr21hufPjA8N+qlnBwAAAAAD//2JiWLci5v1+HmFXDqcnULE/MxgYGBj+f6CaJQAA +AAD//2Ji2FrkY3iYpYC5qDeGgeEMAwPDvwQBBoYvcTwOVLMEAAAA//9isDBgkP///0EOg9z35v// +Gc/eeW7BwPj5+QGZhANUswMAAAD//2JgqGBgYGBgqEMXlvhMPUsAAAAA//8iYDd1AAAAAP//AwDR +w7IkEbzhVQAAAABJRU5ErkJggg== +""" + ) + ) + ), + ) + return f + + def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: """If FreeType support is available, load a version of Aileron Regular, https://dotcolon.net/font/aileron, with a more limited character set. @@ -902,9 +1056,8 @@ 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( + if isinstance(core, ModuleType) or size is not None: + return truetype( BytesIO( base64.b64decode( b""" @@ -1134,137 +1287,4 @@ AAAAAAQAAAADa3tfFAAAAANAan9kAAAAA4QodoQ== 10 if size is None else size, layout_engine=Layout.BASIC, ) - else: - f = ImageFont() - f._load_pilfont_data( - # courB08 - BytesIO( - base64.b64decode( - b""" -UElMZm9udAo7Ozs7OzsxMDsKREFUQQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAA//8AAQAAAAAAAAABAAEA -BgAAAAH/+gADAAAAAQAAAAMABgAGAAAAAf/6AAT//QADAAAABgADAAYAAAAA//kABQABAAYAAAAL -AAgABgAAAAD/+AAFAAEACwAAABAACQAGAAAAAP/5AAUAAAAQAAAAFQAHAAYAAP////oABQAAABUA -AAAbAAYABgAAAAH/+QAE//wAGwAAAB4AAwAGAAAAAf/5AAQAAQAeAAAAIQAIAAYAAAAB//kABAAB -ACEAAAAkAAgABgAAAAD/+QAE//0AJAAAACgABAAGAAAAAP/6AAX//wAoAAAALQAFAAYAAAAB//8A -BAACAC0AAAAwAAMABgAAAAD//AAF//0AMAAAADUAAQAGAAAAAf//AAMAAAA1AAAANwABAAYAAAAB -//kABQABADcAAAA7AAgABgAAAAD/+QAFAAAAOwAAAEAABwAGAAAAAP/5AAYAAABAAAAARgAHAAYA -AAAA//kABQAAAEYAAABLAAcABgAAAAD/+QAFAAAASwAAAFAABwAGAAAAAP/5AAYAAABQAAAAVgAH -AAYAAAAA//kABQAAAFYAAABbAAcABgAAAAD/+QAFAAAAWwAAAGAABwAGAAAAAP/5AAUAAABgAAAA -ZQAHAAYAAAAA//kABQAAAGUAAABqAAcABgAAAAD/+QAFAAAAagAAAG8ABwAGAAAAAf/8AAMAAABv -AAAAcQAEAAYAAAAA//wAAwACAHEAAAB0AAYABgAAAAD/+gAE//8AdAAAAHgABQAGAAAAAP/7AAT/ -/gB4AAAAfAADAAYAAAAB//oABf//AHwAAACAAAUABgAAAAD/+gAFAAAAgAAAAIUABgAGAAAAAP/5 -AAYAAQCFAAAAiwAIAAYAAP////oABgAAAIsAAACSAAYABgAA////+gAFAAAAkgAAAJgABgAGAAAA -AP/6AAUAAACYAAAAnQAGAAYAAP////oABQAAAJ0AAACjAAYABgAA////+gAFAAAAowAAAKkABgAG -AAD////6AAUAAACpAAAArwAGAAYAAAAA//oABQAAAK8AAAC0AAYABgAA////+gAGAAAAtAAAALsA -BgAGAAAAAP/6AAQAAAC7AAAAvwAGAAYAAP////oABQAAAL8AAADFAAYABgAA////+gAGAAAAxQAA -AMwABgAGAAD////6AAUAAADMAAAA0gAGAAYAAP////oABQAAANIAAADYAAYABgAA////+gAGAAAA -2AAAAN8ABgAGAAAAAP/6AAUAAADfAAAA5AAGAAYAAP////oABQAAAOQAAADqAAYABgAAAAD/+gAF -AAEA6gAAAO8ABwAGAAD////6AAYAAADvAAAA9gAGAAYAAAAA//oABQAAAPYAAAD7AAYABgAA//// -+gAFAAAA+wAAAQEABgAGAAD////6AAYAAAEBAAABCAAGAAYAAP////oABgAAAQgAAAEPAAYABgAA -////+gAGAAABDwAAARYABgAGAAAAAP/6AAYAAAEWAAABHAAGAAYAAP////oABgAAARwAAAEjAAYA -BgAAAAD/+gAFAAABIwAAASgABgAGAAAAAf/5AAQAAQEoAAABKwAIAAYAAAAA//kABAABASsAAAEv -AAgABgAAAAH/+QAEAAEBLwAAATIACAAGAAAAAP/5AAX//AEyAAABNwADAAYAAAAAAAEABgACATcA -AAE9AAEABgAAAAH/+QAE//wBPQAAAUAAAwAGAAAAAP/7AAYAAAFAAAABRgAFAAYAAP////kABQAA -AUYAAAFMAAcABgAAAAD/+wAFAAABTAAAAVEABQAGAAAAAP/5AAYAAAFRAAABVwAHAAYAAAAA//sA -BQAAAVcAAAFcAAUABgAAAAD/+QAFAAABXAAAAWEABwAGAAAAAP/7AAYAAgFhAAABZwAHAAYAAP// -//kABQAAAWcAAAFtAAcABgAAAAD/+QAGAAABbQAAAXMABwAGAAAAAP/5AAQAAgFzAAABdwAJAAYA -AP////kABgAAAXcAAAF+AAcABgAAAAD/+QAGAAABfgAAAYQABwAGAAD////7AAUAAAGEAAABigAF -AAYAAP////sABQAAAYoAAAGQAAUABgAAAAD/+wAFAAABkAAAAZUABQAGAAD////7AAUAAgGVAAAB -mwAHAAYAAAAA//sABgACAZsAAAGhAAcABgAAAAD/+wAGAAABoQAAAacABQAGAAAAAP/7AAYAAAGn -AAABrQAFAAYAAAAA//kABgAAAa0AAAGzAAcABgAA////+wAGAAABswAAAboABQAGAAD////7AAUA -AAG6AAABwAAFAAYAAP////sABgAAAcAAAAHHAAUABgAAAAD/+wAGAAABxwAAAc0ABQAGAAD////7 -AAYAAgHNAAAB1AAHAAYAAAAA//sABQAAAdQAAAHZAAUABgAAAAH/+QAFAAEB2QAAAd0ACAAGAAAA -Av/6AAMAAQHdAAAB3gAHAAYAAAAA//kABAABAd4AAAHiAAgABgAAAAD/+wAF//0B4gAAAecAAgAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAB -//sAAwACAecAAAHpAAcABgAAAAD/+QAFAAEB6QAAAe4ACAAGAAAAAP/5AAYAAAHuAAAB9AAHAAYA -AAAA//oABf//AfQAAAH5AAUABgAAAAD/+QAGAAAB+QAAAf8ABwAGAAAAAv/5AAMAAgH/AAACAAAJ -AAYAAAAA//kABQABAgAAAAIFAAgABgAAAAH/+gAE//sCBQAAAggAAQAGAAAAAP/5AAYAAAIIAAAC -DgAHAAYAAAAB//kABf/+Ag4AAAISAAUABgAA////+wAGAAACEgAAAhkABQAGAAAAAP/7AAX//gIZ -AAACHgADAAYAAAAA//wABf/9Ah4AAAIjAAEABgAAAAD/+QAHAAACIwAAAioABwAGAAAAAP/6AAT/ -+wIqAAACLgABAAYAAAAA//kABP/8Ai4AAAIyAAMABgAAAAD/+gAFAAACMgAAAjcABgAGAAAAAf/5 -AAT//QI3AAACOgAEAAYAAAAB//kABP/9AjoAAAI9AAQABgAAAAL/+QAE//sCPQAAAj8AAgAGAAD/ -///7AAYAAgI/AAACRgAHAAYAAAAA//kABgABAkYAAAJMAAgABgAAAAH//AAD//0CTAAAAk4AAQAG -AAAAAf//AAQAAgJOAAACUQADAAYAAAAB//kABP/9AlEAAAJUAAQABgAAAAH/+QAF//4CVAAAAlgA -BQAGAAD////7AAYAAAJYAAACXwAFAAYAAP////kABgAAAl8AAAJmAAcABgAA////+QAGAAACZgAA -Am0ABwAGAAD////5AAYAAAJtAAACdAAHAAYAAAAA//sABQACAnQAAAJ5AAcABgAA////9wAGAAAC -eQAAAoAACQAGAAD////3AAYAAAKAAAAChwAJAAYAAP////cABgAAAocAAAKOAAkABgAA////9wAG -AAACjgAAApUACQAGAAD////4AAYAAAKVAAACnAAIAAYAAP////cABgAAApwAAAKjAAkABgAA//// -+gAGAAACowAAAqoABgAGAAAAAP/6AAUAAgKqAAACrwAIAAYAAP////cABQAAAq8AAAK1AAkABgAA -////9wAFAAACtQAAArsACQAGAAD////3AAUAAAK7AAACwQAJAAYAAP////gABQAAAsEAAALHAAgA -BgAAAAD/9wAEAAACxwAAAssACQAGAAAAAP/3AAQAAALLAAACzwAJAAYAAAAA//cABAAAAs8AAALT -AAkABgAAAAD/+AAEAAAC0wAAAtcACAAGAAD////6AAUAAALXAAAC3QAGAAYAAP////cABgAAAt0A -AALkAAkABgAAAAD/9wAFAAAC5AAAAukACQAGAAAAAP/3AAUAAALpAAAC7gAJAAYAAAAA//cABQAA -Au4AAALzAAkABgAAAAD/9wAFAAAC8wAAAvgACQAGAAAAAP/4AAUAAAL4AAAC/QAIAAYAAAAA//oA -Bf//Av0AAAMCAAUABgAA////+gAGAAADAgAAAwkABgAGAAD////3AAYAAAMJAAADEAAJAAYAAP// -//cABgAAAxAAAAMXAAkABgAA////9wAGAAADFwAAAx4ACQAGAAD////4AAYAAAAAAAoABwASAAYA -AP////cABgAAAAcACgAOABMABgAA////+gAFAAAADgAKABQAEAAGAAD////6AAYAAAAUAAoAGwAQ -AAYAAAAA//gABgAAABsACgAhABIABgAAAAD/+AAGAAAAIQAKACcAEgAGAAAAAP/4AAYAAAAnAAoA -LQASAAYAAAAA//gABgAAAC0ACgAzABIABgAAAAD/+QAGAAAAMwAKADkAEQAGAAAAAP/3AAYAAAA5 -AAoAPwATAAYAAP////sABQAAAD8ACgBFAA8ABgAAAAD/+wAFAAIARQAKAEoAEQAGAAAAAP/4AAUA -AABKAAoATwASAAYAAAAA//gABQAAAE8ACgBUABIABgAAAAD/+AAFAAAAVAAKAFkAEgAGAAAAAP/5 -AAUAAABZAAoAXgARAAYAAAAA//gABgAAAF4ACgBkABIABgAAAAD/+AAGAAAAZAAKAGoAEgAGAAAA -AP/4AAYAAABqAAoAcAASAAYAAAAA//kABgAAAHAACgB2ABEABgAAAAD/+AAFAAAAdgAKAHsAEgAG -AAD////4AAYAAAB7AAoAggASAAYAAAAA//gABQAAAIIACgCHABIABgAAAAD/+AAFAAAAhwAKAIwA -EgAGAAAAAP/4AAUAAACMAAoAkQASAAYAAAAA//gABQAAAJEACgCWABIABgAAAAD/+QAFAAAAlgAK -AJsAEQAGAAAAAP/6AAX//wCbAAoAoAAPAAYAAAAA//oABQABAKAACgClABEABgAA////+AAGAAAA -pQAKAKwAEgAGAAD////4AAYAAACsAAoAswASAAYAAP////gABgAAALMACgC6ABIABgAA////+QAG -AAAAugAKAMEAEQAGAAD////4AAYAAgDBAAoAyAAUAAYAAP////kABQACAMgACgDOABMABgAA//// -+QAGAAIAzgAKANUAEw== -""" - ) - ), - Image.open( - BytesIO( - base64.b64decode( - b""" -iVBORw0KGgoAAAANSUhEUgAAAx4AAAAUAQAAAAArMtZoAAAEwElEQVR4nABlAJr/AHVE4czCI/4u -Mc4b7vuds/xzjz5/3/7u/n9vMe7vnfH/9++vPn/xyf5zhxzjt8GHw8+2d83u8x27199/nxuQ6Od9 -M43/5z2I+9n9ZtmDBwMQECDRQw/eQIQohJXxpBCNVE6QCCAAAAD//wBlAJr/AgALyj1t/wINwq0g -LeNZUworuN1cjTPIzrTX6ofHWeo3v336qPzfEwRmBnHTtf95/fglZK5N0PDgfRTslpGBvz7LFc4F -IUXBWQGjQ5MGCx34EDFPwXiY4YbYxavpnhHFrk14CDAAAAD//wBlAJr/AgKqRooH2gAgPeggvUAA -Bu2WfgPoAwzRAABAAAAAAACQgLz/3Uv4Gv+gX7BJgDeeGP6AAAD1NMDzKHD7ANWr3loYbxsAD791 -NAADfcoIDyP44K/jv4Y63/Z+t98Ovt+ub4T48LAAAAD//wBlAJr/AuplMlADJAAAAGuAphWpqhMx -in0A/fRvAYBABPgBwBUgABBQ/sYAyv9g0bCHgOLoGAAAAAAAREAAwI7nr0ArYpow7aX8//9LaP/9 -SjdavWA8ePHeBIKB//81/83ndznOaXx379wAAAD//wBlAJr/AqDxW+D3AABAAbUh/QMnbQag/gAY -AYDAAACgtgD/gOqAAAB5IA/8AAAk+n9w0AAA8AAAmFRJuPo27ciC0cD5oeW4E7KA/wD3ECMAn2tt -y8PgwH8AfAxFzC0JzeAMtratAsC/ffwAAAD//wBlAJr/BGKAyCAA4AAAAvgeYTAwHd1kmQF5chkG -ABoMIHcL5xVpTfQbUqzlAAAErwAQBgAAEOClA5D9il08AEh/tUzdCBsXkbgACED+woQg8Si9VeqY -lODCn7lmF6NhnAEYgAAA/NMIAAAAAAD//2JgjLZgVGBg5Pv/Tvpc8hwGBjYGJADjHDrAwPzAjv/H -/Wf3PzCwtzcwHmBgYGcwbZz8wHaCAQMDOwMDQ8MCBgYOC3W7mp+f0w+wHOYxO3OG+e376hsMZjk3 -AAAAAP//YmCMY2A4wMAIN5e5gQETPD6AZisDAwMDgzSDAAPjByiHcQMDAwMDg1nOze1lByRu5/47 -c4859311AYNZzg0AAAAA//9iYGDBYihOIIMuwIjGL39/fwffA8b//xv/P2BPtzzHwCBjUQAAAAD/ -/yLFBrIBAAAA//9i1HhcwdhizX7u8NZNzyLbvT97bfrMf/QHI8evOwcSqGUJAAAA//9iYBB81iSw -pEE170Qrg5MIYydHqwdDQRMrAwcVrQAAAAD//2J4x7j9AAMDn8Q/BgYLBoaiAwwMjPdvMDBYM1Tv -oJodAAAAAP//Yqo/83+dxePWlxl3npsel9lvLfPcqlE9725C+acfVLMEAAAA//9i+s9gwCoaaGMR -evta/58PTEWzr21hufPjA8N+qlnBwAAAAAD//2JiWLci5v1+HmFXDqcnULE/MxgYGBj+f6CaJQAA -AAD//2Ji2FrkY3iYpYC5qDeGgeEMAwPDvwQBBoYvcTwOVLMEAAAA//9isDBgkP///0EOg9z35v// -Gc/eeW7BwPj5+QGZhANUswMAAAD//2JgqGBgYGBgqEMXlvhMPUsAAAAA//8iYDd1AAAAAP//AwDR -w7IkEbzhVQAAAABJRU5ErkJggg== -""" - ) - ) - ), - ) - return f + return load_default_imagefont() diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 3f3be706d..e27ca7e50 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -26,7 +26,13 @@ import tempfile from . import Image -def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None): +def grab( + bbox: tuple[int, int, int, int] | None = None, + include_layered_windows: bool = False, + all_screens: bool = False, + xdisplay: str | None = None, +) -> Image.Image: + im: Image.Image if xdisplay is None: if sys.platform == "darwin": fh, filepath = tempfile.mkstemp(".png") @@ -63,14 +69,16 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N left, top, right, bottom = bbox im = im.crop((left - x0, top - y0, right - x0, bottom - y0)) return im + # Cast to Optional[str] needed for Windows and macOS. + display_name: str | None = xdisplay try: if not Image.core.HAVE_XCB: msg = "Pillow was built without XCB support" raise OSError(msg) - size, data = Image.core.grabscreen_x11(xdisplay) + size, data = Image.core.grabscreen_x11(display_name) except OSError: if ( - xdisplay is None + display_name is None and sys.platform not in ("darwin", "win32") and shutil.which("gnome-screenshot") ): @@ -94,7 +102,7 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N return im -def grabclipboard(): +def grabclipboard() -> Image.Image | list[str] | None: if sys.platform == "darwin": fh, filepath = tempfile.mkstemp(".png") os.close(fh) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index cbe189cc9..44aad0c3c 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -21,7 +21,8 @@ from __future__ import annotations import functools import operator import re -from typing import Protocol, Sequence, cast +from collections.abc import Sequence +from typing import Protocol, cast from . import ExifTags, Image, ImagePalette @@ -361,7 +362,9 @@ def pad( else: out = Image.new(image.mode, size, color) if resized.palette: - out.putpalette(resized.getpalette()) + palette = resized.getpalette() + if palette is not None: + out.putpalette(palette) if resized.width != size[0]: x = round((size[0] - resized.width) * max(0, min(centering[0], 1))) out.paste(resized, (x, 0)) @@ -698,7 +701,6 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image transposed_image = image.transpose(method) if in_place: image.im = transposed_image.im - image.pyaccess = None image._size = transposed_image._size exif_image = image if in_place else transposed_image @@ -709,14 +711,18 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image exif_image.info["exif"] = exif.tobytes() elif "Raw profile type exif" in exif_image.info: exif_image.info["Raw profile type exif"] = exif.tobytes().hex() - elif "XML:com.adobe.xmp" in exif_image.info: - for pattern in ( - r'tiff:Orientation="([0-9])"', - r"([0-9])", - ): - exif_image.info["XML:com.adobe.xmp"] = re.sub( - pattern, "", exif_image.info["XML:com.adobe.xmp"] - ) + for key in ("XML:com.adobe.xmp", "xmp"): + if key in exif_image.info: + for pattern in ( + r'tiff:Orientation="([0-9])"', + r"([0-9])", + ): + value = exif_image.info[key] + exif_image.info[key] = ( + re.sub(pattern, "", value) + if isinstance(value, str) + else re.sub(pattern.encode(), b"", value) + ) if not in_place: return transposed_image elif not in_place: diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 6473c4577..8ccecbd07 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -18,7 +18,8 @@ from __future__ import annotations import array -from typing import IO, TYPE_CHECKING, Sequence +from collections.abc import Sequence +from typing import IO, TYPE_CHECKING from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile @@ -38,23 +39,27 @@ class ImagePalette: Defaults to an empty palette. """ - def __init__(self, mode: str = "RGB", palette: Sequence[int] | None = None) -> None: + def __init__( + self, + mode: str = "RGB", + palette: Sequence[int] | bytes | bytearray | None = None, + ) -> None: self.mode = mode - self.rawmode = None # if set, palette contains raw data + self.rawmode: str | None = None # if set, palette contains raw data self.palette = palette or bytearray() self.dirty: int | None = None @property - def palette(self): + def palette(self) -> Sequence[int] | bytes | bytearray: return self._palette @palette.setter - def palette(self, palette): - self._colors = None + def palette(self, palette: Sequence[int] | bytes | bytearray) -> None: + self._colors: dict[tuple[int, ...], int] | None = None self._palette = palette @property - def colors(self) -> dict[tuple[int, int, int] | tuple[int, int, int, int], int]: + def colors(self) -> dict[tuple[int, ...], int]: if self._colors is None: mode_len = len(self.mode) self._colors = {} @@ -66,9 +71,7 @@ class ImagePalette: return self._colors @colors.setter - def colors( - self, colors: dict[tuple[int, int, int] | tuple[int, int, int, int], int] - ) -> None: + def colors(self, colors: dict[tuple[int, ...], int]) -> None: self._colors = colors def copy(self) -> ImagePalette: @@ -82,7 +85,7 @@ class ImagePalette: return new - def getdata(self) -> tuple[str, bytes]: + def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]: """ Get palette contents in format suitable for the low-level ``im.putpalette`` primitive. @@ -137,7 +140,7 @@ class ImagePalette: def getcolor( self, - color: tuple[int, int, int] | tuple[int, int, int, int], + color: tuple[int, ...], image: Image.Image | None = None, ) -> int: """Given an rgb tuple, allocate palette entry. @@ -162,12 +165,13 @@ class ImagePalette: except KeyError as e: # allocate new color slot index = self._new_color_index(image, e) + assert isinstance(self._palette, bytearray) self.colors[color] = index if index * 3 < len(self.palette): self._palette = ( - self.palette[: index * 3] + self._palette[: index * 3] + bytes(color) - + self.palette[index * 3 + 3 :] + + self._palette[index * 3 + 3 :] ) else: self._palette += bytes(color) @@ -204,7 +208,7 @@ class ImagePalette: # Internal -def raw(rawmode, data) -> ImagePalette: +def raw(rawmode, data: Sequence[int] | bytes | bytearray) -> ImagePalette: palette = ImagePalette() palette.rawmode = rawmode palette.palette = data @@ -216,9 +220,9 @@ def raw(rawmode, data) -> ImagePalette: # Factories -def make_linear_lut(black, white): +def make_linear_lut(black: int, white: float) -> list[int]: if black == 0: - return [white * i // 255 for i in range(256)] + return [int(white * i // 255) for i in range(256)] msg = "unavailable when black is non-zero" raise NotImplementedError(msg) # FIXME @@ -251,15 +255,22 @@ def wedge(mode: str = "RGB") -> ImagePalette: return ImagePalette(mode, [i // len(mode) for i in palette]) -def load(filename): +def load(filename: str) -> tuple[bytes, str]: # FIXME: supports GIMP gradients only with open(filename, "rb") as fp: - for paletteHandler in [ + paletteHandlers: list[ + type[ + GimpPaletteFile.GimpPaletteFile + | GimpGradientFile.GimpGradientFile + | PaletteFile.PaletteFile + ] + ] = [ GimpPaletteFile.GimpPaletteFile, GimpGradientFile.GimpGradientFile, PaletteFile.PaletteFile, - ]: + ] + for paletteHandler in paletteHandlers: try: fp.seek(0) lut = paletteHandler(fp).getpalette() diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index 293ba4941..35a37760c 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -152,7 +152,7 @@ def _toqclass_helper(im): elif im.mode == "RGBA": data = im.tobytes("raw", "BGRA") format = qt_format.Format_ARGB32 - elif im.mode == "I;16" and hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+ + elif im.mode == "I;16": im = im.point(lambda i: i * 256) format = qt_format.Format_Grayscale16 @@ -196,7 +196,7 @@ if qt_is_installed: self.setColorTable(im_data["colortable"]) -def toqimage(im): +def toqimage(im) -> ImageQt: return ImageQt(im) diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index f60b1e11e..d62893d9c 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -26,7 +26,7 @@ from . import Image _viewers = [] -def register(viewer, order: int = 1) -> None: +def register(viewer: type[Viewer] | Viewer, order: int = 1) -> None: """ The :py:func:`register` function is used to register additional viewers:: @@ -40,11 +40,8 @@ def register(viewer, order: int = 1) -> None: Zero or a negative integer to prepend this viewer to the list, a positive integer to append it. """ - try: - if issubclass(viewer, Viewer): - viewer = viewer() - except TypeError: - pass # raised if viewer wasn't a class + if isinstance(viewer, type) and issubclass(viewer, Viewer): + viewer = viewer() if order > 0: _viewers.append(viewer) else: @@ -118,6 +115,8 @@ class Viewer: """ Display given file. """ + if not os.path.exists(path): + raise FileNotFoundError os.system(self.get_command(path, **options)) # nosec return 1 @@ -142,6 +141,8 @@ class WindowsViewer(Viewer): """ Display given file. """ + if not os.path.exists(path): + raise FileNotFoundError subprocess.Popen( self.get_command(path, **options), shell=True, @@ -171,6 +172,8 @@ class MacViewer(Viewer): """ Display given file. """ + if not os.path.exists(path): + raise FileNotFoundError subprocess.call(["open", "-a", "Preview.app", path]) executable = sys.executable or shutil.which("python3") if executable: @@ -215,6 +218,8 @@ class XDGViewer(UnixViewer): """ Display given file. """ + if not os.path.exists(path): + raise FileNotFoundError subprocess.Popen(["xdg-open", path]) return 1 @@ -237,6 +242,8 @@ class DisplayViewer(UnixViewer): """ Display given file. """ + if not os.path.exists(path): + raise FileNotFoundError args = ["display"] title = options.get("title") if title: @@ -259,6 +266,8 @@ class GmDisplayViewer(UnixViewer): """ Display given file. """ + if not os.path.exists(path): + raise FileNotFoundError subprocess.Popen(["gm", "display", path]) return 1 @@ -275,6 +284,8 @@ class EogViewer(UnixViewer): """ Display given file. """ + if not os.path.exists(path): + raise FileNotFoundError subprocess.Popen(["eog", "-n", path]) return 1 @@ -299,6 +310,8 @@ class XVViewer(UnixViewer): """ Display given file. """ + if not os.path.exists(path): + raise FileNotFoundError args = ["xv"] title = options.get("title") if title: diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index 90defdbbc..6aa70ced3 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -28,8 +28,9 @@ from __future__ import annotations import tkinter from io import BytesIO +from typing import Any -from . import Image +from . import Image, ImageFile # -------------------------------------------------------------------- # Check for Tkinter interface hooks @@ -49,14 +50,15 @@ def _pilbitmap_check() -> int: return _pilbitmap_ok -def _get_image_from_kw(kw): +def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None: source = None if "file" in kw: source = kw.pop("file") elif "data" in kw: source = BytesIO(kw.pop("data")) - if source: - return Image.open(source) + if not source: + return None + return Image.open(source) def _pyimagingtkcall(command, photo, id): @@ -96,12 +98,27 @@ class PhotoImage: image file). """ - def __init__(self, image=None, size=None, **kw): + def __init__( + self, + image: Image.Image | str | None = None, + size: tuple[int, int] | None = None, + **kw: Any, + ) -> None: # Tk compatibility: file or data if image is None: image = _get_image_from_kw(kw) - if hasattr(image, "mode") and hasattr(image, "size"): + if image is None: + msg = "Image is required" + raise ValueError(msg) + elif isinstance(image, str): + mode = image + image = None + + if size is None: + msg = "If first argument is mode, size is required" + raise ValueError(msg) + else: # got an image instead of a mode mode = image.mode if mode == "P": @@ -114,9 +131,6 @@ class PhotoImage: mode = "RGB" # default size = image.size kw["width"], kw["height"] = size - else: - mode = image - image = None if mode not in ["1", "L", "RGB", "RGBA"]: mode = Image.getmodebase(mode) diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py index 80a6116b7..a3d8f441a 100644 --- a/src/PIL/ImageTransform.py +++ b/src/PIL/ImageTransform.py @@ -14,7 +14,8 @@ # from __future__ import annotations -from typing import Any, Sequence +from collections.abc import Sequence +from typing import Any from . import Image @@ -24,7 +25,7 @@ class Transform(Image.ImageTransformHandler): method: Image.Transform - def __init__(self, data: Sequence[int]) -> None: + def __init__(self, data: Sequence[Any]) -> None: self.data = data def getdata(self) -> tuple[Image.Transform, Sequence[int]]: diff --git a/src/PIL/ImageWin.py b/src/PIL/ImageWin.py index 6c29e2590..978c5a9d1 100644 --- a/src/PIL/ImageWin.py +++ b/src/PIL/ImageWin.py @@ -69,19 +69,22 @@ class Dib: defines the size of the image. """ - def __init__(self, image, size=None): - if hasattr(image, "mode") and hasattr(image, "size"): + def __init__( + self, image: Image.Image | str, size: tuple[int, int] | list[int] | None = None + ) -> None: + if isinstance(image, str): + mode = image + image = "" + else: mode = image.mode size = image.size - else: - mode = image - image = None if mode not in ["1", "L", "P", "RGB"]: mode = Image.getmodebase(mode) self.image = Image.core.display(mode, size) self.mode = mode self.size = size if image: + assert not isinstance(image, str) self.paste(image) def expose(self, handle): diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index 73df83bfb..a04616fbd 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -16,8 +16,8 @@ # from __future__ import annotations +from collections.abc import Sequence from io import BytesIO -from typing import Sequence from . import Image, ImageFile from ._binary import i16be as i16 @@ -148,7 +148,7 @@ class IptcImageFile(ImageFile.ImageFile): if tag == (8, 10): self.tile = [("iptc", (0, 0) + self.size, offset, compression)] - def load(self): + def load(self) -> Image.core.PixelAccess | None: if len(self.tile) != 1 or self.tile[0][0] != "iptc": return ImageFile.ImageFile.load(self) @@ -176,6 +176,7 @@ class IptcImageFile(ImageFile.ImageFile): with Image.open(o) as _im: _im.load() self.im = _im.im + return None Image.register_open(IptcImageFile.format, IptcImageFile) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 39eb1c203..992b9ccaf 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -18,7 +18,7 @@ from __future__ import annotations import io import os import struct -from typing import IO, Tuple, cast +from typing import IO, cast from . import Image, ImageFile, ImagePalette, _binary @@ -82,7 +82,7 @@ class BoxReader: self.remaining_in_box = -1 # Read the length and type of the next box - lbox, tbox = cast(Tuple[int, bytes], self.read_fields(">I4s")) + lbox, tbox = cast(tuple[int, bytes], self.read_fields(">I4s")) if lbox == 1: lbox = cast(int, self.read_fields(">Q")[0]) hlen = 16 @@ -97,7 +97,7 @@ class BoxReader: return tbox -def _parse_codestream(fp): +def _parse_codestream(fp) -> tuple[tuple[int, int], str]: """Parse the JPEG 2000 codestream to extract the size and component count from the SIZ marker segment, returning a PIL (size, mode) tuple.""" @@ -122,7 +122,8 @@ def _parse_codestream(fp): elif csiz == 4: mode = "RGBA" else: - mode = "" + msg = "unable to determine J2K image mode" + raise SyntaxError(msg) return size, mode @@ -237,10 +238,6 @@ class Jpeg2KImageFile(ImageFile.ImageFile): msg = "not a JPEG 2000 file" raise SyntaxError(msg) - if self.size is None or not self.mode: - msg = "unable to determine size/mode" - raise SyntaxError(msg) - self._reduce = 0 self.layers = 0 @@ -302,7 +299,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): def reduce(self, value): self._reduce = value - def load(self): + def load(self) -> Image.core.PixelAccess | None: if self.tile and self._reduce: power = 1 << self._reduce adjust = power >> 1 diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 0c8a67888..b15bf06d2 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -95,6 +95,8 @@ def APP(self, marker): else: self.info["exif"] = s self._exif_offset = self.fp.tell() - n + 6 + elif marker == 0xFFE1 and s[:29] == b"http://ns.adobe.com/xap/1.0/\x00": + self.info["xmp"] = s.split(b"\x00", 1)[1] elif marker == 0xFFE2 and s[:5] == b"FPXR\0": # extract FlashPix information (incomplete) self.info["flashpix"] = s # FIXME: value will change @@ -159,38 +161,6 @@ def APP(self, marker): # plus constant header size self.info["mpoffset"] = self.fp.tell() - n + 4 - # If DPI isn't in JPEG header, fetch from EXIF - if "dpi" not in self.info and "exif" in self.info: - try: - exif = self.getexif() - resolution_unit = exif[0x0128] - x_resolution = exif[0x011A] - try: - dpi = float(x_resolution[0]) / x_resolution[1] - except TypeError: - dpi = x_resolution - if math.isnan(dpi): - msg = "DPI is not a number" - raise ValueError(msg) - if resolution_unit == 3: # cm - # 1 dpcm = 2.54 dpi - dpi *= 2.54 - self.info["dpi"] = dpi, dpi - except ( - struct.error, - KeyError, - SyntaxError, - TypeError, - ValueError, - ZeroDivisionError, - ): - # struct.error for truncated EXIF - # KeyError for dpi not included - # SyntaxError for invalid/unreadable EXIF - # ValueError or TypeError for dpi being an invalid float - # ZeroDivisionError for invalid dpi rational value - self.info["dpi"] = 72, 72 - def COM(self: JpegImageFile, marker: int) -> None: # @@ -409,6 +379,8 @@ class JpegImageFile(ImageFile.ImageFile): msg = "no marker found" raise SyntaxError(msg) + self._read_dpi_from_exif() + def load_read(self, read_bytes: int) -> bytes: """ internal: read more image data @@ -426,7 +398,7 @@ class JpegImageFile(ImageFile.ImageFile): return s def draft( - self, mode: str | None, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] | None ) -> tuple[str, tuple[int, int, float, float]] | None: if len(self.tile) != 1: return None @@ -497,24 +469,38 @@ class JpegImageFile(ImageFile.ImageFile): def _getexif(self) -> dict[str, Any] | None: return _getexif(self) + def _read_dpi_from_exif(self) -> None: + # If DPI isn't in JPEG header, fetch from EXIF + if "dpi" in self.info or "exif" not in self.info: + return + try: + exif = self.getexif() + resolution_unit = exif[0x0128] + x_resolution = exif[0x011A] + try: + dpi = float(x_resolution[0]) / x_resolution[1] + except TypeError: + dpi = x_resolution + if math.isnan(dpi): + msg = "DPI is not a number" + raise ValueError(msg) + if resolution_unit == 3: # cm + # 1 dpcm = 2.54 dpi + dpi *= 2.54 + self.info["dpi"] = dpi, dpi + except ( + struct.error, # truncated EXIF + KeyError, # dpi not included + SyntaxError, # invalid/unreadable EXIF + TypeError, # dpi is an invalid float + ValueError, # dpi is an invalid float + ZeroDivisionError, # invalid dpi rational value + ): + self.info["dpi"] = 72, 72 + def _getmp(self): return _getmp(self) - def getxmp(self) -> dict[str, Any]: - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - - for segment, content in self.applist: - if segment == "APP1": - marker, xmp_tags = content.split(b"\x00")[:2] - if marker == b"http://ns.adobe.com/xap/1.0/": - return self._getxmp(xmp_tags) - return {} - def _getexif(self) -> dict[str, Any] | None: if "exif" not in self.info: @@ -844,6 +830,10 @@ def jpeg_factory(fp=None, filename=None): try: mpheader = im._getmp() if mpheader[45057] > 1: + for segment, content in im.applist: + if segment == "APP1" and b' hdrgm:Version="' in content: + # Ultra HDR images are not yet supported + return im # It's actually an MPO from .MpoImagePlugin import MpoImageFile diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index 07239887f..5f23a34b9 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -70,7 +70,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self.__fp = self.fp self.seek(0) - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return try: diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 9e2231347..7cb2d241b 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -8,7 +8,7 @@ import os import re import time import zlib -from typing import TYPE_CHECKING, Any, List, NamedTuple, Union +from typing import IO, TYPE_CHECKING, Any, NamedTuple, Union # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set @@ -62,7 +62,7 @@ PDFDocEncoding = { } -def decode_text(b): +def decode_text(b: bytes) -> str: if b[: len(codecs.BOM_UTF16_BE)] == codecs.BOM_UTF16_BE: return b[len(codecs.BOM_UTF16_BE) :].decode("utf_16_be") else: @@ -99,7 +99,7 @@ class IndirectReference(IndirectReferenceTuple): assert isinstance(other, IndirectReference) return other.object_id == self.object_id and other.generation == self.generation - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not (self == other) def __hash__(self) -> int: @@ -112,13 +112,17 @@ class IndirectObjectDef(IndirectReference): class XrefTable: - def __init__(self): - self.existing_entries = {} # object ID => (offset, generation) - self.new_entries = {} # object ID => (offset, generation) + def __init__(self) -> None: + self.existing_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) + self.new_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) self.deleted_entries = {0: 65536} # object ID => generation self.reading_finished = False - def __setitem__(self, key, value): + def __setitem__(self, key: int, value: tuple[int, int]) -> None: if self.reading_finished: self.new_entries[key] = value else: @@ -126,13 +130,13 @@ class XrefTable: if key in self.deleted_entries: del self.deleted_entries[key] - def __getitem__(self, key): + def __getitem__(self, key: int) -> tuple[int, int]: try: return self.new_entries[key] except KeyError: return self.existing_entries[key] - def __delitem__(self, key): + def __delitem__(self, key: int) -> None: if key in self.new_entries: generation = self.new_entries[key][1] + 1 del self.new_entries[key] @@ -146,7 +150,7 @@ class XrefTable: msg = f"object ID {key} cannot be deleted because it doesn't exist" raise IndexError(msg) - def __contains__(self, key): + def __contains__(self, key: int) -> bool: return key in self.existing_entries or key in self.new_entries def __len__(self) -> int: @@ -156,19 +160,19 @@ class XrefTable: | set(self.deleted_entries.keys()) ) - def keys(self): + def keys(self) -> set[int]: return ( set(self.existing_entries.keys()) - set(self.deleted_entries.keys()) ) | set(self.new_entries.keys()) - def write(self, f): + def write(self, f: IO[bytes]) -> int: keys = sorted(set(self.new_entries.keys()) | set(self.deleted_entries.keys())) deleted_keys = sorted(set(self.deleted_entries.keys())) startxref = f.tell() f.write(b"xref\n") while keys: # find a contiguous sequence of object IDs - prev = None + prev: int | None = None for index, key in enumerate(keys): if prev is None or prev + 1 == key: prev = key @@ -178,7 +182,7 @@ class XrefTable: break else: contiguous_keys = keys - keys = None + keys = [] f.write(b"%d %d\n" % (contiguous_keys[0], len(contiguous_keys))) for object_id in contiguous_keys: if object_id in self.new_entries: @@ -202,7 +206,9 @@ class XrefTable: class PdfName: - def __init__(self, name): + name: bytes + + def __init__(self, name: PdfName | bytes | str) -> None: if isinstance(name, PdfName): self.name = name.name elif isinstance(name, bytes): @@ -213,7 +219,7 @@ class PdfName: def name_as_str(self) -> str: return self.name.decode("us-ascii") - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return ( isinstance(other, PdfName) and other.name == self.name ) or other == self.name @@ -225,7 +231,7 @@ class PdfName: return f"{self.__class__.__name__}({repr(self.name)})" @classmethod - def from_pdf_stream(cls, data): + def from_pdf_stream(cls, data: bytes) -> PdfName: return cls(PdfParser.interpret_name(data)) allowed_chars = set(range(33, 127)) - {ord(c) for c in "#%/()<>[]{}"} @@ -240,7 +246,7 @@ class PdfName: return bytes(result) -class PdfArray(List[Any]): +class PdfArray(list[Any]): def __bytes__(self) -> bytes: return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]" @@ -252,13 +258,13 @@ else: class PdfDict(_DictBase): - def __setattr__(self, key, value): + def __setattr__(self, key: str, value: Any) -> None: if key == "data": collections.UserDict.__setattr__(self, key, value) else: self[key.encode("us-ascii")] = value - def __getattr__(self, key): + def __getattr__(self, key: str) -> str | time.struct_time: try: value = self[key.encode("us-ascii")] except KeyError as e: @@ -300,7 +306,7 @@ class PdfDict(_DictBase): class PdfBinary: - def __init__(self, data): + def __init__(self, data: list[int] | bytes) -> None: self.data = data def __bytes__(self) -> bytes: @@ -308,27 +314,27 @@ class PdfBinary: class PdfStream: - def __init__(self, dictionary, buf): + def __init__(self, dictionary: PdfDict, buf: bytes) -> None: self.dictionary = dictionary self.buf = buf - def decode(self): + def decode(self) -> bytes: try: - filter = self.dictionary.Filter - except AttributeError: + filter = self.dictionary[b"Filter"] + except KeyError: return self.buf if filter == b"FlateDecode": try: - expected_length = self.dictionary.DL - except AttributeError: - expected_length = self.dictionary.Length + expected_length = self.dictionary[b"DL"] + except KeyError: + expected_length = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expected_length)) else: - msg = f"stream filter {repr(self.dictionary.Filter)} unknown/unsupported" + msg = f"stream filter {repr(filter)} unknown/unsupported" raise NotImplementedError(msg) -def pdf_repr(x): +def pdf_repr(x: Any) -> bytes: if x is True: return b"true" elif x is False: @@ -363,12 +369,19 @@ class PdfParser: Supports PDF up to 1.4 """ - def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): + def __init__( + self, + filename: str | None = None, + f: IO[bytes] | None = None, + buf: bytes | bytearray | None = None, + start_offset: int = 0, + mode: str = "rb", + ) -> None: if buf and f: msg = "specify buf or f or filename, but not both buf and f" raise RuntimeError(msg) self.filename = filename - self.buf = buf + self.buf: bytes | bytearray | mmap.mmap | None = buf self.f = f self.start_offset = start_offset self.should_close_buf = False @@ -377,12 +390,16 @@ class PdfParser: self.f = f = open(filename, mode) self.should_close_file = True if f is not None: - self.buf = buf = self.get_buf_from_file(f) + self.buf = self.get_buf_from_file(f) self.should_close_buf = True if not filename and hasattr(f, "name"): self.filename = f.name - self.cached_objects = {} - if buf: + self.cached_objects: dict[IndirectReference, Any] = {} + self.root_ref: IndirectReference | None + self.info_ref: IndirectReference | None + self.pages_ref: IndirectReference | None + self.last_xref_section_offset: int | None + if self.buf: self.read_pdf_info() else: self.file_size_total = self.file_size_this = 0 @@ -390,12 +407,12 @@ class PdfParser: self.root_ref = None self.info = PdfDict() self.info_ref = None - self.page_tree_root = {} - self.pages = [] - self.orig_pages = [] + self.page_tree_root = PdfDict() + self.pages: list[IndirectReference] = [] + self.orig_pages: list[IndirectReference] = [] self.pages_ref = None self.last_xref_section_offset = None - self.trailer_dict = {} + self.trailer_dict: dict[bytes, Any] = {} self.xref_table = XrefTable() self.xref_table.reading_finished = True if f: @@ -412,10 +429,8 @@ class PdfParser: self.seek_end() def close_buf(self) -> None: - try: + if isinstance(self.buf, mmap.mmap): self.buf.close() - except AttributeError: - pass self.buf = None def close(self) -> None: @@ -426,15 +441,19 @@ class PdfParser: self.f = None def seek_end(self) -> None: + assert self.f is not None self.f.seek(0, os.SEEK_END) def write_header(self) -> None: + assert self.f is not None self.f.write(b"%PDF-1.4\n") - def write_comment(self, s): + def write_comment(self, s: str) -> None: + assert self.f is not None self.f.write(f"% {s}\n".encode()) def write_catalog(self) -> IndirectReference: + assert self.f is not None self.del_root() self.root_ref = self.next_object_id(self.f.tell()) self.pages_ref = self.next_object_id(0) @@ -477,7 +496,10 @@ class PdfParser: pages_tree_node_ref = pages_tree_node.get(b"Parent", None) self.orig_pages = [] - def write_xref_and_trailer(self, new_root_ref=None): + def write_xref_and_trailer( + self, new_root_ref: IndirectReference | None = None + ) -> None: + assert self.f is not None if new_root_ref: self.del_root() self.root_ref = new_root_ref @@ -485,7 +507,10 @@ class PdfParser: self.info_ref = self.write_obj(None, self.info) start_xref = self.xref_table.write(self.f) num_entries = len(self.xref_table) - trailer_dict = {b"Root": self.root_ref, b"Size": num_entries} + trailer_dict: dict[str | bytes, Any] = { + b"Root": self.root_ref, + b"Size": num_entries, + } if self.last_xref_section_offset is not None: trailer_dict[b"Prev"] = self.last_xref_section_offset if self.info: @@ -497,16 +522,20 @@ class PdfParser: + b"\nstartxref\n%d\n%%%%EOF" % start_xref ) - def write_page(self, ref, *objs, **dict_obj): - if isinstance(ref, int): - ref = self.pages[ref] + def write_page( + self, ref: int | IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + obj_ref = self.pages[ref] if isinstance(ref, int) else ref if "Type" not in dict_obj: dict_obj["Type"] = PdfName(b"Page") if "Parent" not in dict_obj: dict_obj["Parent"] = self.pages_ref - return self.write_obj(ref, *objs, **dict_obj) + return self.write_obj(obj_ref, *objs, **dict_obj) - def write_obj(self, ref, *objs, **dict_obj): + def write_obj( + self, ref: IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + assert self.f is not None f = self.f if ref is None: ref = self.next_object_id(f.tell()) @@ -534,7 +563,7 @@ class PdfParser: del self.xref_table[self.root[b"Pages"].object_id] @staticmethod - def get_buf_from_file(f): + def get_buf_from_file(f: IO[bytes]) -> bytes | mmap.mmap: if hasattr(f, "getbuffer"): return f.getbuffer() elif hasattr(f, "getvalue"): @@ -546,10 +575,15 @@ class PdfParser: return b"" def read_pdf_info(self) -> None: + assert self.buf is not None self.file_size_total = len(self.buf) self.file_size_this = self.file_size_total - self.start_offset self.read_trailer() + check_format_condition( + self.trailer_dict.get(b"Root") is not None, "Root is missing" + ) self.root_ref = self.trailer_dict[b"Root"] + assert self.root_ref is not None self.info_ref = self.trailer_dict.get(b"Info", None) self.root = PdfDict(self.read_indirect(self.root_ref)) if self.info_ref is None: @@ -560,12 +594,15 @@ class PdfParser: check_format_condition( self.root[b"Type"] == b"Catalog", "/Type in Root is not /Catalog" ) - check_format_condition(b"Pages" in self.root, "/Pages missing in Root") + check_format_condition( + self.root.get(b"Pages") is not None, "/Pages missing in Root" + ) check_format_condition( isinstance(self.root[b"Pages"], IndirectReference), "/Pages in Root is not an indirect reference", ) self.pages_ref = self.root[b"Pages"] + assert self.pages_ref is not None self.page_tree_root = self.read_indirect(self.pages_ref) self.pages = self.linearize_page_tree(self.page_tree_root) # save the original list of page references @@ -573,7 +610,7 @@ class PdfParser: # and we need to rewrite the pages and their list self.orig_pages = self.pages[:] - def next_object_id(self, offset=None): + def next_object_id(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) @@ -623,12 +660,13 @@ class PdfParser: re.DOTALL, ) - def read_trailer(self): + def read_trailer(self) -> None: + assert self.buf is not None search_start_offset = len(self.buf) - 16384 if search_start_offset < self.start_offset: search_start_offset = self.start_offset m = self.re_trailer_end.search(self.buf, search_start_offset) - check_format_condition(m, "trailer end not found") + check_format_condition(m is not None, "trailer end not found") # make sure we found the LAST trailer last_match = m while m: @@ -636,6 +674,7 @@ class PdfParser: m = self.re_trailer_end.search(self.buf, m.start() + 16) if not m: m = last_match + assert m is not None trailer_data = m.group(1) self.last_xref_section_offset = int(m.group(2)) self.trailer_dict = self.interpret_trailer(trailer_data) @@ -644,12 +683,14 @@ class PdfParser: if b"Prev" in self.trailer_dict: self.read_prev_trailer(self.trailer_dict[b"Prev"]) - def read_prev_trailer(self, xref_section_offset): + def read_prev_trailer(self, xref_section_offset: int) -> None: + assert self.buf is not None trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset) m = self.re_trailer_prev.search( self.buf[trailer_offset : trailer_offset + 16384] ) - check_format_condition(m, "previous trailer not found") + check_format_condition(m is not None, "previous trailer not found") + assert m is not None trailer_data = m.group(1) check_format_condition( int(m.group(2)) == xref_section_offset, @@ -670,7 +711,7 @@ class PdfParser: re_dict_end = re.compile(whitespace_optional + rb">>" + whitespace_optional) @classmethod - def interpret_trailer(cls, trailer_data): + def interpret_trailer(cls, trailer_data: bytes) -> dict[bytes, Any]: trailer = {} offset = 0 while True: @@ -678,14 +719,18 @@ class PdfParser: if not m: m = cls.re_dict_end.match(trailer_data, offset) check_format_condition( - m and m.end() == len(trailer_data), + m is not None and m.end() == len(trailer_data), "name not found in trailer, remaining data: " + repr(trailer_data[offset:]), ) break key = cls.interpret_name(m.group(1)) - value, offset = cls.get_value(trailer_data, m.end()) + assert isinstance(key, bytes) + value, value_offset = cls.get_value(trailer_data, m.end()) trailer[key] = value + if value_offset is None: + break + offset = value_offset check_format_condition( b"Size" in trailer and isinstance(trailer[b"Size"], int), "/Size not in trailer or not an integer", @@ -699,7 +744,7 @@ class PdfParser: re_hashes_in_name = re.compile(rb"([^#]*)(#([0-9a-fA-F]{2}))?") @classmethod - def interpret_name(cls, raw, as_text=False): + def interpret_name(cls, raw: bytes, as_text: bool = False) -> str | bytes: name = b"" for m in cls.re_hashes_in_name.finditer(raw): if m.group(3): @@ -761,7 +806,13 @@ class PdfParser: ) @classmethod - def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): + def get_value( + cls, + data: bytes | bytearray | mmap.mmap, + offset: int, + expect_indirect: IndirectReference | None = None, + max_nesting: int = -1, + ) -> tuple[Any, int | None]: if max_nesting == 0: return None, None m = cls.re_comment.match(data, offset) @@ -783,11 +834,16 @@ class PdfParser: == IndirectReference(int(m.group(1)), int(m.group(2))), "indirect object definition different than expected", ) - object, offset = cls.get_value(data, m.end(), max_nesting=max_nesting - 1) - if offset is None: + object, object_offset = cls.get_value( + data, m.end(), max_nesting=max_nesting - 1 + ) + if object_offset is None: return object, None - m = cls.re_indirect_def_end.match(data, offset) - check_format_condition(m, "indirect object definition end not found") + m = cls.re_indirect_def_end.match(data, object_offset) + check_format_condition( + m is not None, "indirect object definition end not found" + ) + assert m is not None return object, m.end() check_format_condition( not expect_indirect, "indirect object definition not found" @@ -806,46 +862,53 @@ class PdfParser: m = cls.re_dict_start.match(data, offset) if m: offset = m.end() - result = {} + result: dict[Any, Any] = {} m = cls.re_dict_end.match(data, offset) + current_offset: int | None = offset while not m: - key, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) - if offset is None: + assert current_offset is not None + key, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + if current_offset is None: return result, None - value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) result[key] = value - if offset is None: + if current_offset is None: return result, None - m = cls.re_dict_end.match(data, offset) - offset = m.end() - m = cls.re_stream_start.match(data, offset) + m = cls.re_dict_end.match(data, current_offset) + current_offset = m.end() + m = cls.re_stream_start.match(data, current_offset) if m: - try: - stream_len_str = result.get(b"Length") - stream_len = int(stream_len_str) - except (TypeError, ValueError) as e: - msg = f"bad or missing Length in stream dict ({stream_len_str})" - raise PdfFormatError(msg) from e + stream_len = result.get(b"Length") + if stream_len is None or not isinstance(stream_len, int): + msg = f"bad or missing Length in stream dict ({stream_len})" + raise PdfFormatError(msg) stream_data = data[m.end() : m.end() + stream_len] m = cls.re_stream_end.match(data, m.end() + stream_len) - check_format_condition(m, "stream end not found") - offset = m.end() - result = PdfStream(PdfDict(result), stream_data) - else: - result = PdfDict(result) - return result, offset + check_format_condition(m is not None, "stream end not found") + assert m is not None + current_offset = m.end() + return PdfStream(PdfDict(result), stream_data), current_offset + return PdfDict(result), current_offset m = cls.re_array_start.match(data, offset) if m: offset = m.end() - result = [] + results = [] m = cls.re_array_end.match(data, offset) + current_offset = offset while not m: - value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) - result.append(value) - if offset is None: - return result, None - m = cls.re_array_end.match(data, offset) - return result, m.end() + assert current_offset is not None + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + results.append(value) + if current_offset is None: + return results, None + m = cls.re_array_end.match(data, current_offset) + return results, m.end() m = cls.re_null.match(data, offset) if m: return None, m.end() @@ -905,7 +968,9 @@ class PdfParser: } @classmethod - def get_literal_string(cls, data, offset): + def get_literal_string( + cls, data: bytes | bytearray | mmap.mmap, offset: int + ) -> tuple[bytes, int]: nesting_depth = 0 result = bytearray() for m in cls.re_lit_str_token.finditer(data, offset): @@ -941,12 +1006,14 @@ class PdfParser: ) re_xref_entry = re.compile(rb"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)") - def read_xref_table(self, xref_section_offset): + def read_xref_table(self, xref_section_offset: int) -> int: + assert self.buf is not None subsection_found = False m = self.re_xref_section_start.match( self.buf, xref_section_offset + self.start_offset ) - check_format_condition(m, "xref section start not found") + check_format_condition(m is not None, "xref section start not found") + assert m is not None offset = m.end() while True: m = self.re_xref_subsection_start.match(self.buf, offset) @@ -961,7 +1028,8 @@ class PdfParser: num_objects = int(m.group(2)) for i in range(first_object, first_object + num_objects): m = self.re_xref_entry.match(self.buf, offset) - check_format_condition(m, "xref entry not found") + check_format_condition(m is not None, "xref entry not found") + assert m is not None offset = m.end() is_free = m.group(3) == b"f" if not is_free: @@ -971,13 +1039,14 @@ class PdfParser: self.xref_table[i] = new_entry return offset - def read_indirect(self, ref, max_nesting=-1): + def read_indirect(self, ref: IndirectReference, max_nesting: int = -1) -> Any: offset, generation = self.xref_table[ref[0]] check_format_condition( generation == ref[1], f"expected to find generation {ref[1]} for object ID {ref[0]} in xref " f"table, instead found generation {generation} at offset {offset}", ) + assert self.buf is not None value = self.get_value( self.buf, offset + self.start_offset, @@ -987,14 +1056,15 @@ class PdfParser: self.cached_objects[ref] = value return value - def linearize_page_tree(self, node=None): - if node is None: - node = self.page_tree_root + def linearize_page_tree( + self, node: PdfDict | None = None + ) -> list[IndirectReference]: + page_node = node if node is not None else self.page_tree_root check_format_condition( - node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" + page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" ) pages = [] - for kid in node[b"Kids"]: + for kid in page_node[b"Kids"]: kid_object = self.read_indirect(kid) if kid_object[b"Type"] == b"Page": pages.append(kid) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 927d6c0cf..34ea77c5e 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -624,6 +624,8 @@ class PngStream(ChunkStream): return s else: return s + if k == b"XML:com.adobe.xmp": + self.im_info["xmp"] = v try: k = k.decode("latin-1", "strict") lang = lang.decode("utf-8", "strict") @@ -849,8 +851,6 @@ class PngImageFile(ImageFile.ImageFile): self.png.rewind() self.__prepare_idat = self.__rewind_idat self.im = None - if self.pyaccess: - self.pyaccess = None self.info = self.png.im_info self.tile = self.png.im_tile self.fp = self._fp @@ -1037,8 +1037,6 @@ class PngImageFile(ImageFile.ImageFile): mask = updated.convert("RGBA") self._prev_im.paste(updated, self.dispose_extent, mask) self.im = self._prev_im - if self.pyaccess: - self.pyaccess = None def _getexif(self) -> dict[str, Any] | None: if "exif" not in self.info: @@ -1053,19 +1051,6 @@ class PngImageFile(ImageFile.ImageFile): return super().getexif() - def getxmp(self) -> dict[str, Any]: - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return ( - self._getxmp(self.info["XML:com.adobe.xmp"]) - if "XML:com.adobe.xmp" in self.info - else {} - ) - # -------------------------------------------------------------------- # PNG writer @@ -1125,8 +1110,8 @@ class _fdat: self.seq_num += 1 -def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images): - duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) +def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_images): + duration = im.encoderinfo.get("duration") loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) blend = im.encoderinfo.get("blend", im.info.get("blend", Blend.OP_SOURCE)) @@ -1140,13 +1125,15 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) frame_count = 0 for im_seq in chain: for im_frame in ImageSequence.Iterator(im_seq): - if im_frame.mode == rawmode: + if im_frame.mode == mode: im_frame = im_frame.copy() else: - im_frame = im_frame.convert(rawmode) + im_frame = im_frame.convert(mode) encoderinfo = im.encoderinfo.copy() if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] + elif duration is None and "duration" in im_frame.info: + encoderinfo["duration"] = im_frame.info["duration"] if isinstance(disposal, (list, tuple)): encoderinfo["disposal"] = disposal[frame_count] if isinstance(blend, (list, tuple)): @@ -1181,15 +1168,12 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) not bbox and prev_disposal == encoderinfo.get("disposal") and prev_blend == encoderinfo.get("blend") + and "duration" in encoderinfo ): - previous["encoderinfo"]["duration"] += encoderinfo.get( - "duration", duration - ) + previous["encoderinfo"]["duration"] += encoderinfo["duration"] continue else: bbox = None - if "duration" not in encoderinfo: - encoderinfo["duration"] = duration im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) if len(im_frames) == 1 and not default_image: @@ -1205,8 +1189,8 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) # default image IDAT (if it exists) if default_image: - if im.mode != rawmode: - im = im.convert(rawmode) + if im.mode != mode: + im = im.convert(mode) ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) seq_num = 0 @@ -1219,7 +1203,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) im_frame = im_frame.crop(bbox) size = im_frame.size encoderinfo = frame_data["encoderinfo"] - frame_duration = int(round(encoderinfo["duration"])) + frame_duration = int(round(encoderinfo.get("duration", 0))) frame_disposal = encoderinfo.get("disposal", disposal) frame_blend = encoderinfo.get("blend", blend) # frame control @@ -1283,6 +1267,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): size = im.size mode = im.mode + outmode = mode if mode == "P": # # attempt to minimize storage requirements for palette images @@ -1303,7 +1288,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): bits = 2 else: bits = 4 - mode = f"{mode};{bits}" + outmode += f";{bits}" # encoder options im.encoderconfig = ( @@ -1315,7 +1300,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): # get the corresponding PNG mode try: - rawmode, bit_depth, color_type = _OUTMODES[mode] + rawmode, bit_depth, color_type = _OUTMODES[outmode] except KeyError as e: msg = f"cannot write mode {mode} as PNG" raise OSError(msg) from e @@ -1436,7 +1421,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): if save_all: im = _write_multiple_frames( - im, fp, chunk, rawmode, default_image, append_images + im, fp, chunk, mode, rawmode, default_image, append_images ) if im: ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 86c1a6763..edf698bf0 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -18,6 +18,7 @@ from __future__ import annotations import io +from functools import cached_property from . import Image, ImageFile, ImagePalette from ._binary import i8 @@ -118,18 +119,17 @@ class PsdImageFile(ImageFile.ImageFile): # # layer and mask information - self.layers = [] + self._layers_position = None size = i32(read(4)) if size: end = self.fp.tell() + size size = i32(read(4)) if size: - _layer_data = io.BytesIO(ImageFile._safe_read(self.fp, size)) - self.layers = _layerinfo(_layer_data, size) + self._layers_position = self.fp.tell() + self._layers_size = size self.fp.seek(end) - self.n_frames = len(self.layers) - self.is_animated = self.n_frames > 1 + self._n_frames: int | None = None # # image descriptor @@ -141,6 +141,26 @@ class PsdImageFile(ImageFile.ImageFile): self.frame = 1 self._min_frame = 1 + @cached_property + def layers(self): + layers = [] + if self._layers_position is not None: + self._fp.seek(self._layers_position) + _layer_data = io.BytesIO(ImageFile._safe_read(self._fp, self._layers_size)) + layers = _layerinfo(_layer_data, self._layers_size) + self._n_frames = len(layers) + return layers + + @property + def n_frames(self) -> int: + if self._n_frames is None: + self._n_frames = len(self.layers) + return self._n_frames + + @property + def is_animated(self) -> bool: + return len(self.layers) > 1 + def seek(self, layer: int) -> None: if not self._seek_check(layer): return diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py deleted file mode 100644 index fe12cb641..000000000 --- a/src/PIL/PyAccess.py +++ /dev/null @@ -1,376 +0,0 @@ -# -# The Python Imaging Library -# Pillow fork -# -# Python implementation of the PixelAccess Object -# -# Copyright (c) 1997-2009 by Secret Labs AB. All rights reserved. -# Copyright (c) 1995-2009 by Fredrik Lundh. -# Copyright (c) 2013 Eric Soroos -# -# See the README file for information on usage and redistribution -# - -# Notes: -# -# * Implements the pixel access object following Access.c -# * Taking only the tuple form, which is used from python. -# * Fill.c uses the integer form, but it's still going to use the old -# Access.c implementation. -# -from __future__ import annotations - -import logging -import sys -from typing import TYPE_CHECKING - -from ._deprecate import deprecate - -FFI: type -try: - from cffi import FFI - - defs = """ - struct Pixel_RGBA { - unsigned char r,g,b,a; - }; - struct Pixel_I16 { - unsigned char l,r; - }; - """ - ffi = FFI() - ffi.cdef(defs) -except ImportError as ex: - # Allow error import for doc purposes, but error out when accessing - # anything in core. - from ._util import DeferredError - - FFI = ffi = DeferredError.new(ex) - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from . import Image - - -class PyAccess: - def __init__(self, img: Image.Image, readonly: bool = False) -> None: - deprecate("PyAccess", 11) - vals = dict(img.im.unsafe_ptrs) - self.readonly = readonly - self.image8 = ffi.cast("unsigned char **", vals["image8"]) - self.image32 = ffi.cast("int **", vals["image32"]) - self.image = ffi.cast("unsigned char **", vals["image"]) - self.xsize, self.ysize = img.im.size - self._img = img - - # Keep pointer to im object to prevent dereferencing. - self._im = img.im - if self._im.mode in ("P", "PA"): - self._palette = img.palette - - # Debugging is polluting test traces, only useful here - # when hacking on PyAccess - # logger.debug("%s", vals) - self._post_init() - - def _post_init(self) -> None: - pass - - def __setitem__(self, xy, color): - """ - Modifies the pixel at x,y. The color is given as a single - numerical value for single band images, and a tuple for - multi-band images. In addition to this, RGB and RGBA tuples - are accepted for P and PA images. - - :param xy: The pixel coordinate, given as (x, y). See - :ref:`coordinate-system`. - :param color: The pixel value. - """ - if self.readonly: - msg = "Attempt to putpixel a read only image" - raise ValueError(msg) - (x, y) = xy - if x < 0: - x = self.xsize + x - if y < 0: - y = self.ysize + y - (x, y) = self.check_xy((x, y)) - - if ( - self._im.mode in ("P", "PA") - and isinstance(color, (list, tuple)) - and len(color) in [3, 4] - ): - # RGB or RGBA value for a P or PA image - if self._im.mode == "PA": - alpha = color[3] if len(color) == 4 else 255 - color = color[:3] - color = self._palette.getcolor(color, self._img) - if self._im.mode == "PA": - color = (color, alpha) - - return self.set_pixel(x, y, color) - - def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: - """ - Returns the pixel at x,y. The pixel is returned as a single - value for single band images or a tuple for multiple band - images - - :param xy: The pixel coordinate, given as (x, y). See - :ref:`coordinate-system`. - :returns: a pixel value for single band images, a tuple of - pixel values for multiband images. - """ - (x, y) = xy - if x < 0: - x = self.xsize + x - if y < 0: - y = self.ysize + y - (x, y) = self.check_xy((x, y)) - return self.get_pixel(x, y) - - putpixel = __setitem__ - getpixel = __getitem__ - - def check_xy(self, xy: tuple[int, int]) -> tuple[int, int]: - (x, y) = xy - if not (0 <= x < self.xsize and 0 <= y < self.ysize): - msg = "pixel location out of range" - raise ValueError(msg) - return xy - - def get_pixel(self, x: int, y: int) -> float | tuple[int, ...]: - raise NotImplementedError() - - def set_pixel(self, x: int, y: int, color: float | tuple[int, ...]) -> None: - raise NotImplementedError() - - -class _PyAccess32_2(PyAccess): - """PA, LA, stored in first and last bytes of a 32 bit word""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - - def get_pixel(self, x: int, y: int) -> tuple[int, int]: - pixel = self.pixels[y][x] - return pixel.r, pixel.a - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - # tuple - pixel.r = min(color[0], 255) - pixel.a = min(color[1], 255) - - -class _PyAccess32_3(PyAccess): - """RGB and friends, stored in the first three bytes of a 32 bit word""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - - def get_pixel(self, x: int, y: int) -> tuple[int, int, int]: - pixel = self.pixels[y][x] - return pixel.r, pixel.g, pixel.b - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - # tuple - pixel.r = min(color[0], 255) - pixel.g = min(color[1], 255) - pixel.b = min(color[2], 255) - pixel.a = 255 - - -class _PyAccess32_4(PyAccess): - """RGBA etc, all 4 bytes of a 32 bit word""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - - def get_pixel(self, x: int, y: int) -> tuple[int, int, int, int]: - pixel = self.pixels[y][x] - return pixel.r, pixel.g, pixel.b, pixel.a - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - # tuple - pixel.r = min(color[0], 255) - pixel.g = min(color[1], 255) - pixel.b = min(color[2], 255) - pixel.a = min(color[3], 255) - - -class _PyAccess8(PyAccess): - """1, L, P, 8 bit images stored as uint8""" - - def _post_init(self, *args, **kwargs): - self.pixels = self.image8 - - def get_pixel(self, x: int, y: int) -> int: - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - try: - # integer - self.pixels[y][x] = min(color, 255) - except TypeError: - # tuple - self.pixels[y][x] = min(color[0], 255) - - -class _PyAccessI16_N(PyAccess): - """I;16 access, native bitendian without conversion""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("unsigned short **", self.image) - - def get_pixel(self, x: int, y: int) -> int: - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - try: - # integer - self.pixels[y][x] = min(color, 65535) - except TypeError: - # tuple - self.pixels[y][x] = min(color[0], 65535) - - -class _PyAccessI16_L(PyAccess): - """I;16L access, with conversion""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - - def get_pixel(self, x: int, y: int) -> int: - pixel = self.pixels[y][x] - return pixel.l + pixel.r * 256 - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - try: - color = min(color, 65535) - except TypeError: - color = min(color[0], 65535) - - pixel.l = color & 0xFF - pixel.r = color >> 8 - - -class _PyAccessI16_B(PyAccess): - """I;16B access, with conversion""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - - def get_pixel(self, x: int, y: int) -> int: - pixel = self.pixels[y][x] - return pixel.l * 256 + pixel.r - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - try: - color = min(color, 65535) - except Exception: - color = min(color[0], 65535) - - pixel.l = color >> 8 - pixel.r = color & 0xFF - - -class _PyAccessI32_N(PyAccess): - """Signed Int32 access, native endian""" - - def _post_init(self, *args, **kwargs): - self.pixels = self.image32 - - def get_pixel(self, x: int, y: int) -> int: - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - self.pixels[y][x] = color - - -class _PyAccessI32_Swap(PyAccess): - """I;32L/B access, with byteswapping conversion""" - - def _post_init(self, *args, **kwargs): - self.pixels = self.image32 - - def reverse(self, i): - orig = ffi.new("int *", i) - chars = ffi.cast("unsigned char *", orig) - chars[0], chars[1], chars[2], chars[3] = chars[3], chars[2], chars[1], chars[0] - return ffi.cast("int *", chars)[0] - - def get_pixel(self, x: int, y: int) -> int: - return self.reverse(self.pixels[y][x]) - - def set_pixel(self, x, y, color): - self.pixels[y][x] = self.reverse(color) - - -class _PyAccessF(PyAccess): - """32 bit float access""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("float **", self.image32) - - def get_pixel(self, x: int, y: int) -> float: - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - try: - # not a tuple - self.pixels[y][x] = color - except TypeError: - # tuple - self.pixels[y][x] = color[0] - - -mode_map = { - "1": _PyAccess8, - "L": _PyAccess8, - "P": _PyAccess8, - "I;16N": _PyAccessI16_N, - "LA": _PyAccess32_2, - "La": _PyAccess32_2, - "PA": _PyAccess32_2, - "RGB": _PyAccess32_3, - "LAB": _PyAccess32_3, - "HSV": _PyAccess32_3, - "YCbCr": _PyAccess32_3, - "RGBA": _PyAccess32_4, - "RGBa": _PyAccess32_4, - "RGBX": _PyAccess32_4, - "CMYK": _PyAccess32_4, - "F": _PyAccessF, - "I": _PyAccessI32_N, -} - -if sys.byteorder == "little": - mode_map["I;16"] = _PyAccessI16_N - mode_map["I;16L"] = _PyAccessI16_N - mode_map["I;16B"] = _PyAccessI16_B - - mode_map["I;32L"] = _PyAccessI32_N - mode_map["I;32B"] = _PyAccessI32_Swap -else: - mode_map["I;16"] = _PyAccessI16_L - mode_map["I;16L"] = _PyAccessI16_L - mode_map["I;16B"] = _PyAccessI16_N - - mode_map["I;32L"] = _PyAccessI32_Swap - mode_map["I;32B"] = _PyAccessI32_N - - -def new(img: Image.Image, readonly: bool = False) -> PyAccess | None: - access_type = mode_map.get(img.mode, None) - if not access_type: - logger.debug("PyAccess Not Implemented: %s", img.mode) - return None - return access_type(img, readonly) diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index a6cc00019..a07101e54 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -37,12 +37,12 @@ from __future__ import annotations import os import struct import sys -from typing import IO, TYPE_CHECKING +from typing import IO, TYPE_CHECKING, Any, cast from . import Image, ImageFile -def isInt(f): +def isInt(f: Any) -> int: try: i = int(f) if f - i == 0: @@ -62,7 +62,7 @@ iforms = [1, 3, -11, -12, -21, -22] # otherwise returns 0 -def isSpiderHeader(t): +def isSpiderHeader(t: tuple[float, ...]) -> int: h = (99,) + t # add 1 value so can use spider header index start=1 # header values 1,2,5,12,13,22,23 should be integers for i in [1, 2, 5, 12, 13, 22, 23]: @@ -82,7 +82,7 @@ def isSpiderHeader(t): return labbyt -def isSpiderImage(filename): +def isSpiderImage(filename: str) -> int: with open(filename, "rb") as fp: f = fp.read(92) # read 23 * 4 bytes t = struct.unpack(">23f", f) # try big-endian first @@ -184,13 +184,15 @@ class SpiderImageFile(ImageFile.ImageFile): self._open() # returns a byte image after rescaling to 0..255 - def convert2byte(self, depth=255): - (minimum, maximum) = self.getextrema() - m = 1 + def convert2byte(self, depth: int = 255) -> Image.Image: + extrema = self.getextrema() + assert isinstance(extrema[0], float) + minimum, maximum = cast(tuple[float, float], extrema) + m: float = 1 if maximum != minimum: m = depth / (maximum - minimum) b = -m * minimum - return self.point(lambda i, m=m, b=b: i * m + b).convert("L") + return self.point(lambda i: i * m + b).convert("L") if TYPE_CHECKING: from . import ImageTk @@ -207,10 +209,10 @@ class SpiderImageFile(ImageFile.ImageFile): # given a list of filenames, return a list of images -def loadImageSeries(filelist=None): +def loadImageSeries(filelist: list[str] | None = None) -> list[SpiderImageFile] | None: """create a list of :py:class:`~PIL.Image.Image` objects for use in a montage""" if filelist is None or len(filelist) < 1: - return + return None imglist = [] for img in filelist: diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index f16f075df..39104aece 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -36,7 +36,7 @@ MODES = { (3, 1): "1", (3, 8): "L", (3, 16): "LA", - (2, 16): "BGR;5", + (2, 16): "BGRA;15Z", (2, 24): "BGR", (2, 32): "BGRA", } @@ -87,9 +87,7 @@ class TgaImageFile(ImageFile.ImageFile): elif imagetype in (1, 9): self._mode = "P" if colormaptype else "L" elif imagetype in (2, 10): - self._mode = "RGB" - if depth == 32: - self._mode = "RGBA" + self._mode = "RGB" if depth == 24 else "RGBA" else: msg = "unknown TGA mode" raise SyntaxError(msg) @@ -118,15 +116,16 @@ class TgaImageFile(ImageFile.ImageFile): start, size, mapdepth = i16(s, 3), i16(s, 5), s[7] if mapdepth == 16: self.palette = ImagePalette.raw( - "BGR;15", b"\0" * 2 * start + self.fp.read(2 * size) + "BGRA;15Z", bytes(2 * start) + self.fp.read(2 * size) ) + self.palette.mode = "RGBA" elif mapdepth == 24: self.palette = ImagePalette.raw( - "BGR", b"\0" * 3 * start + self.fp.read(3 * size) + "BGR", bytes(3 * start) + self.fp.read(3 * size) ) elif mapdepth == 32: self.palette = ImagePalette.raw( - "BGRA", b"\0" * 4 * start + self.fp.read(4 * size) + "BGRA", bytes(4 * start) + self.fp.read(4 * size) ) else: msg = "unknown TGA map depth" diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 833e12d2b..b89144803 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -201,12 +201,12 @@ OPEN_INFO = { (MM, 2, (1,), 2, (8, 8, 8), ()): ("RGB", "RGB;R"), (II, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples (MM, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples - (II, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGBX", "RGBX"), - (MM, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGBX", "RGBX"), - (II, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGBX", "RGBXX"), - (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGBX", "RGBXX"), - (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGBX", "RGBXXX"), - (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGBX", "RGBXXX"), + (II, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (MM, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), (II, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), (MM, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), (II, 2, (1,), 1, (8, 8, 8, 8, 8), (1, 0)): ("RGBA", "RGBaX"), @@ -225,8 +225,8 @@ OPEN_INFO = { (MM, 2, (1,), 1, (16, 16, 16), ()): ("RGB", "RGB;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16L"), (MM, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16B"), - (II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGBX", "RGBX;16L"), - (MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGBX", "RGBX;16B"), + (II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16L"), + (MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16L"), (MM, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), (2,)): ("RGBA", "RGBA;16L"), @@ -1197,6 +1197,10 @@ class TiffImageFile(ImageFile.ImageFile): self.__frame += 1 self.fp.seek(self._frame_pos[frame]) self.tag_v2.load(self.fp) + if XMP in self.tag_v2: + self.info["xmp"] = self.tag_v2[XMP] + elif "xmp" in self.info: + del self.info["xmp"] self._reload_exif() # fill the legacy tag/ifd entries self.tag = self.ifd = ImageFileDirectory_v1.from_v2(self.tag_v2) @@ -1207,15 +1211,6 @@ class TiffImageFile(ImageFile.ImageFile): """Return the current frame number""" return self.__frame - def getxmp(self) -> dict[str, Any]: - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return self._getxmp(self.tag_v2[XMP]) if XMP in self.tag_v2 else {} - def get_photoshop_blocks(self): """ Returns a dictionary of Photoshop "Image Resource Blocks". @@ -1237,7 +1232,7 @@ class TiffImageFile(ImageFile.ImageFile): val = val[math.ceil((10 + n + size) / 2) * 2 :] return blocks - def load(self): + def load(self) -> Image.core.PixelAccess | None: if self.tile and self.use_load_libtiff: return self._load_libtiff() return super().load() @@ -1663,6 +1658,20 @@ def _save(im, fp, filename): except Exception: pass # might not be an IFD. Might not have populated type + legacy_ifd = {} + if hasattr(im, "tag"): + legacy_ifd = im.tag.to_v2() + + supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})} + for tag in ( + # IFD offset that may not be correct in the saved image + EXIFIFD, + # Determined by the image format and should not be copied from legacy_ifd. + SAMPLEFORMAT, + ): + if tag in supplied_tags: + del supplied_tags[tag] + # additions written by Greg Couch, gregc@cgl.ucsf.edu # inspired by image-sig posting from Kevin Cazabon, kcazabon@home.com if hasattr(im, "tag_v2"): @@ -1676,8 +1685,14 @@ def _save(im, fp, filename): XMP, ): if key in im.tag_v2: - ifd[key] = im.tag_v2[key] - ifd.tagtype[key] = im.tag_v2.tagtype[key] + if key == IPTC_NAA_CHUNK and im.tag_v2.tagtype[key] not in ( + TiffTags.BYTE, + TiffTags.UNDEFINED, + ): + del supplied_tags[key] + else: + ifd[key] = im.tag_v2[key] + ifd.tagtype[key] = im.tag_v2.tagtype[key] # preserve ICC profile (should also work when saving other formats # which support profiles as TIFF) -- 2008-06-06 Florian Hoech @@ -1817,16 +1832,6 @@ def _save(im, fp, filename): # Merge the ones that we have with (optional) more bits from # the original file, e.g x,y resolution so that we can # save(load('')) == original file. - legacy_ifd = {} - if hasattr(im, "tag"): - legacy_ifd = im.tag.to_v2() - - # SAMPLEFORMAT is determined by the image format and should not be copied - # from legacy_ifd. - supplied_tags = {**getattr(im, "tag_v2", {}), **legacy_ifd} - if SAMPLEFORMAT in supplied_tags: - del supplied_tags[SAMPLEFORMAT] - for tag, value in itertools.chain(ifd.items(), supplied_tags.items()): # Libtiff can only process certain core items without adding # them to the custom dictionary. diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index 89fad7033..e318c8739 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -89,7 +89,7 @@ DOUBLE = 12 IFD = 13 LONG8 = 16 -TAGS_V2 = { +_tags_v2 = { 254: ("NewSubfileType", LONG, 1), 255: ("SubfileType", SHORT, 1), 256: ("ImageWidth", LONG, 1), @@ -425,9 +425,11 @@ TAGS = { 50784: "Alias Layer Metadata", } +TAGS_V2: dict[int, TagInfo] = {} + def _populate(): - for k, v in TAGS_V2.items(): + for k, v in _tags_v2.items(): # Populate legacy structure. TAGS[k] = v[0] if len(v) == 4: diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py index fbd7be6ed..895d5616a 100644 --- a/src/PIL/WalImageFile.py +++ b/src/PIL/WalImageFile.py @@ -50,7 +50,7 @@ class WalImageFile(ImageFile.ImageFile): if next_name: self.info["next_name"] = next_name - def load(self): + def load(self) -> Image.core.PixelAccess | None: if not self.im: self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self.size[0] * self.size[1])) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 97debc2ed..530b88c8b 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -101,15 +101,6 @@ class WebPImageFile(ImageFile.ImageFile): return None return self.getexif()._get_merged_dict() - def getxmp(self) -> dict[str, Any]: - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return self._getxmp(self.info["xmp"]) if "xmp" in self.info else {} - def seek(self, frame: int) -> None: if not self._seek_check(frame): return @@ -153,7 +144,7 @@ class WebPImageFile(ImageFile.ImageFile): while self.__physical_frame < frame: self._get_next() # Advance to the requested frame - def load(self): + def load(self) -> Image.core.PixelAccess | None: if _webp.HAVE_WEBPANIM: if self.__loaded != self.__logical_frame: self._seek(self.__logical_frame) diff --git a/src/PIL/_deprecate.py b/src/PIL/_deprecate.py index 33a0e07b3..83952b397 100644 --- a/src/PIL/_deprecate.py +++ b/src/PIL/_deprecate.py @@ -45,8 +45,6 @@ def deprecate( elif when <= int(__version__.split(".")[0]): msg = f"{deprecated} {is_} deprecated and should be removed." raise RuntimeError(msg) - elif when == 11: - removed = "Pillow 11 (2024-10-15)" elif when == 12: removed = "Pillow 12 (2025-10-15)" else: diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index dda06b5a1..8cccd3ac7 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -10,7 +10,10 @@ class ImagingDraw: def __getattr__(self, name: str) -> Any: ... class PixelAccess: - def __getattr__(self, name: str) -> Any: ... + def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: ... + def __setitem__( + self, xy: tuple[int, int], color: float | tuple[int, ...] + ) -> None: ... class ImagingDecoder: def __getattr__(self, name: str) -> Any: ... @@ -18,5 +21,10 @@ class ImagingDecoder: class ImagingEncoder: def __getattr__(self, name: str) -> Any: ... +class _Outline: + def close(self) -> None: ... + def __getattr__(self, name: str) -> Any: ... + def font(image: ImagingCore, glyphdata: bytes) -> ImagingFont: ... +def outline() -> _Outline: ... def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index 6e0ddd2f1..5e97b40b2 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -27,7 +27,7 @@ class Font: def glyphs(self) -> int: ... def render( self, - string: str, + string: str | bytes, fill, mode=..., dir=..., @@ -51,7 +51,7 @@ class Font: /, ) -> tuple[tuple[int, int], tuple[int, int]]: ... def getlength( - self, string: str, mode=..., dir=..., features=..., lang=..., / + self, string: str | bytes, mode=..., dir=..., features=..., lang=..., / ) -> float: ... def getvarnames(self) -> list[bytes]: ... def getvaraxes(self) -> list[_Axis] | None: ... diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 7075e8672..b6bb8d89a 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -2,7 +2,16 @@ from __future__ import annotations import os import sys -from typing import Protocol, Sequence, TypeVar, Union +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, Union + +if TYPE_CHECKING: + try: + import numpy.typing as npt + + NumpyArray = npt.NDArray[Any] # requires numpy>=1.21 + except (ImportError, AttributeError): + pass if sys.version_info >= (3, 10): from typing import TypeGuard @@ -10,7 +19,6 @@ else: try: from typing_extensions import TypeGuard except ImportError: - from typing import Any class TypeGuard: # type: ignore[no-redef] def __class_getitem__(cls, item: Any) -> type[bool]: diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 12d7412ea..c4a72ad7e 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "10.4.0.dev0" +__version__ = "11.0.0.dev0" diff --git a/src/_imaging.c b/src/_imaging.c index f398c6c7c..ddc8d2885 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -1725,10 +1725,11 @@ _putpalette(ImagingObject *self, PyObject *args) { ImagingShuffler unpack; int bits; - char *rawmode, *palette_mode; + char *palette_mode, *rawmode; UINT8 *palette; Py_ssize_t palettesize; - if (!PyArg_ParseTuple(args, "sy#", &rawmode, &palette, &palettesize)) { + if (!PyArg_ParseTuple( + args, "ssy#", &palette_mode, &rawmode, &palette, &palettesize)) { return NULL; } @@ -1738,7 +1739,6 @@ _putpalette(ImagingObject *self, PyObject *args) { return NULL; } - palette_mode = strncmp("RGBA", rawmode, 4) == 0 ? "RGBA" : "RGB"; unpack = ImagingFindUnpacker(palette_mode, rawmode, &bits); if (!unpack) { PyErr_SetString(PyExc_ValueError, wrong_raw_mode); diff --git a/src/_imagingcms.c b/src/_imagingcms.c index 2b9612db7..590e1b983 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -223,20 +223,22 @@ findLCMStype(char *PILmode) { if (strcmp(PILmode, "CMYK") == 0) { return TYPE_CMYK_8; } - if (strcmp(PILmode, "L;16") == 0) { + if (strcmp(PILmode, "I;16") == 0 || strcmp(PILmode, "I;16L") == 0 || + strcmp(PILmode, "L;16") == 0) { return TYPE_GRAY_16; } - if (strcmp(PILmode, "L;16B") == 0) { + if (strcmp(PILmode, "I;16B") == 0 || strcmp(PILmode, "L;16B") == 0) { return TYPE_GRAY_16_SE; } - if (strcmp(PILmode, "YCCA") == 0 || strcmp(PILmode, "YCC") == 0) { + if (strcmp(PILmode, "YCbCr") == 0 || strcmp(PILmode, "YCCA") == 0 || + strcmp(PILmode, "YCC") == 0) { return TYPE_YCbCr_8; } if (strcmp(PILmode, "LAB") == 0) { // LabX equivalent like ALab, but not reversed -- no #define in lcms2 return (COLORSPACE_SH(PT_LabV2) | CHANNELS_SH(3) | BYTES_SH(1) | EXTRA_SH(1)); } - /* presume "L" by default */ + /* presume "1" or "L" by default */ return TYPE_GRAY_8; } diff --git a/src/_imagingft.c b/src/_imagingft.c index e83ddfec1..ba36cc72c 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -233,18 +233,6 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { return (PyObject *)self; } -static int -font_getchar(PyObject *string, int index, FT_ULong *char_out) { - if (PyUnicode_Check(string)) { - if (index >= PyUnicode_GET_LENGTH(string)) { - return 0; - } - *char_out = PyUnicode_READ_CHAR(string, index); - return 1; - } - return 0; -} - #ifdef HAVE_RAQM static size_t @@ -266,28 +254,34 @@ text_layout_raqm( goto failed; } + Py_ssize_t size; + int set_text; if (PyUnicode_Check(string)) { Py_UCS4 *text = PyUnicode_AsUCS4Copy(string); - Py_ssize_t size = PyUnicode_GET_LENGTH(string); + size = PyUnicode_GET_LENGTH(string); if (!text || !size) { /* return 0 and clean up, no glyphs==no size, and raqm fails with empty strings */ goto failed; } - int set_text = raqm_set_text(rq, text, size); + set_text = raqm_set_text(rq, text, size); PyMem_Free(text); - if (!set_text) { - PyErr_SetString(PyExc_ValueError, "raqm_set_text() failed"); + } else { + char *buffer; + PyBytes_AsStringAndSize(string, &buffer, &size); + if (!buffer || !size) { + /* return 0 and clean up, no glyphs==no size, + and raqm fails with empty strings */ goto failed; } - if (lang) { - if (!raqm_set_language(rq, lang, start, size)) { - PyErr_SetString(PyExc_ValueError, "raqm_set_language() failed"); - goto failed; - } - } - } else { - PyErr_SetString(PyExc_TypeError, "expected string"); + set_text = raqm_set_text_utf8(rq, buffer, size); + } + if (!set_text) { + PyErr_SetString(PyExc_ValueError, "raqm_set_text() failed"); + goto failed; + } + if (lang && !raqm_set_language(rq, lang, start, size)) { + PyErr_SetString(PyExc_ValueError, "raqm_set_language() failed"); goto failed; } @@ -405,13 +399,13 @@ text_layout_fallback( GlyphInfo **glyph_info, int mask, int color) { - int error, load_flags; + int error, load_flags, i; + char *buffer = NULL; FT_ULong ch; Py_ssize_t count; FT_GlyphSlot glyph; FT_Bool kerning = FT_HAS_KERNING(self->face); FT_UInt last_index = 0; - int i; if (features != Py_None || dir != NULL || lang != NULL) { PyErr_SetString( @@ -419,14 +413,11 @@ text_layout_fallback( "setting text direction, language or font features is not supported " "without libraqm"); } - if (!PyUnicode_Check(string)) { - PyErr_SetString(PyExc_TypeError, "expected string"); - return 0; - } - count = 0; - while (font_getchar(string, count, &ch)) { - count++; + if (PyUnicode_Check(string)) { + count = PyUnicode_GET_LENGTH(string); + } else { + PyBytes_AsStringAndSize(string, &buffer, &count); } if (count == 0) { return 0; @@ -445,7 +436,12 @@ text_layout_fallback( if (color) { load_flags |= FT_LOAD_COLOR; } - for (i = 0; font_getchar(string, i, &ch); i++) { + for (i = 0; i < count; i++) { + if (buffer) { + ch = buffer[i]; + } else { + ch = PyUnicode_READ_CHAR(string, i); + } (*glyph_info)[i].index = FT_Get_Char_Index(self->face, ch); error = FT_Load_Glyph(self->face, (*glyph_info)[i].index, load_flags); if (error) { diff --git a/src/display.c b/src/display.c index abf94f1e1..990f4b0a5 100644 --- a/src/display.c +++ b/src/display.c @@ -618,7 +618,7 @@ windowCallback(HWND wnd, UINT message, WPARAM wParam, LPARAM lParam) { if (callback) { /* restore thread state */ PyEval_SaveThread(); - PyThreadState_Swap(threadstate); + PyThreadState_Swap(current_threadstate); } return status; diff --git a/src/libImaging/Paste.c b/src/libImaging/Paste.c index a018225b2..dc67cb41d 100644 --- a/src/libImaging/Paste.c +++ b/src/libImaging/Paste.c @@ -65,15 +65,32 @@ paste_mask_1( int x, y; if (imOut->image8) { + int in_i16 = strncmp(imIn->mode, "I;16", 4) == 0; + int out_i16 = strncmp(imOut->mode, "I;16", 4) == 0; for (y = 0; y < ysize; y++) { UINT8 *out = imOut->image8[y + dy] + dx; + if (out_i16) { + out += dx; + } UINT8 *in = imIn->image8[y + sy] + sx; + if (in_i16) { + in += sx; + } UINT8 *mask = imMask->image8[y + sy] + sx; for (x = 0; x < xsize; x++) { - if (*mask++) { + if (*mask) { *out = *in; } - out++, in++; + if (in_i16) { + in++; + } + if (out_i16) { + out++; + if (*mask) { + *out = *in; + } + } + out++, in++, mask++; } } @@ -415,15 +432,16 @@ fill_mask_L( unsigned int tmp1; if (imOut->image8) { + int i16 = strncmp(imOut->mode, "I;16", 4) == 0; for (y = 0; y < ysize; y++) { UINT8 *out = imOut->image8[y + dy] + dx; - if (strncmp(imOut->mode, "I;16", 4) == 0) { + if (i16) { out += dx; } UINT8 *mask = imMask->image8[y + sy] + sx; for (x = 0; x < xsize; x++) { *out = BLEND(*mask, *out, ink[0], tmp1); - if (strncmp(imOut->mode, "I;16", 4) == 0) { + if (i16) { out++; *out = BLEND(*mask, *out, ink[1], tmp1); } diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c index 1b84cd68f..eaa4374e3 100644 --- a/src/libImaging/Unpack.c +++ b/src/libImaging/Unpack.c @@ -718,6 +718,21 @@ ImagingUnpackBGRA15(UINT8 *out, const UINT8 *in, int pixels) { } } +void +ImagingUnpackBGRA15Z(UINT8 *out, const UINT8 *in, int pixels) { + int i, pixel; + /* RGB, rearranged channels, 5/5/5/1 bits per pixel, inverted alpha */ + for (i = 0; i < pixels; i++) { + pixel = in[0] + (in[1] << 8); + out[B] = (pixel & 31) * 255 / 31; + out[G] = ((pixel >> 5) & 31) * 255 / 31; + out[R] = ((pixel >> 10) & 31) * 255 / 31; + out[A] = ~((pixel >> 15) * 255); + out += 4; + in += 2; + } +} + void ImagingUnpackRGB16(UINT8 *out, const UINT8 *in, int pixels) { int i, pixel; @@ -1538,7 +1553,7 @@ static struct { /* flags: "I" inverted data; "R" reversed bit order; "B" big endian byte order (default is little endian); "L" line - interleave, "S" signed, "F" floating point */ + interleave, "S" signed, "F" floating point, "Z" inverted alpha */ /* exception: rawmodes "I" and "F" are always native endian byte order */ @@ -1600,10 +1615,14 @@ static struct { {"RGB", "BGR;15", 16, ImagingUnpackBGR15}, {"RGB", "RGB;16", 16, ImagingUnpackRGB16}, {"RGB", "BGR;16", 16, ImagingUnpackBGR16}, + {"RGB", "RGBX;16L", 64, unpackRGBA16L}, + {"RGB", "RGBX;16B", 64, unpackRGBA16B}, {"RGB", "RGB;4B", 16, ImagingUnpackRGB4B}, {"RGB", "BGR;5", 16, ImagingUnpackBGR15}, /* compat */ {"RGB", "RGBX", 32, copy4}, {"RGB", "RGBX;L", 32, unpackRGBAL}, + {"RGB", "RGBXX", 40, copy4skip1}, + {"RGB", "RGBXXX", 48, copy4skip2}, {"RGB", "RGBA;L", 32, unpackRGBAL}, {"RGB", "RGBA;15", 16, ImagingUnpackRGBA15}, {"RGB", "BGRX", 32, ImagingUnpackBGRX}, @@ -1642,6 +1661,7 @@ static struct { {"RGBA", "RGBA;L", 32, unpackRGBAL}, {"RGBA", "RGBA;15", 16, ImagingUnpackRGBA15}, {"RGBA", "BGRA;15", 16, ImagingUnpackBGRA15}, + {"RGBA", "BGRA;15Z", 16, ImagingUnpackBGRA15Z}, {"RGBA", "RGBA;4B", 16, ImagingUnpackRGBA4B}, {"RGBA", "RGBA;16L", 64, unpackRGBA16L}, {"RGBA", "RGBA;16B", 64, unpackRGBA16B}, diff --git a/tox.ini b/tox.ini index 85a2020d6..c1bc3b17d 100644 --- a/tox.ini +++ b/tox.ini @@ -3,11 +3,10 @@ requires = tox>=4.2 env_list = lint - py{py3, 312, 311, 310, 39, 38} + py{py3, 313, 312, 311, 310, 39} [testenv] deps = - cffi numpy extras = tests @@ -39,10 +38,11 @@ deps = ipython numpy packaging - types-cffi + pytest types-defusedxml types-olefile + types-setuptools extras = typing commands = - mypy src {posargs} + mypy src Tests {posargs} diff --git a/winbuild/README.md b/winbuild/README.md index 7e81abcb0..c8048bcc9 100644 --- a/winbuild/README.md +++ b/winbuild/README.md @@ -16,7 +16,7 @@ For more extensive info, see the [Windows build instructions](build.rst). The following is a simplified version of the script used on AppVeyor: ``` -set PYTHON=C:\Python38\bin +set PYTHON=C:\Python39\bin cd /D C:\Pillow\winbuild %PYTHON%\python.exe build_prepare.py -v --depends=C:\pillow-depends build\build_dep_all.cmd diff --git a/winbuild/build.rst b/winbuild/build.rst index d0be2943e..96b8803b4 100644 --- a/winbuild/build.rst +++ b/winbuild/build.rst @@ -114,7 +114,7 @@ Example The following is a simplified version of the script used on AppVeyor:: - set PYTHON=C:\Python38\bin + set PYTHON=C:\Python39\bin cd /D C:\Pillow\winbuild %PYTHON%\python.exe build_prepare.py -v --depends C:\pillow-depends build\build_dep_all.cmd