Merge branch 'main' into main
|  | @ -34,8 +34,8 @@ install: | ||||||
| - xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images | - 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 | - 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:\ | - 7z x nasm-win64.zip -oc:\ | ||||||
| - choco install ghostscript --version=10.3.1 | - choco install ghostscript --version=10.4.0 | ||||||
| - path c:\nasm-2.16.03;C:\Program Files\gs\gs10.03.1\bin;%PATH% | - path c:\nasm-2.16.03;C:\Program Files\gs\gs10.04.0\bin;%PATH% | ||||||
| - cd c:\pillow\winbuild\ | - cd c:\pillow\winbuild\ | ||||||
| - ps: | | - ps: | | ||||||
|         c:\python39\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ |         c:\python39\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ | ||||||
|  | @ -51,11 +51,10 @@ build_script: | ||||||
| 
 | 
 | ||||||
| test_script: | test_script: | ||||||
| - cd c:\pillow | - 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% | - c:\"Program Files (x86)"\"Windows Kits"\10\Debuggers\x86\gflags.exe /p /enable %PYTHON%\%EXECUTABLE% | ||||||
| - '%PYTHON%\%EXECUTABLE% -c "from PIL import Image"' | - path %PYTHON%;%PATH% | ||||||
| - '%PYTHON%\%EXECUTABLE% -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests' | - .ci\test.cmd | ||||||
| #- '%PYTHON%\%EXECUTABLE% test-installed.py -v -s %TEST_OPTIONS%' TODO TEST_OPTIONS with pytest? |  | ||||||
| 
 | 
 | ||||||
| after_test: | after_test: | ||||||
| - curl -Os https://uploader.codecov.io/latest/windows/codecov.exe | - curl -Os https://uploader.codecov.io/latest/windows/codecov.exe | ||||||
|  |  | ||||||
|  | @ -21,7 +21,7 @@ set -e | ||||||
| 
 | 
 | ||||||
| if [[ $(uname) != CYGWIN* ]]; then | if [[ $(uname) != CYGWIN* ]]; then | ||||||
|     sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ |     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\ |                              cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ | ||||||
|                              sway wl-clipboard libopenblas-dev |                              sway wl-clipboard libopenblas-dev | ||||||
| fi | fi | ||||||
|  | @ -30,6 +30,7 @@ python3 -m pip install --upgrade pip | ||||||
| python3 -m pip install --upgrade wheel | python3 -m pip install --upgrade wheel | ||||||
| python3 -m pip install coverage | python3 -m pip install coverage | ||||||
| python3 -m pip install defusedxml | python3 -m pip install defusedxml | ||||||
|  | python3 -m pip install ipython | ||||||
| python3 -m pip install olefile | python3 -m pip install olefile | ||||||
| python3 -m pip install -U pytest | python3 -m pip install -U pytest | ||||||
| python3 -m pip install -U pytest-cov | python3 -m pip install -U pytest-cov | ||||||
|  | @ -37,12 +38,7 @@ python3 -m pip install -U pytest-timeout | ||||||
| python3 -m pip install pyroma | python3 -m pip install pyroma | ||||||
| 
 | 
 | ||||||
| if [[ $(uname) != CYGWIN* ]]; then | 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 |     python3 -m pip install numpy | ||||||
|     fi |  | ||||||
| 
 | 
 | ||||||
|     # PyQt6 doesn't support PyPy3 |     # PyQt6 doesn't support PyPy3 | ||||||
|     if [[ $GHA_PYTHON_VERSION == 3.* ]]; then |     if [[ $GHA_PYTHON_VERSION == 3.* ]]; then | ||||||
|  | @ -52,10 +48,7 @@ if [[ $(uname) != CYGWIN* ]]; then | ||||||
|     fi |     fi | ||||||
| 
 | 
 | ||||||
|     # Pyroma uses non-isolated build and fails with old setuptools |     # Pyroma uses non-isolated build and fails with old setuptools | ||||||
|     if [[ |     if [[ $GHA_PYTHON_VERSION == 3.9 ]]; then | ||||||
|         $GHA_PYTHON_VERSION == pypy3.9 |  | ||||||
|         || $GHA_PYTHON_VERSION == 3.9 |  | ||||||
|     ]]; then |  | ||||||
|         # To match pyproject.toml |         # To match pyproject.toml | ||||||
|         python3 -m pip install "setuptools>=67.8" |         python3 -m pip install "setuptools>=67.8" | ||||||
|     fi |     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 -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: |     paths: | ||||||
|       - ".github/workflows/cifuzz.yml" |       - ".github/workflows/cifuzz.yml" | ||||||
|  |       - ".github/workflows/wheels-dependencies.sh" | ||||||
|       - "**.c" |       - "**.c" | ||||||
|       - "**.h" |       - "**.h" | ||||||
|   pull_request: |   pull_request: | ||||||
|     paths: |     paths: | ||||||
|       - ".github/workflows/cifuzz.yml" |       - ".github/workflows/cifuzz.yml" | ||||||
|  |       - ".github/workflows/wheels-dependencies.sh" | ||||||
|       - "**.c" |       - "**.c" | ||||||
|       - "**.h" |       - "**.h" | ||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
|  | @ -24,8 +26,6 @@ concurrency: | ||||||
| 
 | 
 | ||||||
| jobs: | jobs: | ||||||
|   Fuzzing: |   Fuzzing: | ||||||
|     # Disabled until google/oss-fuzz#11419 upgrades Python to 3.9+ |  | ||||||
|     if: false |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|     - name: Build Fuzzers |     - name: Build Fuzzers | ||||||
|  |  | ||||||
							
								
								
									
										4
									
								
								.github/workflows/macos-install.sh
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -2,6 +2,9 @@ | ||||||
| 
 | 
 | ||||||
| set -e | set -e | ||||||
| 
 | 
 | ||||||
|  | if [[ "$ImageOS" == "macos13" ]]; then | ||||||
|  |     brew uninstall gradle maven | ||||||
|  | fi | ||||||
| brew install \ | brew install \ | ||||||
|     freetype \ |     freetype \ | ||||||
|     ghostscript \ |     ghostscript \ | ||||||
|  | @ -20,6 +23,7 @@ export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" | ||||||
| 
 | 
 | ||||||
| python3 -m pip install coverage | python3 -m pip install coverage | ||||||
| python3 -m pip install defusedxml | python3 -m pip install defusedxml | ||||||
|  | python3 -m pip install ipython | ||||||
| python3 -m pip install olefile | python3 -m pip install olefile | ||||||
| python3 -m pip install -U pytest | python3 -m pip install -U pytest | ||||||
| python3 -m pip install -U pytest-cov | python3 -m pip install -U pytest-cov | ||||||
|  |  | ||||||
							
								
								
									
										1
									
								
								.github/workflows/test-cygwin.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -74,6 +74,7 @@ jobs: | ||||||
|             perl |             perl | ||||||
|             python3${{ matrix.python-minor-version }}-cython |             python3${{ matrix.python-minor-version }}-cython | ||||||
|             python3${{ matrix.python-minor-version }}-devel |             python3${{ matrix.python-minor-version }}-devel | ||||||
|  |             python3${{ matrix.python-minor-version }}-ipython | ||||||
|             python3${{ matrix.python-minor-version }}-numpy |             python3${{ matrix.python-minor-version }}-numpy | ||||||
|             python3${{ matrix.python-minor-version }}-sip |             python3${{ matrix.python-minor-version }}-sip | ||||||
|             python3${{ matrix.python-minor-version }}-tkinter |             python3${{ matrix.python-minor-version }}-tkinter | ||||||
|  |  | ||||||
							
								
								
									
										3
									
								
								.github/workflows/test-mingw.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -80,8 +80,7 @@ jobs: | ||||||
|       - name: Test Pillow |       - name: Test Pillow | ||||||
|         run: | |         run: | | ||||||
|           python3 selftest.py --installed |           python3 selftest.py --installed | ||||||
|           python3 -c "from PIL import Image" |           .ci/test.sh | ||||||
|           python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests |  | ||||||
| 
 | 
 | ||||||
|       - name: Upload coverage |       - name: Upload coverage | ||||||
|         uses: codecov/codecov-action@v4 |         uses: codecov/codecov-action@v4 | ||||||
|  |  | ||||||
							
								
								
									
										10
									
								
								.github/workflows/test-windows.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -35,7 +35,7 @@ jobs: | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       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 |     timeout-minutes: 30 | ||||||
| 
 | 
 | ||||||
|  | @ -86,8 +86,8 @@ jobs: | ||||||
|         choco install nasm --no-progress |         choco install nasm --no-progress | ||||||
|         echo "C:\Program Files\NASM" >> $env:GITHUB_PATH |         echo "C:\Program Files\NASM" >> $env:GITHUB_PATH | ||||||
| 
 | 
 | ||||||
|         choco install ghostscript --version=10.3.1 --no-progress |         choco install ghostscript --version=10.4.0 --no-progress | ||||||
|         echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH |         echo "C:\Program Files\gs\gs10.04.0\bin" >> $env:GITHUB_PATH | ||||||
| 
 | 
 | ||||||
|         # Install extra test images |         # Install extra test images | ||||||
|         xcopy /S /Y Tests\test-images\* Tests\images |         xcopy /S /Y Tests\test-images\* Tests\images | ||||||
|  | @ -190,8 +190,8 @@ jobs: | ||||||
| 
 | 
 | ||||||
|     - name: Test Pillow |     - name: Test Pillow | ||||||
|       run: | |       run: | | ||||||
|         path %GITHUB_WORKSPACE%\\winbuild\\build\\bin;%PATH% |         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 |         .ci\test.cmd | ||||||
|       shell: cmd |       shell: cmd | ||||||
| 
 | 
 | ||||||
|     - name: Prepare to upload errors |     - name: Prepare to upload errors | ||||||
|  |  | ||||||
							
								
								
									
										7
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -37,12 +37,11 @@ jobs: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         os: [ |         os: [ | ||||||
|           "macos-14", |           "macos-latest", | ||||||
|           "ubuntu-latest", |           "ubuntu-latest", | ||||||
|         ] |         ] | ||||||
|         python-version: [ |         python-version: [ | ||||||
|           "pypy3.10", |           "pypy3.10", | ||||||
|           "pypy3.9", |  | ||||||
|           "3.13", |           "3.13", | ||||||
|           "3.12", |           "3.12", | ||||||
|           "3.11", |           "3.11", | ||||||
|  | @ -57,7 +56,7 @@ jobs: | ||||||
|         # M1 only available for 3.10+ |         # M1 only available for 3.10+ | ||||||
|         - { os: "macos-13", python-version: "3.9" } |         - { os: "macos-13", python-version: "3.9" } | ||||||
|         exclude: |         exclude: | ||||||
|         - { os: "macos-14", python-version: "3.9" } |         - { os: "macos-latest", python-version: "3.9" } | ||||||
| 
 | 
 | ||||||
|     runs-on: ${{ matrix.os }} |     runs-on: ${{ matrix.os }} | ||||||
|     name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }} |     name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }} | ||||||
|  | @ -77,7 +76,7 @@ jobs: | ||||||
|           "pyproject.toml" |           "pyproject.toml" | ||||||
| 
 | 
 | ||||||
|     - name: Set up Python ${{ matrix.python-version }} (free-threaded) |     - 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 }}" |       if: "${{ matrix.disable-gil }}" | ||||||
|       with: |       with: | ||||||
|         python-version: ${{ matrix.python-version }} |         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 | # Package versions for fresh source builds | ||||||
| FREETYPE_VERSION=2.13.2 | FREETYPE_VERSION=2.13.2 | ||||||
| HARFBUZZ_VERSION=8.5.0 | HARFBUZZ_VERSION=10.0.1 | ||||||
| LIBPNG_VERSION=1.6.43 | LIBPNG_VERSION=1.6.44 | ||||||
| JPEGTURBO_VERSION=3.0.3 | JPEGTURBO_VERSION=3.0.4 | ||||||
| OPENJPEG_VERSION=2.5.2 | OPENJPEG_VERSION=2.5.2 | ||||||
| XZ_VERSION=5.4.5 | XZ_VERSION=5.6.3 | ||||||
| TIFF_VERSION=4.6.0 | TIFF_VERSION=4.6.0 | ||||||
| LCMS2_VERSION=2.16 | LCMS2_VERSION=2.16 | ||||||
| if [[ -n "$IS_MACOS" ]]; then | if [[ -n "$IS_MACOS" ]]; then | ||||||
|  | @ -40,7 +40,7 @@ BROTLI_VERSION=1.1.0 | ||||||
| 
 | 
 | ||||||
| if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then | if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then | ||||||
|     function build_openjpeg { |     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 \ |         (cd $out_dir \ | ||||||
|             && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ |             && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ | ||||||
|             && make install) |             && make install) | ||||||
|  | @ -50,7 +50,7 @@ fi | ||||||
| 
 | 
 | ||||||
