Merge branch 'main' into main
|  | @ -34,8 +34,8 @@ install: | |||
| - xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images | ||||
| - curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip | ||||
| - 7z x nasm-win64.zip -oc:\ | ||||
| - choco install ghostscript --version=10.3.1 | ||||
| - path c:\nasm-2.16.03;C:\Program Files\gs\gs10.03.1\bin;%PATH% | ||||
| - choco install ghostscript --version=10.4.0 | ||||
| - path c:\nasm-2.16.03;C:\Program Files\gs\gs10.04.0\bin;%PATH% | ||||
| - cd c:\pillow\winbuild\ | ||||
| - ps: | | ||||
|         c:\python39\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ | ||||
|  | @ -51,11 +51,10 @@ build_script: | |||
| 
 | ||||
| test_script: | ||||
| - cd c:\pillow | ||||
| - '%PYTHON%\%EXECUTABLE% -m pip install pytest pytest-cov pytest-timeout defusedxml numpy olefile pyroma' | ||||
| - '%PYTHON%\%EXECUTABLE% -m pip install pytest pytest-cov pytest-timeout defusedxml ipython numpy olefile pyroma' | ||||
| - c:\"Program Files (x86)"\"Windows Kits"\10\Debuggers\x86\gflags.exe /p /enable %PYTHON%\%EXECUTABLE% | ||||
| - '%PYTHON%\%EXECUTABLE% -c "from PIL import Image"' | ||||
| - '%PYTHON%\%EXECUTABLE% -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests' | ||||
| #- '%PYTHON%\%EXECUTABLE% test-installed.py -v -s %TEST_OPTIONS%' TODO TEST_OPTIONS with pytest? | ||||
| - path %PYTHON%;%PATH% | ||||
| - .ci\test.cmd | ||||
| 
 | ||||
| after_test: | ||||
| - curl -Os https://uploader.codecov.io/latest/windows/codecov.exe | ||||
|  |  | |||
|  | @ -21,7 +21,7 @@ set -e | |||
| 
 | ||||
| if [[ $(uname) != CYGWIN* ]]; then | ||||
|     sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ | ||||
|                              ghostscript libffi-dev libjpeg-turbo-progs libopenjp2-7-dev\ | ||||
|                              ghostscript libjpeg-turbo-progs libopenjp2-7-dev\ | ||||
|                              cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ | ||||
|                              sway wl-clipboard libopenblas-dev | ||||
| fi | ||||
|  | @ -30,6 +30,7 @@ python3 -m pip install --upgrade pip | |||
| python3 -m pip install --upgrade wheel | ||||
| python3 -m pip install coverage | ||||
| python3 -m pip install defusedxml | ||||
| python3 -m pip install ipython | ||||
| python3 -m pip install olefile | ||||
| python3 -m pip install -U pytest | ||||
| python3 -m pip install -U pytest-cov | ||||
|  | @ -37,12 +38,7 @@ python3 -m pip install -U pytest-timeout | |||
| python3 -m pip install pyroma | ||||
| 
 | ||||
| if [[ $(uname) != CYGWIN* ]]; then | ||||
|     # TODO Update condition when NumPy supports free-threading | ||||
|     if [[ "$PYTHON_GIL" == "0" ]]; then | ||||
|         python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple | ||||
|     else | ||||
|     python3 -m pip install numpy | ||||
|     fi | ||||
| 
 | ||||
|     # PyQt6 doesn't support PyPy3 | ||||
|     if [[ $GHA_PYTHON_VERSION == 3.* ]]; then | ||||
|  | @ -52,10 +48,7 @@ if [[ $(uname) != CYGWIN* ]]; then | |||
|     fi | ||||
| 
 | ||||
|     # Pyroma uses non-isolated build and fails with old setuptools | ||||
|     if [[ | ||||
|         $GHA_PYTHON_VERSION == pypy3.9 | ||||
|         || $GHA_PYTHON_VERSION == 3.9 | ||||
|     ]]; then | ||||
|     if [[ $GHA_PYTHON_VERSION == 3.9 ]]; then | ||||
|         # To match pyproject.toml | ||||
|         python3 -m pip install "setuptools>=67.8" | ||||
|     fi | ||||
|  |  | |||
|  | @ -1 +1 @@ | |||
| cibuildwheel==2.19.2 | ||||
| cibuildwheel==2.21.2 | ||||
|  |  | |||
|  | @ -1 +1,12 @@ | |||
| mypy==1.10.1 | ||||
| mypy==1.11.2 | ||||
| IceSpringPySideStubs-PyQt6 | ||||
| IceSpringPySideStubs-PySide6 | ||||
| ipython | ||||
| numpy | ||||
| packaging | ||||
| pytest | ||||
| sphinx | ||||
| types-atheris | ||||
| types-defusedxml | ||||
| types-olefile | ||||
| types-setuptools | ||||
|  |  | |||
							
								
								
									
										3
									
								
								.ci/test.cmd
									
									
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,3 @@ | |||
| python.exe -c "from PIL import Image" | ||||
| IF ERRORLEVEL 1 EXIT /B | ||||
| python.exe -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests | ||||
|  | @ -4,4 +4,4 @@ set -e | |||
| 
 | ||||
| python3 -c "from PIL import Image" | ||||
| 
 | ||||
| python3 -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term Tests $REVERSE | ||||
| python3 -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests $REVERSE | ||||
|  |  | |||
							
								
								
									
										4
									
								
								.github/workflows/cifuzz.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -6,11 +6,13 @@ on: | |||
|       - "**" | ||||
|     paths: | ||||
|       - ".github/workflows/cifuzz.yml" | ||||
|       - ".github/workflows/wheels-dependencies.sh" | ||||
|       - "**.c" | ||||
|       - "**.h" | ||||
|   pull_request: | ||||
|     paths: | ||||
|       - ".github/workflows/cifuzz.yml" | ||||
|       - ".github/workflows/wheels-dependencies.sh" | ||||
|       - "**.c" | ||||
|       - "**.h" | ||||
|   workflow_dispatch: | ||||
|  | @ -24,8 +26,6 @@ concurrency: | |||
| 
 | ||||
| jobs: | ||||
|   Fuzzing: | ||||
|     # Disabled until google/oss-fuzz#11419 upgrades Python to 3.9+ | ||||
|     if: false | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - name: Build Fuzzers | ||||
|  |  | |||
							
								
								
									
										4
									
								
								.github/workflows/macos-install.sh
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -2,6 +2,9 @@ | |||
| 
 | ||||
| set -e | ||||
| 
 | ||||
| if [[ "$ImageOS" == "macos13" ]]; then | ||||
|     brew uninstall gradle maven | ||||
| fi | ||||
| brew install \ | ||||
|     freetype \ | ||||
|     ghostscript \ | ||||
|  | @ -20,6 +23,7 @@ export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" | |||
| 
 | ||||
| python3 -m pip install coverage | ||||
| python3 -m pip install defusedxml | ||||
| python3 -m pip install ipython | ||||
| python3 -m pip install olefile | ||||
| python3 -m pip install -U pytest | ||||
| python3 -m pip install -U pytest-cov | ||||
|  |  | |||
							
								
								
									
										1
									
								
								.github/workflows/test-cygwin.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -74,6 +74,7 @@ jobs: | |||
|             perl | ||||
|             python3${{ matrix.python-minor-version }}-cython | ||||
|             python3${{ matrix.python-minor-version }}-devel | ||||
|             python3${{ matrix.python-minor-version }}-ipython | ||||
|             python3${{ matrix.python-minor-version }}-numpy | ||||
|             python3${{ matrix.python-minor-version }}-sip | ||||
|             python3${{ matrix.python-minor-version }}-tkinter | ||||
|  |  | |||
							
								
								
									
										3
									
								
								.github/workflows/test-mingw.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -80,8 +80,7 @@ jobs: | |||
|       - name: Test Pillow | ||||
|         run: | | ||||
|           python3 selftest.py --installed | ||||
|           python3 -c "from PIL import Image" | ||||
|           python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests | ||||
|           .ci/test.sh | ||||
| 
 | ||||
|       - name: Upload coverage | ||||
|         uses: codecov/codecov-action@v4 | ||||
|  |  | |||
							
								
								
									
										10
									
								
								.github/workflows/test-windows.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -35,7 +35,7 @@ jobs: | |||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         python-version: ["pypy3.10", "pypy3.9", "3.9", "3.10", "3.11", "3.12", "3.13"] | ||||
|         python-version: ["pypy3.10", "3.9", "3.10", "3.11", "3.12", "3.13"] | ||||
| 
 | ||||
|     timeout-minutes: 30 | ||||
| 
 | ||||
|  | @ -86,8 +86,8 @@ jobs: | |||
|         choco install nasm --no-progress | ||||
|         echo "C:\Program Files\NASM" >> $env:GITHUB_PATH | ||||
| 
 | ||||
|         choco install ghostscript --version=10.3.1 --no-progress | ||||
|         echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH | ||||
|         choco install ghostscript --version=10.4.0 --no-progress | ||||
|         echo "C:\Program Files\gs\gs10.04.0\bin" >> $env:GITHUB_PATH | ||||
| 
 | ||||
|         # Install extra test images | ||||
|         xcopy /S /Y Tests\test-images\* Tests\images | ||||
|  | @ -190,8 +190,8 @@ jobs: | |||
| 
 | ||||
|     - name: Test Pillow | ||||
|       run: | | ||||
|         path %GITHUB_WORKSPACE%\\winbuild\\build\\bin;%PATH% | ||||
|         python.exe -m pytest -vx -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests | ||||
|         path %GITHUB_WORKSPACE%\winbuild\build\bin;%PATH% | ||||
|         .ci\test.cmd | ||||
|       shell: cmd | ||||
| 
 | ||||
|     - name: Prepare to upload errors | ||||
|  |  | |||
							
								
								
									
										7
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -37,12 +37,11 @@ jobs: | |||
|       fail-fast: false | ||||
|       matrix: | ||||
|         os: [ | ||||
|           "macos-14", | ||||
|           "macos-latest", | ||||
|           "ubuntu-latest", | ||||
|         ] | ||||
|         python-version: [ | ||||
|           "pypy3.10", | ||||
|           "pypy3.9", | ||||
|           "3.13", | ||||
|           "3.12", | ||||
|           "3.11", | ||||
|  | @ -57,7 +56,7 @@ jobs: | |||
|         # M1 only available for 3.10+ | ||||
|         - { os: "macos-13", python-version: "3.9" } | ||||
|         exclude: | ||||
|         - { os: "macos-14", python-version: "3.9" } | ||||
|         - { os: "macos-latest", python-version: "3.9" } | ||||
| 
 | ||||
|     runs-on: ${{ matrix.os }} | ||||
|     name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }} | ||||
|  | @ -77,7 +76,7 @@ jobs: | |||
|           "pyproject.toml" | ||||
| 
 | ||||
|     - name: Set up Python ${{ matrix.python-version }} (free-threaded) | ||||
|       uses: deadsnakes/action@v3.1.0 | ||||
|       uses: deadsnakes/action@v3.2.0 | ||||
|       if: "${{ matrix.disable-gil }}" | ||||
|       with: | ||||
|         python-version: ${{ matrix.python-version }} | ||||
|  |  | |||
							
								
								
									
										35
									
								
								.github/workflows/wheels-dependencies.sh
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -16,11 +16,11 @@ ARCHIVE_SDIR=pillow-depends-main | |||
| 
 | ||||
| # Package versions for fresh source builds | ||||
| FREETYPE_VERSION=2.13.2 | ||||
| HARFBUZZ_VERSION=8.5.0 | ||||
| LIBPNG_VERSION=1.6.43 | ||||
| JPEGTURBO_VERSION=3.0.3 | ||||
| HARFBUZZ_VERSION=10.0.1 | ||||
| LIBPNG_VERSION=1.6.44 | ||||
| JPEGTURBO_VERSION=3.0.4 | ||||
| OPENJPEG_VERSION=2.5.2 | ||||
| XZ_VERSION=5.4.5 | ||||
| XZ_VERSION=5.6.3 | ||||
| TIFF_VERSION=4.6.0 | ||||
| LCMS2_VERSION=2.16 | ||||
| if [[ -n "$IS_MACOS" ]]; then | ||||
|  | @ -40,7 +40,7 @@ BROTLI_VERSION=1.1.0 | |||
| 
 | ||||
| if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then | ||||
|     function build_openjpeg { | ||||
|         local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v${OPENJPEG_VERSION}.tar.gz openjpeg-${OPENJPEG_VERSION}.tar.gz) | ||||
|         local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v$OPENJPEG_VERSION.tar.gz openjpeg-$OPENJPEG_VERSION.tar.gz) | ||||
|         (cd $out_dir \ | ||||
|             && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ | ||||
|             && make install) | ||||
|  | @ -50,7 +50,7 @@ fi | |||
| 
 | ||||
| function build_brotli { | ||||
|     local cmake=$(get_modern_cmake) | ||||
|     local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-1.1.0.tar.gz) | ||||
|     local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-$BROTLI_VERSION.tar.gz) | ||||
|     (cd $out_dir \ | ||||
|         && $cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ | ||||
|         && make install) | ||||
|  | @ -60,6 +60,19 @@ function build_brotli { | |||
|     fi | ||||
| } | ||||
| 
 | ||||
| function build_harfbuzz { | ||||
|     python3 -m pip install meson ninja | ||||
| 
 | ||||
|     local out_dir=$(fetch_unpack https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION/$HARFBUZZ_VERSION.tar.xz harfbuzz-$HARFBUZZ_VERSION.tar.xz) | ||||
|     (cd $out_dir \ | ||||
|         && meson setup build --buildtype=release -Dfreetype=enabled -Dglib=disabled) | ||||
|     (cd $out_dir/build \ | ||||
|         && meson install) | ||||
|     if [[ "$MB_ML_LIBC" == "manylinux" ]]; then | ||||
|         cp /usr/local/lib64/libharfbuzz* /usr/local/lib | ||||
|     fi | ||||
| } | ||||
| 
 | ||||
| function build { | ||||
|     if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then | ||||
|         sudo chown -R runner /usr/local | ||||
|  | @ -109,15 +122,7 @@ function build { | |||
|         build_freetype | ||||
|     fi | ||||
| 
 | ||||
|     if [ -z "$IS_MACOS" ]; then | ||||
|         export FREETYPE_LIBS=-lfreetype | ||||
|         export FREETYPE_CFLAGS=-I/usr/local/include/freetype2/ | ||||
|     fi | ||||
|     build_simple harfbuzz $HARFBUZZ_VERSION https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION tar.xz --with-freetype=yes --with-glib=no | ||||
|     if [ -z "$IS_MACOS" ]; then | ||||
|         export FREETYPE_LIBS="" | ||||
|         export FREETYPE_CFLAGS="" | ||||
|     fi | ||||
|     build_harfbuzz | ||||
| } | ||||
| 
 | ||||
| # Any stuff that you need to do before you start building the wheels | ||||
|  |  | |||
							
								
								
									
										9
									
								
								.github/workflows/wheels-test.sh
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -13,14 +13,7 @@ else | |||
|     yum install -y fribidi | ||||
| fi | ||||
| 
 | ||||
| if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then | ||||
|   # TODO Update condition when NumPy supports free-threading | ||||
|   if [ $(python3 -c "import sysconfig;print(sysconfig.get_config_var('Py_GIL_DISABLED'))") == "1" ]; then | ||||
|     python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple | ||||
|   else | ||||
|     python3 -m pip install numpy | ||||
|   fi | ||||
| fi | ||||
| python3 -m pip install numpy | ||||
| 
 | ||||
| if [ ! -d "test-images-main" ]; then | ||||
|     curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip | ||||
|  |  | |||
							
								
								
									
										27
									
								
								.github/workflows/wheels.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -48,7 +48,6 @@ jobs: | |||
|       fail-fast: false | ||||
|       matrix: | ||||
|         python-version: | ||||
|           - pp39 | ||||
|           - pp310 | ||||
|           - cp3{9,10,11} | ||||
|           - cp3{12,13} | ||||
|  | @ -57,7 +56,6 @@ jobs: | |||
|           - manylinux_2_28 | ||||
|           - musllinux | ||||
|         exclude: | ||||
|           - { python-version: pp39, spec: musllinux } | ||||
|           - { python-version: pp310, spec: musllinux } | ||||
| 
 | ||||
|     steps: | ||||
|  | @ -97,18 +95,30 @@ jobs: | |||
|           path: ./wheelhouse/*.whl | ||||
| 
 | ||||
|   build-2-native-wheels: | ||||
|     if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' | ||||
|     name: ${{ matrix.name }} | ||||
|     runs-on: ${{ matrix.os }} | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         include: | ||||
|           - name: "macOS x86_64" | ||||
|           - name: "macOS 10.10 x86_64" | ||||
|             os: macos-13 | ||||
|             cibw_arch: x86_64 | ||||
|             build: "cp3{9,10,11}*" | ||||
|             macosx_deployment_target: "10.10" | ||||
|           - name: "macOS 10.13 x86_64" | ||||
|             os: macos-13 | ||||
|             cibw_arch: x86_64 | ||||
|             build: "cp3{12,13}*" | ||||
|             macosx_deployment_target: "10.13" | ||||
|           - name: "macOS 10.15 x86_64" | ||||
|             os: macos-13 | ||||
|             cibw_arch: x86_64 | ||||
|             build: "pp310*" | ||||
|             macosx_deployment_target: "10.15" | ||||
|           - name: "macOS arm64" | ||||
|             os: macos-14 | ||||
|             os: macos-latest | ||||
|             cibw_arch: arm64 | ||||
|             macosx_deployment_target: "11.0" | ||||
|           - name: "manylinux2014 and musllinux x86_64" | ||||
|  | @ -146,10 +156,11 @@ jobs: | |||
| 
 | ||||
|       - uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: dist-${{ matrix.os }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }} | ||||
|           name: dist-${{ matrix.os }}${{ matrix.macosx_deployment_target && format('-{0}', matrix.macosx_deployment_target) }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }} | ||||
|           path: ./wheelhouse/*.whl | ||||
| 
 | ||||
|   windows: | ||||
|     if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' | ||||
|     name: Windows ${{ matrix.cibw_arch }} | ||||
|     runs-on: windows-latest | ||||
|     strategy: | ||||
|  | @ -256,7 +267,7 @@ jobs: | |||
|         path: dist/*.tar.gz | ||||
| 
 | ||||
|   scientific-python-nightly-wheels-publish: | ||||
|     if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | ||||
|     if: github.repository_owner == 'python-pillow' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') | ||||
|     needs: [build-2-native-wheels, windows] | ||||
|     runs-on: ubuntu-latest | ||||
|     name: Upload wheels to scientific-python-nightly-wheels | ||||
|  | @ -267,13 +278,13 @@ jobs: | |||
|           path: dist | ||||
|           merge-multiple: true | ||||
|       - name: Upload wheels to scientific-python-nightly-wheels | ||||
|         uses: scientific-python/upload-nightly-action@b67d7fcc0396e1128a474d1ab2b48aa94680f9fc # 0.5.0 | ||||
|         uses: scientific-python/upload-nightly-action@82396a2ed4269ba06c6b2988bb4fd568ef3c3d6b # 0.6.1 | ||||
|         with: | ||||
|           artifacts_path: dist | ||||
|           anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} | ||||
| 
 | ||||
|   pypi-publish: | ||||
|     if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') | ||||
|     if: github.repository_owner == 'python-pillow' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') | ||||
|     needs: [build-1-QEMU-emulated-wheels, build-2-native-wheels, windows, sdist] | ||||
|     runs-on: ubuntu-latest | ||||
|     name: Upload release to PyPI | ||||
|  |  | |||
|  | @ -1,12 +1,12 @@ | |||
| repos: | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.5.0 | ||||
|     rev: v0.6.3 | ||||
|     hooks: | ||||
|       - id: ruff | ||||
|         args: [--exit-non-zero-on-fix] | ||||
| 
 | ||||
|   - repo: https://github.com/psf/black-pre-commit-mirror | ||||
|     rev: 24.4.2 | ||||
|     rev: 24.8.0 | ||||
|     hooks: | ||||
|       - id: black | ||||
| 
 | ||||
|  | @ -50,7 +50,7 @@ repos: | |||
|         exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ | ||||
| 
 | ||||
|   - repo: https://github.com/python-jsonschema/check-jsonschema | ||||
|     rev: 0.28.6 | ||||
|     rev: 0.29.2 | ||||
|     hooks: | ||||
|       - id: check-github-workflows | ||||
|       - id: check-readthedocs | ||||
|  | @ -62,12 +62,12 @@ repos: | |||
|       - id: sphinx-lint | ||||
| 
 | ||||
|   - repo: https://github.com/tox-dev/pyproject-fmt | ||||
|     rev: 2.1.3 | ||||
|     rev: 2.2.1 | ||||
|     hooks: | ||||
|       - id: pyproject-fmt | ||||
| 
 | ||||
|   - repo: https://github.com/abravalheri/validate-pyproject | ||||
|     rev: v0.18 | ||||
|     rev: v0.19 | ||||
|     hooks: | ||||
|       - id: validate-pyproject | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										114
									
								
								CHANGES.rst
									
									
									
									
									
								
							
							
						
						|  | @ -5,6 +5,120 @@ Changelog (Pillow) | |||
| 11.0.0 (unreleased) | ||||
| ------------------- | ||||
| 
 | ||||
| - Support all resampling filters when resizing I;16* images #8422 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Free memory on early return #8413 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Cast int before potentially exceeding INT_MAX #8402 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Check image value before use #8400 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Improved copying imagequant libraries #8420 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Use Capsule for WebP saving #8386 | ||||
|   [homm, radarhere] | ||||
| 
 | ||||
| - Fixed writing multiple StripOffsets to TIFF #8317 | ||||
|   [Yay295, radarhere] | ||||
| 
 | ||||
| - Fix dereference before checking for NULL in ImagingTransformAffine #8398 | ||||
|   [PavlNekrasov] | ||||
| 
 | ||||
| - Use transposed size after opening for TIFF images #8390 | ||||
|   [radarhere, homm] | ||||
| 
 | ||||
| - Improve ImageFont error messages #8338 | ||||
|   [yngvem, radarhere, hugovk] | ||||
| 
 | ||||
| - Mention MAX_TEXT_CHUNK limit in PNG error message #8391 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Cast Dib handle to int #8385 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Accept float stroke widths #8369 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Deprecate ICNS (width, height, scale) sizes in favour of load(scale) #8352 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Improved handling of RGBA palettes when saving GIF images #8366 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Deprecate isImageType #8364 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Support converting more modes to LAB by converting to RGBA first #8358 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Deprecate support for FreeType 2.9.0 #8356 | ||||
|   [hugovk, radarhere] | ||||
| 
 | ||||
| - Removed unused TiffImagePlugin IFD_LEGACY_API #8355 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Handle duplicate EXIF header #8350 | ||||
|   [zakajd, radarhere] | ||||
| 
 | ||||
| - Return early from BoxBlur if either width or height is zero #8347 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Check text is either string or bytes #8308 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Added writing XMP bytes to JPEG #8286 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Support JPEG2000 RGBA palettes #8256 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Expand C image to match GIF frame image size #8237 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Allow saving I;16 images as PPM #8231 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - When IFD is missing, connect get_ifd() dictionary to Exif #8230 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Skip truncated ICO mask if LOAD_TRUNCATED_IMAGES is enabled #8180 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Treat unknown JPEG2000 colorspace as unspecified #8343 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Updated error message when saving WebP with invalid width or height #8322 | ||||
|   [radarhere, hugovk] | ||||
| 
 | ||||
| - Remove warning if NumPy failed to raise an error during conversion #8326 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - If left and right sides meet in ImageDraw.rounded_rectangle(), do not draw rectangle to fill gap #8304 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Remove WebP support without anim, mux/demux, and with buggy alpha #8213 | ||||
|   [homm, radarhere] | ||||
| 
 | ||||
| - Add missing TIFF CMYK;16B reader #8298 | ||||
|   [homm] | ||||
| 
 | ||||
| - Remove all WITH_* flags from _imaging.c and other flags #8211 | ||||
|   [homm] | ||||
| 
 | ||||
| - Improve ImageDraw2 shape methods #8265 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Lock around usages of imaging memory arenas #8238 | ||||
|   [lysnikolaou] | ||||
| 
 | ||||
| - Deprecate JpegImageFile huffman_ac and huffman_dc #8274 | ||||
|   [radarhere] | ||||
| 
 | ||||
| - Deprecate ImageMath lambda_eval and unsafe_eval options argument #8242 | ||||
|   [radarhere] | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										2
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						|  | @ -117,7 +117,7 @@ lint-fix: | |||
| 	python3 -c "import black" > /dev/null 2>&1 || python3 -m pip install black | ||||
| 	python3 -m black . | ||||
| 	python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff | ||||
| 	python3 -m ruff --fix . | ||||
| 	python3 -m ruff check --fix . | ||||
| 
 | ||||
| .PHONY: mypy | ||||
| mypy: | ||||
|  |  | |||
|  | @ -51,7 +51,7 @@ As of 2019, Pillow development is | |||
|             <a href="https://app.codecov.io/gh/python-pillow/Pillow"><img | ||||
|                 alt="Code coverage" | ||||
|                 src="https://codecov.io/gh/python-pillow/Pillow/branch/main/graph/badge.svg"></a> | ||||
|             <a href="https://bugs.chromium.org/p/oss-fuzz/issues/list?sort=-opened&can=1&q=proj:pillow"><img | ||||
|             <a href="https://issues.oss-fuzz.com/issues?q=title:pillow"><img | ||||
|                 alt="Fuzzing Status" | ||||
|                 src="https://oss-fuzz-build-logs.storage.googleapis.com/badges/pillow.svg"></a> | ||||
|         </td> | ||||
|  |  | |||
| After Width: | Height: | Size: 411 B | 
							
								
								
									
										
											BIN
										
									
								
								Tests/images/imagedraw_stroke_float.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								Tests/images/test_extents_transparency.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 415 B | 
|  | @ -16,8 +16,9 @@ | |||
| 
 | ||||
| 
 | ||||
| import atheris | ||||
| from atheris.import_hook import instrument_imports | ||||
| 
 | ||||
| with atheris.instrument_imports(): | ||||
| with instrument_imports(): | ||||
|     import sys | ||||
| 
 | ||||
|     import fuzzers | ||||
|  |  | |||
|  | @ -14,8 +14,9 @@ | |||
| 
 | ||||
| 
 | ||||
| import atheris | ||||
| from atheris.import_hook import instrument_imports | ||||
| 
 | ||||
| with atheris.instrument_imports(): | ||||
| with instrument_imports(): | ||||
|     import sys | ||||
| 
 | ||||
|     import fuzzers | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| { | ||||
|    <py3_8_encode_current_locale> | ||||
|    <py3_10_encode_current_locale> | ||||
|    Memcheck:Cond | ||||
|    ... | ||||
|    fun:encode_current_locale | ||||
|  |  | |||
|  | @ -71,6 +71,11 @@ def test_color_modes() -> None: | |||
|         box_blur(sample.convert("YCbCr")) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize("size", ((0, 1), (1, 0))) | ||||
| def test_zero_dimension(size: tuple[int, int]) -> None: | ||||
|     assert box_blur(Image.new("L", size)).size == size | ||||
| 
 | ||||
| 
 | ||||
| def test_radius_0() -> None: | ||||
|     assert_blur( | ||||
|         sample, | ||||
|  |  | |||
|  | @ -105,91 +105,65 @@ class TestColorLut3DCoreAPI: | |||
|         with pytest.raises(TypeError): | ||||
|             im.im.color_lut_3d("RGB", Image.Resampling.BILINEAR, 3, 2, 2, 2, 16) | ||||
| 
 | ||||
|     def test_correct_args(self) -> None: | ||||
|     @pytest.mark.parametrize( | ||||
|         "lut_mode, table_channels, table_size", | ||||
|         [ | ||||
|             ("RGB", 3, 3), | ||||
|             ("CMYK", 4, 3), | ||||
|             ("RGB", 3, (2, 3, 3)), | ||||
|             ("RGB", 3, (65, 3, 3)), | ||||
|             ("RGB", 3, (3, 65, 3)), | ||||
|             ("RGB", 3, (2, 3, 65)), | ||||
|         ], | ||||
|     ) | ||||
|     def test_correct_args( | ||||
|         self, lut_mode: str, table_channels: int, table_size: int | tuple[int, int, int] | ||||
|     ) -> None: | ||||
|         im = Image.new("RGB", (10, 10), 0) | ||||
| 
 | ||||
|         im.im.color_lut_3d( | ||||
|             "RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) | ||||
|         ) | ||||
| 
 | ||||
|         im.im.color_lut_3d( | ||||
|             "CMYK", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) | ||||
|         ) | ||||
| 
 | ||||
|         im.im.color_lut_3d( | ||||
|             "RGB", | ||||
|             lut_mode, | ||||
|             Image.Resampling.BILINEAR, | ||||
|             *self.generate_identity_table(3, (2, 3, 3)), | ||||
|             *self.generate_identity_table(table_channels, table_size), | ||||
|         ) | ||||
| 
 | ||||
|     @pytest.mark.parametrize( | ||||
|         "image_mode, lut_mode, table_channels, table_size", | ||||
|         [ | ||||
|             ("L", "RGB", 3, 3), | ||||
|             ("RGB", "L", 3, 3), | ||||
|             ("L", "L", 3, 3), | ||||
|             ("RGB", "RGBA", 3, 3), | ||||
|             ("RGB", "RGB", 4, 3), | ||||
|         ], | ||||
|     ) | ||||
|     def test_wrong_mode( | ||||
|         self, image_mode: str, lut_mode: str, table_channels: int, table_size: int | ||||
|     ) -> None: | ||||
|         with pytest.raises(ValueError, match="wrong mode"): | ||||
|             im = Image.new(image_mode, (10, 10), 0) | ||||
|             im.im.color_lut_3d( | ||||
|             "RGB", | ||||
|                 lut_mode, | ||||
|                 Image.Resampling.BILINEAR, | ||||
|             *self.generate_identity_table(3, (65, 3, 3)), | ||||
|                 *self.generate_identity_table(table_channels, table_size), | ||||
|             ) | ||||
| 
 | ||||
|     @pytest.mark.parametrize( | ||||
|         "image_mode, lut_mode, table_channels, table_size", | ||||
|         [ | ||||
|             ("RGBA", "RGBA", 3, 3), | ||||
|             ("RGBA", "RGBA", 4, 3), | ||||
|             ("RGB", "HSV", 3, 3), | ||||
|             ("RGB", "RGBA", 4, 3), | ||||
|         ], | ||||
|     ) | ||||
|     def test_correct_mode( | ||||
|         self, image_mode: str, lut_mode: str, table_channels: int, table_size: int | ||||
|     ) -> None: | ||||
|         im = Image.new(image_mode, (10, 10), 0) | ||||
|         im.im.color_lut_3d( | ||||
|             "RGB", | ||||
|             lut_mode, | ||||
|             Image.Resampling.BILINEAR, | ||||
|             *self.generate_identity_table(3, (3, 65, 3)), | ||||
|         ) | ||||
| 
 | ||||
|         im.im.color_lut_3d( | ||||
|             "RGB", | ||||
|             Image.Resampling.BILINEAR, | ||||
|             *self.generate_identity_table(3, (3, 3, 65)), | ||||
|         ) | ||||
| 
 | ||||
|     def test_wrong_mode(self) -> None: | ||||
|         with pytest.raises(ValueError, match="wrong mode"): | ||||
|             im = Image.new("L", (10, 10), 0) | ||||
|             im.im.color_lut_3d( | ||||
|                 "RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) | ||||
|             ) | ||||
| 
 | ||||
|         with pytest.raises(ValueError, match="wrong mode"): | ||||
|             im = Image.new("RGB", (10, 10), 0) | ||||
|             im.im.color_lut_3d( | ||||
|                 "L", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) | ||||
|             ) | ||||
| 
 | ||||
|         with pytest.raises(ValueError, match="wrong mode"): | ||||
|             im = Image.new("L", (10, 10), 0) | ||||
|             im.im.color_lut_3d( | ||||
|                 "L", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) | ||||
|             ) | ||||
| 
 | ||||
|         with pytest.raises(ValueError, match="wrong mode"): | ||||
|             im = Image.new("RGB", (10, 10), 0) | ||||
|             im.im.color_lut_3d( | ||||
|                 "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) | ||||
|             ) | ||||
| 
 | ||||
|         with pytest.raises(ValueError, match="wrong mode"): | ||||
|             im = Image.new("RGB", (10, 10), 0) | ||||
|             im.im.color_lut_3d( | ||||
|                 "RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) | ||||
|             ) | ||||
| 
 | ||||
|     def test_correct_mode(self) -> None: | ||||
|         im = Image.new("RGBA", (10, 10), 0) | ||||
|         im.im.color_lut_3d( | ||||
|             "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) | ||||
|         ) | ||||
| 
 | ||||
|         im = Image.new("RGBA", (10, 10), 0) | ||||
|         im.im.color_lut_3d( | ||||
|             "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) | ||||
|         ) | ||||
| 
 | ||||
|         im = Image.new("RGB", (10, 10), 0) | ||||
|         im.im.color_lut_3d( | ||||
|             "HSV", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) | ||||
|         ) | ||||
| 
 | ||||
|         im = Image.new("RGB", (10, 10), 0) | ||||
|         im.im.color_lut_3d( | ||||
|             "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) | ||||
|             *self.generate_identity_table(table_channels, table_size), | ||||
|         ) | ||||
| 
 | ||||
|     def test_identities(self) -> None: | ||||
|  |  | |||
|  | @ -10,11 +10,6 @@ from PIL import features | |||
| 
 | ||||
| from .helper import skip_unless_feature | ||||
| 
 | ||||
| try: | ||||
|     from PIL import _webp | ||||
| except ImportError: | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| def test_check() -> None: | ||||
|     # Check the correctness of the convenience function | ||||
|  | @ -23,6 +18,10 @@ def test_check() -> None: | |||
|     for codec in features.codecs: | ||||
|         assert features.check_codec(codec) == features.check(codec) | ||||
|     for feature in features.features: | ||||
|         if "webp" in feature: | ||||
|             with pytest.warns(DeprecationWarning): | ||||
|                 assert features.check_feature(feature) == features.check(feature) | ||||
|         else: | ||||
|             assert features.check_feature(feature) == features.check(feature) | ||||
| 
 | ||||
| 
 | ||||
|  | @ -48,23 +47,26 @@ def test_version() -> None: | |||
|     for codec in features.codecs: | ||||
|         test(codec, features.version_codec) | ||||
|     for feature in features.features: | ||||
|         if "webp" in feature: | ||||
|             with pytest.warns(DeprecationWarning): | ||||
|                 test(feature, features.version_feature) | ||||
|         else: | ||||
|             test(feature, features.version_feature) | ||||
| 
 | ||||
| 
 | ||||
| @skip_unless_feature("webp") | ||||
| def test_webp_transparency() -> None: | ||||
|     assert features.check("transp_webp") != _webp.WebPDecoderBuggyAlpha() | ||||
|     assert features.check("transp_webp") == _webp.HAVE_TRANSPARENCY | ||||
|     with pytest.warns(DeprecationWarning): | ||||
|         assert features.check("transp_webp") == features.check_module("webp") | ||||
| 
 | ||||
| 
 | ||||
| @skip_unless_feature("webp") | ||||
| def test_webp_mux() -> None: | ||||
|     assert features.check("webp_mux") == _webp.HAVE_WEBPMUX | ||||
|     with pytest.warns(DeprecationWarning): | ||||
|         assert features.check("webp_mux") == features.check_module("webp") | ||||
| 
 | ||||
| 
 | ||||
| @skip_unless_feature("webp") | ||||
| def test_webp_anim() -> None: | ||||
|     assert features.check("webp_anim") == _webp.HAVE_WEBPANIM | ||||
|     with pytest.warns(DeprecationWarning): | ||||
|         assert features.check("webp_anim") == features.check_module("webp") | ||||
| 
 | ||||
| 
 | ||||
| @skip_unless_feature("libjpeg_turbo") | ||||
|  |  | |||
|  | @ -152,7 +152,7 @@ def test_sanity_ati2_bc5u(image_path: str) -> None: | |||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize( | ||||
|     ("image_path", "expected_path"), | ||||
|     "image_path, expected_path", | ||||
|     ( | ||||
|         # hexeditted to be typeless | ||||
|         (TEST_FILE_DX10_BC5_TYPELESS, TEST_FILE_DX10_BC5_UNORM), | ||||
|  | @ -248,7 +248,7 @@ def test_dx10_r8g8b8a8_unorm_srgb() -> None: | |||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize( | ||||
|     ("mode", "size", "test_file"), | ||||
|     "mode, size, test_file", | ||||
|     [ | ||||
|         ("L", (128, 128), TEST_FILE_UNCOMPRESSED_L), | ||||
|         ("LA", (128, 128), TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA), | ||||
|  | @ -373,7 +373,7 @@ def test_save_unsupported_mode(tmp_path: Path) -> None: | |||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize( | ||||
|     ("mode", "test_file"), | ||||
|     "mode, test_file", | ||||
|     [ | ||||
|         ("L", "Tests/images/linear_gradient.png"), | ||||
|         ("LA", "Tests/images/uncompressed_la.png"), | ||||
|  |  | |||
|  | @ -80,9 +80,7 @@ simple_eps_file_with_long_binary_data = ( | |||
| 
 | ||||
| 
 | ||||
| @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") | ||||
| @pytest.mark.parametrize( | ||||
|     ("filename", "size"), ((FILE1, (460, 352)), (FILE2, (360, 252))) | ||||
| ) | ||||
| @pytest.mark.parametrize("filename, size", ((FILE1, (460, 352)), (FILE2, (360, 252)))) | ||||
| @pytest.mark.parametrize("scale", (1, 2)) | ||||
| def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: | ||||
|     expected_size = tuple(s * scale for s in size) | ||||
|  |  | |||
|  | @ -978,7 +978,7 @@ def test_webp_background(tmp_path: Path) -> None: | |||
|     out = str(tmp_path / "temp.gif") | ||||
| 
 | ||||
|     # Test opaque WebP background | ||||
|     if features.check("webp") and features.check("webp_anim"): | ||||
|     if features.check("webp"): | ||||
|         with Image.open("Tests/images/hopper.webp") as im: | ||||
|             assert im.info["background"] == (255, 255, 255, 255) | ||||
|             im.save(out) | ||||
|  | @ -1378,8 +1378,26 @@ def test_lzw_bits() -> None: | |||
|         im.load() | ||||
| 
 | ||||
| 
 | ||||
| def test_extents() -> None: | ||||
|     with Image.open("Tests/images/test_extents.gif") as im: | ||||
| @pytest.mark.parametrize( | ||||
|     "test_file, loading_strategy", | ||||
|     ( | ||||
|         ("test_extents.gif", GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST), | ||||
|         ( | ||||
|             "test_extents.gif", | ||||
|             GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY, | ||||
|         ), | ||||
|         ( | ||||
|             "test_extents_transparency.gif", | ||||
|             GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST, | ||||
|         ), | ||||
|     ), | ||||
| ) | ||||
| def test_extents( | ||||
|     test_file: str, loading_strategy: GifImagePlugin.LoadingStrategy | ||||
| ) -> None: | ||||
|     GifImagePlugin.LOADING_STRATEGY = loading_strategy | ||||
|     try: | ||||
|         with Image.open("Tests/images/" + test_file) as im: | ||||
|             assert im.size == (100, 100) | ||||
| 
 | ||||
|             # Check that n_frames does not change the size | ||||
|  | @ -1389,6 +1407,11 @@ def test_extents() -> None: | |||
|             im.seek(1) | ||||
|             assert im.size == (150, 150) | ||||
| 
 | ||||
|             im.load() | ||||
|             assert im.im.size == (150, 150) | ||||
|     finally: | ||||
|         GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST | ||||
| 
 | ||||
| 
 | ||||
| def test_missing_background() -> None: | ||||
|     # The Global Color Table Flag isn't set, so there is no background color index, | ||||
|  | @ -1406,3 +1429,21 @@ def test_saving_rgba(tmp_path: Path) -> None: | |||
|     with Image.open(out) as reloaded: | ||||
|         reloaded_rgba = reloaded.convert("RGBA") | ||||
|         assert reloaded_rgba.load()[0, 0][3] == 0 | ||||
| 
 | ||||
| 
 | ||||
| def test_optimizing_p_rgba(tmp_path: Path) -> None: | ||||
|     out = str(tmp_path / "temp.gif") | ||||
| 
 | ||||
|     im1 = Image.new("P", (100, 100)) | ||||
|     d = ImageDraw.Draw(im1) | ||||
|     d.ellipse([(40, 40), (60, 60)], fill=1) | ||||
|     data = [0, 0, 0, 0, 0, 0, 0, 255] + [0, 0, 0, 0] * 254 | ||||
|     im1.putpalette(data, "RGBA") | ||||
| 
 | ||||
|     im2 = Image.new("P", (100, 100)) | ||||
|     im2.putpalette(data, "RGBA") | ||||
| 
 | ||||
|     im1.save(out, save_all=True, append_images=[im2]) | ||||
| 
 | ||||
|     with Image.open(out) as reloaded: | ||||
|         assert reloaded.n_frames == 2 | ||||
|  |  | |||
|  | @ -63,8 +63,8 @@ def test_save_append_images(tmp_path: Path) -> None: | |||
|         assert_image_similar_tofile(im, temp_file, 1) | ||||
| 
 | ||||
|         with Image.open(temp_file) as reread: | ||||
|             reread.size = (16, 16, 2) | ||||
|             reread.load() | ||||
|             reread.size = (16, 16) | ||||
|             reread.load(2) | ||||
|             assert_image_equal(reread, provided_im) | ||||
| 
 | ||||
| 
 | ||||
|  | @ -87,14 +87,21 @@ def test_sizes() -> None: | |||
|         for w, h, r in im.info["sizes"]: | ||||
|             wr = w * r | ||||
|             hr = h * r | ||||
|             with pytest.warns(DeprecationWarning): | ||||
|                 im.size = (w, h, r) | ||||
|             im.load() | ||||
|             assert im.mode == "RGBA" | ||||
|             assert im.size == (wr, hr) | ||||
| 
 | ||||
|             # Test using load() with scale | ||||
|             im.size = (w, h) | ||||
|             im.load(scale=r) | ||||
|             assert im.mode == "RGBA" | ||||
|             assert im.size == (wr, hr) | ||||
| 
 | ||||
|         # Check that we cannot load an incorrect size | ||||
|         with pytest.raises(ValueError): | ||||
|             im.size = (1, 1) | ||||
|             im.size = (1, 2) | ||||
| 
 | ||||
| 
 | ||||
| def test_older_icon() -> None: | ||||
|  | @ -105,8 +112,8 @@ def test_older_icon() -> None: | |||
|             wr = w * r | ||||
|             hr = h * r | ||||
|             with Image.open("Tests/images/pillow2.icns") as im2: | ||||
|                 im2.size = (w, h, r) | ||||
|                 im2.load() | ||||
|                 im2.size = (w, h) | ||||
|                 im2.load(r) | ||||
|                 assert im2.mode == "RGBA" | ||||
|                 assert im2.size == (wr, hr) | ||||
| 
 | ||||
|  | @ -122,8 +129,8 @@ def test_jp2_icon() -> None: | |||
|             wr = w * r | ||||
|             hr = h * r | ||||
|             with Image.open("Tests/images/pillow3.icns") as im2: | ||||
|                 im2.size = (w, h, r) | ||||
|                 im2.load() | ||||
|                 im2.size = (w, h) | ||||
|                 im2.load(r) | ||||
|                 assert im2.mode == "RGBA" | ||||
|                 assert im2.size == (wr, hr) | ||||
| 
 | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ from pathlib import Path | |||
| 
 | ||||
| import pytest | ||||
| 
 | ||||
| from PIL import IcoImagePlugin, Image, ImageDraw | ||||
| from PIL import IcoImagePlugin, Image, ImageDraw, ImageFile | ||||
| 
 | ||||
| from .helper import assert_image_equal, assert_image_equal_tofile, hopper | ||||
| 
 | ||||
|  | @ -241,3 +241,29 @@ def test_draw_reloaded(tmp_path: Path) -> None: | |||
| 
 | ||||
|     with Image.open(outfile) as im: | ||||
|         assert_image_equal_tofile(im, "Tests/images/hopper_draw.ico") | ||||
| 
 | ||||
| 
 | ||||
| def test_truncated_mask() -> None: | ||||
|     # 1 bpp | ||||
|     with open("Tests/images/hopper_mask.ico", "rb") as fp: | ||||
|         data = fp.read() | ||||
| 
 | ||||
|     ImageFile.LOAD_TRUNCATED_IMAGES = True | ||||
|     data = data[:-3] | ||||
| 
 | ||||
|     try: | ||||
|         with Image.open(io.BytesIO(data)) as im: | ||||
|             with Image.open("Tests/images/hopper_mask.png") as expected: | ||||
|                 assert im.mode == "1" | ||||
| 
 | ||||
|         # 32 bpp | ||||
|         output = io.BytesIO() | ||||
|         expected = hopper("RGBA") | ||||
|         expected.save(output, "ico", bitmap_format="bmp") | ||||
| 
 | ||||
|         data = output.getvalue()[:-1] | ||||
| 
 | ||||
|         with Image.open(io.BytesIO(data)) as im: | ||||
|             assert im.mode == "RGB" | ||||
|     finally: | ||||
|         ImageFile.LOAD_TRUNCATED_IMAGES = False | ||||
|  |  | |||
|  | @ -57,6 +57,7 @@ def test_getiptcinfo_fotostation() -> None: | |||
|         iptc = IptcImagePlugin.getiptcinfo(im) | ||||
| 
 | ||||
|     # Assert | ||||
|     assert iptc is not None | ||||
|     for tag in iptc.keys(): | ||||
|         if tag[0] == 240: | ||||
|             return | ||||
|  | @ -76,6 +77,16 @@ def test_getiptcinfo_zero_padding() -> None: | |||
|     assert len(iptc) == 3 | ||||
| 
 | ||||
| 
 | ||||
| def test_getiptcinfo_tiff() -> None: | ||||
|     # Arrange | ||||
|     with Image.open("Tests/images/hopper.Lab.tif") as im: | ||||
|         # Act | ||||
|         iptc = IptcImagePlugin.getiptcinfo(im) | ||||
| 
 | ||||
|     # Assert | ||||
|     assert iptc == {(1, 90): b"\x1b%G", (2, 0): b"\xcf\xc0"} | ||||
| 
 | ||||
| 
 | ||||
| def test_getiptcinfo_tiff_none() -> None: | ||||
|     # Arrange | ||||
|     with Image.open("Tests/images/hopper.tif") as im: | ||||
|  |  | |||
|  | @ -154,7 +154,7 @@ class TestFileJpeg: | |||
|             assert k > 0.9 | ||||
| 
 | ||||
|     def test_rgb(self) -> None: | ||||
|         def getchannels(im: JpegImagePlugin.JpegImageFile) -> tuple[int, int, int]: | ||||
|         def getchannels(im: JpegImagePlugin.JpegImageFile) -> tuple[int, ...]: | ||||
|             return tuple(v[0] for v in im.layer) | ||||
| 
 | ||||
|         im = hopper() | ||||
|  | @ -829,7 +829,7 @@ class TestFileJpeg: | |||
|         with Image.open("Tests/images/no-dpi-in-exif.jpg") as im: | ||||
|             # Act / Assert | ||||
|             # "When the image resolution is unknown, 72 [dpi] is designated." | ||||
|             # https://web.archive.org/web/20240227115053/https://exiv2.org/tags.html | ||||
|             # https://exiv2.org/tags.html | ||||
|             assert im.info.get("dpi") == (72, 72) | ||||
| 
 | ||||
|     def test_invalid_exif(self) -> None: | ||||
|  | @ -991,12 +991,29 @@ class TestFileJpeg: | |||
|             else: | ||||
|                 assert im.getxmp() == {"xmpmeta": None} | ||||
| 
 | ||||
|     def test_save_xmp(self, tmp_path: Path) -> None: | ||||
|         f = str(tmp_path / "temp.jpg") | ||||
|         im = hopper() | ||||
|         im.save(f, xmp=b"XMP test") | ||||
|         with Image.open(f) as reloaded: | ||||
|             assert reloaded.info["xmp"] == b"XMP test" | ||||
| 
 | ||||
|         im.info["xmp"] = b"1" * 65504 | ||||
|         im.save(f) | ||||
|         with Image.open(f) as reloaded: | ||||
|             assert reloaded.info["xmp"] == b"1" * 65504 | ||||
| 
 | ||||
|         with pytest.raises(ValueError): | ||||
|             im.save(f, xmp=b"1" * 65505) | ||||
| 
 | ||||
|     @pytest.mark.timeout(timeout=1) | ||||
|     def test_eof(self) -> None: | ||||
|         # Even though this decoder never says that it is finished | ||||
|         # the image should still end when there is no new data | ||||
|         class InfiniteMockPyDecoder(ImageFile.PyDecoder): | ||||
|             def decode(self, buffer: bytes) -> tuple[int, int]: | ||||
|             def decode( | ||||
|                 self, buffer: bytes | Image.SupportsArrayInterface | ||||
|             ) -> tuple[int, int]: | ||||
|                 return 0, 0 | ||||
| 
 | ||||
|         Image.register_decoder("INFINITE", InfiniteMockPyDecoder) | ||||
|  | @ -1019,13 +1036,16 @@ class TestFileJpeg: | |||
| 
 | ||||
|         # SOI, EOI | ||||
|         for marker in b"\xff\xd8", b"\xff\xd9": | ||||
|             assert marker in data[1] and marker in data[2] | ||||
|             assert marker in data[1] | ||||
|             assert marker in data[2] | ||||
|         # DHT, DQT | ||||
|         for marker in b"\xff\xc4", b"\xff\xdb": | ||||
|             assert marker in data[1] and marker not in data[2] | ||||
|             assert marker in data[1] | ||||
|             assert marker not in data[2] | ||||
|         # SOF0, SOS, APP0 (JFIF header) | ||||
|         for marker in b"\xff\xc0", b"\xff\xda", b"\xff\xe0": | ||||
|             assert marker not in data[1] and marker in data[2] | ||||
|             assert marker not in data[1] | ||||
|             assert marker in data[2] | ||||
| 
 | ||||
|         with Image.open(BytesIO(data[0])) as interchange_im: | ||||
|             with Image.open(BytesIO(data[1] + data[2])) as combined_im: | ||||
|  | @ -1045,6 +1065,13 @@ class TestFileJpeg: | |||
| 
 | ||||
|         assert im._repr_jpeg_() is None | ||||
| 
 | ||||
|     def test_deprecation(self) -> None: | ||||
|         with Image.open(TEST_FILE) as im: | ||||
|             with pytest.warns(DeprecationWarning): | ||||
|                 assert im.huffman_ac == {} | ||||
|             with pytest.warns(DeprecationWarning): | ||||
|                 assert im.huffman_dc == {} | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.skipif(not is_win32(), reason="Windows only") | ||||
| @skip_unless_feature("jpg") | ||||
|  |  | |||
|  | @ -182,6 +182,15 @@ def test_restricted_icc_profile() -> None: | |||
|         ImageFile.LOAD_TRUNCATED_IMAGES = False | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.skipif( | ||||
|     not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" | ||||
| ) | ||||
| def test_unknown_colorspace() -> None: | ||||
|     with Image.open(f"{EXTRA_DIR}/file8.jp2") as im: | ||||
|         im.load() | ||||
|         assert im.mode == "L" | ||||
| 
 | ||||
| 
 | ||||
| def test_header_errors() -> None: | ||||
|     for path in ( | ||||
|         "Tests/images/invalid_header_length.jp2", | ||||
|  | @ -233,7 +242,7 @@ def test_layers() -> None: | |||
|         ("foo.jp2", {"no_jp2": True}, 0, b"\xff\x4f"), | ||||
|         ("foo.j2k", {"no_jp2": False}, 0, b"\xff\x4f"), | ||||
|         ("foo.jp2", {"no_jp2": False}, 4, b"jP"), | ||||
|         ("foo.jp2", {"no_jp2": False}, 4, b"jP"), | ||||
|         (None, {"no_jp2": False}, 4, b"jP"), | ||||
|     ), | ||||
| ) | ||||
| def test_no_jp2(name: str, args: dict[str, bool], offset: int, data: bytes) -> None: | ||||
|  | @ -391,6 +400,13 @@ def test_pclr() -> None: | |||
|         assert len(im.palette.colors) == 256 | ||||
|         assert im.palette.colors[(255, 255, 255)] == 0 | ||||
| 
 | ||||
|     with Image.open( | ||||
|         f"{EXTRA_DIR}/147af3f1083de4393666b7d99b01b58b_signal_sigsegv_130c531_6155_5136.jp2" | ||||
|     ) as im: | ||||
|         assert im.mode == "P" | ||||
|         assert len(im.palette.colors) == 139 | ||||
|         assert im.palette.colors[(0, 0, 0, 0)] == 0 | ||||
| 
 | ||||
| 
 | ||||
| def test_comment() -> None: | ||||
|     with Image.open("Tests/images/comment.jp2") as im: | ||||
|  |  | |||
|  | @ -240,9 +240,10 @@ class TestFileLibTiff(LibTiffTestCase): | |||
| 
 | ||||
|             new_ifd = TiffImagePlugin.ImageFileDirectory_v2() | ||||
|             for tag, info in core_items.items(): | ||||
|                 assert info.type is not None | ||||
|                 if info.length == 1: | ||||
|                     new_ifd[tag] = values[info.type] | ||||
|                 if info.length == 0: | ||||
|                 elif not info.length: | ||||
|                     new_ifd[tag] = tuple(values[info.type] for _ in range(3)) | ||||
|                 else: | ||||
|                     new_ifd[tag] = tuple(values[info.type] for _ in range(info.length)) | ||||
|  |  | |||
|  | @ -85,7 +85,9 @@ def test_exif(test_file: str) -> None: | |||
|         im_reloaded = roundtrip(im_original, save_all=True, exif=im_original.getexif()) | ||||
| 
 | ||||
|     for im in (im_original, im_reloaded): | ||||
|         assert isinstance(im, MpoImagePlugin.MpoImageFile) | ||||
|         info = im._getexif() | ||||
|         assert info is not None | ||||
|         assert info[272] == "Nintendo 3DS" | ||||
|         assert info[296] == 2 | ||||
|         assert info[34665] == 188 | ||||
|  |  | |||
|  | @ -424,8 +424,10 @@ class TestFilePng: | |||
|         im = roundtrip(im, pnginfo=info) | ||||
|         assert im.info == {"spam": "Eggs", "eggs": "Spam"} | ||||
|         assert im.text == {"spam": "Eggs", "eggs": "Spam"} | ||||
|         assert isinstance(im.text["spam"], PngImagePlugin.iTXt) | ||||
|         assert im.text["spam"].lang == "en" | ||||
|         assert im.text["spam"].tkey == "Spam" | ||||
|         assert isinstance(im.text["eggs"], PngImagePlugin.iTXt) | ||||
|         assert im.text["eggs"].lang == "en" | ||||
|         assert im.text["eggs"].tkey == "Eggs" | ||||
| 
 | ||||
|  | @ -776,7 +778,7 @@ class TestFilePng: | |||
| 
 | ||||
|         mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() | ||||
| 
 | ||||
|         sys.stdout = mystdout  # type: ignore[assignment] | ||||
|         sys.stdout = mystdout | ||||
| 
 | ||||
|         with Image.open(TEST_PNG_FILE) as im: | ||||
|             im.save(sys.stdout, "PNG") | ||||
|  |  | |||
|  | @ -95,7 +95,9 @@ def test_16bit_pgm_write(tmp_path: Path) -> None: | |||
|     with Image.open("Tests/images/16_bit_binary.pgm") as im: | ||||
|         filename = str(tmp_path / "temp.pgm") | ||||
|         im.save(filename, "PPM") | ||||
|         assert_image_equal_tofile(im, filename) | ||||
| 
 | ||||
|         im.convert("I;16").save(filename, "PPM") | ||||
|         assert_image_equal_tofile(im, filename) | ||||
| 
 | ||||
| 
 | ||||
|  | @ -373,7 +375,7 @@ def test_save_stdout(buffer: bool) -> None: | |||
| 
 | ||||
|     mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() | ||||
| 
 | ||||
|     sys.stdout = mystdout  # type: ignore[assignment] | ||||
|     sys.stdout = mystdout | ||||
| 
 | ||||
|     with Image.open(TEST_FILE) as im: | ||||
|         im.save(sys.stdout, "PPM") | ||||
|  |  | |||
|  | @ -108,7 +108,8 @@ class TestFileTiff: | |||
|             assert_image_equal_tofile(im, "Tests/images/hopper.tif") | ||||
| 
 | ||||
|         with Image.open("Tests/images/hopper_bigtiff.tif") as im: | ||||
|             # multistrip support not yet implemented | ||||
|             # The data type of this file's StripOffsets tag is LONG8, | ||||
|             # which is not yet supported for offset data when saving multiple frames. | ||||
|             del im.tag_v2[273] | ||||
| 
 | ||||
|             outfile = str(tmp_path / "temp.tif") | ||||
|  | @ -684,6 +685,13 @@ class TestFileTiff: | |||
|             with Image.open(outfile) as reloaded: | ||||
|                 assert_image_equal_tofile(reloaded, infile) | ||||
| 
 | ||||
|     def test_invalid_tiled_dimensions(self) -> None: | ||||
|         with open("Tests/images/tiff_tiled_planar_raw.tif", "rb") as fp: | ||||
|             data = fp.read() | ||||
|         b = BytesIO(data[:144] + b"\x02" + data[145:]) | ||||
|         with pytest.raises(ValueError): | ||||
|             Image.open(b) | ||||
| 
 | ||||
|     @pytest.mark.parametrize("mode", ("P", "PA")) | ||||
|     def test_palette(self, mode: str, tmp_path: Path) -> None: | ||||
|         outfile = str(tmp_path / "temp.tif") | ||||
|  |  | |||
|  | @ -181,6 +181,29 @@ def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None: | |||
|         assert reloaded.tag_v2.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] == TiffTags.LONG | ||||
| 
 | ||||
| 
 | ||||
| def test_save_multiple_stripoffsets() -> None: | ||||
|     ifd = TiffImagePlugin.ImageFileDirectory_v2() | ||||
|     ifd[TiffImagePlugin.STRIPOFFSETS] = (123, 456) | ||||
|     assert ifd.tagtype[TiffImagePlugin.STRIPOFFSETS] == TiffTags.LONG | ||||
| 
 | ||||
|     # all values are in little-endian | ||||
|     assert ifd.tobytes() == ( | ||||
|         # number of tags == 1 | ||||
|         b"\x01\x00" | ||||
|         # tag id (2 bytes), type (2 bytes), count (4 bytes), value (4 bytes) | ||||
|         # TiffImagePlugin.STRIPOFFSETS, TiffTags.LONG, 2, 18 | ||||
|         # where STRIPOFFSETS is 273, LONG is 4 | ||||
|         # and 18 is the offset of the tag data | ||||
|         b"\x11\x01\x04\x00\x02\x00\x00\x00\x12\x00\x00\x00" | ||||
|         # end of entries | ||||
|         b"\x00\x00\x00\x00" | ||||
|         # 26 is the total number of bytes output, | ||||
|         # the offset for any auxiliary strip data that will then be appended | ||||
|         # (123 + 26, 456 + 26) == (149, 482) | ||||
|         b"\x95\x00\x00\x00\xe2\x01\x00\x00" | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| def test_no_duplicate_50741_tag() -> None: | ||||
|     assert TAG_IDS["MakerNoteSafety"] == 50741 | ||||
|     assert TAG_IDS["BestQualityScale"] == 50780 | ||||
|  |  | |||
|  | @ -48,8 +48,6 @@ class TestFileWebp: | |||
|         self.rgb_mode = "RGB" | ||||
| 
 | ||||
|     def test_version(self) -> None: | ||||
|         _webp.WebPDecoderVersion() | ||||
|         _webp.WebPDecoderBuggyAlpha() | ||||
|         version = features.version_module("webp") | ||||
|         assert version is not None | ||||
|         assert re.search(r"\d+\.\d+\.\d+$", version) | ||||
|  | @ -74,7 +72,7 @@ class TestFileWebp: | |||
|     def _roundtrip( | ||||
|         self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {} | ||||
|     ) -> None: | ||||
|         temp_file = str(tmp_path / "temp.webp") | ||||
|         temp_file = tmp_path / "temp.webp" | ||||
| 
 | ||||
|         hopper(mode).save(temp_file, **args) | ||||
|         with Image.open(temp_file) as image: | ||||
|  | @ -117,9 +115,8 @@ class TestFileWebp: | |||
|         hopper().save(buffer_method, format="WEBP", method=6) | ||||
|         assert buffer_no_args.getbuffer() != buffer_method.getbuffer() | ||||
| 
 | ||||
|     @skip_unless_feature("webp_anim") | ||||
|     def test_save_all(self, tmp_path: Path) -> None: | ||||
|         temp_file = str(tmp_path / "temp.webp") | ||||
|         temp_file = tmp_path / "temp.webp" | ||||
|         im = Image.new("RGB", (1, 1)) | ||||
|         im2 = Image.new("RGB", (1, 1), "#f00") | ||||
|         im.save(temp_file, save_all=True, append_images=[im2]) | ||||
|  | @ -130,9 +127,13 @@ class TestFileWebp: | |||
|             reloaded.seek(1) | ||||
|             assert_image_similar(im2, reloaded, 1) | ||||
| 
 | ||||
|     def test_unsupported_image_mode(self) -> None: | ||||
|         im = Image.new("1", (1, 1)) | ||||
|         with pytest.raises(ValueError): | ||||
|             _webp.WebPEncode(im.getim(), False, 0, 0, "", 4, 0, b"", "") | ||||
| 
 | ||||
|     def test_icc_profile(self, tmp_path: Path) -> None: | ||||
|         self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) | ||||
|         if _webp.HAVE_WEBPANIM: | ||||
|         self._roundtrip( | ||||
|             tmp_path, self.rgb_mode, 12.5, {"icc_profile": None, "save_all": True} | ||||
|         ) | ||||
|  | @ -155,40 +156,42 @@ class TestFileWebp: | |||
| 
 | ||||
|     @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") | ||||
|     def test_write_encoding_error_message(self, tmp_path: Path) -> None: | ||||
|         temp_file = str(tmp_path / "temp.webp") | ||||
|         im = Image.new("RGB", (15000, 15000)) | ||||
|         with pytest.raises(ValueError) as e: | ||||
|             im.save(temp_file, method=0) | ||||
|             im.save(tmp_path / "temp.webp", method=0) | ||||
|         assert str(e.value) == "encoding error 6" | ||||
| 
 | ||||
|     @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") | ||||
|     def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None: | ||||
|         im = Image.new("L", (16384, 16384)) | ||||
|         with pytest.raises(ValueError) as e: | ||||
|             im.save(tmp_path / "temp.webp") | ||||
|         assert ( | ||||
|             str(e.value) | ||||
|             == "encoding error 5: Image size exceeds WebP limit of 16383 pixels" | ||||
|         ) | ||||
| 
 | ||||
|     def test_WebPEncode_with_invalid_args(self) -> None: | ||||
|         """ | ||||
|         Calling encoder functions with no arguments should result in an error. | ||||
|         """ | ||||
| 
 | ||||
|         if _webp.HAVE_WEBPANIM: | ||||
|         with pytest.raises(TypeError): | ||||
|             _webp.WebPAnimEncoder() | ||||
|         with pytest.raises(TypeError): | ||||
|             _webp.WebPEncode() | ||||
| 
 | ||||
|     def test_WebPDecode_with_invalid_args(self) -> None: | ||||
|     def test_WebPAnimDecoder_with_invalid_args(self) -> None: | ||||
|         """ | ||||
|         Calling decoder functions with no arguments should result in an error. | ||||
|         """ | ||||
| 
 | ||||
|         if _webp.HAVE_WEBPANIM: | ||||
|         with pytest.raises(TypeError): | ||||
|             _webp.WebPAnimDecoder() | ||||
|         with pytest.raises(TypeError): | ||||
|             _webp.WebPDecode() | ||||
| 
 | ||||
|     def test_no_resource_warning(self, tmp_path: Path) -> None: | ||||
|         file_path = "Tests/images/hopper.webp" | ||||
|         with Image.open(file_path) as image: | ||||
|             temp_file = str(tmp_path / "temp.webp") | ||||
|             with warnings.catch_warnings(): | ||||
|                 image.save(temp_file) | ||||
|                 image.save(tmp_path / "temp.webp") | ||||
| 
 | ||||
|     def test_file_pointer_could_be_reused(self) -> None: | ||||
|         file_path = "Tests/images/hopper.webp" | ||||
|  | @ -200,20 +203,19 @@ class TestFileWebp: | |||
|         "background", | ||||
|         (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), | ||||
|     ) | ||||
|     @skip_unless_feature("webp_anim") | ||||
|     def test_invalid_background( | ||||
|         self, background: int | tuple[int, ...], tmp_path: Path | ||||
|     ) -> None: | ||||
|         temp_file = str(tmp_path / "temp.webp") | ||||
|         temp_file = tmp_path / "temp.webp" | ||||
|         im = hopper() | ||||
|         with pytest.raises(OSError): | ||||
|             im.save(temp_file, save_all=True, append_images=[im], background=background) | ||||
| 
 | ||||
|     @skip_unless_feature("webp_anim") | ||||
|     def test_background_from_gif(self, tmp_path: Path) -> None: | ||||
|         out_webp = tmp_path / "temp.webp" | ||||
| 
 | ||||
|         # Save L mode GIF with background | ||||
|         with Image.open("Tests/images/no_palette_with_background.gif") as im: | ||||
|             out_webp = str(tmp_path / "temp.webp") | ||||
|             im.save(out_webp, save_all=True) | ||||
| 
 | ||||
|         # Save P mode GIF with background | ||||
|  | @ -221,11 +223,10 @@ class TestFileWebp: | |||
|             original_value = im.convert("RGB").getpixel((1, 1)) | ||||
| 
 | ||||
|             # Save as WEBP | ||||
|             out_webp = str(tmp_path / "temp.webp") | ||||
|             im.save(out_webp, save_all=True) | ||||
| 
 | ||||
|         # Save as GIF | ||||
|         out_gif = str(tmp_path / "temp.gif") | ||||
|         out_gif = tmp_path / "temp.gif" | ||||
|         with Image.open(out_webp) as im: | ||||
|             im.save(out_gif) | ||||
| 
 | ||||
|  | @ -234,12 +235,11 @@ class TestFileWebp: | |||
|         difference = sum(abs(original_value[i] - reread_value[i]) for i in range(0, 3)) | ||||
|         assert difference < 5 | ||||
| 
 | ||||
|     @skip_unless_feature("webp_anim") | ||||
|     def test_duration(self, tmp_path: Path) -> None: | ||||
|         out_webp = tmp_path / "temp.webp" | ||||
| 
 | ||||
|         with Image.open("Tests/images/dispose_bgnd.gif") as im: | ||||
|             assert im.info["duration"] == 1000 | ||||
| 
 | ||||
|             out_webp = str(tmp_path / "temp.webp") | ||||
|             im.save(out_webp, save_all=True) | ||||
| 
 | ||||
|         with Image.open(out_webp) as reloaded: | ||||
|  | @ -247,9 +247,10 @@ class TestFileWebp: | |||
|             assert reloaded.info["duration"] == 1000 | ||||
| 
 | ||||
|     def test_roundtrip_rgba_palette(self, tmp_path: Path) -> None: | ||||
|         temp_file = str(tmp_path / "temp.webp") | ||||
|         temp_file = tmp_path / "temp.webp" | ||||
|         im = Image.new("RGBA", (1, 1)).convert("P") | ||||
|         assert im.mode == "P" | ||||
|         assert im.palette is not None | ||||
|         assert im.palette.mode == "RGBA" | ||||
|         im.save(temp_file) | ||||
| 
 | ||||
|  |  | |||
|  | @ -13,12 +13,7 @@ from .helper import ( | |||
|     hopper, | ||||
| ) | ||||
| 
 | ||||
| _webp = pytest.importorskip("PIL._webp", reason="WebP support not installed") | ||||
| 
 | ||||
| 
 | ||||
| def setup_module() -> None: | ||||
|     if _webp.WebPDecoderBuggyAlpha(): | ||||
|         pytest.skip("Buggy early version of WebP installed, not testing transparency") | ||||
| pytest.importorskip("PIL._webp", reason="WebP support not installed") | ||||
| 
 | ||||
| 
 | ||||
| def test_read_rgba() -> None: | ||||
|  | @ -81,9 +76,6 @@ def test_write_rgba(tmp_path: Path) -> None: | |||
|     pil_image = Image.new("RGBA", (10, 10), (255, 0, 0, 20)) | ||||
|     pil_image.save(temp_file) | ||||
| 
 | ||||
|     if _webp.WebPDecoderBuggyAlpha(): | ||||
|         return | ||||
| 
 | ||||
|     with Image.open(temp_file) as image: | ||||
|         image.load() | ||||
| 
 | ||||
|  | @ -93,11 +85,6 @@ def test_write_rgba(tmp_path: Path) -> None: | |||
|         image.load() | ||||
|         image.getdata() | ||||
| 
 | ||||
|         # Early versions of WebP are known to produce higher deviations: | ||||
|         # deal with it | ||||
|         if _webp.WebPDecoderVersion() <= 0x201: | ||||
|             assert_image_similar(image, pil_image, 3.0) | ||||
|         else: | ||||
|         assert_image_similar(image, pil_image, 1.0) | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -15,10 +15,7 @@ from .helper import ( | |||
|     skip_unless_feature, | ||||
| ) | ||||
| 
 | ||||
| pytestmark = [ | ||||
|     skip_unless_feature("webp"), | ||||
|     skip_unless_feature("webp_anim"), | ||||
| ] | ||||
| pytestmark = skip_unless_feature("webp") | ||||
| 
 | ||||
| 
 | ||||
| def test_n_frames() -> None: | ||||
|  |  | |||
|  | @ -8,14 +8,11 @@ from PIL import Image | |||
| 
 | ||||
| from .helper import assert_image_equal, hopper | ||||
| 
 | ||||
| _webp = pytest.importorskip("PIL._webp", reason="WebP support not installed") | ||||
| pytest.importorskip("PIL._webp", reason="WebP support not installed") | ||||
| RGB_MODE = "RGB" | ||||
| 
 | ||||
| 
 | ||||
| def test_write_lossless_rgb(tmp_path: Path) -> None: | ||||
|     if _webp.WebPDecoderVersion() < 0x0200: | ||||
|         pytest.skip("lossless not included") | ||||
| 
 | ||||
|     temp_file = str(tmp_path / "temp.webp") | ||||
| 
 | ||||
|     hopper(RGB_MODE).save(temp_file, lossless=True) | ||||
|  |  | |||
|  | @ -10,10 +10,7 @@ from PIL import Image | |||
| 
 | ||||
| from .helper import mark_if_feature_version, skip_unless_feature | ||||
| 
 | ||||
| pytestmark = [ | ||||
|     skip_unless_feature("webp"), | ||||
|     skip_unless_feature("webp_mux"), | ||||
| ] | ||||
| pytestmark = skip_unless_feature("webp") | ||||
| 
 | ||||
| ElementTree: ModuleType | None | ||||
| try: | ||||
|  | @ -119,7 +116,15 @@ def test_read_no_exif() -> None: | |||
| def test_getxmp() -> None: | ||||
|     with Image.open("Tests/images/flower.webp") as im: | ||||
|         assert "xmp" not in im.info | ||||
|         assert im.getxmp() == {} | ||||
|         if ElementTree is None: | ||||
|             with pytest.warns( | ||||
|                 UserWarning, | ||||
|                 match="XMP data cannot be read without defusedxml dependency", | ||||
|             ): | ||||
|                 xmp = im.getxmp() | ||||
|         else: | ||||
|             xmp = im.getxmp() | ||||
|         assert xmp == {} | ||||
| 
 | ||||
|     with Image.open("Tests/images/flower2.webp") as im: | ||||
|         if ElementTree is None: | ||||
|  | @ -136,7 +141,6 @@ def test_getxmp() -> None: | |||
|             ) | ||||
| 
 | ||||
| 
 | ||||
| @skip_unless_feature("webp_anim") | ||||
| def test_write_animated_metadata(tmp_path: Path) -> None: | ||||
|     iccp_data = b"<iccp_data>" | ||||
|     exif_data = b"<exif_data>" | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ from __future__ import annotations | |||
| 
 | ||||
| import os | ||||
| from pathlib import Path | ||||
| from typing import AnyStr | ||||
| 
 | ||||
| import pytest | ||||
| 
 | ||||
|  | @ -92,7 +93,7 @@ def test_textsize(request: pytest.FixtureRequest, tmp_path: Path) -> None: | |||
| 
 | ||||
| 
 | ||||
| def _test_high_characters( | ||||
|     request: pytest.FixtureRequest, tmp_path: Path, message: str | bytes | ||||
|     request: pytest.FixtureRequest, tmp_path: Path, message: AnyStr | ||||
| ) -> None: | ||||
|     tempname = save_font(request, tmp_path) | ||||
|     font = ImageFont.load(tempname) | ||||
|  |  | |||
|  | @ -42,6 +42,12 @@ try: | |||
| except ImportError: | ||||
|     ElementTree = None | ||||
| 
 | ||||
| PrettyPrinter: type | None | ||||
| try: | ||||
|     from IPython.lib.pretty import PrettyPrinter | ||||
| except ImportError: | ||||
|     PrettyPrinter = None | ||||
| 
 | ||||
| 
 | ||||
| # Deprecation helper | ||||
| def helper_image_new(mode: str, size: tuple[int, int]) -> Image.Image: | ||||
|  | @ -91,16 +97,15 @@ class TestImage: | |||
|         # with pytest.raises(MemoryError): | ||||
|         #   Image.new("L", (1000000, 1000000)) | ||||
| 
 | ||||
|     @pytest.mark.skipif(PrettyPrinter is None, reason="IPython is not installed") | ||||
|     def test_repr_pretty(self) -> None: | ||||
|         class Pretty: | ||||
|             def text(self, text: str) -> None: | ||||
|                 self.pretty_output = text | ||||
| 
 | ||||
|         im = Image.new("L", (100, 100)) | ||||
| 
 | ||||
|         p = Pretty() | ||||
|         im._repr_pretty_(p, None) | ||||
|         assert p.pretty_output == "<PIL.Image.Image image mode=L size=100x100>" | ||||
|         output = io.StringIO() | ||||
|         assert PrettyPrinter is not None | ||||
|         p = PrettyPrinter(output) | ||||
|         im._repr_pretty_(p, False) | ||||
|         assert output.getvalue() == "<PIL.Image.Image image mode=L size=100x100>" | ||||
| 
 | ||||
|     def test_open_formats(self) -> None: | ||||
|         PNGFILE = "Tests/images/hopper.png" | ||||
|  | @ -700,6 +705,7 @@ class TestImage: | |||
|             assert new_image.size == image.size | ||||
|             assert new_image.info == base_image.info | ||||
|             if palette_result is not None: | ||||
|                 assert new_image.palette is not None | ||||
|                 assert new_image.palette.tobytes() == palette_result.tobytes() | ||||
|             else: | ||||
|                 assert new_image.palette is None | ||||
|  | @ -769,6 +775,22 @@ class TestImage: | |||
|         exif.load(b"Exif\x00\x00") | ||||
|         assert not dict(exif) | ||||
| 
 | ||||
|     def test_duplicate_exif_header(self) -> None: | ||||
|         with Image.open("Tests/images/exif.png") as im: | ||||
|             im.load() | ||||
|             im.info["exif"] = b"Exif\x00\x00" + im.info["exif"] | ||||
| 
 | ||||
|             exif = im.getexif() | ||||
|         assert exif[274] == 1 | ||||
| 
 | ||||
|     def test_empty_get_ifd(self) -> None: | ||||
|         exif = Image.Exif() | ||||
|         ifd = exif.get_ifd(0x8769) | ||||
|         assert ifd == {} | ||||
| 
 | ||||
|         ifd[36864] = b"0220" | ||||
|         assert exif.get_ifd(0x8769) == {36864: b"0220"} | ||||
| 
 | ||||
|     @mark_if_feature_version( | ||||
|         pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" | ||||
|     ) | ||||
|  | @ -817,7 +839,6 @@ class TestImage: | |||
|             assert reloaded_exif[305] == "Pillow test" | ||||
| 
 | ||||
|     @skip_unless_feature("webp") | ||||
|     @skip_unless_feature("webp_anim") | ||||
|     def test_exif_webp(self, tmp_path: Path) -> None: | ||||
|         with Image.open("Tests/images/hopper.webp") as im: | ||||
|             exif = im.getexif() | ||||
|  | @ -939,7 +960,15 @@ class TestImage: | |||
| 
 | ||||
|     def test_empty_xmp(self) -> None: | ||||
|         with Image.open("Tests/images/hopper.gif") as im: | ||||
|             assert im.getxmp() == {} | ||||
|             if ElementTree is None: | ||||
|                 with pytest.warns( | ||||
|                     UserWarning, | ||||
|                     match="XMP data cannot be read without defusedxml dependency", | ||||
|                 ): | ||||
|                     xmp = im.getxmp() | ||||
|             else: | ||||
|                 xmp = im.getxmp() | ||||
|             assert xmp == {} | ||||
| 
 | ||||
|     def test_getxmp_padded(self) -> None: | ||||
|         im = Image.new("RGB", (1, 1)) | ||||
|  | @ -990,12 +1019,14 @@ class TestImage: | |||
|         # P mode with RGBA palette | ||||
|         im = Image.new("RGBA", (1, 1)).convert("P") | ||||
|         assert im.mode == "P" | ||||
|         assert im.palette is not None | ||||
|         assert im.palette.mode == "RGBA" | ||||
|         assert im.has_transparency_data | ||||
| 
 | ||||
|     def test_apply_transparency(self) -> None: | ||||
|         im = Image.new("P", (1, 1)) | ||||
|         im.putpalette((0, 0, 0, 1, 1, 1)) | ||||
|         assert im.palette is not None | ||||
|         assert im.palette.colors == {(0, 0, 0): 0, (1, 1, 1): 1} | ||||
| 
 | ||||
|         # Test that no transformation is applied without transparency | ||||
|  | @ -1013,13 +1044,16 @@ class TestImage: | |||
|         im.putpalette((0, 0, 0, 255, 1, 1, 1, 128), "RGBA") | ||||
|         im.info["transparency"] = 0 | ||||
|         im.apply_transparency() | ||||
|         assert im.palette is not None | ||||
|         assert im.palette.colors == {(0, 0, 0, 0): 0, (1, 1, 1, 128): 1} | ||||
| 
 | ||||
|         # Test that transparency bytes are applied | ||||
|         with Image.open("Tests/images/pil123p.png") as im: | ||||
|             assert isinstance(im.info["transparency"], bytes) | ||||
|             assert im.palette is not None | ||||
|             assert im.palette.colors[(27, 35, 6)] == 24 | ||||
|             im.apply_transparency() | ||||
|             assert im.palette is not None | ||||
|             assert im.palette.colors[(27, 35, 6, 214)] == 24 | ||||
| 
 | ||||
|     def test_constants(self) -> None: | ||||
|  | @ -1052,22 +1086,17 @@ class TestImage: | |||
|         valgrind pytest -qq Tests/test_image.py::TestImage::test_overrun | grep decode.c | ||||
|         """ | ||||
|         with Image.open(os.path.join("Tests/images", path)) as im: | ||||
|             try: | ||||
|             with pytest.raises(OSError) as e: | ||||
|                 im.load() | ||||
|                 pytest.fail() | ||||
|             except OSError as e: | ||||
|                 buffer_overrun = str(e) == "buffer overrun when reading image file" | ||||
|                 truncated = "image file is truncated" in str(e) | ||||
|         buffer_overrun = str(e.value) == "buffer overrun when reading image file" | ||||
|         truncated = "image file is truncated" in str(e.value) | ||||
| 
 | ||||
|         assert buffer_overrun or truncated | ||||
| 
 | ||||
|     def test_fli_overrun2(self) -> None: | ||||
|         with Image.open("Tests/images/fli_overrun2.bin") as im: | ||||
|             try: | ||||
|             with pytest.raises(OSError, match="buffer overrun when reading image file"): | ||||
|                 im.seek(1) | ||||
|                 pytest.fail() | ||||
|             except OSError as e: | ||||
|                 assert str(e) == "buffer overrun when reading image file" | ||||
| 
 | ||||
|     def test_exit_fp(self) -> None: | ||||
|         with Image.new("L", (1, 1)) as im: | ||||
|  | @ -1083,6 +1112,10 @@ class TestImage: | |||
|             assert len(caplog.records) == 0 | ||||
|             assert im.fp is None | ||||
| 
 | ||||
|     def test_deprecation(self) -> None: | ||||
|         with pytest.warns(DeprecationWarning): | ||||
|             assert not Image.isImageType(None) | ||||
| 
 | ||||
| 
 | ||||
| class TestImageBytes: | ||||
|     @pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"]) | ||||
|  |  | |||
|  | @ -230,7 +230,7 @@ class TestImagePutPixelError: | |||
|                 im.putpixel((0, 0), v)  # type: ignore[arg-type] | ||||
| 
 | ||||
|     @pytest.mark.parametrize( | ||||
|         ("mode", "band_numbers", "match"), | ||||
|         "mode, band_numbers, match", | ||||
|         ( | ||||
|             ("L", (0, 2), "color must be int or single-element tuple"), | ||||
|             ("LA", (0, 3), "color must be int, or tuple of one or two elements"), | ||||
|  |  | |||
|  | @ -24,7 +24,7 @@ def test_toarray() -> None: | |||
| 
 | ||||
|     def test_with_dtype(dtype: npt.DTypeLike) -> None: | ||||
|         ai = numpy.array(im, dtype=dtype) | ||||
|         assert ai.dtype == dtype | ||||
|         assert ai.dtype.type is dtype | ||||
| 
 | ||||
|     # assert test("1") == ((100, 128), '|b1', 1600)) | ||||
|     assert test("L") == ((100, 128), "|u1", 12800) | ||||
|  | @ -47,7 +47,7 @@ def test_toarray() -> None: | |||
|             with pytest.raises(OSError): | ||||
|                 numpy.array(im_truncated) | ||||
|         else: | ||||
|             with pytest.warns(UserWarning): | ||||
|             with pytest.warns(DeprecationWarning): | ||||
|                 numpy.array(im_truncated) | ||||
| 
 | ||||
| 
 | ||||
|  | @ -113,4 +113,5 @@ def test_fromarray_palette() -> None: | |||
|     out = Image.fromarray(a, "P") | ||||
| 
 | ||||
|     # Assert that the Python and C palettes match | ||||
|     assert out.palette is not None | ||||
|     assert len(out.palette.colors) == len(out.im.getpalette()) / 3 | ||||
|  |  | |||
|  | @ -218,6 +218,7 @@ def test_trns_RGB(tmp_path: Path) -> None: | |||
| def test_l_macro_rounding(convert_mode: str) -> None: | ||||
|     for mode in ("P", "PA"): | ||||
|         im = Image.new(mode, (1, 1)) | ||||
|         assert im.palette is not None | ||||
|         im.palette.getcolor((0, 1, 2)) | ||||
| 
 | ||||
|         converted_im = im.convert(convert_mode) | ||||
|  |  | |||
|  | @ -49,5 +49,7 @@ def test_copy_zero() -> None: | |||
| @skip_unless_feature("libtiff") | ||||
| def test_deepcopy() -> None: | ||||
|     with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||
|         assert im.size == (590, 88) | ||||
| 
 | ||||
|         out = copy.deepcopy(im) | ||||
|     assert out.size == (590, 88) | ||||
|  |  | |||
|  | @ -86,6 +86,7 @@ def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None: | |||
|     im = Image.new("P", (1, 1)) | ||||
|     im.putpalette(palette, mode) | ||||
|     assert im.getpalette() == [1, 2, 3] | ||||
|     assert im.palette is not None | ||||
|     assert im.palette.colors == {(1, 2, 3, 4): 0} | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -69,6 +69,7 @@ def test_quantize_no_dither() -> None: | |||
| 
 | ||||
|     converted = image.quantize(dither=Image.Dither.NONE, palette=palette) | ||||
|     assert converted.mode == "P" | ||||
|     assert converted.palette is not None | ||||
|     assert converted.palette.palette == palette.palette.palette | ||||
| 
 | ||||
| 
 | ||||
|  | @ -81,6 +82,7 @@ def test_quantize_no_dither2() -> None: | |||
|     palette.putpalette(data) | ||||
|     quantized = im.quantize(dither=Image.Dither.NONE, palette=palette) | ||||
| 
 | ||||
|     assert quantized.palette is not None | ||||
|     assert tuple(quantized.palette.palette) == data | ||||
| 
 | ||||
|     px = quantized.load() | ||||
|  | @ -117,6 +119,7 @@ def test_colors() -> None: | |||
|     im = hopper() | ||||
|     colors = 2 | ||||
|     converted = im.quantize(colors) | ||||
|     assert converted.palette is not None | ||||
|     assert len(converted.palette.palette) == colors * len("RGB") | ||||
| 
 | ||||
| 
 | ||||
|  | @ -147,6 +150,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.palette is not None | ||||
|     assert converted_px[0, 0] == converted.palette.colors[color] | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -44,9 +44,19 @@ class TestImagingCoreResize: | |||
|             self.resize(hopper("1"), (15, 12), Image.Resampling.BILINEAR) | ||||
|         with pytest.raises(ValueError): | ||||
|             self.resize(hopper("P"), (15, 12), Image.Resampling.BILINEAR) | ||||
|         with pytest.raises(ValueError): | ||||
|             self.resize(hopper("I;16"), (15, 12), Image.Resampling.BILINEAR) | ||||
|         for mode in ["L", "I", "F", "RGB", "RGBA", "CMYK", "YCbCr"]: | ||||
|         for mode in [ | ||||
|             "L", | ||||
|             "I", | ||||
|             "I;16", | ||||
|             "I;16L", | ||||
|             "I;16B", | ||||
|             "I;16N", | ||||
|             "F", | ||||
|             "RGB", | ||||
|             "RGBA", | ||||
|             "CMYK", | ||||
|             "YCbCr", | ||||
|         ]: | ||||
|             im = hopper(mode) | ||||
|             r = self.resize(im, (15, 12), Image.Resampling.BILINEAR) | ||||
|             assert r.mode == mode | ||||
|  | @ -300,21 +310,19 @@ class TestImageResize: | |||
|                 im.resize((10, 10), "unknown") | ||||
| 
 | ||||
|     @skip_unless_feature("libtiff") | ||||
|     def test_load_first(self) -> None: | ||||
|         # load() may change the size of the image | ||||
|         # Test that resize() is calling it before getting the size | ||||
|     def test_transposed(self) -> None: | ||||
|         with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||
|             im = im.resize((64, 64)) | ||||
|             assert im.size == (64, 64) | ||||
| 
 | ||||
|     @pytest.mark.parametrize("mode", ("L", "RGB", "I", "F")) | ||||
|     @pytest.mark.parametrize( | ||||
|         "mode", ("L", "RGB", "I", "I;16", "I;16L", "I;16B", "I;16N", "F") | ||||
|     ) | ||||
|     def test_default_filter_bicubic(self, mode: str) -> None: | ||||
|         im = hopper(mode) | ||||
|         assert im.resize((20, 20), Image.Resampling.BICUBIC) == im.resize((20, 20)) | ||||
| 
 | ||||
|     @pytest.mark.parametrize( | ||||
|         "mode", ("1", "P", "I;16", "I;16L", "I;16B", "BGR;15", "BGR;16") | ||||
|     ) | ||||
|     @pytest.mark.parametrize("mode", ("1", "P", "BGR;15", "BGR;16")) | ||||
|     def test_default_filter_nearest(self, mode: str) -> None: | ||||
|         im = hopper(mode) | ||||
|         assert im.resize((20, 20), Image.Resampling.NEAREST) == im.resize((20, 20)) | ||||
|  |  | |||
|  | @ -92,15 +92,13 @@ def test_no_resize() -> None: | |||
| 
 | ||||
| 
 | ||||
| @skip_unless_feature("libtiff") | ||||
| def test_load_first() -> None: | ||||
|     # load() may change the size of the image | ||||
|     # Test that thumbnail() is calling it before performing size calculations | ||||
| def test_transposed() -> None: | ||||
|     with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||
|         assert im.size == (590, 88) | ||||
| 
 | ||||
|         im.thumbnail((64, 64)) | ||||
|         assert im.size == (64, 10) | ||||
| 
 | ||||
|     # Test thumbnail(), without draft(), | ||||
|     # on an image that is large enough once load() has changed the size | ||||
|     with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||
|         im.thumbnail((590, 88), reducing_gap=None) | ||||
|         assert im.size == (590, 88) | ||||
|  |  | |||
|  | @ -398,7 +398,8 @@ def test_logical() -> None: | |||
|             for y in (a, b): | ||||
|                 imy = Image.new("1", (1, 1), y) | ||||
|                 value = op(imx, imy).getpixel((0, 0)) | ||||
|                 assert not isinstance(value, tuple) and value is not None | ||||
|                 assert not isinstance(value, tuple) | ||||
|                 assert value is not None | ||||
|                 out.append(value) | ||||
|         return out | ||||
| 
 | ||||
|  |  | |||
|  | @ -696,6 +696,12 @@ def test_rgb_lab(mode: str) -> None: | |||
|     assert value[:3] == (0, 255, 255) | ||||
| 
 | ||||
| 
 | ||||
| def test_cmyk_lab() -> None: | ||||
|     im = Image.new("CMYK", (1, 1)) | ||||
|     converted_im = im.convert("LAB") | ||||
|     assert converted_im.getpixel((0, 0)) == (255, 128, 128) | ||||
| 
 | ||||
| 
 | ||||
| def test_deprecation() -> None: | ||||
|     with pytest.warns(DeprecationWarning): | ||||
|         assert ImageCms.DESCRIPTION.strip().startswith("pyCMS") | ||||
|  |  | |||
|  | @ -857,6 +857,27 @@ def test_rounded_rectangle_corners( | |||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| def test_rounded_rectangle_joined_x_different_corners() -> None: | ||||
|     # Arrange | ||||
|     im = Image.new("RGB", (W, H)) | ||||
|     draw = ImageDraw.Draw(im, "RGBA") | ||||
| 
 | ||||
|     # Act | ||||
|     draw.rounded_rectangle( | ||||
|         (20, 10, 80, 90), | ||||
|         30, | ||||
|         fill="red", | ||||
|         outline="green", | ||||
|         width=5, | ||||
|         corners=(True, False, False, False), | ||||
|     ) | ||||
| 
 | ||||
|     # Assert | ||||
|     assert_image_equal_tofile( | ||||
|         im, "Tests/images/imagedraw_rounded_rectangle_joined_x_different_corners.png" | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize( | ||||
|     "xy, radius, type", | ||||
|     [ | ||||
|  | @ -1347,6 +1368,20 @@ def test_stroke() -> None: | |||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| @skip_unless_feature("freetype2") | ||||
| def test_stroke_float() -> None: | ||||
|     # Arrange | ||||
|     im = Image.new("RGB", (120, 130)) | ||||
|     draw = ImageDraw.Draw(im) | ||||
|     font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 120) | ||||
| 
 | ||||
|     # Act | ||||
|     draw.text((12, 12), "A", "#f00", font, stroke_width=0.5) | ||||
| 
 | ||||
|     # Assert | ||||
|     assert_image_similar_tofile(im, "Tests/images/imagedraw_stroke_float.png", 3.1) | ||||
| 
 | ||||
| 
 | ||||
| @skip_unless_feature("freetype2") | ||||
| def test_stroke_descender() -> None: | ||||
|     # Arrange | ||||
|  |  | |||
|  | @ -65,6 +65,36 @@ def test_mode() -> None: | |||
|         ImageDraw2.Draw("L") | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize("bbox", BBOX) | ||||
| @pytest.mark.parametrize("start, end", ((0, 180), (0.5, 180.4))) | ||||
| def test_arc(bbox: Coords, start: float, end: float) -> None: | ||||
|     # Arrange | ||||
|     im = Image.new("RGB", (W, H)) | ||||
|     draw = ImageDraw2.Draw(im) | ||||
|     pen = ImageDraw2.Pen("white", width=1) | ||||
| 
 | ||||
|     # Act | ||||
|     draw.arc(bbox, pen, start, end) | ||||
| 
 | ||||
|     # Assert | ||||
|     assert_image_similar_tofile(im, "Tests/images/imagedraw_arc.png", 1) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize("bbox", BBOX) | ||||
| def test_chord(bbox: Coords) -> None: | ||||
|     # Arrange | ||||
|     im = Image.new("RGB", (W, H)) | ||||
|     draw = ImageDraw2.Draw(im) | ||||
|     pen = ImageDraw2.Pen("yellow") | ||||
|     brush = ImageDraw2.Brush("red") | ||||
| 
 | ||||
|     # Act | ||||
|     draw.chord(bbox, pen, 0, 180, brush) | ||||
| 
 | ||||
|     # Assert | ||||
|     assert_image_similar_tofile(im, "Tests/images/imagedraw_chord_RGB.png", 1) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize("bbox", BBOX) | ||||
| def test_ellipse(bbox: Coords) -> None: | ||||
|     # Arrange | ||||
|  | @ -123,6 +153,22 @@ def test_line_pen_as_brush(points: Coords) -> None: | |||
|     assert_image_equal_tofile(im, "Tests/images/imagedraw_line.png") | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize("bbox", BBOX) | ||||
| @pytest.mark.parametrize("start, end", ((-92, 46), (-92.2, 46.2))) | ||||
| def test_pieslice(bbox: Coords, start: float, end: float) -> None: | ||||
|     # Arrange | ||||
|     im = Image.new("RGB", (W, H)) | ||||
|     draw = ImageDraw2.Draw(im) | ||||
|     pen = ImageDraw2.Pen("blue") | ||||
|     brush = ImageDraw2.Brush("white") | ||||
| 
 | ||||
|     # Act | ||||
|     draw.pieslice(bbox, pen, start, end, brush) | ||||
| 
 | ||||
|     # Assert | ||||
|     assert_image_similar_tofile(im, "Tests/images/imagedraw_pieslice.png", 1) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize("points", POINTS) | ||||
| def test_polygon(points: Coords) -> None: | ||||
|     # Arrange | ||||
|  |  | |||
|  | @ -94,7 +94,6 @@ class TestImageFile: | |||
|             assert (48, 48) == p.image.size | ||||
| 
 | ||||
|     @skip_unless_feature("webp") | ||||
|     @skip_unless_feature("webp_anim") | ||||
|     def test_incremental_webp(self) -> None: | ||||
|         with ImageFile.Parser() as p: | ||||
|             with open("Tests/images/hopper.webp", "rb") as f: | ||||
|  | @ -211,7 +210,7 @@ class MockPyDecoder(ImageFile.PyDecoder): | |||
| 
 | ||||
|         super().__init__(mode, *args) | ||||
| 
 | ||||
|     def decode(self, buffer: bytes) -> tuple[int, int]: | ||||
|     def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: | ||||
|         # eof | ||||
|         return -1, 0 | ||||
| 
 | ||||
|  | @ -239,7 +238,9 @@ class MockImageFile(ImageFile.ImageFile): | |||
|         self.rawmode = "RGBA" | ||||
|         self._mode = "RGBA" | ||||
|         self._size = (200, 200) | ||||
|         self.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None)] | ||||
|         self.tile = [ | ||||
|             ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None) | ||||
|         ] | ||||
| 
 | ||||
| 
 | ||||
| class CodecsTest: | ||||
|  | @ -269,7 +270,7 @@ class TestPyDecoder(CodecsTest): | |||
|         buf = BytesIO(b"\x00" * 255) | ||||
| 
 | ||||
|         im = MockImageFile(buf) | ||||
|         im.tile = [("MOCK", None, 32, None)] | ||||
|         im.tile = [ImageFile._Tile("MOCK", None, 32, None)] | ||||
| 
 | ||||
|         im.load() | ||||
| 
 | ||||
|  | @ -282,12 +283,12 @@ class TestPyDecoder(CodecsTest): | |||
|         buf = BytesIO(b"\x00" * 255) | ||||
| 
 | ||||
|         im = MockImageFile(buf) | ||||
|         im.tile = [("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)] | ||||
|         im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)] | ||||
| 
 | ||||
|         with pytest.raises(ValueError): | ||||
|             im.load() | ||||
| 
 | ||||
|         im.tile = [("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)] | ||||
|         im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)] | ||||
|         with pytest.raises(ValueError): | ||||
|             im.load() | ||||
| 
 | ||||
|  | @ -295,12 +296,20 @@ class TestPyDecoder(CodecsTest): | |||
|         buf = BytesIO(b"\x00" * 255) | ||||
| 
 | ||||
|         im = MockImageFile(buf) | ||||
|         im.tile = [("MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None)] | ||||
|         im.tile = [ | ||||
|             ImageFile._Tile( | ||||
|                 "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None | ||||
|             ) | ||||
|         ] | ||||
| 
 | ||||
|         with pytest.raises(ValueError): | ||||
|             im.load() | ||||
| 
 | ||||
|         im.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None)] | ||||
|         im.tile = [ | ||||
|             ImageFile._Tile( | ||||
|                 "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None | ||||
|             ) | ||||
|         ] | ||||
|         with pytest.raises(ValueError): | ||||
|             im.load() | ||||
| 
 | ||||
|  | @ -318,7 +327,13 @@ class TestPyEncoder(CodecsTest): | |||
| 
 | ||||
|         fp = BytesIO() | ||||
|         ImageFile._save( | ||||
|             im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")] | ||||
|             im, | ||||
|             fp, | ||||
|             [ | ||||
|                 ImageFile._Tile( | ||||
|                     "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB" | ||||
|                 ) | ||||
|             ], | ||||
|         ) | ||||
| 
 | ||||
|         assert MockPyEncoder.last | ||||
|  | @ -331,10 +346,10 @@ class TestPyEncoder(CodecsTest): | |||
|         buf = BytesIO(b"\x00" * 255) | ||||
| 
 | ||||
|         im = MockImageFile(buf) | ||||
|         im.tile = [("MOCK", None, 32, None)] | ||||
|         im.tile = [ImageFile._Tile("MOCK", None, 32, None)] | ||||
| 
 | ||||
|         fp = BytesIO() | ||||
|         ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")]) | ||||
|         ImageFile._save(im, fp, [ImageFile._Tile("MOCK", None, 0, "RGB")]) | ||||
| 
 | ||||
|         assert MockPyEncoder.last | ||||
|         assert MockPyEncoder.last.state.xoff == 0 | ||||
|  | @ -351,7 +366,9 @@ class TestPyEncoder(CodecsTest): | |||
|         MockPyEncoder.last = None | ||||
|         with pytest.raises(ValueError): | ||||
|             ImageFile._save( | ||||
|                 im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")] | ||||
|                 im, | ||||
|                 fp, | ||||
|                 [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")], | ||||
|             ) | ||||
|         last: MockPyEncoder | None = MockPyEncoder.last | ||||
|         assert last | ||||
|  | @ -359,7 +376,9 @@ class TestPyEncoder(CodecsTest): | |||
| 
 | ||||
|         with pytest.raises(ValueError): | ||||
|             ImageFile._save( | ||||
|                 im, fp, [("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")] | ||||
|                 im, | ||||
|                 fp, | ||||
|                 [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")], | ||||
|             ) | ||||
| 
 | ||||
|     def test_oversize(self) -> None: | ||||
|  | @ -372,14 +391,22 @@ class TestPyEncoder(CodecsTest): | |||
|             ImageFile._save( | ||||
|                 im, | ||||
|                 fp, | ||||
|                 [("MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB")], | ||||
|                 [ | ||||
|                     ImageFile._Tile( | ||||
|                         "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB" | ||||
|                     ) | ||||
|                 ], | ||||
|             ) | ||||
| 
 | ||||
|         with pytest.raises(ValueError): | ||||
|             ImageFile._save( | ||||
|                 im, | ||||
|                 fp, | ||||
|                 [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB")], | ||||
|                 [ | ||||
|                     ImageFile._Tile( | ||||
|                         "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB" | ||||
|                     ) | ||||
|                 ], | ||||
|             ) | ||||
| 
 | ||||
|     def test_encode(self) -> None: | ||||
|  | @ -395,9 +422,8 @@ class TestPyEncoder(CodecsTest): | |||
|         with pytest.raises(NotImplementedError): | ||||
|             encoder.encode_to_pyfd() | ||||
| 
 | ||||
|         fh = BytesIO() | ||||
|         with pytest.raises(NotImplementedError): | ||||
|             encoder.encode_to_file(fh, 0) | ||||
|             encoder.encode_to_file(0, 0) | ||||
| 
 | ||||
|     def test_zero_height(self) -> None: | ||||
|         with pytest.raises(UnidentifiedImageError): | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ import os | |||
| import re | ||||
| import shutil | ||||
| import sys | ||||
| import tempfile | ||||
| from io import BytesIO | ||||
| from pathlib import Path | ||||
| from typing import Any, BinaryIO | ||||
|  | @ -460,17 +461,43 @@ def test_free_type_font_get_mask(font: ImageFont.FreeTypeFont) -> None: | |||
|     assert mask.size == (108, 13) | ||||
| 
 | ||||
| 
 | ||||
| def test_load_when_image_not_found() -> None: | ||||
|     with tempfile.NamedTemporaryFile(delete=False) as tmp: | ||||
|         pass | ||||
|     with pytest.raises(OSError) as e: | ||||
|         ImageFont.load(tmp.name) | ||||
| 
 | ||||
|     os.unlink(tmp.name) | ||||
| 
 | ||||
|     root = os.path.splitext(tmp.name)[0] | ||||
|     assert str(e.value) == f"cannot find glyph data file {root}.{{gif|pbm|png}}" | ||||
| 
 | ||||
| 
 | ||||
| def test_load_path_not_found() -> None: | ||||
|     # Arrange | ||||
|     filename = "somefilenamethatdoesntexist.ttf" | ||||
| 
 | ||||
|     # Act/Assert | ||||
|     with pytest.raises(OSError): | ||||
|     with pytest.raises(OSError) as e: | ||||
|         ImageFont.load_path(filename) | ||||
| 
 | ||||
|     # The file doesn't exist, so don't suggest `load` | ||||
|     assert filename in str(e.value) | ||||
|     assert "did you mean" not in str(e.value) | ||||
|     with pytest.raises(OSError): | ||||
|         ImageFont.truetype(filename) | ||||
| 
 | ||||
| 
 | ||||
| def test_load_path_existing_path() -> None: | ||||
|     with tempfile.NamedTemporaryFile() as tmp: | ||||
|         with pytest.raises(OSError) as e: | ||||
|             ImageFont.load_path(tmp.name) | ||||
| 
 | ||||
|     # The file exists, so the error message suggests to use `load` instead | ||||
|     assert tmp.name in str(e.value) | ||||
|     assert " did you mean" in str(e.value) | ||||
| 
 | ||||
| 
 | ||||
| def test_load_non_font_bytes() -> None: | ||||
|     with open("Tests/images/hopper.jpg", "rb") as f: | ||||
|         with pytest.raises(OSError): | ||||
|  | @ -717,14 +744,14 @@ def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None: | |||
| 
 | ||||
|     font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf", 36) | ||||
|     _check_text(font, "Tests/images/variation_adobe.png", 11) | ||||
|     for name in ["Bold", b"Bold"]: | ||||
|     for name in ("Bold", b"Bold"): | ||||
|         font.set_variation_by_name(name) | ||||
|         assert font.getname()[1] == "Bold" | ||||
|     _check_text(font, "Tests/images/variation_adobe_name.png", 16) | ||||
| 
 | ||||
|     font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf", 36) | ||||
|     _check_text(font, "Tests/images/variation_tiny.png", 40) | ||||
|     for name in ["200", b"200"]: | ||||
|     for name in ("200", b"200"): | ||||
|         font.set_variation_by_name(name) | ||||
|         assert font.getname()[1] == "200" | ||||
|     _check_text(font, "Tests/images/variation_tiny_name.png", 40) | ||||
|  | @ -1113,6 +1140,9 @@ def test_bytes(font: ImageFont.FreeTypeFont) -> None: | |||
|     ) | ||||
|     assert font.getmask2(b"test")[1] == font.getmask2("test")[1] | ||||
| 
 | ||||
|     with pytest.raises(TypeError): | ||||
|         font.getlength((0, 0))  # type: ignore[arg-type] | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize( | ||||
|     "test_file", | ||||
|  | @ -1147,3 +1177,15 @@ def test_invalid_truetype_sizes_raise_valueerror( | |||
| ) -> None: | ||||
|     with pytest.raises(ValueError): | ||||
|         ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine) | ||||
| 
 | ||||
| 
 | ||||
| def test_freetype_deprecation(monkeypatch: pytest.MonkeyPatch) -> None: | ||||
|     # Arrange: mock features.version_module to return fake FreeType version | ||||
|     def fake_version_module(module: str) -> str: | ||||
|         return "2.9.0" | ||||
| 
 | ||||
|     monkeypatch.setattr(features, "version_module", fake_version_module) | ||||
| 
 | ||||
|     # Act / Assert | ||||
|     with pytest.warns(DeprecationWarning): | ||||
|         ImageFont.truetype(FONT_PATH, FONT_SIZE) | ||||
|  |  | |||
|  | @ -46,7 +46,8 @@ def img_to_string(im: Image.Image) -> str: | |||
|         line = "" | ||||
|         for c in range(im.width): | ||||
|             value = im.getpixel((c, r)) | ||||
|             assert not isinstance(value, tuple) and value is not None | ||||
|             assert not isinstance(value, tuple) | ||||
|             assert value is not None | ||||
|             line += chars[value > 0] | ||||
|         result.append(line) | ||||
|     return "\n".join(result) | ||||
|  |  | |||
|  | @ -390,7 +390,7 @@ def test_colorize_3color_offset() -> None: | |||
| 
 | ||||
| def test_exif_transpose() -> None: | ||||
|     exts = [".jpg"] | ||||
|     if features.check("webp") and features.check("webp_anim"): | ||||
|     if features.check("webp"): | ||||
|         exts.append(".webp") | ||||
|     for ext in exts: | ||||
|         with Image.open("Tests/images/hopper" + ext) as base_im: | ||||
|  |  | |||
|  | @ -204,6 +204,17 @@ def test_overflow_segfault() -> None: | |||
|             x[i] = b"0" * 16 | ||||
| 
 | ||||
| 
 | ||||
| def test_compact_within_map() -> None: | ||||
|     p = ImagePath.Path([0, 1]) | ||||
| 
 | ||||
|     def map_func(x: float, y: float) -> tuple[float, float]: | ||||
|         p.compact() | ||||
|         return 0, 0 | ||||
| 
 | ||||
|     with pytest.raises(ValueError): | ||||
|         p.map(map_func) | ||||
| 
 | ||||
| 
 | ||||
| class Evil: | ||||
|     def __init__(self) -> None: | ||||
|         self.corrupt = Image.core.path(0x4000000000000000) | ||||
|  |  | |||
|  | @ -115,7 +115,7 @@ def test_ipythonviewer() -> None: | |||
|             test_viewer = viewer | ||||
|             break | ||||
|     else: | ||||
|         pytest.fail() | ||||
|         pytest.fail("IPythonViewer not found") | ||||
| 
 | ||||
|     im = hopper() | ||||
|     assert test_viewer.show(im) == 1 | ||||
|  |  | |||
|  | @ -60,6 +60,18 @@ class TestImageWinDib: | |||
|         with pytest.raises(ValueError): | ||||
|             ImageWin.Dib(mode) | ||||
| 
 | ||||
|     def test_dib_hwnd(self) -> None: | ||||
|         mode = "RGBA" | ||||
|         size = (128, 128) | ||||
|         wnd = 0 | ||||
| 
 | ||||
|         dib = ImageWin.Dib(mode, size) | ||||
|         hwnd = ImageWin.HWND(wnd) | ||||
| 
 | ||||
|         dib.expose(hwnd) | ||||
|         dib.draw(hwnd, (0, 0) + size) | ||||
|         assert isinstance(dib.query_palette(hwnd), int) | ||||
| 
 | ||||
|     def test_dib_paste(self) -> None: | ||||
|         # Arrange | ||||
|         im = hopper() | ||||
|  |  | |||
|  | @ -238,8 +238,10 @@ def test_zero_size() -> None: | |||
| 
 | ||||
| 
 | ||||
| @skip_unless_feature("libtiff") | ||||
| def test_load_first() -> None: | ||||
| def test_transposed() -> None: | ||||
|     with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||
|         assert im.size == (590, 88) | ||||
| 
 | ||||
|         a = numpy.array(im) | ||||
|         assert a.shape == (88, 590) | ||||
| 
 | ||||
|  |  | |||
|  | @ -46,7 +46,7 @@ def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> Non | |||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize( | ||||
|     ("test_file", "test_mode"), | ||||
|     "test_file, test_mode", | ||||
|     [ | ||||
|         ("Tests/images/hopper.jpg", None), | ||||
|         ("Tests/images/hopper.jpg", "L"), | ||||
|  |  | |||
|  | @ -5,8 +5,6 @@ import sys | |||
| from io import BytesIO | ||||
| from pathlib import Path | ||||
| 
 | ||||
| import pytest | ||||
| 
 | ||||
| from PIL import Image, PSDraw | ||||
| 
 | ||||
| 
 | ||||
|  | @ -49,17 +47,16 @@ def test_draw_postscript(tmp_path: Path) -> None: | |||
|     assert os.path.getsize(tempfile) > 0 | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize("buffer", (True, False)) | ||||
| def test_stdout(buffer: bool) -> None: | ||||
| def test_stdout() -> None: | ||||
|     # Temporarily redirect stdout | ||||
|     old_stdout = sys.stdout | ||||
| 
 | ||||
|     class MyStdOut: | ||||
|         buffer = BytesIO() | ||||
| 
 | ||||
|     mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() | ||||
|     mystdout = MyStdOut() | ||||
| 
 | ||||
|     sys.stdout = mystdout  # type: ignore[assignment] | ||||
|     sys.stdout = mystdout | ||||
| 
 | ||||
|     ps = PSDraw.PSDraw() | ||||
|     _create_document(ps) | ||||
|  | @ -67,6 +64,4 @@ def test_stdout(buffer: bool) -> None: | |||
|     # Reset stdout | ||||
|     sys.stdout = old_stdout | ||||
| 
 | ||||
|     if isinstance(mystdout, MyStdOut): | ||||
|         mystdout = mystdout.buffer | ||||
|     assert mystdout.getvalue() != b"" | ||||
|     assert mystdout.buffer.getvalue() != b"" | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| from __future__ import annotations | ||||
| 
 | ||||
| from pathlib import Path | ||||
| from typing import TYPE_CHECKING, Union | ||||
| 
 | ||||
| import pytest | ||||
| 
 | ||||
|  | @ -8,6 +9,20 @@ from PIL import Image, ImageQt | |||
| 
 | ||||
| from .helper import assert_image_equal_tofile, assert_image_similar, hopper | ||||
| 
 | ||||
| if TYPE_CHECKING: | ||||
|     import PyQt6 | ||||
|     import PySide6 | ||||
| 
 | ||||
|     QApplication = Union[PyQt6.QtWidgets.QApplication, PySide6.QtWidgets.QApplication] | ||||
|     QHBoxLayout = Union[PyQt6.QtWidgets.QHBoxLayout, PySide6.QtWidgets.QHBoxLayout] | ||||
|     QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage] | ||||
|     QLabel = Union[PyQt6.QtWidgets.QLabel, PySide6.QtWidgets.QLabel] | ||||
|     QPainter = Union[PyQt6.QtGui.QPainter, PySide6.QtGui.QPainter] | ||||
|     QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap] | ||||
|     QPoint = Union[PyQt6.QtCore.QPoint, PySide6.QtCore.QPoint] | ||||
|     QRegion = Union[PyQt6.QtGui.QRegion, PySide6.QtGui.QRegion] | ||||
|     QWidget = Union[PyQt6.QtWidgets.QWidget, PySide6.QtWidgets.QWidget] | ||||
| 
 | ||||
| if ImageQt.qt_is_installed: | ||||
|     from PIL.ImageQt import QPixmap | ||||
| 
 | ||||
|  | @ -20,7 +35,7 @@ if ImageQt.qt_is_installed: | |||
|         from PySide6.QtGui import QImage, QPainter, QRegion | ||||
|         from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget | ||||
| 
 | ||||
|     class Example(QWidget): | ||||
|     class Example(QWidget):  # type: ignore[misc] | ||||
|         def __init__(self) -> None: | ||||
|             super().__init__() | ||||
| 
 | ||||
|  | @ -28,11 +43,12 @@ if ImageQt.qt_is_installed: | |||
| 
 | ||||
|             qimage = ImageQt.ImageQt(img) | ||||
| 
 | ||||
|             pixmap1 = ImageQt.QPixmap.fromImage(qimage) | ||||
|             pixmap1 = getattr(ImageQt.QPixmap, "fromImage")(qimage) | ||||
| 
 | ||||
|             QHBoxLayout(self)  # hbox | ||||
|             # hbox | ||||
|             QHBoxLayout(self)  # type: ignore[operator] | ||||
| 
 | ||||
|             lbl = QLabel(self) | ||||
|             lbl = QLabel(self)  # type: ignore[operator] | ||||
|             # Segfault in the problem | ||||
|             lbl.setPixmap(pixmap1.copy()) | ||||
| 
 | ||||
|  | @ -46,7 +62,7 @@ def roundtrip(expected: Image.Image) -> None: | |||
| @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") | ||||
| def test_sanity(tmp_path: Path) -> None: | ||||
|     # Segfault test | ||||
|     app: QApplication | None = QApplication([]) | ||||
|     app: QApplication | None = QApplication([])  # type: ignore[operator] | ||||
|     ex = Example() | ||||
|     assert app  # Silence warning | ||||
|     assert ex  # Silence warning | ||||
|  | @ -56,7 +72,7 @@ def test_sanity(tmp_path: Path) -> None: | |||
|         im = hopper(mode) | ||||
|         data = ImageQt.toqpixmap(im) | ||||
| 
 | ||||
|         assert isinstance(data, QPixmap) | ||||
|         assert data.__class__.__name__ == "QPixmap" | ||||
|         assert not data.isNull() | ||||
| 
 | ||||
|         # Test saving the file | ||||
|  | @ -64,14 +80,14 @@ def test_sanity(tmp_path: Path) -> None: | |||
|         data.save(tempfile) | ||||
| 
 | ||||
|         # Render the image | ||||
|         qimage = ImageQt.ImageQt(im) | ||||
|         data = QPixmap.fromImage(qimage) | ||||
|         qt_format = QImage.Format if ImageQt.qt_version == "6" else QImage | ||||
|         qimage = QImage(128, 128, qt_format.Format_ARGB32) | ||||
|         painter = QPainter(qimage) | ||||
|         image_label = QLabel() | ||||
|         imageqt = ImageQt.ImageQt(im) | ||||
|         data = getattr(QPixmap, "fromImage")(imageqt) | ||||
|         qt_format = getattr(QImage, "Format") if ImageQt.qt_version == "6" else QImage | ||||
|         qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32"))  # type: ignore[operator] | ||||
|         painter = QPainter(qimage)  # type: ignore[operator] | ||||
|         image_label = QLabel()  # type: ignore[operator] | ||||
|         image_label.setPixmap(data) | ||||
|         image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) | ||||
|         image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128))  # type: ignore[operator] | ||||
|         painter.end() | ||||
|         rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png") | ||||
|         qimage.save(rendered_tempfile) | ||||
|  |  | |||
|  | @ -21,7 +21,7 @@ def test_sanity(mode: str, tmp_path: Path) -> None: | |||
|     src = hopper(mode) | ||||
|     data = ImageQt.toqimage(src) | ||||
| 
 | ||||
|     assert isinstance(data, QImage) | ||||
|     assert isinstance(data, QImage)  # type: ignore[arg-type, misc] | ||||
|     assert not data.isNull() | ||||
| 
 | ||||
|     # reload directly from the qimage | ||||
|  |  | |||
|  | @ -54,8 +54,8 @@ def test_nonetype() -> None: | |||
|     assert xres.denominator is not None | ||||
|     assert yres._val is not None | ||||
| 
 | ||||
|     assert xres and 1 | ||||
|     assert xres and yres | ||||
|     assert xres | ||||
|     assert yres | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize( | ||||
|  |  | |||
|  | @ -30,28 +30,6 @@ def test_is_not_path(tmp_path: Path) -> None: | |||
|     assert not it_is_not | ||||
| 
 | ||||
| 
 | ||||
| def test_is_directory() -> None: | ||||
|     # Arrange | ||||
|     directory = "Tests" | ||||
| 
 | ||||
|     # Act | ||||
|     it_is = _util.is_directory(directory) | ||||
| 
 | ||||
|     # Assert | ||||
|     assert it_is | ||||
| 
 | ||||
| 
 | ||||
| def test_is_not_directory() -> None: | ||||
|     # Arrange | ||||
|     text = "abc" | ||||
| 
 | ||||
|     # Act | ||||
|     it_is_not = _util.is_directory(text) | ||||
| 
 | ||||
|     # Assert | ||||
|     assert not it_is_not | ||||
| 
 | ||||
| 
 | ||||
| def test_deferred_error() -> None: | ||||
|     # Arrange | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
| # install libimagequant | ||||
| 
 | ||||
| archive_name=libimagequant | ||||
| archive_version=4.3.1 | ||||
| archive_version=4.3.3 | ||||
| 
 | ||||
| archive=$archive_name-$archive_version | ||||
| 
 | ||||
|  | @ -23,14 +23,14 @@ else | |||
|     cargo cinstall --prefix=/usr --destdir=. | ||||
| 
 | ||||
|     # Copy into place | ||||
|     sudo cp usr/lib/libimagequant.so* /usr/lib/ | ||||
|     sudo find usr -name libimagequant.so* -exec cp {} /usr/lib/ \; | ||||
|     sudo cp usr/include/libimagequant.h /usr/include/ | ||||
| 
 | ||||
|     if [ -n "$GITHUB_ACTIONS" ]; then | ||||
|         # Copy to cache | ||||
|         rm -rf ~/cache-$archive_name | ||||
|         mkdir ~/cache-$archive_name | ||||
|         cp usr/lib/libimagequant.so* ~/cache-$archive_name/ | ||||
|         find usr -name libimagequant.so* -exec cp {} ~/cache-$archive_name/ \; | ||||
|         cp usr/include/libimagequant.h ~/cache-$archive_name/ | ||||
|     fi | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
| # install raqm | ||||
| 
 | ||||
| 
 | ||||
| archive=libraqm-0.10.1 | ||||
| archive=libraqm-0.10.2 | ||||
| 
 | ||||
| ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz | ||||
| 
 | ||||
|  |  | |||
|  | @ -121,7 +121,7 @@ nitpicky = True | |||
| # generating warnings in “nitpicky mode”. Note that type should include the domain name | ||||
| # if present. Example entries would be ('py:func', 'int') or | ||||
| # ('envvar', 'LD_LIBRARY_PATH'). | ||||
| # nitpick_ignore = [] | ||||
| nitpick_ignore = [("py:class", "_io.BytesIO")] | ||||
| 
 | ||||
| 
 | ||||
| # -- Options for HTML output ---------------------------------------------- | ||||
|  |  | |||
|  | @ -109,6 +109,35 @@ ImageDraw.getdraw hints parameter | |||
| 
 | ||||
| The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. | ||||
| 
 | ||||
| FreeType 2.9.0 | ||||
| ^^^^^^^^^^^^^^ | ||||
| 
 | ||||
| .. deprecated:: 11.0.0 | ||||
| 
 | ||||
| Support for FreeType 2.9.0 is deprecated and will be removed in Pillow 12.0.0 | ||||
| (2025-10-15), when FreeType 2.9.1 will be the minimum supported. | ||||
| 
 | ||||
| We recommend upgrading to at least FreeType `2.10.4`_, which fixed a severe | ||||
| vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`). | ||||
| 
 | ||||
| .. _2.10.4: https://sourceforge.net/projects/freetype/files/freetype2/2.10.4/ | ||||
| 
 | ||||
| ICNS (width, height, scale) sizes | ||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
| 
 | ||||
| .. deprecated:: 11.0.0 | ||||
| 
 | ||||
| Setting an ICNS image size to ``(width, height, scale)`` before loading has been | ||||
| deprecated. Instead, ``load(scale)`` can be used. | ||||
| 
 | ||||
| Image isImageType() | ||||
| ^^^^^^^^^^^^^^^^^^^ | ||||
| 
 | ||||
| .. deprecated:: 11.0.0 | ||||
| 
 | ||||
| ``Image.isImageType(im)`` has been deprecated. Use ``isinstance(im, Image.Image)`` | ||||
| instead. | ||||
| 
 | ||||
| ImageMath.lambda_eval and ImageMath.unsafe_eval options parameter | ||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
| 
 | ||||
|  | @ -118,12 +147,37 @@ The ``options`` parameter in :py:meth:`~PIL.ImageMath.lambda_eval()` and | |||
| :py:meth:`~PIL.ImageMath.unsafe_eval()` has been deprecated. One or more keyword | ||||
| arguments can be used instead. | ||||
| 
 | ||||
| JpegImageFile.huffman_ac and JpegImageFile.huffman_dc | ||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
| 
 | ||||
| .. deprecated:: 11.0.0 | ||||
| 
 | ||||
| The ``huffman_ac`` and ``huffman_dc`` dictionaries on JPEG images were unused. They | ||||
| have been deprecated, and will be removed in Pillow 12 (2025-10-15). | ||||
| 
 | ||||
| Specific WebP Feature Checks | ||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
| 
 | ||||
| .. deprecated:: 11.0.0 | ||||
| 
 | ||||
| ``features.check("transp_webp")``, ``features.check("webp_mux")`` and | ||||
| ``features.check("webp_anim")`` are now deprecated. They will always return | ||||
| ``True`` if the WebP module is installed, until they are removed in Pillow | ||||
| 12.0.0 (2025-10-15). | ||||
| 
 | ||||
| Removed features | ||||
| ---------------- | ||||
| 
 | ||||
| Deprecated features are only removed in major releases after an appropriate | ||||
| period of deprecation has passed. | ||||
| 
 | ||||
| TiffImagePlugin IFD_LEGACY_API | ||||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||
| 
 | ||||
| .. versionremoved:: 11.0.0 | ||||
| 
 | ||||
| ``TiffImagePlugin.IFD_LEGACY_API`` was removed, as it was an unused setting. | ||||
| 
 | ||||
| PSFile | ||||
| ~~~~~~ | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,6 +14,7 @@ from __future__ import annotations | |||
| 
 | ||||
| import struct | ||||
| from io import BytesIO | ||||
| from typing import IO | ||||
| 
 | ||||
| from PIL import Image, ImageFile | ||||
| 
 | ||||
|  | @ -94,26 +95,26 @@ DXT3_FOURCC = 0x33545844 | |||
| DXT5_FOURCC = 0x35545844 | ||||
| 
 | ||||
| 
 | ||||
| def _decode565(bits): | ||||
| def _decode565(bits: int) -> tuple[int, int, int]: | ||||
|     a = ((bits >> 11) & 0x1F) << 3 | ||||
|     b = ((bits >> 5) & 0x3F) << 2 | ||||
|     c = (bits & 0x1F) << 3 | ||||
|     return a, b, c | ||||
| 
 | ||||
| 
 | ||||
| def _c2a(a, b): | ||||
| def _c2a(a: int, b: int) -> int: | ||||
|     return (2 * a + b) // 3 | ||||
| 
 | ||||
| 
 | ||||
| def _c2b(a, b): | ||||
| def _c2b(a: int, b: int) -> int: | ||||
|     return (a + b) // 2 | ||||
| 
 | ||||
| 
 | ||||
| def _c3(a, b): | ||||
| def _c3(a: int, b: int) -> int: | ||||
|     return (2 * b + a) // 3 | ||||
| 
 | ||||
| 
 | ||||
| def _dxt1(data, width, height): | ||||
| def _dxt1(data: IO[bytes], width: int, height: int) -> bytes: | ||||
|     # TODO implement this function as pixel format in decode.c | ||||
|     ret = bytearray(4 * width * height) | ||||
| 
 | ||||
|  | @ -151,7 +152,7 @@ def _dxt1(data, width, height): | |||
|     return bytes(ret) | ||||
| 
 | ||||
| 
 | ||||
| def _dxtc_alpha(a0, a1, ac0, ac1, ai): | ||||
| def _dxtc_alpha(a0: int, a1: int, ac0: int, ac1: int, ai: int) -> int: | ||||
|     if ai <= 12: | ||||
|         ac = (ac0 >> ai) & 7 | ||||
|     elif ai == 15: | ||||
|  | @ -175,7 +176,7 @@ def _dxtc_alpha(a0, a1, ac0, ac1, ai): | |||
|     return alpha | ||||
| 
 | ||||
| 
 | ||||
| def _dxt5(data, width, height): | ||||
| def _dxt5(data: IO[bytes], width: int, height: int) -> bytes: | ||||
|     # TODO implement this function as pixel format in decode.c | ||||
|     ret = bytearray(4 * width * height) | ||||
| 
 | ||||
|  | @ -211,7 +212,7 @@ class DdsImageFile(ImageFile.ImageFile): | |||
|     format = "DDS" | ||||
|     format_description = "DirectDraw Surface" | ||||
| 
 | ||||
|     def _open(self): | ||||
|     def _open(self) -> None: | ||||
|         if not _accept(self.fp.read(4)): | ||||
|             msg = "not a DDS file" | ||||
|             raise SyntaxError(msg) | ||||
|  | @ -242,19 +243,22 @@ class DdsImageFile(ImageFile.ImageFile): | |||
|         elif fourcc == b"DXT5": | ||||
|             self.decoder = "DXT5" | ||||
|         else: | ||||
|             msg = f"Unimplemented pixel format {fourcc}" | ||||
|             msg = f"Unimplemented pixel format {repr(fourcc)}" | ||||
|             raise NotImplementedError(msg) | ||||
| 
 | ||||
|         self.tile = [(self.decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))] | ||||
|         self.tile = [ | ||||
|             ImageFile._Tile(self.decoder, (0, 0) + self.size, 0, (self.mode, 0, 1)) | ||||
|         ] | ||||
| 
 | ||||
|     def load_seek(self, pos): | ||||
|     def load_seek(self, pos: int) -> None: | ||||
|         pass | ||||
| 
 | ||||
| 
 | ||||
| class DXT1Decoder(ImageFile.PyDecoder): | ||||
|     _pulls_fd = True | ||||
| 
 | ||||
|     def decode(self, buffer): | ||||
|     def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: | ||||
|         assert self.fd is not None | ||||
|         try: | ||||
|             self.set_as_raw(_dxt1(self.fd, self.state.xsize, self.state.ysize)) | ||||
|         except struct.error as e: | ||||
|  | @ -266,7 +270,8 @@ class DXT1Decoder(ImageFile.PyDecoder): | |||
| class DXT5Decoder(ImageFile.PyDecoder): | ||||
|     _pulls_fd = True | ||||
| 
 | ||||
|     def decode(self, buffer): | ||||
|     def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: | ||||
|         assert self.fd is not None | ||||
|         try: | ||||
|             self.set_as_raw(_dxt5(self.fd, self.state.xsize, self.state.ysize)) | ||||
|         except struct.error as e: | ||||
|  | @ -279,7 +284,7 @@ Image.register_decoder("DXT1", DXT1Decoder) | |||
| Image.register_decoder("DXT5", DXT5Decoder) | ||||
| 
 | ||||
| 
 | ||||
| def _accept(prefix): | ||||
| def _accept(prefix: bytes) -> bool: | ||||
|     return prefix[:4] == b"DDS " | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/animated_hopper.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 57 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/contrasted_hopper.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/cropped_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/enhanced_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/flip_left_right_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/flip_top_bottom_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/hopper_ps.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 8.1 KiB | 
|  | @ -324,12 +324,19 @@ sets the following :py:attr:`~PIL.Image.Image.info` property: | |||
| **sizes** | ||||
|     A list of supported sizes found in this icon file; these are a | ||||
|     3-tuple, ``(width, height, scale)``, where ``scale`` is 2 for a retina | ||||
|     icon and 1 for a standard icon.  You *are* permitted to use this 3-tuple | ||||
|     format for the :py:attr:`~PIL.Image.Image.size` property if you set it | ||||
|     before calling :py:meth:`~PIL.Image.Image.load`; after loading, the size | ||||
|     will be reset to a 2-tuple containing pixel dimensions (so, e.g. if you | ||||
|     ask for ``(512, 512, 2)``, the final value of | ||||
|     :py:attr:`~PIL.Image.Image.size` will be ``(1024, 1024)``). | ||||
|     icon and 1 for a standard icon. | ||||
| 
 | ||||
| .. _icns-loading: | ||||
| 
 | ||||
| Loading | ||||
| ~~~~~~~ | ||||
| 
 | ||||
| You can call the :py:meth:`~PIL.Image.Image.load` method with the following parameter. | ||||
| 
 | ||||
| **scale** | ||||
|     Affects the scale of the resultant image. If the size is set to ``(512, 512)``, | ||||
|     after loading at scale 2, the final value of :py:attr:`~PIL.Image.Image.size` will | ||||
|     be ``(1024, 1024)``. | ||||
| 
 | ||||
| .. _icns-saving: | ||||
| 
 | ||||
|  | @ -1220,8 +1227,7 @@ using the general tags available through tiffinfo. | |||
| WebP | ||||
| ^^^^ | ||||
| 
 | ||||
| Pillow reads and writes WebP files. The specifics of Pillow's capabilities with | ||||
| this format are currently undocumented. | ||||
| Pillow reads and writes WebP files. Requires libwebp v0.5.0 or later. | ||||
| 
 | ||||
| .. _webp-saving: | ||||
| 
 | ||||
|  | @ -1249,29 +1255,19 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: | |||
| **exact** | ||||
|     If true, preserve the transparent RGB values. Otherwise, discard | ||||
|     invisible RGB values for better compression. Defaults to false. | ||||
|     Requires libwebp 0.5.0 or later. | ||||
| 
 | ||||
| **icc_profile** | ||||
|     The ICC Profile to include in the saved file. Only supported if | ||||
|     the system WebP library was built with webpmux support. | ||||
|     The ICC Profile to include in the saved file. | ||||
| 
 | ||||
| **exif** | ||||
|     The exif data to include in the saved file. Only supported if | ||||
|     the system WebP library was built with webpmux support. | ||||
|     The exif data to include in the saved file. | ||||
| 
 | ||||
| **xmp** | ||||
|     The XMP data to include in the saved file. Only supported if | ||||
|     the system WebP library was built with webpmux support. | ||||
|     The XMP data to include in the saved file. | ||||
| 
 | ||||
| Saving sequences | ||||
| ~~~~~~~~~~~~~~~~ | ||||
| 
 | ||||
| .. note:: | ||||
| 
 | ||||
|     Support for animated WebP files will only be enabled if the system WebP | ||||
|     library is v0.5.0 or later. You can check webp animation support at | ||||
|     runtime by calling ``features.check("webp_anim")``. | ||||
| 
 | ||||
| When calling :py:meth:`~PIL.Image.Image.save` to write a WebP file, by default | ||||
| only the first frame of a multiframe image will be saved. If the ``save_all`` | ||||
| argument is present and true, then all frames will be saved, and the following | ||||
|  | @ -1528,19 +1524,21 @@ To add other read or write support, use | |||
| :py:func:`PIL.WmfImagePlugin.register_handler` to register a WMF and EMF | ||||
| handler. :: | ||||
| 
 | ||||
|     from PIL import Image | ||||
|     from typing import IO | ||||
| 
 | ||||
|     from PIL import Image, ImageFile | ||||
|     from PIL import WmfImagePlugin | ||||
| 
 | ||||
| 
 | ||||
|     class WmfHandler: | ||||
|         def open(self, im): | ||||
|     class WmfHandler(ImageFile.StubHandler): | ||||
|         def open(self, im: ImageFile.StubImageFile) -> None: | ||||
|             ... | ||||
| 
 | ||||
|         def load(self, im): | ||||
|         def load(self, im: ImageFile.StubImageFile) -> Image.Image: | ||||
|             ... | ||||
|             return image | ||||
| 
 | ||||
|         def save(self, im, fp, filename): | ||||
|         def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: | ||||
|             ... | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/masked_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/merged_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/pasted_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/rebanded_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/rolled_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/rotated_hopper_180.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/rotated_hopper_270.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/rotated_hopper_90.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/show_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/thumbnail_hopper.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								docs/handbook/transformed_hopper.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.0 KiB | 
|  | @ -37,6 +37,9 @@ example, let’s display the image we just loaded:: | |||
| 
 | ||||
|     >>> im.show() | ||||
| 
 | ||||
| .. image:: show_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| .. note:: | ||||
| 
 | ||||
|     The standard version of :py:meth:`~PIL.Image.Image.show` is not very | ||||
|  | @ -79,6 +82,9 @@ Convert files to JPEG | |||
|             except OSError: | ||||
|                 print("cannot convert", infile) | ||||
| 
 | ||||
| .. image:: ../../Tests/images/hopper.jpg | ||||
|     :align: center | ||||
| 
 | ||||
| A second argument can be supplied to the :py:meth:`~PIL.Image.Image.save` | ||||
| method which explicitly specifies a file format. If you use a non-standard | ||||
| extension, you must always specify the format this way: | ||||
|  | @ -103,6 +109,9 @@ Create JPEG thumbnails | |||
|             except OSError: | ||||
|                 print("cannot create thumbnail for", infile) | ||||
| 
 | ||||
| .. image:: thumbnail_hopper.jpg | ||||
|     :align: center | ||||
| 
 | ||||
| It is important to note that the library doesn’t decode or load the raster data | ||||
| unless it really has to. When you open a file, the file header is read to | ||||
| determine the file format and extract things like mode, size, and other | ||||
|  | @ -140,16 +149,19 @@ Copying a subrectangle from an image | |||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     box = (100, 100, 400, 400) | ||||
|     box = (0, 0, 64, 64) | ||||
|     region = im.crop(box) | ||||
| 
 | ||||
| The region is defined by a 4-tuple, where coordinates are (left, upper, right, | ||||
| lower). The Python Imaging Library uses a coordinate system with (0, 0) in the | ||||
| upper left corner. Also note that coordinates refer to positions between the | ||||
| pixels, so the region in the above example is exactly 300x300 pixels. | ||||
| pixels, so the region in the above example is exactly 64x64 pixels. | ||||
| 
 | ||||
| The region could now be processed in a certain manner and pasted back. | ||||
| 
 | ||||
| .. image:: cropped_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| Processing a subrectangle, and pasting it back | ||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
| 
 | ||||
|  | @ -164,6 +176,9 @@ modes of the original image and the region do not need to match. If they don’t | |||
| the region is automatically converted before being pasted (see the section on | ||||
| :ref:`color-transforms` below for details). | ||||
| 
 | ||||
| .. image:: pasted_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| Here’s an additional example: | ||||
| 
 | ||||
| Rolling an image | ||||
|  | @ -171,7 +186,7 @@ Rolling an image | |||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     def roll(im, delta): | ||||
|     def roll(im: Image.Image, delta: int) -> Image.Image: | ||||
|         """Roll an image sideways.""" | ||||
|         xsize, ysize = im.size | ||||
| 
 | ||||
|  | @ -186,6 +201,9 @@ Rolling an image | |||
| 
 | ||||
|         return im | ||||
| 
 | ||||
| .. image:: rolled_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| Or if you would like to merge two images into a wider image: | ||||
| 
 | ||||
| Merging images | ||||
|  | @ -193,7 +211,7 @@ Merging images | |||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     def merge(im1, im2): | ||||
|     def merge(im1: Image.Image, im2: Image.Image) -> Image.Image: | ||||
|         w = im1.size[0] + im2.size[0] | ||||
|         h = max(im1.size[1], im2.size[1]) | ||||
|         im = Image.new("RGBA", (w, h)) | ||||
|  | @ -203,6 +221,9 @@ Merging images | |||
| 
 | ||||
|         return im | ||||
| 
 | ||||
| .. image:: merged_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| For more advanced tricks, the paste method can also take a transparency mask as | ||||
| an optional argument. In this mask, the value 255 indicates that the pasted | ||||
| image is opaque in that position (that is, the pasted image should be used as | ||||
|  | @ -229,6 +250,9 @@ Note that for a single-band image, :py:meth:`~PIL.Image.Image.split` returns | |||
| the image itself. To work with individual color bands, you may want to convert | ||||
| the image to “RGB” first. | ||||
| 
 | ||||
| .. image:: rebanded_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| Geometrical transforms | ||||
| ---------------------- | ||||
| 
 | ||||
|  | @ -245,6 +269,9 @@ Simple geometry transforms | |||
|     out = im.resize((128, 128)) | ||||
|     out = im.rotate(45) # degrees counter-clockwise | ||||
| 
 | ||||
| .. image:: rotated_hopper_90.webp | ||||
|     :align: center | ||||
| 
 | ||||
| To rotate the image in 90 degree steps, you can either use the | ||||
| :py:meth:`~PIL.Image.Image.rotate` method or the | ||||
| :py:meth:`~PIL.Image.Image.transpose` method. The latter can also be used to | ||||
|  | @ -256,11 +283,38 @@ Transposing an image | |||
| :: | ||||
| 
 | ||||
|     out = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) | ||||
| 
 | ||||
| .. image:: flip_left_right_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     out = im.transpose(Image.Transpose.FLIP_TOP_BOTTOM) | ||||
| 
 | ||||
| .. image:: flip_top_bottom_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     out = im.transpose(Image.Transpose.ROTATE_90) | ||||
| 
 | ||||
| .. image:: rotated_hopper_90.webp | ||||
|     :align: center | ||||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     out = im.transpose(Image.Transpose.ROTATE_180) | ||||
| 
 | ||||
| .. image:: rotated_hopper_180.webp | ||||
|     :align: center | ||||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     out = im.transpose(Image.Transpose.ROTATE_270) | ||||
| 
 | ||||
| .. image:: rotated_hopper_270.webp | ||||
|     :align: center | ||||
| 
 | ||||
| ``transpose(ROTATE)`` operations can also be performed identically with | ||||
| :py:meth:`~PIL.Image.Image.rotate` operations, provided the ``expand`` flag is | ||||
| true, to provide for the same changes to the image's size. | ||||
|  | @ -278,7 +332,7 @@ choose to resize relative to a given size. | |||
| 
 | ||||
|     from PIL import Image, ImageOps | ||||
|     size = (100, 150) | ||||
|     with Image.open("Tests/images/hopper.webp") as im: | ||||
|     with Image.open("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") | ||||
|  | @ -342,6 +396,9 @@ Applying filters | |||
|     from PIL import ImageFilter | ||||
|     out = im.filter(ImageFilter.DETAIL) | ||||
| 
 | ||||
| .. image:: enhanced_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| Point Operations | ||||
| ^^^^^^^^^^^^^^^^ | ||||
| 
 | ||||
|  | @ -355,8 +412,11 @@ Applying point transforms | |||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     # multiply each pixel by 1.2 | ||||
|     out = im.point(lambda i: i * 1.2) | ||||
|     # multiply each pixel by 20 | ||||
|     out = im.point(lambda i: i * 20) | ||||
| 
 | ||||
| .. image:: transformed_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| Using the above technique, you can quickly apply any simple expression to an | ||||
| image. You can also combine the :py:meth:`~PIL.Image.Image.point` and | ||||
|  | @ -388,6 +448,9 @@ Note the syntax used to create the mask:: | |||
| 
 | ||||
|     imout = im.point(lambda i: expression and 255) | ||||
| 
 | ||||
| .. image:: masked_hopper.webp | ||||
|     :align: center | ||||
| 
 | ||||
| Python only evaluates the portion of a logical expression as is necessary to | ||||
| determine the outcome, and returns the last value examined as the result of the | ||||
| expression. So if the expression above is false (0), Python does not look at | ||||
|  | @ -412,6 +475,10 @@ Enhancing images | |||
|     enh = ImageEnhance.Contrast(im) | ||||
|     enh.enhance(1.3).show("30% more contrast") | ||||
| 
 | ||||
| 
 | ||||
| .. image:: contrasted_hopper.jpg | ||||
|     :align: center | ||||
| 
 | ||||
| Image sequences | ||||
| --------------- | ||||
| 
 | ||||
|  | @ -444,10 +511,43 @@ Reading sequences | |||
| As seen in this example, you’ll get an :py:exc:`EOFError` exception when the | ||||
| sequence ends. | ||||
| 
 | ||||
| Writing sequences | ||||
| ^^^^^^^^^^^^^^^^^ | ||||
| 
 | ||||
| You can create animated GIFs with Pillow, e.g. | ||||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     from PIL import Image | ||||
| 
 | ||||
|     # List of image filenames | ||||
|     image_filenames = [ | ||||
|         "hopper.jpg", | ||||
|         "rotated_hopper_270.jpg", | ||||
|         "rotated_hopper_180.jpg", | ||||
|         "rotated_hopper_90.jpg", | ||||
|     ] | ||||
| 
 | ||||
|     # Open images and create a list | ||||
|     images = [Image.open(filename) for filename in image_filenames] | ||||
| 
 | ||||
|     # Save the images as an animated GIF | ||||
|     images[0].save( | ||||
|         "animated_hopper.gif", | ||||
|         save_all=True, | ||||
|         append_images=images[1:], | ||||
|         duration=500,  # duration of each frame in milliseconds | ||||
|         loop=0,  # loop forever | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| .. image:: animated_hopper.gif | ||||
|     :align: center | ||||
| 
 | ||||
| The following class lets you use the for-statement to loop over the sequence: | ||||
| 
 | ||||
| Using the ImageSequence Iterator class | ||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
| Using the :py:class:`~PIL.ImageSequence.Iterator` class | ||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
| 
 | ||||
| :: | ||||
| 
 | ||||
|  | @ -467,25 +567,61 @@ Drawing PostScript | |||
| 
 | ||||
| :: | ||||
| 
 | ||||
|     from PIL import Image | ||||
|     from PIL import PSDraw | ||||
|     from PIL import Image, PSDraw | ||||
|     import os | ||||
| 
 | ||||
|     with Image.open("hopper.ppm") as im: | ||||
|         title = "hopper" | ||||
|         box = (1 * 72, 2 * 72, 7 * 72, 10 * 72)  # in points | ||||
|     # Define the PostScript file | ||||
|     ps_file = open("hopper.ps", "wb") | ||||
| 
 | ||||
|         ps = PSDraw.PSDraw()  # default is sys.stdout or sys.stdout.buffer | ||||
|         ps.begin_document(title) | ||||
|     # Create a PSDraw object | ||||
|     ps = PSDraw.PSDraw(ps_file) | ||||
| 
 | ||||
|         # draw the image (75 dpi) | ||||
|         ps.image(box, im, 75) | ||||
|         ps.rectangle(box) | ||||
|     # Start the document | ||||
|     ps.begin_document() | ||||
| 
 | ||||
|         # draw title | ||||
|         ps.setfont("HelveticaNarrow-Bold", 36) | ||||
|         ps.text((3 * 72, 4 * 72), title) | ||||
|     # Set the text to be drawn | ||||
|     text = "Hopper" | ||||
| 
 | ||||
|     # Define the PostScript font | ||||
|     font_name = "Helvetica-Narrow-Bold" | ||||
|     font_size = 36 | ||||
| 
 | ||||
|     # Calculate text size (approximation as PSDraw doesn't provide direct method) | ||||
|     # Assuming average character width as 0.6 of the font size | ||||
|     text_width = len(text) * font_size * 0.6 | ||||
|     text_height = font_size | ||||
| 
 | ||||
|     # Set the position (top-center) | ||||
|     page_width, page_height = 595, 842  # A4 size in points | ||||
|     text_x = (page_width - text_width) // 2 | ||||
|     text_y = page_height - text_height - 50  # Distance from the top of the page | ||||
| 
 | ||||
|     # Load the image | ||||
|     image_path = "hopper.ppm"  # Update this with your image path | ||||
|     with Image.open(image_path) as im: | ||||
|         # Resize the image if it's too large | ||||
|         im.thumbnail((page_width - 100, page_height // 2)) | ||||
| 
 | ||||
|         # Define the box where the image will be placed | ||||
|         img_x = (page_width - im.width) // 2 | ||||
|         img_y = text_y + text_height - 200  # 200 points below the text | ||||
| 
 | ||||
|         # Draw the image (75 dpi) | ||||
|         ps.image((img_x, img_y, img_x + im.width, img_y + im.height), im, 75) | ||||
| 
 | ||||
|     # Draw the text | ||||
|     ps.setfont(font_name, font_size) | ||||
|     ps.text((text_x, text_y), text) | ||||
| 
 | ||||
|     # End the document | ||||
|     ps.end_document() | ||||
|     ps_file.close() | ||||
| 
 | ||||
| .. image:: hopper_ps.webp | ||||
| 
 | ||||
| .. note:: | ||||
| 
 | ||||
|     PostScript converted to PDF for display purposes | ||||
| 
 | ||||
| More on reading images | ||||
| ---------------------- | ||||
|  | @ -553,7 +689,7 @@ Reading from a tar archive | |||
| 
 | ||||
|     from PIL import Image, TarIO | ||||
| 
 | ||||
|     fp = TarIO.TarIO("Tests/images/hopper.tar", "hopper.jpg") | ||||
|     fp = TarIO.TarIO("hopper.tar", "hopper.jpg") | ||||
|     im = Image.open(fp) | ||||
| 
 | ||||
| 
 | ||||
|  | @ -568,8 +704,7 @@ in the current directory can be saved as JPEGs at reduced quality. | |||
|     import glob | ||||
|     from PIL import Image | ||||
| 
 | ||||
| 
 | ||||
|     def compress_image(source_path, dest_path): | ||||
|     def compress_image(source_path: str, dest_path: str) -> None: | ||||
|         with Image.open(source_path) as img: | ||||
|             if img.mode != "RGB": | ||||
|                 img = img.convert("RGB") | ||||
|  |  | |||