| function build_brotli { | function build_brotli { | ||||||
|     local cmake=$(get_modern_cmake) |     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 \ |     (cd $out_dir \ | ||||||
|         && $cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ |         && $cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ | ||||||
|         && make install) |         && make install) | ||||||
|  | @ -60,6 +60,19 @@ function build_brotli { | ||||||
|     fi |     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 { | function build { | ||||||
|     if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then |     if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then | ||||||
|         sudo chown -R runner /usr/local |         sudo chown -R runner /usr/local | ||||||
|  | @ -109,15 +122,7 @@ function build { | ||||||
|         build_freetype |         build_freetype | ||||||
|     fi |     fi | ||||||
| 
 | 
 | ||||||
|     if [ -z "$IS_MACOS" ]; then |     build_harfbuzz | ||||||
|         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 |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| # Any stuff that you need to do before you start building the wheels | # Any stuff that you need to do before you start building the wheels | ||||||
|  |  | ||||||
							
								
								
									
										7
									
								
								.github/workflows/wheels-test.sh
									
									
									
									
										vendored
									
									
								
							
							
						
						|  | @ -13,14 +13,7 @@ else | ||||||
|     yum install -y fribidi |     yum install -y fribidi | ||||||
| fi | 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 | python3 -m pip install numpy | ||||||
|   fi |  | ||||||
| fi |  | ||||||
| 
 | 
 | ||||||
| if [ ! -d "test-images-main" ]; then | if [ ! -d "test-images-main" ]; then | ||||||
|     curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip |     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 |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         python-version: |         python-version: | ||||||
|           - pp39 |  | ||||||
|           - pp310 |           - pp310 | ||||||
|           - cp3{9,10,11} |           - cp3{9,10,11} | ||||||
|           - cp3{12,13} |           - cp3{12,13} | ||||||
|  | @ -57,7 +56,6 @@ jobs: | ||||||
|           - manylinux_2_28 |           - manylinux_2_28 | ||||||
|           - musllinux |           - musllinux | ||||||
|         exclude: |         exclude: | ||||||
|           - { python-version: pp39, spec: musllinux } |  | ||||||
|           - { python-version: pp310, spec: musllinux } |           - { python-version: pp310, spec: musllinux } | ||||||
| 
 | 
 | ||||||
|     steps: |     steps: | ||||||
|  | @ -97,18 +95,30 @@ jobs: | ||||||
|           path: ./wheelhouse/*.whl |           path: ./wheelhouse/*.whl | ||||||
| 
 | 
 | ||||||
|   build-2-native-wheels: |   build-2-native-wheels: | ||||||
|  |     if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' | ||||||
|     name: ${{ matrix.name }} |     name: ${{ matrix.name }} | ||||||
|     runs-on: ${{ matrix.os }} |     runs-on: ${{ matrix.os }} | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         include: |         include: | ||||||
|           - name: "macOS x86_64" |           - name: "macOS 10.10 x86_64" | ||||||
|             os: macos-13 |             os: macos-13 | ||||||
|             cibw_arch: x86_64 |             cibw_arch: x86_64 | ||||||
|  |             build: "cp3{9,10,11}*" | ||||||
|             macosx_deployment_target: "10.10" |             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" |           - name: "macOS arm64" | ||||||
|             os: macos-14 |             os: macos-latest | ||||||
|             cibw_arch: arm64 |             cibw_arch: arm64 | ||||||
|             macosx_deployment_target: "11.0" |             macosx_deployment_target: "11.0" | ||||||
|           - name: "manylinux2014 and musllinux x86_64" |           - name: "manylinux2014 and musllinux x86_64" | ||||||
|  | @ -146,10 +156,11 @@ jobs: | ||||||
| 
 | 
 | ||||||
|       - uses: actions/upload-artifact@v4 |       - uses: actions/upload-artifact@v4 | ||||||
|         with: |         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 |           path: ./wheelhouse/*.whl | ||||||
| 
 | 
 | ||||||
|   windows: |   windows: | ||||||
|  |     if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' | ||||||
|     name: Windows ${{ matrix.cibw_arch }} |     name: Windows ${{ matrix.cibw_arch }} | ||||||
|     runs-on: windows-latest |     runs-on: windows-latest | ||||||
|     strategy: |     strategy: | ||||||
|  | @ -256,7 +267,7 @@ jobs: | ||||||
|         path: dist/*.tar.gz |         path: dist/*.tar.gz | ||||||
| 
 | 
 | ||||||
|   scientific-python-nightly-wheels-publish: |   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] |     needs: [build-2-native-wheels, windows] | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     name: Upload wheels to scientific-python-nightly-wheels |     name: Upload wheels to scientific-python-nightly-wheels | ||||||
|  | @ -267,13 +278,13 @@ jobs: | ||||||
|           path: dist |           path: dist | ||||||
|           merge-multiple: true |           merge-multiple: true | ||||||
|       - name: Upload wheels to scientific-python-nightly-wheels |       - 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: |         with: | ||||||
|           artifacts_path: dist |           artifacts_path: dist | ||||||
|           anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} |           anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} | ||||||
| 
 | 
 | ||||||
|   pypi-publish: |   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] |     needs: [build-1-QEMU-emulated-wheels, build-2-native-wheels, windows, sdist] | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     name: Upload release to PyPI |     name: Upload release to PyPI | ||||||
|  |  | ||||||
|  | @ -1,12 +1,12 @@ | ||||||
| repos: | repos: | ||||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit |   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||||
|     rev: v0.5.0 |     rev: v0.6.3 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: ruff |       - id: ruff | ||||||
|         args: [--exit-non-zero-on-fix] |         args: [--exit-non-zero-on-fix] | ||||||
| 
 | 
 | ||||||
|   - repo: https://github.com/psf/black-pre-commit-mirror |   - repo: https://github.com/psf/black-pre-commit-mirror | ||||||
|     rev: 24.4.2 |     rev: 24.8.0 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: black |       - id: black | ||||||
| 
 | 
 | ||||||
|  | @ -50,7 +50,7 @@ repos: | ||||||
|         exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ |         exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ | ||||||
| 
 | 
 | ||||||
|   - repo: https://github.com/python-jsonschema/check-jsonschema |   - repo: https://github.com/python-jsonschema/check-jsonschema | ||||||
|     rev: 0.28.6 |     rev: 0.29.2 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: check-github-workflows |       - id: check-github-workflows | ||||||
|       - id: check-readthedocs |       - id: check-readthedocs | ||||||
|  | @ -62,12 +62,12 @@ repos: | ||||||
|       - id: sphinx-lint |       - id: sphinx-lint | ||||||
| 
 | 
 | ||||||
|   - repo: https://github.com/tox-dev/pyproject-fmt |   - repo: https://github.com/tox-dev/pyproject-fmt | ||||||
|     rev: 2.1.3 |     rev: 2.2.1 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: pyproject-fmt |       - id: pyproject-fmt | ||||||
| 
 | 
 | ||||||
|   - repo: https://github.com/abravalheri/validate-pyproject |   - repo: https://github.com/abravalheri/validate-pyproject | ||||||
|     rev: v0.18 |     rev: v0.19 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: validate-pyproject |       - id: validate-pyproject | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										114
									
								
								CHANGES.rst
									
									
									
									
									
								
							
							
						
						|  | @ -5,6 +5,120 @@ Changelog (Pillow) | ||||||
| 11.0.0 (unreleased) | 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 | - Deprecate ImageMath lambda_eval and unsafe_eval options argument #8242 | ||||||
|   [radarhere] |   [radarhere] | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										2
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						|  | @ -117,7 +117,7 @@ lint-fix: | ||||||
| 	python3 -c "import black" > /dev/null 2>&1 || python3 -m pip install black | 	python3 -c "import black" > /dev/null 2>&1 || python3 -m pip install black | ||||||
| 	python3 -m black . | 	python3 -m black . | ||||||
| 	python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff | 	python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff | ||||||
| 	python3 -m ruff --fix . | 	python3 -m ruff check --fix . | ||||||
| 
 | 
 | ||||||
| .PHONY: mypy | .PHONY: mypy | ||||||
| mypy: | mypy: | ||||||
|  |  | ||||||
|  | @ -51,7 +51,7 @@ As of 2019, Pillow development is | ||||||
|             <a href="https://app.codecov.io/gh/python-pillow/Pillow"><img |             <a href="https://app.codecov.io/gh/python-pillow/Pillow"><img | ||||||
|                 alt="Code coverage" |                 alt="Code coverage" | ||||||
|                 src="https://codecov.io/gh/python-pillow/Pillow/branch/main/graph/badge.svg"></a> |                 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" |                 alt="Fuzzing Status" | ||||||
|                 src="https://oss-fuzz-build-logs.storage.googleapis.com/badges/pillow.svg"></a> |                 src="https://oss-fuzz-build-logs.storage.googleapis.com/badges/pillow.svg"></a> | ||||||
|         </td> |         </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 | import atheris | ||||||
|  | from atheris.import_hook import instrument_imports | ||||||
| 
 | 
 | ||||||
| with atheris.instrument_imports(): | with instrument_imports(): | ||||||
|     import sys |     import sys | ||||||
| 
 | 
 | ||||||
|     import fuzzers |     import fuzzers | ||||||
|  |  | ||||||
|  | @ -14,8 +14,9 @@ | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| import atheris | import atheris | ||||||
|  | from atheris.import_hook import instrument_imports | ||||||
| 
 | 
 | ||||||
| with atheris.instrument_imports(): | with instrument_imports(): | ||||||
|     import sys |     import sys | ||||||
| 
 | 
 | ||||||
|     import fuzzers |     import fuzzers | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| { | { | ||||||
|    <py3_8_encode_current_locale> |    <py3_10_encode_current_locale> | ||||||
|    Memcheck:Cond |    Memcheck:Cond | ||||||
|    ... |    ... | ||||||
|    fun:encode_current_locale |    fun:encode_current_locale | ||||||
|  |  | ||||||
|  | @ -71,6 +71,11 @@ def test_color_modes() -> None: | ||||||
|         box_blur(sample.convert("YCbCr")) |         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: | def test_radius_0() -> None: | ||||||
|     assert_blur( |     assert_blur( | ||||||
|         sample, |         sample, | ||||||
|  |  | ||||||
|  | @ -105,91 +105,65 @@ class TestColorLut3DCoreAPI: | ||||||
|         with pytest.raises(TypeError): |         with pytest.raises(TypeError): | ||||||
|             im.im.color_lut_3d("RGB", Image.Resampling.BILINEAR, 3, 2, 2, 2, 16) |             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 = Image.new("RGB", (10, 10), 0) | ||||||
| 
 |  | ||||||
|         im.im.color_lut_3d( |         im.im.color_lut_3d( | ||||||
|             "RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) |             lut_mode, | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         im.im.color_lut_3d( |  | ||||||
|             "CMYK", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         im.im.color_lut_3d( |  | ||||||
|             "RGB", |  | ||||||
|             Image.Resampling.BILINEAR, |             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( |             im.im.color_lut_3d( | ||||||
|             "RGB", |                 lut_mode, | ||||||
|                 Image.Resampling.BILINEAR, |                 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( |         im.im.color_lut_3d( | ||||||
|             "RGB", |             lut_mode, | ||||||
|             Image.Resampling.BILINEAR, |             Image.Resampling.BILINEAR, | ||||||
|             *self.generate_identity_table(3, (3, 65, 3)), |             *self.generate_identity_table(table_channels, table_size), | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         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) |  | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     def test_identities(self) -> None: |     def test_identities(self) -> None: | ||||||
|  |  | ||||||
|  | @ -10,11 +10,6 @@ from PIL import features | ||||||
| 
 | 
 | ||||||
| from .helper import skip_unless_feature | from .helper import skip_unless_feature | ||||||
| 
 | 
 | ||||||
| try: |  | ||||||
|     from PIL import _webp |  | ||||||
| except ImportError: |  | ||||||
|     pass |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| def test_check() -> None: | def test_check() -> None: | ||||||
|     # Check the correctness of the convenience function |     # Check the correctness of the convenience function | ||||||
|  | @ -23,6 +18,10 @@ def test_check() -> None: | ||||||
|     for codec in features.codecs: |     for codec in features.codecs: | ||||||
|         assert features.check_codec(codec) == features.check(codec) |         assert features.check_codec(codec) == features.check(codec) | ||||||
|     for feature in features.features: |     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) |             assert features.check_feature(feature) == features.check(feature) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -48,23 +47,26 @@ def test_version() -> None: | ||||||
|     for codec in features.codecs: |     for codec in features.codecs: | ||||||
|         test(codec, features.version_codec) |         test(codec, features.version_codec) | ||||||
|     for feature in features.features: |     for feature in features.features: | ||||||
|  |         if "webp" in feature: | ||||||
|  |             with pytest.warns(DeprecationWarning): | ||||||
|  |                 test(feature, features.version_feature) | ||||||
|  |         else: | ||||||
|             test(feature, features.version_feature) |             test(feature, features.version_feature) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @skip_unless_feature("webp") |  | ||||||
| def test_webp_transparency() -> None: | def test_webp_transparency() -> None: | ||||||
|     assert features.check("transp_webp") != _webp.WebPDecoderBuggyAlpha() |     with pytest.warns(DeprecationWarning): | ||||||
|     assert features.check("transp_webp") == _webp.HAVE_TRANSPARENCY |         assert features.check("transp_webp") == features.check_module("webp") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @skip_unless_feature("webp") |  | ||||||
| def test_webp_mux() -> None: | 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: | 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") | @skip_unless_feature("libjpeg_turbo") | ||||||
|  |  | ||||||
|  | @ -152,7 +152,7 @@ def test_sanity_ati2_bc5u(image_path: str) -> None: | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     ("image_path", "expected_path"), |     "image_path, expected_path", | ||||||
|     ( |     ( | ||||||
|         # hexeditted to be typeless |         # hexeditted to be typeless | ||||||
|         (TEST_FILE_DX10_BC5_TYPELESS, TEST_FILE_DX10_BC5_UNORM), |         (TEST_FILE_DX10_BC5_TYPELESS, TEST_FILE_DX10_BC5_UNORM), | ||||||
|  | @ -248,7 +248,7 @@ def test_dx10_r8g8b8a8_unorm_srgb() -> None: | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     ("mode", "size", "test_file"), |     "mode, size, test_file", | ||||||
|     [ |     [ | ||||||
|         ("L", (128, 128), TEST_FILE_UNCOMPRESSED_L), |         ("L", (128, 128), TEST_FILE_UNCOMPRESSED_L), | ||||||
|         ("LA", (128, 128), TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA), |         ("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( | @pytest.mark.parametrize( | ||||||
|     ("mode", "test_file"), |     "mode, test_file", | ||||||
|     [ |     [ | ||||||
|         ("L", "Tests/images/linear_gradient.png"), |         ("L", "Tests/images/linear_gradient.png"), | ||||||
|         ("LA", "Tests/images/uncompressed_la.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.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize("filename, size", ((FILE1, (460, 352)), (FILE2, (360, 252)))) | ||||||
|     ("filename", "size"), ((FILE1, (460, 352)), (FILE2, (360, 252))) |  | ||||||
| ) |  | ||||||
| @pytest.mark.parametrize("scale", (1, 2)) | @pytest.mark.parametrize("scale", (1, 2)) | ||||||
| def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: | def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: | ||||||
|     expected_size = tuple(s * scale for s in size) |     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") |     out = str(tmp_path / "temp.gif") | ||||||
| 
 | 
 | ||||||
|     # Test opaque WebP background |     # 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: |         with Image.open("Tests/images/hopper.webp") as im: | ||||||
|             assert im.info["background"] == (255, 255, 255, 255) |             assert im.info["background"] == (255, 255, 255, 255) | ||||||
|             im.save(out) |             im.save(out) | ||||||
|  | @ -1378,8 +1378,26 @@ def test_lzw_bits() -> None: | ||||||
|         im.load() |         im.load() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_extents() -> None: | @pytest.mark.parametrize( | ||||||
|     with Image.open("Tests/images/test_extents.gif") as im: |     "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) |             assert im.size == (100, 100) | ||||||
| 
 | 
 | ||||||
|             # Check that n_frames does not change the size |             # Check that n_frames does not change the size | ||||||
|  | @ -1389,6 +1407,11 @@ def test_extents() -> None: | ||||||
|             im.seek(1) |             im.seek(1) | ||||||
|             assert im.size == (150, 150) |             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: | def test_missing_background() -> None: | ||||||
|     # The Global Color Table Flag isn't set, so there is no background color index, |     # 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: |     with Image.open(out) as reloaded: | ||||||
|         reloaded_rgba = reloaded.convert("RGBA") |         reloaded_rgba = reloaded.convert("RGBA") | ||||||
|         assert reloaded_rgba.load()[0, 0][3] == 0 |         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) |         assert_image_similar_tofile(im, temp_file, 1) | ||||||
| 
 | 
 | ||||||
|         with Image.open(temp_file) as reread: |         with Image.open(temp_file) as reread: | ||||||
|             reread.size = (16, 16, 2) |             reread.size = (16, 16) | ||||||
|             reread.load() |             reread.load(2) | ||||||
|             assert_image_equal(reread, provided_im) |             assert_image_equal(reread, provided_im) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -87,14 +87,21 @@ def test_sizes() -> None: | ||||||
|         for w, h, r in im.info["sizes"]: |         for w, h, r in im.info["sizes"]: | ||||||
|             wr = w * r |             wr = w * r | ||||||
|             hr = h * r |             hr = h * r | ||||||
|  |             with pytest.warns(DeprecationWarning): | ||||||
|                 im.size = (w, h, r) |                 im.size = (w, h, r) | ||||||
|             im.load() |             im.load() | ||||||
|             assert im.mode == "RGBA" |             assert im.mode == "RGBA" | ||||||
|             assert im.size == (wr, hr) |             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 |         # Check that we cannot load an incorrect size | ||||||
|         with pytest.raises(ValueError): |         with pytest.raises(ValueError): | ||||||
|             im.size = (1, 1) |             im.size = (1, 2) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_older_icon() -> None: | def test_older_icon() -> None: | ||||||
|  | @ -105,8 +112,8 @@ def test_older_icon() -> None: | ||||||
|             wr = w * r |             wr = w * r | ||||||
|             hr = h * r |             hr = h * r | ||||||
|             with Image.open("Tests/images/pillow2.icns") as im2: |             with Image.open("Tests/images/pillow2.icns") as im2: | ||||||
|                 im2.size = (w, h, r) |                 im2.size = (w, h) | ||||||
|                 im2.load() |                 im2.load(r) | ||||||
|                 assert im2.mode == "RGBA" |                 assert im2.mode == "RGBA" | ||||||
|                 assert im2.size == (wr, hr) |                 assert im2.size == (wr, hr) | ||||||
| 
 | 
 | ||||||
|  | @ -122,8 +129,8 @@ def test_jp2_icon() -> None: | ||||||
|             wr = w * r |             wr = w * r | ||||||
|             hr = h * r |             hr = h * r | ||||||
|             with Image.open("Tests/images/pillow3.icns") as im2: |             with Image.open("Tests/images/pillow3.icns") as im2: | ||||||
|                 im2.size = (w, h, r) |                 im2.size = (w, h) | ||||||
|                 im2.load() |                 im2.load(r) | ||||||
|                 assert im2.mode == "RGBA" |                 assert im2.mode == "RGBA" | ||||||
|                 assert im2.size == (wr, hr) |                 assert im2.size == (wr, hr) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ from pathlib import Path | ||||||
| 
 | 
 | ||||||
| import pytest | 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 | 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: |     with Image.open(outfile) as im: | ||||||
|         assert_image_equal_tofile(im, "Tests/images/hopper_draw.ico") |         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) |         iptc = IptcImagePlugin.getiptcinfo(im) | ||||||
| 
 | 
 | ||||||
|     # Assert |     # Assert | ||||||
|  |     assert iptc is not None | ||||||
|     for tag in iptc.keys(): |     for tag in iptc.keys(): | ||||||
|         if tag[0] == 240: |         if tag[0] == 240: | ||||||
|             return |             return | ||||||
|  | @ -76,6 +77,16 @@ def test_getiptcinfo_zero_padding() -> None: | ||||||
|     assert len(iptc) == 3 |     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: | def test_getiptcinfo_tiff_none() -> None: | ||||||
|     # Arrange |     # Arrange | ||||||
|     with Image.open("Tests/images/hopper.tif") as im: |     with Image.open("Tests/images/hopper.tif") as im: | ||||||
|  |  | ||||||
|  | @ -154,7 +154,7 @@ class TestFileJpeg: | ||||||
|             assert k > 0.9 |             assert k > 0.9 | ||||||
| 
 | 
 | ||||||
|     def test_rgb(self) -> None: |     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) |             return tuple(v[0] for v in im.layer) | ||||||
| 
 | 
 | ||||||
|         im = hopper() |         im = hopper() | ||||||
|  | @ -829,7 +829,7 @@ class TestFileJpeg: | ||||||
|         with Image.open("Tests/images/no-dpi-in-exif.jpg") as im: |         with Image.open("Tests/images/no-dpi-in-exif.jpg") as im: | ||||||
|             # Act / Assert |             # Act / Assert | ||||||
|             # "When the image resolution is unknown, 72 [dpi] is designated." |             # "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) |             assert im.info.get("dpi") == (72, 72) | ||||||
| 
 | 
 | ||||||
|     def test_invalid_exif(self) -> None: |     def test_invalid_exif(self) -> None: | ||||||
|  | @ -991,12 +991,29 @@ class TestFileJpeg: | ||||||
|             else: |             else: | ||||||
|                 assert im.getxmp() == {"xmpmeta": None} |                 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) |     @pytest.mark.timeout(timeout=1) | ||||||
|     def test_eof(self) -> None: |     def test_eof(self) -> None: | ||||||
|         # Even though this decoder never says that it is finished |         # Even though this decoder never says that it is finished | ||||||
|         # the image should still end when there is no new data |         # the image should still end when there is no new data | ||||||
|         class InfiniteMockPyDecoder(ImageFile.PyDecoder): |         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 |                 return 0, 0 | ||||||
| 
 | 
 | ||||||
|         Image.register_decoder("INFINITE", InfiniteMockPyDecoder) |         Image.register_decoder("INFINITE", InfiniteMockPyDecoder) | ||||||
|  | @ -1019,13 +1036,16 @@ class TestFileJpeg: | ||||||
| 
 | 
 | ||||||
|         # SOI, EOI |         # SOI, EOI | ||||||
|         for marker in b"\xff\xd8", b"\xff\xd9": |         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 |         # DHT, DQT | ||||||
|         for marker in b"\xff\xc4", b"\xff\xdb": |         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) |         # SOF0, SOS, APP0 (JFIF header) | ||||||
|         for marker in b"\xff\xc0", b"\xff\xda", b"\xff\xe0": |         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[0])) as interchange_im: | ||||||
|             with Image.open(BytesIO(data[1] + data[2])) as combined_im: |             with Image.open(BytesIO(data[1] + data[2])) as combined_im: | ||||||
|  | @ -1045,6 +1065,13 @@ class TestFileJpeg: | ||||||
| 
 | 
 | ||||||
|         assert im._repr_jpeg_() is None |         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") | @pytest.mark.skipif(not is_win32(), reason="Windows only") | ||||||
| @skip_unless_feature("jpg") | @skip_unless_feature("jpg") | ||||||
|  |  | ||||||
|  | @ -182,6 +182,15 @@ def test_restricted_icc_profile() -> None: | ||||||
|         ImageFile.LOAD_TRUNCATED_IMAGES = False |         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: | def test_header_errors() -> None: | ||||||
|     for path in ( |     for path in ( | ||||||
|         "Tests/images/invalid_header_length.jp2", |         "Tests/images/invalid_header_length.jp2", | ||||||
|  | @ -233,7 +242,7 @@ def test_layers() -> None: | ||||||
|         ("foo.jp2", {"no_jp2": True}, 0, b"\xff\x4f"), |         ("foo.jp2", {"no_jp2": True}, 0, b"\xff\x4f"), | ||||||
|         ("foo.j2k", {"no_jp2": False}, 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"), | ||||||
|         ("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: | 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 len(im.palette.colors) == 256 | ||||||
|         assert im.palette.colors[(255, 255, 255)] == 0 |         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: | def test_comment() -> None: | ||||||
|     with Image.open("Tests/images/comment.jp2") as im: |     with Image.open("Tests/images/comment.jp2") as im: | ||||||
|  |  | ||||||
|  | @ -240,9 +240,10 @@ class TestFileLibTiff(LibTiffTestCase): | ||||||
| 
 | 
 | ||||||
|             new_ifd = TiffImagePlugin.ImageFileDirectory_v2() |             new_ifd = TiffImagePlugin.ImageFileDirectory_v2() | ||||||
|             for tag, info in core_items.items(): |             for tag, info in core_items.items(): | ||||||
|  |                 assert info.type is not None | ||||||
|                 if info.length == 1: |                 if info.length == 1: | ||||||
|                     new_ifd[tag] = values[info.type] |                     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)) |                     new_ifd[tag] = tuple(values[info.type] for _ in range(3)) | ||||||
|                 else: |                 else: | ||||||
|                     new_ifd[tag] = tuple(values[info.type] for _ in range(info.length)) |                     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()) |         im_reloaded = roundtrip(im_original, save_all=True, exif=im_original.getexif()) | ||||||
| 
 | 
 | ||||||
|     for im in (im_original, im_reloaded): |     for im in (im_original, im_reloaded): | ||||||
|  |         assert isinstance(im, MpoImagePlugin.MpoImageFile) | ||||||
|         info = im._getexif() |         info = im._getexif() | ||||||
|  |         assert info is not None | ||||||
|         assert info[272] == "Nintendo 3DS" |         assert info[272] == "Nintendo 3DS" | ||||||
|         assert info[296] == 2 |         assert info[296] == 2 | ||||||
|         assert info[34665] == 188 |         assert info[34665] == 188 | ||||||
|  |  | ||||||
|  | @ -424,8 +424,10 @@ class TestFilePng: | ||||||
|         im = roundtrip(im, pnginfo=info) |         im = roundtrip(im, pnginfo=info) | ||||||
|         assert im.info == {"spam": "Eggs", "eggs": "Spam"} |         assert im.info == {"spam": "Eggs", "eggs": "Spam"} | ||||||
|         assert im.text == {"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"].lang == "en" | ||||||
|         assert im.text["spam"].tkey == "Spam" |         assert im.text["spam"].tkey == "Spam" | ||||||
|  |         assert isinstance(im.text["eggs"], PngImagePlugin.iTXt) | ||||||
|         assert im.text["eggs"].lang == "en" |         assert im.text["eggs"].lang == "en" | ||||||
|         assert im.text["eggs"].tkey == "Eggs" |         assert im.text["eggs"].tkey == "Eggs" | ||||||
| 
 | 
 | ||||||
|  | @ -776,7 +778,7 @@ class TestFilePng: | ||||||
| 
 | 
 | ||||||
|         mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() |         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: |         with Image.open(TEST_PNG_FILE) as im: | ||||||
|             im.save(sys.stdout, "PNG") |             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: |     with Image.open("Tests/images/16_bit_binary.pgm") as im: | ||||||
|         filename = str(tmp_path / "temp.pgm") |         filename = str(tmp_path / "temp.pgm") | ||||||
|         im.save(filename, "PPM") |         im.save(filename, "PPM") | ||||||
|  |         assert_image_equal_tofile(im, filename) | ||||||
| 
 | 
 | ||||||
|  |         im.convert("I;16").save(filename, "PPM") | ||||||
|         assert_image_equal_tofile(im, filename) |         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() |     mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() | ||||||
| 
 | 
 | ||||||
|     sys.stdout = mystdout  # type: ignore[assignment] |     sys.stdout = mystdout | ||||||
| 
 | 
 | ||||||
|     with Image.open(TEST_FILE) as im: |     with Image.open(TEST_FILE) as im: | ||||||
|         im.save(sys.stdout, "PPM") |         im.save(sys.stdout, "PPM") | ||||||
|  |  | ||||||
|  | @ -108,7 +108,8 @@ class TestFileTiff: | ||||||
|             assert_image_equal_tofile(im, "Tests/images/hopper.tif") |             assert_image_equal_tofile(im, "Tests/images/hopper.tif") | ||||||
| 
 | 
 | ||||||
|         with Image.open("Tests/images/hopper_bigtiff.tif") as im: |         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] |             del im.tag_v2[273] | ||||||
| 
 | 
 | ||||||
|             outfile = str(tmp_path / "temp.tif") |             outfile = str(tmp_path / "temp.tif") | ||||||
|  | @ -684,6 +685,13 @@ class TestFileTiff: | ||||||
|             with Image.open(outfile) as reloaded: |             with Image.open(outfile) as reloaded: | ||||||
|                 assert_image_equal_tofile(reloaded, infile) |                 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")) |     @pytest.mark.parametrize("mode", ("P", "PA")) | ||||||
|     def test_palette(self, mode: str, tmp_path: Path) -> None: |     def test_palette(self, mode: str, tmp_path: Path) -> None: | ||||||
|         outfile = str(tmp_path / "temp.tif") |         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 |         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: | def test_no_duplicate_50741_tag() -> None: | ||||||
|     assert TAG_IDS["MakerNoteSafety"] == 50741 |     assert TAG_IDS["MakerNoteSafety"] == 50741 | ||||||
|     assert TAG_IDS["BestQualityScale"] == 50780 |     assert TAG_IDS["BestQualityScale"] == 50780 | ||||||
|  |  | ||||||
|  | @ -48,8 +48,6 @@ class TestFileWebp: | ||||||
|         self.rgb_mode = "RGB" |         self.rgb_mode = "RGB" | ||||||
| 
 | 
 | ||||||
|     def test_version(self) -> None: |     def test_version(self) -> None: | ||||||
|         _webp.WebPDecoderVersion() |  | ||||||
|         _webp.WebPDecoderBuggyAlpha() |  | ||||||
|         version = features.version_module("webp") |         version = features.version_module("webp") | ||||||
|         assert version is not None |         assert version is not None | ||||||
|         assert re.search(r"\d+\.\d+\.\d+$", version) |         assert re.search(r"\d+\.\d+\.\d+$", version) | ||||||
|  | @ -74,7 +72,7 @@ class TestFileWebp: | ||||||
|     def _roundtrip( |     def _roundtrip( | ||||||
|         self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {} |         self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {} | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         temp_file = str(tmp_path / "temp.webp") |         temp_file = tmp_path / "temp.webp" | ||||||
| 
 | 
 | ||||||
|         hopper(mode).save(temp_file, **args) |         hopper(mode).save(temp_file, **args) | ||||||
|         with Image.open(temp_file) as image: |         with Image.open(temp_file) as image: | ||||||
|  | @ -117,9 +115,8 @@ class TestFileWebp: | ||||||
|         hopper().save(buffer_method, format="WEBP", method=6) |         hopper().save(buffer_method, format="WEBP", method=6) | ||||||
|         assert buffer_no_args.getbuffer() != buffer_method.getbuffer() |         assert buffer_no_args.getbuffer() != buffer_method.getbuffer() | ||||||
| 
 | 
 | ||||||
|     @skip_unless_feature("webp_anim") |  | ||||||
|     def test_save_all(self, tmp_path: Path) -> None: |     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)) |         im = Image.new("RGB", (1, 1)) | ||||||
|         im2 = Image.new("RGB", (1, 1), "#f00") |         im2 = Image.new("RGB", (1, 1), "#f00") | ||||||
|         im.save(temp_file, save_all=True, append_images=[im2]) |         im.save(temp_file, save_all=True, append_images=[im2]) | ||||||
|  | @ -130,9 +127,13 @@ class TestFileWebp: | ||||||
|             reloaded.seek(1) |             reloaded.seek(1) | ||||||
|             assert_image_similar(im2, reloaded, 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: |     def test_icc_profile(self, tmp_path: Path) -> None: | ||||||
|         self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) |         self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) | ||||||
|         if _webp.HAVE_WEBPANIM: |  | ||||||
|         self._roundtrip( |         self._roundtrip( | ||||||
|             tmp_path, self.rgb_mode, 12.5, {"icc_profile": None, "save_all": True} |             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") |     @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") | ||||||
|     def test_write_encoding_error_message(self, tmp_path: Path) -> None: |     def test_write_encoding_error_message(self, tmp_path: Path) -> None: | ||||||
|         temp_file = str(tmp_path / "temp.webp") |  | ||||||
|         im = Image.new("RGB", (15000, 15000)) |         im = Image.new("RGB", (15000, 15000)) | ||||||
|         with pytest.raises(ValueError) as e: |         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" |         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: |     def test_WebPEncode_with_invalid_args(self) -> None: | ||||||
|         """ |         """ | ||||||
|         Calling encoder functions with no arguments should result in an error. |         Calling encoder functions with no arguments should result in an error. | ||||||
|         """ |         """ | ||||||
| 
 |  | ||||||
|         if _webp.HAVE_WEBPANIM: |  | ||||||
|         with pytest.raises(TypeError): |         with pytest.raises(TypeError): | ||||||
|             _webp.WebPAnimEncoder() |             _webp.WebPAnimEncoder() | ||||||
|         with pytest.raises(TypeError): |         with pytest.raises(TypeError): | ||||||
|             _webp.WebPEncode() |             _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. |         Calling decoder functions with no arguments should result in an error. | ||||||
|         """ |         """ | ||||||
| 
 |  | ||||||
|         if _webp.HAVE_WEBPANIM: |  | ||||||
|         with pytest.raises(TypeError): |         with pytest.raises(TypeError): | ||||||
|             _webp.WebPAnimDecoder() |             _webp.WebPAnimDecoder() | ||||||
|         with pytest.raises(TypeError): |  | ||||||
|             _webp.WebPDecode() |  | ||||||
| 
 | 
 | ||||||
|     def test_no_resource_warning(self, tmp_path: Path) -> None: |     def test_no_resource_warning(self, tmp_path: Path) -> None: | ||||||
|         file_path = "Tests/images/hopper.webp" |         file_path = "Tests/images/hopper.webp" | ||||||
|         with Image.open(file_path) as image: |         with Image.open(file_path) as image: | ||||||
|             temp_file = str(tmp_path / "temp.webp") |  | ||||||
|             with warnings.catch_warnings(): |             with warnings.catch_warnings(): | ||||||
|                 image.save(temp_file) |                 image.save(tmp_path / "temp.webp") | ||||||
| 
 | 
 | ||||||
|     def test_file_pointer_could_be_reused(self) -> None: |     def test_file_pointer_could_be_reused(self) -> None: | ||||||
|         file_path = "Tests/images/hopper.webp" |         file_path = "Tests/images/hopper.webp" | ||||||
|  | @ -200,20 +203,19 @@ class TestFileWebp: | ||||||
|         "background", |         "background", | ||||||
|         (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), |         (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), | ||||||
|     ) |     ) | ||||||
|     @skip_unless_feature("webp_anim") |  | ||||||
|     def test_invalid_background( |     def test_invalid_background( | ||||||
|         self, background: int | tuple[int, ...], tmp_path: Path |         self, background: int | tuple[int, ...], tmp_path: Path | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         temp_file = str(tmp_path / "temp.webp") |         temp_file = tmp_path / "temp.webp" | ||||||
|         im = hopper() |         im = hopper() | ||||||
|         with pytest.raises(OSError): |         with pytest.raises(OSError): | ||||||
|             im.save(temp_file, save_all=True, append_images=[im], background=background) |             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: |     def test_background_from_gif(self, tmp_path: Path) -> None: | ||||||
|  |         out_webp = tmp_path / "temp.webp" | ||||||
|  | 
 | ||||||
|         # Save L mode GIF with background |         # Save L mode GIF with background | ||||||
|         with Image.open("Tests/images/no_palette_with_background.gif") as im: |         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) |             im.save(out_webp, save_all=True) | ||||||
| 
 | 
 | ||||||
|         # Save P mode GIF with background |         # Save P mode GIF with background | ||||||
|  | @ -221,11 +223,10 @@ class TestFileWebp: | ||||||
|             original_value = im.convert("RGB").getpixel((1, 1)) |             original_value = im.convert("RGB").getpixel((1, 1)) | ||||||
| 
 | 
 | ||||||
|             # Save as WEBP |             # Save as WEBP | ||||||
|             out_webp = str(tmp_path / "temp.webp") |  | ||||||
|             im.save(out_webp, save_all=True) |             im.save(out_webp, save_all=True) | ||||||
| 
 | 
 | ||||||
|         # Save as GIF |         # Save as GIF | ||||||
|         out_gif = str(tmp_path / "temp.gif") |         out_gif = tmp_path / "temp.gif" | ||||||
|         with Image.open(out_webp) as im: |         with Image.open(out_webp) as im: | ||||||
|             im.save(out_gif) |             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)) |         difference = sum(abs(original_value[i] - reread_value[i]) for i in range(0, 3)) | ||||||
|         assert difference < 5 |         assert difference < 5 | ||||||
| 
 | 
 | ||||||
|     @skip_unless_feature("webp_anim") |  | ||||||
|     def test_duration(self, tmp_path: Path) -> None: |     def test_duration(self, tmp_path: Path) -> None: | ||||||
|  |         out_webp = tmp_path / "temp.webp" | ||||||
|  | 
 | ||||||
|         with Image.open("Tests/images/dispose_bgnd.gif") as im: |         with Image.open("Tests/images/dispose_bgnd.gif") as im: | ||||||
|             assert im.info["duration"] == 1000 |             assert im.info["duration"] == 1000 | ||||||
| 
 |  | ||||||
|             out_webp = str(tmp_path / "temp.webp") |  | ||||||
|             im.save(out_webp, save_all=True) |             im.save(out_webp, save_all=True) | ||||||
| 
 | 
 | ||||||
|         with Image.open(out_webp) as reloaded: |         with Image.open(out_webp) as reloaded: | ||||||
|  | @ -247,9 +247,10 @@ class TestFileWebp: | ||||||
|             assert reloaded.info["duration"] == 1000 |             assert reloaded.info["duration"] == 1000 | ||||||
| 
 | 
 | ||||||
|     def test_roundtrip_rgba_palette(self, tmp_path: Path) -> None: |     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") |         im = Image.new("RGBA", (1, 1)).convert("P") | ||||||
|         assert im.mode == "P" |         assert im.mode == "P" | ||||||
|  |         assert im.palette is not None | ||||||
|         assert im.palette.mode == "RGBA" |         assert im.palette.mode == "RGBA" | ||||||
|         im.save(temp_file) |         im.save(temp_file) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -13,12 +13,7 @@ from .helper import ( | ||||||
|     hopper, |     hopper, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| _webp = pytest.importorskip("PIL._webp", reason="WebP support not installed") | 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") |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_read_rgba() -> None: | 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 = Image.new("RGBA", (10, 10), (255, 0, 0, 20)) | ||||||
|     pil_image.save(temp_file) |     pil_image.save(temp_file) | ||||||
| 
 | 
 | ||||||
|     if _webp.WebPDecoderBuggyAlpha(): |  | ||||||
|         return |  | ||||||
| 
 |  | ||||||
|     with Image.open(temp_file) as image: |     with Image.open(temp_file) as image: | ||||||
|         image.load() |         image.load() | ||||||
| 
 | 
 | ||||||
|  | @ -93,11 +85,6 @@ def test_write_rgba(tmp_path: Path) -> None: | ||||||
|         image.load() |         image.load() | ||||||
|         image.getdata() |         image.getdata() | ||||||
| 
 | 
 | ||||||
|         # Early versions of WebP are known to produce higher deviations: |  | ||||||
|         # deal with it |  | ||||||
|         if _webp.WebPDecoderVersion() <= 0x201: |  | ||||||
|             assert_image_similar(image, pil_image, 3.0) |  | ||||||
|         else: |  | ||||||
|         assert_image_similar(image, pil_image, 1.0) |         assert_image_similar(image, pil_image, 1.0) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -15,10 +15,7 @@ from .helper import ( | ||||||
|     skip_unless_feature, |     skip_unless_feature, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| pytestmark = [ | pytestmark = skip_unless_feature("webp") | ||||||
|     skip_unless_feature("webp"), |  | ||||||
|     skip_unless_feature("webp_anim"), |  | ||||||
| ] |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_n_frames() -> None: | def test_n_frames() -> None: | ||||||
|  |  | ||||||
|  | @ -8,14 +8,11 @@ from PIL import Image | ||||||
| 
 | 
 | ||||||
| from .helper import assert_image_equal, hopper | 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" | RGB_MODE = "RGB" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_write_lossless_rgb(tmp_path: Path) -> None: | 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") |     temp_file = str(tmp_path / "temp.webp") | ||||||
| 
 | 
 | ||||||
|     hopper(RGB_MODE).save(temp_file, lossless=True) |     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 | from .helper import mark_if_feature_version, skip_unless_feature | ||||||
| 
 | 
 | ||||||
| pytestmark = [ | pytestmark = skip_unless_feature("webp") | ||||||
|     skip_unless_feature("webp"), |  | ||||||
|     skip_unless_feature("webp_mux"), |  | ||||||
| ] |  | ||||||
| 
 | 
 | ||||||
| ElementTree: ModuleType | None | ElementTree: ModuleType | None | ||||||
| try: | try: | ||||||
|  | @ -119,7 +116,15 @@ def test_read_no_exif() -> None: | ||||||
| def test_getxmp() -> None: | def test_getxmp() -> None: | ||||||
|     with Image.open("Tests/images/flower.webp") as im: |     with Image.open("Tests/images/flower.webp") as im: | ||||||
|         assert "xmp" not in im.info |         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: |     with Image.open("Tests/images/flower2.webp") as im: | ||||||
|         if ElementTree is None: |         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: | def test_write_animated_metadata(tmp_path: Path) -> None: | ||||||
|     iccp_data = b"<iccp_data>" |     iccp_data = b"<iccp_data>" | ||||||
|     exif_data = b"<exif_data>" |     exif_data = b"<exif_data>" | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ from __future__ import annotations | ||||||
| 
 | 
 | ||||||
| import os | import os | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  | from typing import AnyStr | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| 
 | 
 | ||||||
|  | @ -92,7 +93,7 @@ def test_textsize(request: pytest.FixtureRequest, tmp_path: Path) -> None: | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _test_high_characters( | def _test_high_characters( | ||||||
|     request: pytest.FixtureRequest, tmp_path: Path, message: str | bytes |     request: pytest.FixtureRequest, tmp_path: Path, message: AnyStr | ||||||
| ) -> None: | ) -> None: | ||||||
|     tempname = save_font(request, tmp_path) |     tempname = save_font(request, tmp_path) | ||||||
|     font = ImageFont.load(tempname) |     font = ImageFont.load(tempname) | ||||||
|  |  | ||||||
|  | @ -42,6 +42,12 @@ try: | ||||||
| except ImportError: | except ImportError: | ||||||
|     ElementTree = None |     ElementTree = None | ||||||
| 
 | 
 | ||||||
|  | PrettyPrinter: type | None | ||||||
|  | try: | ||||||
|  |     from IPython.lib.pretty import PrettyPrinter | ||||||
|  | except ImportError: | ||||||
|  |     PrettyPrinter = None | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| # Deprecation helper | # Deprecation helper | ||||||
| def helper_image_new(mode: str, size: tuple[int, int]) -> Image.Image: | def helper_image_new(mode: str, size: tuple[int, int]) -> Image.Image: | ||||||
|  | @ -91,16 +97,15 @@ class TestImage: | ||||||
|         # with pytest.raises(MemoryError): |         # with pytest.raises(MemoryError): | ||||||
|         #   Image.new("L", (1000000, 1000000)) |         #   Image.new("L", (1000000, 1000000)) | ||||||
| 
 | 
 | ||||||
|  |     @pytest.mark.skipif(PrettyPrinter is None, reason="IPython is not installed") | ||||||
|     def test_repr_pretty(self) -> None: |     def test_repr_pretty(self) -> None: | ||||||
|         class Pretty: |  | ||||||
|             def text(self, text: str) -> None: |  | ||||||
|                 self.pretty_output = text |  | ||||||
| 
 |  | ||||||
|         im = Image.new("L", (100, 100)) |         im = Image.new("L", (100, 100)) | ||||||
| 
 | 
 | ||||||
|         p = Pretty() |         output = io.StringIO() | ||||||
|         im._repr_pretty_(p, None) |         assert PrettyPrinter is not None | ||||||
|         assert p.pretty_output == "<PIL.Image.Image image mode=L size=100x100>" |         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: |     def test_open_formats(self) -> None: | ||||||
|         PNGFILE = "Tests/images/hopper.png" |         PNGFILE = "Tests/images/hopper.png" | ||||||
|  | @ -700,6 +705,7 @@ class TestImage: | ||||||
|             assert new_image.size == image.size |             assert new_image.size == image.size | ||||||
|             assert new_image.info == base_image.info |             assert new_image.info == base_image.info | ||||||
|             if palette_result is not None: |             if palette_result is not None: | ||||||
|  |                 assert new_image.palette is not None | ||||||
|                 assert new_image.palette.tobytes() == palette_result.tobytes() |                 assert new_image.palette.tobytes() == palette_result.tobytes() | ||||||
|             else: |             else: | ||||||
|                 assert new_image.palette is None |                 assert new_image.palette is None | ||||||
|  | @ -769,6 +775,22 @@ class TestImage: | ||||||
|         exif.load(b"Exif\x00\x00") |         exif.load(b"Exif\x00\x00") | ||||||
|         assert not dict(exif) |         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( |     @mark_if_feature_version( | ||||||
|         pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" |         pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" | ||||||
|     ) |     ) | ||||||
|  | @ -817,7 +839,6 @@ class TestImage: | ||||||
|             assert reloaded_exif[305] == "Pillow test" |             assert reloaded_exif[305] == "Pillow test" | ||||||
| 
 | 
 | ||||||
|     @skip_unless_feature("webp") |     @skip_unless_feature("webp") | ||||||
|     @skip_unless_feature("webp_anim") |  | ||||||
|     def test_exif_webp(self, tmp_path: Path) -> None: |     def test_exif_webp(self, tmp_path: Path) -> None: | ||||||
|         with Image.open("Tests/images/hopper.webp") as im: |         with Image.open("Tests/images/hopper.webp") as im: | ||||||
|             exif = im.getexif() |             exif = im.getexif() | ||||||
|  | @ -939,7 +960,15 @@ class TestImage: | ||||||
| 
 | 
 | ||||||
|     def test_empty_xmp(self) -> None: |     def test_empty_xmp(self) -> None: | ||||||
|         with Image.open("Tests/images/hopper.gif") as im: |         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: |     def test_getxmp_padded(self) -> None: | ||||||
|         im = Image.new("RGB", (1, 1)) |         im = Image.new("RGB", (1, 1)) | ||||||
|  | @ -990,12 +1019,14 @@ class TestImage: | ||||||
|         # P mode with RGBA palette |         # P mode with RGBA palette | ||||||
|         im = Image.new("RGBA", (1, 1)).convert("P") |         im = Image.new("RGBA", (1, 1)).convert("P") | ||||||
|         assert im.mode == "P" |         assert im.mode == "P" | ||||||
|  |         assert im.palette is not None | ||||||
|         assert im.palette.mode == "RGBA" |         assert im.palette.mode == "RGBA" | ||||||
|         assert im.has_transparency_data |         assert im.has_transparency_data | ||||||
| 
 | 
 | ||||||
|     def test_apply_transparency(self) -> None: |     def test_apply_transparency(self) -> None: | ||||||
|         im = Image.new("P", (1, 1)) |         im = Image.new("P", (1, 1)) | ||||||
|         im.putpalette((0, 0, 0, 1, 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} |         assert im.palette.colors == {(0, 0, 0): 0, (1, 1, 1): 1} | ||||||
| 
 | 
 | ||||||
|         # Test that no transformation is applied without transparency |         # 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.putpalette((0, 0, 0, 255, 1, 1, 1, 128), "RGBA") | ||||||
|         im.info["transparency"] = 0 |         im.info["transparency"] = 0 | ||||||
|         im.apply_transparency() |         im.apply_transparency() | ||||||
|  |         assert im.palette is not None | ||||||
|         assert im.palette.colors == {(0, 0, 0, 0): 0, (1, 1, 1, 128): 1} |         assert im.palette.colors == {(0, 0, 0, 0): 0, (1, 1, 1, 128): 1} | ||||||
| 
 | 
 | ||||||
|         # Test that transparency bytes are applied |         # Test that transparency bytes are applied | ||||||
|         with Image.open("Tests/images/pil123p.png") as im: |         with Image.open("Tests/images/pil123p.png") as im: | ||||||
|             assert isinstance(im.info["transparency"], bytes) |             assert isinstance(im.info["transparency"], bytes) | ||||||
|  |             assert im.palette is not None | ||||||
|             assert im.palette.colors[(27, 35, 6)] == 24 |             assert im.palette.colors[(27, 35, 6)] == 24 | ||||||
|             im.apply_transparency() |             im.apply_transparency() | ||||||
|  |             assert im.palette is not None | ||||||
|             assert im.palette.colors[(27, 35, 6, 214)] == 24 |             assert im.palette.colors[(27, 35, 6, 214)] == 24 | ||||||
| 
 | 
 | ||||||
|     def test_constants(self) -> None: |     def test_constants(self) -> None: | ||||||
|  | @ -1052,22 +1086,17 @@ class TestImage: | ||||||
|         valgrind pytest -qq Tests/test_image.py::TestImage::test_overrun | grep decode.c |         valgrind pytest -qq Tests/test_image.py::TestImage::test_overrun | grep decode.c | ||||||
|         """ |         """ | ||||||
|         with Image.open(os.path.join("Tests/images", path)) as im: |         with Image.open(os.path.join("Tests/images", path)) as im: | ||||||
|             try: |             with pytest.raises(OSError) as e: | ||||||
|                 im.load() |                 im.load() | ||||||
|                 pytest.fail() |         buffer_overrun = str(e.value) == "buffer overrun when reading image file" | ||||||
|             except OSError as e: |         truncated = "image file is truncated" in str(e.value) | ||||||
|                 buffer_overrun = str(e) == "buffer overrun when reading image file" |  | ||||||
|                 truncated = "image file is truncated" in str(e) |  | ||||||
| 
 | 
 | ||||||
|         assert buffer_overrun or truncated |         assert buffer_overrun or truncated | ||||||
| 
 | 
 | ||||||
|     def test_fli_overrun2(self) -> None: |     def test_fli_overrun2(self) -> None: | ||||||
|         with Image.open("Tests/images/fli_overrun2.bin") as im: |         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) |                 im.seek(1) | ||||||
|                 pytest.fail() |  | ||||||
|             except OSError as e: |  | ||||||
|                 assert str(e) == "buffer overrun when reading image file" |  | ||||||
| 
 | 
 | ||||||
|     def test_exit_fp(self) -> None: |     def test_exit_fp(self) -> None: | ||||||
|         with Image.new("L", (1, 1)) as im: |         with Image.new("L", (1, 1)) as im: | ||||||
|  | @ -1083,6 +1112,10 @@ class TestImage: | ||||||
|             assert len(caplog.records) == 0 |             assert len(caplog.records) == 0 | ||||||
|             assert im.fp is None |             assert im.fp is None | ||||||
| 
 | 
 | ||||||
|  |     def test_deprecation(self) -> None: | ||||||
|  |         with pytest.warns(DeprecationWarning): | ||||||
|  |             assert not Image.isImageType(None) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class TestImageBytes: | class TestImageBytes: | ||||||
|     @pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"]) |     @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] |                 im.putpixel((0, 0), v)  # type: ignore[arg-type] | ||||||
| 
 | 
 | ||||||
|     @pytest.mark.parametrize( |     @pytest.mark.parametrize( | ||||||
|         ("mode", "band_numbers", "match"), |         "mode, band_numbers, match", | ||||||
|         ( |         ( | ||||||
|             ("L", (0, 2), "color must be int or single-element tuple"), |             ("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"), |             ("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: |     def test_with_dtype(dtype: npt.DTypeLike) -> None: | ||||||
|         ai = numpy.array(im, dtype=dtype) |         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("1") == ((100, 128), '|b1', 1600)) | ||||||
|     assert test("L") == ((100, 128), "|u1", 12800) |     assert test("L") == ((100, 128), "|u1", 12800) | ||||||
|  | @ -47,7 +47,7 @@ def test_toarray() -> None: | ||||||
|             with pytest.raises(OSError): |             with pytest.raises(OSError): | ||||||
|                 numpy.array(im_truncated) |                 numpy.array(im_truncated) | ||||||
|         else: |         else: | ||||||
|             with pytest.warns(UserWarning): |             with pytest.warns(DeprecationWarning): | ||||||
|                 numpy.array(im_truncated) |                 numpy.array(im_truncated) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -113,4 +113,5 @@ def test_fromarray_palette() -> None: | ||||||
|     out = Image.fromarray(a, "P") |     out = Image.fromarray(a, "P") | ||||||
| 
 | 
 | ||||||
|     # Assert that the Python and C palettes match |     # Assert that the Python and C palettes match | ||||||
|  |     assert out.palette is not None | ||||||
|     assert len(out.palette.colors) == len(out.im.getpalette()) / 3 |     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: | def test_l_macro_rounding(convert_mode: str) -> None: | ||||||
|     for mode in ("P", "PA"): |     for mode in ("P", "PA"): | ||||||
|         im = Image.new(mode, (1, 1)) |         im = Image.new(mode, (1, 1)) | ||||||
|  |         assert im.palette is not None | ||||||
|         im.palette.getcolor((0, 1, 2)) |         im.palette.getcolor((0, 1, 2)) | ||||||
| 
 | 
 | ||||||
|         converted_im = im.convert(convert_mode) |         converted_im = im.convert(convert_mode) | ||||||
|  |  | ||||||
|  | @ -49,5 +49,7 @@ def test_copy_zero() -> None: | ||||||
| @skip_unless_feature("libtiff") | @skip_unless_feature("libtiff") | ||||||
| def test_deepcopy() -> None: | def test_deepcopy() -> None: | ||||||
|     with Image.open("Tests/images/g4_orientation_5.tif") as im: |     with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||||
|  |         assert im.size == (590, 88) | ||||||
|  | 
 | ||||||
|         out = copy.deepcopy(im) |         out = copy.deepcopy(im) | ||||||
|     assert out.size == (590, 88) |     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 = Image.new("P", (1, 1)) | ||||||
|     im.putpalette(palette, mode) |     im.putpalette(palette, mode) | ||||||
|     assert im.getpalette() == [1, 2, 3] |     assert im.getpalette() == [1, 2, 3] | ||||||
|  |     assert im.palette is not None | ||||||
|     assert im.palette.colors == {(1, 2, 3, 4): 0} |     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) |     converted = image.quantize(dither=Image.Dither.NONE, palette=palette) | ||||||
|     assert converted.mode == "P" |     assert converted.mode == "P" | ||||||
|  |     assert converted.palette is not None | ||||||
|     assert converted.palette.palette == palette.palette.palette |     assert converted.palette.palette == palette.palette.palette | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -81,6 +82,7 @@ def test_quantize_no_dither2() -> None: | ||||||
|     palette.putpalette(data) |     palette.putpalette(data) | ||||||
|     quantized = im.quantize(dither=Image.Dither.NONE, palette=palette) |     quantized = im.quantize(dither=Image.Dither.NONE, palette=palette) | ||||||
| 
 | 
 | ||||||
|  |     assert quantized.palette is not None | ||||||
|     assert tuple(quantized.palette.palette) == data |     assert tuple(quantized.palette.palette) == data | ||||||
| 
 | 
 | ||||||
|     px = quantized.load() |     px = quantized.load() | ||||||
|  | @ -117,6 +119,7 @@ def test_colors() -> None: | ||||||
|     im = hopper() |     im = hopper() | ||||||
|     colors = 2 |     colors = 2 | ||||||
|     converted = im.quantize(colors) |     converted = im.quantize(colors) | ||||||
|  |     assert converted.palette is not None | ||||||
|     assert len(converted.palette.palette) == colors * len("RGB") |     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 = im.quantize(method=method) | ||||||
|     converted_px = converted.load() |     converted_px = converted.load() | ||||||
|     assert converted_px is not None |     assert converted_px is not None | ||||||
|  |     assert converted.palette is not None | ||||||
|     assert converted_px[0, 0] == converted.palette.colors[color] |     assert converted_px[0, 0] == converted.palette.colors[color] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -44,9 +44,19 @@ class TestImagingCoreResize: | ||||||
|             self.resize(hopper("1"), (15, 12), Image.Resampling.BILINEAR) |             self.resize(hopper("1"), (15, 12), Image.Resampling.BILINEAR) | ||||||
|         with pytest.raises(ValueError): |         with pytest.raises(ValueError): | ||||||
|             self.resize(hopper("P"), (15, 12), Image.Resampling.BILINEAR) |             self.resize(hopper("P"), (15, 12), Image.Resampling.BILINEAR) | ||||||
|         with pytest.raises(ValueError): |         for mode in [ | ||||||
|             self.resize(hopper("I;16"), (15, 12), Image.Resampling.BILINEAR) |             "L", | ||||||
|         for mode in ["L", "I", "F", "RGB", "RGBA", "CMYK", "YCbCr"]: |             "I", | ||||||
|  |             "I;16", | ||||||
|  |             "I;16L", | ||||||
|  |             "I;16B", | ||||||
|  |             "I;16N", | ||||||
|  |             "F", | ||||||
|  |             "RGB", | ||||||
|  |             "RGBA", | ||||||
|  |             "CMYK", | ||||||
|  |             "YCbCr", | ||||||
|  |         ]: | ||||||
|             im = hopper(mode) |             im = hopper(mode) | ||||||
|             r = self.resize(im, (15, 12), Image.Resampling.BILINEAR) |             r = self.resize(im, (15, 12), Image.Resampling.BILINEAR) | ||||||
|             assert r.mode == mode |             assert r.mode == mode | ||||||
|  | @ -300,21 +310,19 @@ class TestImageResize: | ||||||
|                 im.resize((10, 10), "unknown") |                 im.resize((10, 10), "unknown") | ||||||
| 
 | 
 | ||||||
|     @skip_unless_feature("libtiff") |     @skip_unless_feature("libtiff") | ||||||
|     def test_load_first(self) -> None: |     def test_transposed(self) -> None: | ||||||
|         # load() may change the size of the image |  | ||||||
|         # Test that resize() is calling it before getting the size |  | ||||||
|         with Image.open("Tests/images/g4_orientation_5.tif") as im: |         with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||||
|             im = im.resize((64, 64)) |             im = im.resize((64, 64)) | ||||||
|             assert im.size == (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: |     def test_default_filter_bicubic(self, mode: str) -> None: | ||||||
|         im = hopper(mode) |         im = hopper(mode) | ||||||
|         assert im.resize((20, 20), Image.Resampling.BICUBIC) == im.resize((20, 20)) |         assert im.resize((20, 20), Image.Resampling.BICUBIC) == im.resize((20, 20)) | ||||||
| 
 | 
 | ||||||
|     @pytest.mark.parametrize( |     @pytest.mark.parametrize("mode", ("1", "P", "BGR;15", "BGR;16")) | ||||||
|         "mode", ("1", "P", "I;16", "I;16L", "I;16B", "BGR;15", "BGR;16") |  | ||||||
|     ) |  | ||||||
|     def test_default_filter_nearest(self, mode: str) -> None: |     def test_default_filter_nearest(self, mode: str) -> None: | ||||||
|         im = hopper(mode) |         im = hopper(mode) | ||||||
|         assert im.resize((20, 20), Image.Resampling.NEAREST) == im.resize((20, 20)) |         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") | @skip_unless_feature("libtiff") | ||||||
| def test_load_first() -> None: | def test_transposed() -> None: | ||||||
|     # load() may change the size of the image |  | ||||||
|     # Test that thumbnail() is calling it before performing size calculations |  | ||||||
|     with Image.open("Tests/images/g4_orientation_5.tif") as im: |     with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||||
|  |         assert im.size == (590, 88) | ||||||
|  | 
 | ||||||
|         im.thumbnail((64, 64)) |         im.thumbnail((64, 64)) | ||||||
|         assert im.size == (64, 10) |         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: |     with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||||
|         im.thumbnail((590, 88), reducing_gap=None) |         im.thumbnail((590, 88), reducing_gap=None) | ||||||
|         assert im.size == (590, 88) |         assert im.size == (590, 88) | ||||||
|  |  | ||||||
|  | @ -398,7 +398,8 @@ def test_logical() -> None: | ||||||
|             for y in (a, b): |             for y in (a, b): | ||||||
|                 imy = Image.new("1", (1, 1), y) |                 imy = Image.new("1", (1, 1), y) | ||||||
|                 value = op(imx, imy).getpixel((0, 0)) |                 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) |                 out.append(value) | ||||||
|         return out |         return out | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -696,6 +696,12 @@ def test_rgb_lab(mode: str) -> None: | ||||||
|     assert value[:3] == (0, 255, 255) |     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: | def test_deprecation() -> None: | ||||||
|     with pytest.warns(DeprecationWarning): |     with pytest.warns(DeprecationWarning): | ||||||
|         assert ImageCms.DESCRIPTION.strip().startswith("pyCMS") |         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( | @pytest.mark.parametrize( | ||||||
|     "xy, radius, type", |     "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") | @skip_unless_feature("freetype2") | ||||||
| def test_stroke_descender() -> None: | def test_stroke_descender() -> None: | ||||||
|     # Arrange |     # Arrange | ||||||
|  |  | ||||||
|  | @ -65,6 +65,36 @@ def test_mode() -> None: | ||||||
|         ImageDraw2.Draw("L") |         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) | @pytest.mark.parametrize("bbox", BBOX) | ||||||
| def test_ellipse(bbox: Coords) -> None: | def test_ellipse(bbox: Coords) -> None: | ||||||
|     # Arrange |     # Arrange | ||||||
|  | @ -123,6 +153,22 @@ def test_line_pen_as_brush(points: Coords) -> None: | ||||||
|     assert_image_equal_tofile(im, "Tests/images/imagedraw_line.png") |     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) | @pytest.mark.parametrize("points", POINTS) | ||||||
| def test_polygon(points: Coords) -> None: | def test_polygon(points: Coords) -> None: | ||||||
|     # Arrange |     # Arrange | ||||||
|  |  | ||||||
|  | @ -94,7 +94,6 @@ class TestImageFile: | ||||||
|             assert (48, 48) == p.image.size |             assert (48, 48) == p.image.size | ||||||
| 
 | 
 | ||||||
|     @skip_unless_feature("webp") |     @skip_unless_feature("webp") | ||||||
|     @skip_unless_feature("webp_anim") |  | ||||||
|     def test_incremental_webp(self) -> None: |     def test_incremental_webp(self) -> None: | ||||||
|         with ImageFile.Parser() as p: |         with ImageFile.Parser() as p: | ||||||
|             with open("Tests/images/hopper.webp", "rb") as f: |             with open("Tests/images/hopper.webp", "rb") as f: | ||||||
|  | @ -211,7 +210,7 @@ class MockPyDecoder(ImageFile.PyDecoder): | ||||||
| 
 | 
 | ||||||
|         super().__init__(mode, *args) |         super().__init__(mode, *args) | ||||||
| 
 | 
 | ||||||
|     def decode(self, buffer: bytes) -> tuple[int, int]: |     def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: | ||||||
|         # eof |         # eof | ||||||
|         return -1, 0 |         return -1, 0 | ||||||
| 
 | 
 | ||||||
|  | @ -239,7 +238,9 @@ class MockImageFile(ImageFile.ImageFile): | ||||||
|         self.rawmode = "RGBA" |         self.rawmode = "RGBA" | ||||||
|         self._mode = "RGBA" |         self._mode = "RGBA" | ||||||
|         self._size = (200, 200) |         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: | class CodecsTest: | ||||||
|  | @ -269,7 +270,7 @@ class TestPyDecoder(CodecsTest): | ||||||
|         buf = BytesIO(b"\x00" * 255) |         buf = BytesIO(b"\x00" * 255) | ||||||
| 
 | 
 | ||||||
|         im = MockImageFile(buf) |         im = MockImageFile(buf) | ||||||
|         im.tile = [("MOCK", None, 32, None)] |         im.tile = [ImageFile._Tile("MOCK", None, 32, None)] | ||||||
| 
 | 
 | ||||||
|         im.load() |         im.load() | ||||||
| 
 | 
 | ||||||
|  | @ -282,12 +283,12 @@ class TestPyDecoder(CodecsTest): | ||||||
|         buf = BytesIO(b"\x00" * 255) |         buf = BytesIO(b"\x00" * 255) | ||||||
| 
 | 
 | ||||||
|         im = MockImageFile(buf) |         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): |         with pytest.raises(ValueError): | ||||||
|             im.load() |             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): |         with pytest.raises(ValueError): | ||||||
|             im.load() |             im.load() | ||||||
| 
 | 
 | ||||||
|  | @ -295,12 +296,20 @@ class TestPyDecoder(CodecsTest): | ||||||
|         buf = BytesIO(b"\x00" * 255) |         buf = BytesIO(b"\x00" * 255) | ||||||
| 
 | 
 | ||||||
|         im = MockImageFile(buf) |         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): |         with pytest.raises(ValueError): | ||||||
|             im.load() |             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): |         with pytest.raises(ValueError): | ||||||
|             im.load() |             im.load() | ||||||
| 
 | 
 | ||||||
|  | @ -318,7 +327,13 @@ class TestPyEncoder(CodecsTest): | ||||||
| 
 | 
 | ||||||
|         fp = BytesIO() |         fp = BytesIO() | ||||||
|         ImageFile._save( |         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 |         assert MockPyEncoder.last | ||||||
|  | @ -331,10 +346,10 @@ class TestPyEncoder(CodecsTest): | ||||||
|         buf = BytesIO(b"\x00" * 255) |         buf = BytesIO(b"\x00" * 255) | ||||||
| 
 | 
 | ||||||
|         im = MockImageFile(buf) |         im = MockImageFile(buf) | ||||||
|         im.tile = [("MOCK", None, 32, None)] |         im.tile = [ImageFile._Tile("MOCK", None, 32, None)] | ||||||
| 
 | 
 | ||||||
|         fp = BytesIO() |         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 | ||||||
|         assert MockPyEncoder.last.state.xoff == 0 |         assert MockPyEncoder.last.state.xoff == 0 | ||||||
|  | @ -351,7 +366,9 @@ class TestPyEncoder(CodecsTest): | ||||||
|         MockPyEncoder.last = None |         MockPyEncoder.last = None | ||||||
|         with pytest.raises(ValueError): |         with pytest.raises(ValueError): | ||||||
|             ImageFile._save( |             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 |         last: MockPyEncoder | None = MockPyEncoder.last | ||||||
|         assert last |         assert last | ||||||
|  | @ -359,7 +376,9 @@ class TestPyEncoder(CodecsTest): | ||||||
| 
 | 
 | ||||||
|         with pytest.raises(ValueError): |         with pytest.raises(ValueError): | ||||||
|             ImageFile._save( |             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: |     def test_oversize(self) -> None: | ||||||
|  | @ -372,14 +391,22 @@ class TestPyEncoder(CodecsTest): | ||||||
|             ImageFile._save( |             ImageFile._save( | ||||||
|                 im, |                 im, | ||||||
|                 fp, |                 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): |         with pytest.raises(ValueError): | ||||||
|             ImageFile._save( |             ImageFile._save( | ||||||
|                 im, |                 im, | ||||||
|                 fp, |                 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: |     def test_encode(self) -> None: | ||||||
|  | @ -395,9 +422,8 @@ class TestPyEncoder(CodecsTest): | ||||||
|         with pytest.raises(NotImplementedError): |         with pytest.raises(NotImplementedError): | ||||||
|             encoder.encode_to_pyfd() |             encoder.encode_to_pyfd() | ||||||
| 
 | 
 | ||||||
|         fh = BytesIO() |  | ||||||
|         with pytest.raises(NotImplementedError): |         with pytest.raises(NotImplementedError): | ||||||
|             encoder.encode_to_file(fh, 0) |             encoder.encode_to_file(0, 0) | ||||||
| 
 | 
 | ||||||
|     def test_zero_height(self) -> None: |     def test_zero_height(self) -> None: | ||||||
|         with pytest.raises(UnidentifiedImageError): |         with pytest.raises(UnidentifiedImageError): | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ import os | ||||||
| import re | import re | ||||||
| import shutil | import shutil | ||||||
| import sys | import sys | ||||||
|  | import tempfile | ||||||
| from io import BytesIO | from io import BytesIO | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from typing import Any, BinaryIO | 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) |     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: | def test_load_path_not_found() -> None: | ||||||
|     # Arrange |     # Arrange | ||||||
|     filename = "somefilenamethatdoesntexist.ttf" |     filename = "somefilenamethatdoesntexist.ttf" | ||||||
| 
 | 
 | ||||||
|     # Act/Assert |     # Act/Assert | ||||||
|     with pytest.raises(OSError): |     with pytest.raises(OSError) as e: | ||||||
|         ImageFont.load_path(filename) |         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): |     with pytest.raises(OSError): | ||||||
|         ImageFont.truetype(filename) |         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: | def test_load_non_font_bytes() -> None: | ||||||
|     with open("Tests/images/hopper.jpg", "rb") as f: |     with open("Tests/images/hopper.jpg", "rb") as f: | ||||||
|         with pytest.raises(OSError): |         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) |     font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf", 36) | ||||||
|     _check_text(font, "Tests/images/variation_adobe.png", 11) |     _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) |         font.set_variation_by_name(name) | ||||||
|         assert font.getname()[1] == "Bold" |         assert font.getname()[1] == "Bold" | ||||||
|     _check_text(font, "Tests/images/variation_adobe_name.png", 16) |     _check_text(font, "Tests/images/variation_adobe_name.png", 16) | ||||||
| 
 | 
 | ||||||
|     font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf", 36) |     font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf", 36) | ||||||
|     _check_text(font, "Tests/images/variation_tiny.png", 40) |     _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) |         font.set_variation_by_name(name) | ||||||
|         assert font.getname()[1] == "200" |         assert font.getname()[1] == "200" | ||||||
|     _check_text(font, "Tests/images/variation_tiny_name.png", 40) |     _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] |     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( | @pytest.mark.parametrize( | ||||||
|     "test_file", |     "test_file", | ||||||
|  | @ -1147,3 +1177,15 @@ def test_invalid_truetype_sizes_raise_valueerror( | ||||||
| ) -> None: | ) -> None: | ||||||
|     with pytest.raises(ValueError): |     with pytest.raises(ValueError): | ||||||
|         ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine) |         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 = "" |         line = "" | ||||||
|         for c in range(im.width): |         for c in range(im.width): | ||||||
|             value = im.getpixel((c, r)) |             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] |             line += chars[value > 0] | ||||||
|         result.append(line) |         result.append(line) | ||||||
|     return "\n".join(result) |     return "\n".join(result) | ||||||
|  |  | ||||||
|  | @ -390,7 +390,7 @@ def test_colorize_3color_offset() -> None: | ||||||
| 
 | 
 | ||||||
| def test_exif_transpose() -> None: | def test_exif_transpose() -> None: | ||||||
|     exts = [".jpg"] |     exts = [".jpg"] | ||||||
|     if features.check("webp") and features.check("webp_anim"): |     if features.check("webp"): | ||||||
|         exts.append(".webp") |         exts.append(".webp") | ||||||
|     for ext in exts: |     for ext in exts: | ||||||
|         with Image.open("Tests/images/hopper" + ext) as base_im: |         with Image.open("Tests/images/hopper" + ext) as base_im: | ||||||
|  |  | ||||||
|  | @ -204,6 +204,17 @@ def test_overflow_segfault() -> None: | ||||||
|             x[i] = b"0" * 16 |             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: | class Evil: | ||||||
|     def __init__(self) -> None: |     def __init__(self) -> None: | ||||||
|         self.corrupt = Image.core.path(0x4000000000000000) |         self.corrupt = Image.core.path(0x4000000000000000) | ||||||
|  |  | ||||||
|  | @ -115,7 +115,7 @@ def test_ipythonviewer() -> None: | ||||||
|             test_viewer = viewer |             test_viewer = viewer | ||||||
|             break |             break | ||||||
|     else: |     else: | ||||||
|         pytest.fail() |         pytest.fail("IPythonViewer not found") | ||||||
| 
 | 
 | ||||||
|     im = hopper() |     im = hopper() | ||||||
|     assert test_viewer.show(im) == 1 |     assert test_viewer.show(im) == 1 | ||||||
|  |  | ||||||
|  | @ -60,6 +60,18 @@ class TestImageWinDib: | ||||||
|         with pytest.raises(ValueError): |         with pytest.raises(ValueError): | ||||||
|             ImageWin.Dib(mode) |             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: |     def test_dib_paste(self) -> None: | ||||||
|         # Arrange |         # Arrange | ||||||
|         im = hopper() |         im = hopper() | ||||||
|  |  | ||||||
|  | @ -238,8 +238,10 @@ def test_zero_size() -> None: | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @skip_unless_feature("libtiff") | @skip_unless_feature("libtiff") | ||||||
| def test_load_first() -> None: | def test_transposed() -> None: | ||||||
|     with Image.open("Tests/images/g4_orientation_5.tif") as im: |     with Image.open("Tests/images/g4_orientation_5.tif") as im: | ||||||
|  |         assert im.size == (590, 88) | ||||||
|  | 
 | ||||||
|         a = numpy.array(im) |         a = numpy.array(im) | ||||||
|         assert a.shape == (88, 590) |         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( | @pytest.mark.parametrize( | ||||||
|     ("test_file", "test_mode"), |     "test_file, test_mode", | ||||||
|     [ |     [ | ||||||
|         ("Tests/images/hopper.jpg", None), |         ("Tests/images/hopper.jpg", None), | ||||||
|         ("Tests/images/hopper.jpg", "L"), |         ("Tests/images/hopper.jpg", "L"), | ||||||
|  |  | ||||||
|  | @ -5,8 +5,6 @@ import sys | ||||||
| from io import BytesIO | from io import BytesIO | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| 
 | 
 | ||||||
| import pytest |  | ||||||
| 
 |  | ||||||
| from PIL import Image, PSDraw | from PIL import Image, PSDraw | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -49,17 +47,16 @@ def test_draw_postscript(tmp_path: Path) -> None: | ||||||
|     assert os.path.getsize(tempfile) > 0 |     assert os.path.getsize(tempfile) > 0 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize("buffer", (True, False)) | def test_stdout() -> None: | ||||||
| def test_stdout(buffer: bool) -> None: |  | ||||||
|     # Temporarily redirect stdout |     # Temporarily redirect stdout | ||||||
|     old_stdout = sys.stdout |     old_stdout = sys.stdout | ||||||
| 
 | 
 | ||||||
|     class MyStdOut: |     class MyStdOut: | ||||||
|         buffer = BytesIO() |         buffer = BytesIO() | ||||||
| 
 | 
 | ||||||
|     mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() |     mystdout = MyStdOut() | ||||||
| 
 | 
 | ||||||
|     sys.stdout = mystdout  # type: ignore[assignment] |     sys.stdout = mystdout | ||||||
| 
 | 
 | ||||||
|     ps = PSDraw.PSDraw() |     ps = PSDraw.PSDraw() | ||||||
|     _create_document(ps) |     _create_document(ps) | ||||||
|  | @ -67,6 +64,4 @@ def test_stdout(buffer: bool) -> None: | ||||||
|     # Reset stdout |     # Reset stdout | ||||||
|     sys.stdout = old_stdout |     sys.stdout = old_stdout | ||||||
| 
 | 
 | ||||||
|     if isinstance(mystdout, MyStdOut): |     assert mystdout.buffer.getvalue() != b"" | ||||||
|         mystdout = mystdout.buffer |  | ||||||
|     assert mystdout.getvalue() != b"" |  | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
| 
 | 
 | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  | from typing import TYPE_CHECKING, Union | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| 
 | 
 | ||||||
|  | @ -8,6 +9,20 @@ from PIL import Image, ImageQt | ||||||
| 
 | 
 | ||||||
| from .helper import assert_image_equal_tofile, assert_image_similar, hopper | 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: | if ImageQt.qt_is_installed: | ||||||
|     from PIL.ImageQt import QPixmap |     from PIL.ImageQt import QPixmap | ||||||
| 
 | 
 | ||||||
|  | @ -20,7 +35,7 @@ if ImageQt.qt_is_installed: | ||||||
|         from PySide6.QtGui import QImage, QPainter, QRegion |         from PySide6.QtGui import QImage, QPainter, QRegion | ||||||
|         from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget |         from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget | ||||||
| 
 | 
 | ||||||
|     class Example(QWidget): |     class Example(QWidget):  # type: ignore[misc] | ||||||
|         def __init__(self) -> None: |         def __init__(self) -> None: | ||||||
|             super().__init__() |             super().__init__() | ||||||
| 
 | 
 | ||||||
|  | @ -28,11 +43,12 @@ if ImageQt.qt_is_installed: | ||||||
| 
 | 
 | ||||||
|             qimage = ImageQt.ImageQt(img) |             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 |             # Segfault in the problem | ||||||
|             lbl.setPixmap(pixmap1.copy()) |             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") | @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") | ||||||
| def test_sanity(tmp_path: Path) -> None: | def test_sanity(tmp_path: Path) -> None: | ||||||
|     # Segfault test |     # Segfault test | ||||||
|     app: QApplication | None = QApplication([]) |     app: QApplication | None = QApplication([])  # type: ignore[operator] | ||||||
|     ex = Example() |     ex = Example() | ||||||
|     assert app  # Silence warning |     assert app  # Silence warning | ||||||
|     assert ex  # Silence warning |     assert ex  # Silence warning | ||||||
|  | @ -56,7 +72,7 @@ def test_sanity(tmp_path: Path) -> None: | ||||||
|         im = hopper(mode) |         im = hopper(mode) | ||||||
|         data = ImageQt.toqpixmap(im) |         data = ImageQt.toqpixmap(im) | ||||||
| 
 | 
 | ||||||
|         assert isinstance(data, QPixmap) |         assert data.__class__.__name__ == "QPixmap" | ||||||
|         assert not data.isNull() |         assert not data.isNull() | ||||||
| 
 | 
 | ||||||
|         # Test saving the file |         # Test saving the file | ||||||
|  | @ -64,14 +80,14 @@ def test_sanity(tmp_path: Path) -> None: | ||||||
|         data.save(tempfile) |         data.save(tempfile) | ||||||
| 
 | 
 | ||||||
|         # Render the image |         # Render the image | ||||||
|         qimage = ImageQt.ImageQt(im) |         imageqt = ImageQt.ImageQt(im) | ||||||
|         data = QPixmap.fromImage(qimage) |         data = getattr(QPixmap, "fromImage")(imageqt) | ||||||
|         qt_format = QImage.Format if ImageQt.qt_version == "6" else QImage |         qt_format = getattr(QImage, "Format") if ImageQt.qt_version == "6" else QImage | ||||||
|         qimage = QImage(128, 128, qt_format.Format_ARGB32) |         qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32"))  # type: ignore[operator] | ||||||
|         painter = QPainter(qimage) |         painter = QPainter(qimage)  # type: ignore[operator] | ||||||
|         image_label = QLabel() |         image_label = QLabel()  # type: ignore[operator] | ||||||
|         image_label.setPixmap(data) |         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() |         painter.end() | ||||||
|         rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png") |         rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png") | ||||||
|         qimage.save(rendered_tempfile) |         qimage.save(rendered_tempfile) | ||||||
|  |  | ||||||
|  | @ -21,7 +21,7 @@ def test_sanity(mode: str, tmp_path: Path) -> None: | ||||||
|     src = hopper(mode) |     src = hopper(mode) | ||||||
|     data = ImageQt.toqimage(src) |     data = ImageQt.toqimage(src) | ||||||
| 
 | 
 | ||||||
|     assert isinstance(data, QImage) |     assert isinstance(data, QImage)  # type: ignore[arg-type, misc] | ||||||
|     assert not data.isNull() |     assert not data.isNull() | ||||||
| 
 | 
 | ||||||
|     # reload directly from the qimage |     # reload directly from the qimage | ||||||
|  |  | ||||||
|  | @ -54,8 +54,8 @@ def test_nonetype() -> None: | ||||||
|     assert xres.denominator is not None |     assert xres.denominator is not None | ||||||
|     assert yres._val is not None |     assert yres._val is not None | ||||||
| 
 | 
 | ||||||
|     assert xres and 1 |     assert xres | ||||||
|     assert xres and yres |     assert yres | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|  |  | ||||||
|  | @ -30,28 +30,6 @@ def test_is_not_path(tmp_path: Path) -> None: | ||||||
|     assert not it_is_not |     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: | def test_deferred_error() -> None: | ||||||
|     # Arrange |     # Arrange | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| # install libimagequant | # install libimagequant | ||||||
| 
 | 
 | ||||||
| archive_name=libimagequant | archive_name=libimagequant | ||||||
| archive_version=4.3.1 | archive_version=4.3.3 | ||||||
| 
 | 
 | ||||||
| archive=$archive_name-$archive_version | archive=$archive_name-$archive_version | ||||||
| 
 | 
 | ||||||
|  | @ -23,14 +23,14 @@ else | ||||||
|     cargo cinstall --prefix=/usr --destdir=. |     cargo cinstall --prefix=/usr --destdir=. | ||||||
| 
 | 
 | ||||||
|     # Copy into place |     # 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/ |     sudo cp usr/include/libimagequant.h /usr/include/ | ||||||
| 
 | 
 | ||||||
|     if [ -n "$GITHUB_ACTIONS" ]; then |     if [ -n "$GITHUB_ACTIONS" ]; then | ||||||
|         # Copy to cache |         # Copy to cache | ||||||
|         rm -rf ~/cache-$archive_name |         rm -rf ~/cache-$archive_name | ||||||
|         mkdir ~/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/ |         cp usr/include/libimagequant.h ~/cache-$archive_name/ | ||||||
|     fi |     fi | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| # install raqm | # 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 | ./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 | # generating warnings in “nitpicky mode”. Note that type should include the domain name | ||||||
| # if present. Example entries would be ('py:func', 'int') or | # if present. Example entries would be ('py:func', 'int') or | ||||||
| # ('envvar', 'LD_LIBRARY_PATH'). | # ('envvar', 'LD_LIBRARY_PATH'). | ||||||
| # nitpick_ignore = [] | nitpick_ignore = [("py:class", "_io.BytesIO")] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # -- Options for HTML output ---------------------------------------------- | # -- Options for HTML output ---------------------------------------------- | ||||||
|  |  | ||||||
|  | @ -109,6 +109,35 @@ ImageDraw.getdraw hints parameter | ||||||
| 
 | 
 | ||||||
| The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. | 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 | 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 | :py:meth:`~PIL.ImageMath.unsafe_eval()` has been deprecated. One or more keyword | ||||||
| arguments can be used instead. | 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 | Removed features | ||||||
| ---------------- | ---------------- | ||||||
| 
 | 
 | ||||||
| Deprecated features are only removed in major releases after an appropriate | Deprecated features are only removed in major releases after an appropriate | ||||||
| period of deprecation has passed. | 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 | PSFile | ||||||
| ~~~~~~ | ~~~~~~ | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -14,6 +14,7 @@ from __future__ import annotations | ||||||
| 
 | 
 | ||||||
| import struct | import struct | ||||||
| from io import BytesIO | from io import BytesIO | ||||||
|  | from typing import IO | ||||||
| 
 | 
 | ||||||
| from PIL import Image, ImageFile | from PIL import Image, ImageFile | ||||||
| 
 | 
 | ||||||
|  | @ -94,26 +95,26 @@ DXT3_FOURCC = 0x33545844 | ||||||
| DXT5_FOURCC = 0x35545844 | DXT5_FOURCC = 0x35545844 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _decode565(bits): | def _decode565(bits: int) -> tuple[int, int, int]: | ||||||
|     a = ((bits >> 11) & 0x1F) << 3 |     a = ((bits >> 11) & 0x1F) << 3 | ||||||
|     b = ((bits >> 5) & 0x3F) << 2 |     b = ((bits >> 5) & 0x3F) << 2 | ||||||
|     c = (bits & 0x1F) << 3 |     c = (bits & 0x1F) << 3 | ||||||
|     return a, b, c |     return a, b, c | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _c2a(a, b): | def _c2a(a: int, b: int) -> int: | ||||||
|     return (2 * a + b) // 3 |     return (2 * a + b) // 3 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _c2b(a, b): | def _c2b(a: int, b: int) -> int: | ||||||
|     return (a + b) // 2 |     return (a + b) // 2 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _c3(a, b): | def _c3(a: int, b: int) -> int: | ||||||
|     return (2 * b + a) // 3 |     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 |     # TODO implement this function as pixel format in decode.c | ||||||
|     ret = bytearray(4 * width * height) |     ret = bytearray(4 * width * height) | ||||||
| 
 | 
 | ||||||
|  | @ -151,7 +152,7 @@ def _dxt1(data, width, height): | ||||||
|     return bytes(ret) |     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: |     if ai <= 12: | ||||||
|         ac = (ac0 >> ai) & 7 |         ac = (ac0 >> ai) & 7 | ||||||
|     elif ai == 15: |     elif ai == 15: | ||||||
|  | @ -175,7 +176,7 @@ def _dxtc_alpha(a0, a1, ac0, ac1, ai): | ||||||
|     return alpha |     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 |     # TODO implement this function as pixel format in decode.c | ||||||
|     ret = bytearray(4 * width * height) |     ret = bytearray(4 * width * height) | ||||||
| 
 | 
 | ||||||
|  | @ -211,7 +212,7 @@ class DdsImageFile(ImageFile.ImageFile): | ||||||
|     format = "DDS" |     format = "DDS" | ||||||
|     format_description = "DirectDraw Surface" |     format_description = "DirectDraw Surface" | ||||||
| 
 | 
 | ||||||
|     def _open(self): |     def _open(self) -> None: | ||||||
|         if not _accept(self.fp.read(4)): |         if not _accept(self.fp.read(4)): | ||||||
|             msg = "not a DDS file" |             msg = "not a DDS file" | ||||||
|             raise SyntaxError(msg) |             raise SyntaxError(msg) | ||||||
|  | @ -242,19 +243,22 @@ class DdsImageFile(ImageFile.ImageFile): | ||||||
|         elif fourcc == b"DXT5": |         elif fourcc == b"DXT5": | ||||||
|             self.decoder = "DXT5" |             self.decoder = "DXT5" | ||||||
|         else: |         else: | ||||||
|             msg = f"Unimplemented pixel format {fourcc}" |             msg = f"Unimplemented pixel format {repr(fourcc)}" | ||||||
|             raise NotImplementedError(msg) |             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 |         pass | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class DXT1Decoder(ImageFile.PyDecoder): | class DXT1Decoder(ImageFile.PyDecoder): | ||||||
|     _pulls_fd = True |     _pulls_fd = True | ||||||
| 
 | 
 | ||||||
|     def decode(self, buffer): |     def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: | ||||||
|  |         assert self.fd is not None | ||||||
|         try: |         try: | ||||||
|             self.set_as_raw(_dxt1(self.fd, self.state.xsize, self.state.ysize)) |             self.set_as_raw(_dxt1(self.fd, self.state.xsize, self.state.ysize)) | ||||||
|         except struct.error as e: |         except struct.error as e: | ||||||
|  | @ -266,7 +270,8 @@ class DXT1Decoder(ImageFile.PyDecoder): | ||||||
| class DXT5Decoder(ImageFile.PyDecoder): | class DXT5Decoder(ImageFile.PyDecoder): | ||||||
|     _pulls_fd = True |     _pulls_fd = True | ||||||
| 
 | 
 | ||||||
|     def decode(self, buffer): |     def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: | ||||||
|  |         assert self.fd is not None | ||||||
|         try: |         try: | ||||||
|             self.set_as_raw(_dxt5(self.fd, self.state.xsize, self.state.ysize)) |             self.set_as_raw(_dxt5(self.fd, self.state.xsize, self.state.ysize)) | ||||||
|         except struct.error as e: |         except struct.error as e: | ||||||
|  | @ -279,7 +284,7 @@ Image.register_decoder("DXT1", DXT1Decoder) | ||||||
| Image.register_decoder("DXT5", DXT5Decoder) | Image.register_decoder("DXT5", DXT5Decoder) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _accept(prefix): | def _accept(prefix: bytes) -> bool: | ||||||
|     return prefix[:4] == b"DDS " |     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** | **sizes** | ||||||
|     A list of supported sizes found in this icon file; these are a |     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 |     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 |     icon and 1 for a standard icon. | ||||||
|     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 | .. _icns-loading: | ||||||
|     will be reset to a 2-tuple containing pixel dimensions (so, e.g. if you | 
 | ||||||
|     ask for ``(512, 512, 2)``, the final value of | Loading | ||||||
|     :py:attr:`~PIL.Image.Image.size` will be ``(1024, 1024)``). | ~~~~~~~ | ||||||
|  | 
 | ||||||
|  | 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: | .. _icns-saving: | ||||||
| 
 | 
 | ||||||
|  | @ -1220,8 +1227,7 @@ using the general tags available through tiffinfo. | ||||||
| WebP | WebP | ||||||
| ^^^^ | ^^^^ | ||||||
| 
 | 
 | ||||||
| Pillow reads and writes WebP files. The specifics of Pillow's capabilities with | Pillow reads and writes WebP files. Requires libwebp v0.5.0 or later. | ||||||
| this format are currently undocumented. |  | ||||||
| 
 | 
 | ||||||
| .. _webp-saving: | .. _webp-saving: | ||||||
| 
 | 
 | ||||||
|  | @ -1249,29 +1255,19 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: | ||||||
| **exact** | **exact** | ||||||
|     If true, preserve the transparent RGB values. Otherwise, discard |     If true, preserve the transparent RGB values. Otherwise, discard | ||||||
|     invisible RGB values for better compression. Defaults to false. |     invisible RGB values for better compression. Defaults to false. | ||||||
|     Requires libwebp 0.5.0 or later. |  | ||||||
| 
 | 
 | ||||||
| **icc_profile** | **icc_profile** | ||||||
|     The ICC Profile to include in the saved file. Only supported if |     The ICC Profile to include in the saved file. | ||||||
|     the system WebP library was built with webpmux support. |  | ||||||
| 
 | 
 | ||||||
| **exif** | **exif** | ||||||
|     The exif data to include in the saved file. Only supported if |     The exif data to include in the saved file. | ||||||
|     the system WebP library was built with webpmux support. |  | ||||||
| 
 | 
 | ||||||
| **xmp** | **xmp** | ||||||
|     The XMP data to include in the saved file. Only supported if |     The XMP data to include in the saved file. | ||||||
|     the system WebP library was built with webpmux support. |  | ||||||
| 
 | 
 | ||||||
| Saving sequences | 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 | 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`` | 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 | 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 | :py:func:`PIL.WmfImagePlugin.register_handler` to register a WMF and EMF | ||||||
| handler. :: | handler. :: | ||||||
| 
 | 
 | ||||||
|     from PIL import Image |     from typing import IO | ||||||
|  | 
 | ||||||
|  |     from PIL import Image, ImageFile | ||||||
|     from PIL import WmfImagePlugin |     from PIL import WmfImagePlugin | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|     class WmfHandler: |     class WmfHandler(ImageFile.StubHandler): | ||||||
|         def open(self, im): |         def open(self, im: ImageFile.StubImageFile) -> None: | ||||||
|             ... |             ... | ||||||
| 
 | 
 | ||||||
|         def load(self, im): |         def load(self, im: ImageFile.StubImageFile) -> Image.Image: | ||||||
|             ... |             ... | ||||||
|             return 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() |     >>> im.show() | ||||||
| 
 | 
 | ||||||
|  | .. image:: show_hopper.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
| .. note:: | .. note:: | ||||||
| 
 | 
 | ||||||
|     The standard version of :py:meth:`~PIL.Image.Image.show` is not very |     The standard version of :py:meth:`~PIL.Image.Image.show` is not very | ||||||
|  | @ -79,6 +82,9 @@ Convert files to JPEG | ||||||
|             except OSError: |             except OSError: | ||||||
|                 print("cannot convert", infile) |                 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` | 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 | method which explicitly specifies a file format. If you use a non-standard | ||||||
| extension, you must always specify the format this way: | extension, you must always specify the format this way: | ||||||
|  | @ -103,6 +109,9 @@ Create JPEG thumbnails | ||||||
|             except OSError: |             except OSError: | ||||||
|                 print("cannot create thumbnail for", infile) |                 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 | 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 | 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 | 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) |     region = im.crop(box) | ||||||
| 
 | 
 | ||||||
| The region is defined by a 4-tuple, where coordinates are (left, upper, right, | 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 | 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 | 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. | 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 | 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 | the region is automatically converted before being pasted (see the section on | ||||||
| :ref:`color-transforms` below for details). | :ref:`color-transforms` below for details). | ||||||
| 
 | 
 | ||||||
|  | .. image:: pasted_hopper.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
| Here’s an additional example: | Here’s an additional example: | ||||||
| 
 | 
 | ||||||
| Rolling an image | 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.""" |         """Roll an image sideways.""" | ||||||
|         xsize, ysize = im.size |         xsize, ysize = im.size | ||||||
| 
 | 
 | ||||||
|  | @ -186,6 +201,9 @@ Rolling an image | ||||||
| 
 | 
 | ||||||
|         return im |         return im | ||||||
| 
 | 
 | ||||||
|  | .. image:: rolled_hopper.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
| Or if you would like to merge two images into a wider image: | Or if you would like to merge two images into a wider image: | ||||||
| 
 | 
 | ||||||
| Merging images | 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] |         w = im1.size[0] + im2.size[0] | ||||||
|         h = max(im1.size[1], im2.size[1]) |         h = max(im1.size[1], im2.size[1]) | ||||||
|         im = Image.new("RGBA", (w, h)) |         im = Image.new("RGBA", (w, h)) | ||||||
|  | @ -203,6 +221,9 @@ Merging images | ||||||
| 
 | 
 | ||||||
|         return im |         return im | ||||||
| 
 | 
 | ||||||
|  | .. image:: merged_hopper.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
| For more advanced tricks, the paste method can also take a transparency mask as | 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 | 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 | 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 itself. To work with individual color bands, you may want to convert | ||||||
| the image to “RGB” first. | the image to “RGB” first. | ||||||
| 
 | 
 | ||||||
|  | .. image:: rebanded_hopper.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
| Geometrical transforms | Geometrical transforms | ||||||
| ---------------------- | ---------------------- | ||||||
| 
 | 
 | ||||||
|  | @ -245,6 +269,9 @@ Simple geometry transforms | ||||||
|     out = im.resize((128, 128)) |     out = im.resize((128, 128)) | ||||||
|     out = im.rotate(45) # degrees counter-clockwise |     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 | 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.rotate` method or the | ||||||
| :py:meth:`~PIL.Image.Image.transpose` method. The latter can also be used to | :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) |     out = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) | ||||||
|  | 
 | ||||||
|  | .. image:: flip_left_right_hopper.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
|  | :: | ||||||
|  | 
 | ||||||
|     out = im.transpose(Image.Transpose.FLIP_TOP_BOTTOM) |     out = im.transpose(Image.Transpose.FLIP_TOP_BOTTOM) | ||||||
|  | 
 | ||||||
|  | .. image:: flip_top_bottom_hopper.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
|  | :: | ||||||
|  | 
 | ||||||
|     out = im.transpose(Image.Transpose.ROTATE_90) |     out = im.transpose(Image.Transpose.ROTATE_90) | ||||||
|  | 
 | ||||||
|  | .. image:: rotated_hopper_90.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
|  | :: | ||||||
|  | 
 | ||||||
|     out = im.transpose(Image.Transpose.ROTATE_180) |     out = im.transpose(Image.Transpose.ROTATE_180) | ||||||
|  | 
 | ||||||
|  | .. image:: rotated_hopper_180.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
|  | :: | ||||||
|  | 
 | ||||||
|     out = im.transpose(Image.Transpose.ROTATE_270) |     out = im.transpose(Image.Transpose.ROTATE_270) | ||||||
| 
 | 
 | ||||||
|  | .. image:: rotated_hopper_270.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
| ``transpose(ROTATE)`` operations can also be performed identically with | ``transpose(ROTATE)`` operations can also be performed identically with | ||||||
| :py:meth:`~PIL.Image.Image.rotate` operations, provided the ``expand`` flag is | :py:meth:`~PIL.Image.Image.rotate` operations, provided the ``expand`` flag is | ||||||
| true, to provide for the same changes to the image's size. | 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 |     from PIL import Image, ImageOps | ||||||
|     size = (100, 150) |     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.contain(im, size).save("imageops_contain.webp") | ||||||
|         ImageOps.cover(im, size).save("imageops_cover.webp") |         ImageOps.cover(im, size).save("imageops_cover.webp") | ||||||
|         ImageOps.fit(im, size).save("imageops_fit.webp") |         ImageOps.fit(im, size).save("imageops_fit.webp") | ||||||
|  | @ -342,6 +396,9 @@ Applying filters | ||||||
|     from PIL import ImageFilter |     from PIL import ImageFilter | ||||||
|     out = im.filter(ImageFilter.DETAIL) |     out = im.filter(ImageFilter.DETAIL) | ||||||
| 
 | 
 | ||||||
|  | .. image:: enhanced_hopper.webp | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
| Point Operations | Point Operations | ||||||
| ^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^ | ||||||
| 
 | 
 | ||||||
|  | @ -355,8 +412,11 @@ Applying point transforms | ||||||
| 
 | 
 | ||||||
| :: | :: | ||||||
| 
 | 
 | ||||||
|     # multiply each pixel by 1.2 |     # multiply each pixel by 20 | ||||||
|     out = im.point(lambda i: i * 1.2) |     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 | 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 | 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) |     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 | 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 | 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 | 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 = ImageEnhance.Contrast(im) | ||||||
|     enh.enhance(1.3).show("30% more contrast") |     enh.enhance(1.3).show("30% more contrast") | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | .. image:: contrasted_hopper.jpg | ||||||
|  |     :align: center | ||||||
|  | 
 | ||||||
| Image sequences | Image sequences | ||||||
| --------------- | --------------- | ||||||
| 
 | 
 | ||||||
|  | @ -444,10 +511,43 @@ Reading sequences | ||||||
| As seen in this example, you’ll get an :py:exc:`EOFError` exception when the | As seen in this example, you’ll get an :py:exc:`EOFError` exception when the | ||||||
| sequence ends. | 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: | 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 Image, PSDraw | ||||||
|     from PIL import PSDraw |     import os | ||||||
| 
 | 
 | ||||||
|     with Image.open("hopper.ppm") as im: |     # Define the PostScript file | ||||||
|         title = "hopper" |     ps_file = open("hopper.ps", "wb") | ||||||
|         box = (1 * 72, 2 * 72, 7 * 72, 10 * 72)  # in points |  | ||||||
| 
 | 
 | ||||||
|         ps = PSDraw.PSDraw()  # default is sys.stdout or sys.stdout.buffer |     # Create a PSDraw object | ||||||
|         ps.begin_document(title) |     ps = PSDraw.PSDraw(ps_file) | ||||||
| 
 | 
 | ||||||
|         # draw the image (75 dpi) |     # Start the document | ||||||
|         ps.image(box, im, 75) |     ps.begin_document() | ||||||
|         ps.rectangle(box) |  | ||||||
| 
 | 
 | ||||||
|         # draw title |     # Set the text to be drawn | ||||||
|         ps.setfont("HelveticaNarrow-Bold", 36) |     text = "Hopper" | ||||||
|         ps.text((3 * 72, 4 * 72), title) |  | ||||||
| 
 | 
 | ||||||
|  |     # 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.end_document() | ||||||
|  |     ps_file.close() | ||||||
|  | 
 | ||||||
|  | .. image:: hopper_ps.webp | ||||||
|  | 
 | ||||||
|  | .. note:: | ||||||
|  | 
 | ||||||
|  |     PostScript converted to PDF for display purposes | ||||||
| 
 | 
 | ||||||
| More on reading images | More on reading images | ||||||
| ---------------------- | ---------------------- | ||||||
|  | @ -553,7 +689,7 @@ Reading from a tar archive | ||||||
| 
 | 
 | ||||||
|     from PIL import Image, TarIO |     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) |     im = Image.open(fp) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -568,8 +704,7 @@ in the current directory can be saved as JPEGs at reduced quality. | ||||||
|     import glob |     import glob | ||||||
|     from PIL import Image |     from PIL import Image | ||||||
| 
 | 
 | ||||||
| 
 |     def compress_image(source_path: str, dest_path: str) -> None: | ||||||
|     def compress_image(source_path, dest_path): |  | ||||||
|         with Image.open(source_path) as img: |         with Image.open(source_path) as img: | ||||||
|             if img.mode != "RGB": |             if img.mode != "RGB": | ||||||
|                 img = img.convert("RGB") |                 img = img.convert("RGB") | ||||||
|  |  | ||||||