diff --git a/.ci/install.sh b/.ci/install.sh
index acb84f046..2178c6646 100755
--- a/.ci/install.sh
+++ b/.ci/install.sh
@@ -13,24 +13,21 @@ aptget_update()
return 1
fi
}
-if [[ $(uname) != CYGWIN* ]]; then
- aptget_update || aptget_update retry || aptget_update retry
-fi
+aptget_update || aptget_update retry || aptget_update retry
set -e
-if [[ $(uname) != CYGWIN* ]]; then
- sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\
- ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\
- cmake meson imagemagick libharfbuzz-dev libfribidi-dev\
- sway wl-clipboard libopenblas-dev nasm
-fi
+sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\
+ ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\
+ cmake meson imagemagick libharfbuzz-dev libfribidi-dev\
+ sway wl-clipboard libopenblas-dev nasm
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade wheel
python3 -m pip install coverage
python3 -m pip install defusedxml
python3 -m pip install ipython
+python3 -m pip install numpy
python3 -m pip install olefile
python3 -m pip install -U pytest
python3 -m pip install -U pytest-cov
@@ -40,36 +37,24 @@ python3 -m pip install pyroma
# fails on beta 3.14 and PyPy
python3 -m pip install --only-binary=:all: pyarrow || true
-if [[ $(uname) != CYGWIN* ]]; then
- python3 -m pip install numpy
-
- # PyQt6 doesn't support PyPy3
- if [[ $GHA_PYTHON_VERSION == 3.* ]]; then
- sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0
- # TODO Update condition when pyqt6 supports free-threading
- if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi
- fi
-
- # Pyroma uses non-isolated build and fails with old setuptools
- if [[ $GHA_PYTHON_VERSION == 3.9 ]]; then
- # To match pyproject.toml
- python3 -m pip install "setuptools>=77"
- fi
-
- # webp
- pushd depends && ./install_webp.sh && popd
-
- # libimagequant
- pushd depends && ./install_imagequant.sh && popd
-
- # raqm
- pushd depends && ./install_raqm.sh && popd
-
- # libavif
- pushd depends && ./install_libavif.sh && popd
-
- # extra test images
- pushd depends && ./install_extra_test_images.sh && popd
-else
- cd depends && ./install_extra_test_images.sh && cd ..
+# PyQt6 doesn't support PyPy3
+if [[ $GHA_PYTHON_VERSION == 3.* ]]; then
+ sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0
+ # TODO Update condition when pyqt6 supports free-threading
+ if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi
fi
+
+# webp
+pushd depends && ./install_webp.sh && popd
+
+# libimagequant
+pushd depends && ./install_imagequant.sh && popd
+
+# raqm
+pushd depends && ./install_raqm.sh && popd
+
+# libavif
+pushd depends && ./install_libavif.sh && popd
+
+# extra test images
+pushd depends && ./install_extra_test_images.sh && popd
diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt
index 520b6e320..d87d7956f 100644
--- a/.ci/requirements-cibw.txt
+++ b/.ci/requirements-cibw.txt
@@ -1 +1 @@
-cibuildwheel==3.0.0
+cibuildwheel==3.1.4
diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt
index 44b5badab..bd9563800 100644
--- a/.ci/requirements-mypy.txt
+++ b/.ci/requirements-mypy.txt
@@ -1,10 +1,11 @@
-mypy==1.16.1
+mypy==1.17.1
IceSpringPySideStubs-PyQt6
IceSpringPySideStubs-PySide6
ipython
numpy
packaging
pyarrow-stubs
+pybind11
pytest
sphinx
types-atheris
diff --git a/.github/mergify.yml b/.github/mergify.yml
index 9bb089615..14222db10 100644
--- a/.github/mergify.yml
+++ b/.github/mergify.yml
@@ -8,7 +8,6 @@ pull_request_rules:
- status-success=Docker Test Successful
- status-success=Windows Test Successful
- status-success=MinGW
- - status-success=Cygwin Test Successful
actions:
merge:
method: merge
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 626824f38..761dc1125 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -32,7 +32,7 @@ jobs:
name: Docs
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 8e789a734..9827ef1cd 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -20,7 +20,7 @@ jobs:
name: Lint
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml
deleted file mode 100644
index abfeaa77f..000000000
--- a/.github/workflows/test-cygwin.yml
+++ /dev/null
@@ -1,154 +0,0 @@
-name: Test Cygwin
-
-on:
- push:
- branches:
- - "**"
- paths-ignore:
- - ".github/workflows/docs.yml"
- - ".github/workflows/wheels*"
- - ".gitmodules"
- - "docs/**"
- - "wheels/**"
- pull_request:
- paths-ignore:
- - ".github/workflows/docs.yml"
- - ".github/workflows/wheels*"
- - ".gitmodules"
- - "docs/**"
- - "wheels/**"
- workflow_dispatch:
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-env:
- COVERAGE_CORE: sysmon
-
-jobs:
- build:
- runs-on: windows-latest
- strategy:
- fail-fast: false
- matrix:
- python-minor-version: [9]
-
- timeout-minutes: 40
-
- name: Python 3.${{ matrix.python-minor-version }}
-
- steps:
- - name: Fix line endings
- run: |
- git config --global core.autocrlf input
-
- - name: Checkout Pillow
- uses: actions/checkout@v4
- with:
- persist-credentials: false
-
- - name: Install Cygwin
- uses: cygwin/cygwin-install-action@v5
- with:
- packages: >
- gcc-g++
- ghostscript
- git
- ImageMagick
- jpeg
- libfreetype-devel
- libimagequant-devel
- libjpeg-devel
- liblapack-devel
- liblcms2-devel
- libopenjp2-devel
- libraqm-devel
- libtiff-devel
- libwebp-devel
- libxcb-devel
- libxcb-xinerama0
- make
- netpbm
- perl
- python3${{ matrix.python-minor-version }}-cython
- python3${{ matrix.python-minor-version }}-devel
- python3${{ matrix.python-minor-version }}-ipython
- python3${{ matrix.python-minor-version }}-numpy
- python3${{ matrix.python-minor-version }}-sip
- python3${{ matrix.python-minor-version }}-tkinter
- wget
- xorg-server-extra
- zlib-devel
-
- - name: Add Lapack to PATH
- uses: egor-tensin/cleanup-path@v4
- with:
- dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack'
-
- - name: Select Python version
- run: |
- ln -sf c:/cygwin/bin/python3.${{ matrix.python-minor-version }} c:/cygwin/bin/python3
-
- - name: pip cache
- uses: actions/cache@v4
- with:
- path: 'C:\cygwin\home\runneradmin\.cache\pip'
- key: ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-${{ hashFiles('.ci/install.sh') }}
- restore-keys: |
- ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-
-
- - name: Build system information
- run: |
- dash.exe -c "python3 .github/workflows/system-info.py"
-
- - name: Install dependencies
- run: |
- bash.exe .ci/install.sh
-
- - name: Build
- shell: bash.exe -eo pipefail -o igncr "{0}"
- run: |
- .ci/build.sh
-
- - name: Test
- run: |
- bash.exe xvfb-run -s '-screen 0 1024x768x24' .ci/test.sh
-
- - name: Prepare to upload errors
- if: failure()
- run: |
- dash.exe -c "mkdir -p Tests/errors"
-
- - name: Upload errors
- uses: actions/upload-artifact@v4
- if: failure()
- with:
- name: errors
- path: Tests/errors
-
- - name: After success
- run: |
- bash.exe .ci/after_success.sh
- rm C:\cygwin\bin\bash.EXE
-
- - name: Upload coverage
- uses: codecov/codecov-action@v5
- with:
- files: ./coverage.xml
- flags: GHA_Cygwin
- name: Cygwin Python 3.${{ matrix.python-minor-version }}
- token: ${{ secrets.CODECOV_ORG_TOKEN }}
-
- success:
- permissions:
- contents: none
- needs: build
- runs-on: ubuntu-latest
- name: Cygwin Test Successful
- steps:
- - name: Success
- run: echo Cygwin Test Successful
diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml
index 0b90732eb..30e5c494d 100644
--- a/.github/workflows/test-docker.yml
+++ b/.github/workflows/test-docker.yml
@@ -47,6 +47,8 @@ jobs:
centos-stream-10-amd64,
debian-12-bookworm-x86,
debian-12-bookworm-amd64,
+ debian-13-trixie-x86,
+ debian-13-trixie-amd64,
fedora-41-amd64,
fedora-42-amd64,
gentoo,
@@ -66,7 +68,7 @@ jobs:
name: ${{ matrix.docker }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml
index 5a83c16c3..6c4206083 100644
--- a/.github/workflows/test-mingw.yml
+++ b/.github/workflows/test-mingw.yml
@@ -45,7 +45,7 @@ jobs:
steps:
- name: Checkout Pillow
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/test-valgrind-memory.yml b/.github/workflows/test-valgrind-memory.yml
index e6a5f6e77..0f36fe30d 100644
--- a/.github/workflows/test-valgrind-memory.yml
+++ b/.github/workflows/test-valgrind-memory.yml
@@ -41,7 +41,7 @@ jobs:
name: ${{ matrix.docker }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml
index 8818b3b23..30caa0d4e 100644
--- a/.github/workflows/test-valgrind.yml
+++ b/.github/workflows/test-valgrind.yml
@@ -39,7 +39,7 @@ jobs:
name: ${{ matrix.docker }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml
index 6d8acc44f..d55a8e5f5 100644
--- a/.github/workflows/test-windows.yml
+++ b/.github/workflows/test-windows.yml
@@ -35,11 +35,11 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["pypy3.11", "pypy3.10", "3.10", "3.11", "3.12", ">=3.13.5", "3.14"]
+ python-version: ["pypy3.11", "3.11", "3.12", "3.13", "3.14"]
architecture: ["x64"]
include:
# Test the oldest Python on 32-bit
- - { python-version: "3.9", architecture: "x86" }
+ - { python-version: "3.10", architecture: "x86" }
timeout-minutes: 45
@@ -47,19 +47,19 @@ jobs:
steps:
- name: Checkout Pillow
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
- name: Checkout cached dependencies
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
repository: python-pillow/pillow-depends
path: winbuild\depends
- name: Checkout extra test images
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
repository: python-pillow/test-images
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b4b516228..b17d08892 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -42,7 +42,6 @@ jobs:
]
python-version: [
"pypy3.11",
- "pypy3.10",
"3.14t",
"3.14",
"3.13t",
@@ -50,24 +49,23 @@ jobs:
"3.12",
"3.11",
"3.10",
- "3.9",
]
include:
- - { python-version: "3.11", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" }
- - { python-version: "3.10", PYTHONOPTIMIZE: 2 }
+ - { python-version: "3.12", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" }
+ - { python-version: "3.11", PYTHONOPTIMIZE: 2 }
# Free-threaded
- { python-version: "3.14t", disable-gil: true }
- { python-version: "3.13t", disable-gil: true }
- # M1 only available for 3.10+
- - { os: "macos-13", python-version: "3.9" }
+ # Intel
+ - { os: "macos-13", python-version: "3.10" }
exclude:
- - { os: "macos-latest", python-version: "3.9" }
+ - { os: "macos-latest", python-version: "3.10" }
runs-on: ${{ matrix.os }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
@@ -113,7 +111,7 @@ jobs:
GHA_PYTHON_VERSION: ${{ matrix.python-version }}
- name: Register gcc problem matcher
- if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'"
+ if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14'"
run: echo "::add-matcher::.github/problem-matchers/gcc.json"
- name: Build
diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh
index d761d93b6..cbd8534aa 100755
--- a/.github/workflows/wheels-dependencies.sh
+++ b/.github/workflows/wheels-dependencies.sh
@@ -60,7 +60,7 @@ if [[ "$CIBW_PLATFORM" == "ios" ]]; then
# on using the Xcode builder, which isn't very helpful for most of Pillow's
# dependencies. Therefore, we lean on the OSX configurations, plus CC, CFLAGS
# etc. to ensure the right sysroot is selected.
- HOST_CMAKE_FLAGS="-DCMAKE_SYSTEM_NAME=$CMAKE_SYSTEM_NAME -DCMAKE_SYSTEM_PROCESSOR=$GNU_ARCH -DCMAKE_OSX_DEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET -DCMAKE_OSX_SYSROOT=$IOS_SDK_PATH -DBUILD_SHARED_LIBS=NO"
+ HOST_CMAKE_FLAGS="-DCMAKE_SYSTEM_NAME=$CMAKE_SYSTEM_NAME -DCMAKE_SYSTEM_PROCESSOR=$GNU_ARCH -DCMAKE_OSX_DEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET -DCMAKE_OSX_SYSROOT=$IOS_SDK_PATH -DBUILD_SHARED_LIBS=NO -DENABLE_SHARED=NO"
# Meson needs to be pointed at a cross-platform configuration file
# This will be generated once CC etc. have been evaluated.
@@ -94,16 +94,16 @@ ARCHIVE_SDIR=pillow-depends-main
# annotations have a source code patch that is required for some platforms. If
# you change those versions, ensure the patch is also updated.
FREETYPE_VERSION=2.13.3
-HARFBUZZ_VERSION=11.2.1
-LIBPNG_VERSION=1.6.49
-JPEGTURBO_VERSION=3.1.1
+HARFBUZZ_VERSION=11.3.3
+LIBPNG_VERSION=1.6.50
+JPEGTURBO_VERSION=3.1.2
OPENJPEG_VERSION=2.5.3
XZ_VERSION=5.8.1
+ZSTD_VERSION=1.5.7
TIFF_VERSION=4.7.0
LCMS2_VERSION=2.17
-ZLIB_VERSION=1.3.1
-ZLIB_NG_VERSION=2.2.4
-LIBWEBP_VERSION=1.5.0 # Patched; next release won't need patching. See patch file.
+ZLIB_NG_VERSION=2.2.5
+LIBWEBP_VERSION=1.6.0
BZIP2_VERSION=1.0.8
LIBXCB_VERSION=1.17.0
BROTLI_VERSION=1.1.0 # Patched; next release won't need patching. See patch file.
@@ -165,7 +165,7 @@ function build_brotli {
local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-$BROTLI_VERSION.tar.gz)
(cd $out_dir \
&& cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib $HOST_CMAKE_FLAGS . \
- && make install)
+ && make -j4 install)
touch brotli-stamp
}
@@ -186,30 +186,43 @@ function build_libavif {
python3 -m pip install meson ninja
- if [[ "$PLAT" == "x86_64" ]] || [ -n "$SANITIZER" ]; then
+ if ([[ "$PLAT" == "x86_64" ]] && [[ -z "$IOS_SDK" ]]) || [ -n "$SANITIZER" ]; then
build_simple nasm 2.16.03 https://www.nasm.us/pub/nasm/releasebuilds/2.16.03
fi
local build_type=MinSizeRel
+ local build_shared=ON
local lto=ON
local libavif_cmake_flags
- if [ -n "$IS_MACOS" ]; then
+ if [[ -n "$IS_MACOS" ]]; then
lto=OFF
libavif_cmake_flags=(
-DCMAKE_C_FLAGS_MINSIZEREL="-Oz -DNDEBUG -flto" \
-DCMAKE_CXX_FLAGS_MINSIZEREL="-Oz -DNDEBUG -flto" \
-DCMAKE_SHARED_LINKER_FLAGS_INIT="-Wl,-S,-x,-dead_strip_dylibs" \
)
+ if [[ -n "$IOS_SDK" ]]; then
+ build_shared=OFF
+ fi
else
if [[ "$MB_ML_VER" == 2014 ]] && [[ "$PLAT" == "x86_64" ]]; then
build_type=Release
fi
libavif_cmake_flags=(-DCMAKE_SHARED_LINKER_FLAGS_INIT="-Wl,--strip-all,-z,relro,-z,now")
fi
+ if [[ -n "$IOS_SDK" ]] && [[ "$PLAT" == "x86_64" ]]; then
+ libavif_cmake_flags+=(-DAOM_TARGET_CPU=generic)
+ else
+ libavif_cmake_flags+=(
+ -DAVIF_CODEC_AOM_DECODE=OFF \
+ -DAVIF_CODEC_DAV1D=LOCAL
+ )
+ fi
local out_dir=$(fetch_unpack https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$LIBAVIF_VERSION.tar.gz libavif-$LIBAVIF_VERSION.tar.gz)
+
# CONFIG_AV1_HIGHBITDEPTH=0 is a flag for libaom (included as a subproject
# of libavif) that disables support for encoding high bit depth images.
(cd $out_dir \
@@ -217,33 +230,44 @@ function build_libavif {
-DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX \
-DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib \
-DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib \
- -DBUILD_SHARED_LIBS=ON \
+ -DBUILD_SHARED_LIBS=$build_shared \
-DAVIF_LIBSHARPYUV=LOCAL \
-DAVIF_LIBYUV=LOCAL \
-DAVIF_CODEC_AOM=LOCAL \
-DCONFIG_AV1_HIGHBITDEPTH=0 \
- -DAVIF_CODEC_AOM_DECODE=OFF \
- -DAVIF_CODEC_DAV1D=LOCAL \
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=$lto \
-DCMAKE_C_VISIBILITY_PRESET=hidden \
-DCMAKE_CXX_VISIBILITY_PRESET=hidden \
-DCMAKE_BUILD_TYPE=$build_type \
"${libavif_cmake_flags[@]}" \
- . \
- && make install)
+ $HOST_CMAKE_FLAGS . )
+
+ if [[ -n "$IOS_SDK" ]]; then
+ # libavif's CMake configuration generates a meson cross file... but it
+ # doesn't work for iOS cross-compilation. Copy in Pillow-generated
+ # meson-cross config to replace the cmake-generated version.
+ cp $WORKDIR/meson-cross.txt $out_dir/crossfile-apple.meson
+ fi
+
+ (cd $out_dir && make -j4 install)
+
touch libavif-stamp
}
+function build_zstd {
+ if [ -e zstd-stamp ]; then return; fi
+ local out_dir=$(fetch_unpack https://github.com/facebook/zstd/releases/download/v$ZSTD_VERSION/zstd-$ZSTD_VERSION.tar.gz)
+ (cd $out_dir \
+ && make -j4 install)
+ touch zstd-stamp
+}
+
function build {
build_xz
if [ -z "$IS_ALPINE" ] && [ -z "$SANITIZER" ] && [ -z "$IS_MACOS" ]; then
yum remove -y zlib-devel
fi
- if [[ -n "$IS_MACOS" ]] && [[ "$MACOSX_DEPLOYMENT_TARGET" == "10.10" || "$MACOSX_DEPLOYMENT_TARGET" == "10.13" ]]; then
- build_new_zlib
- else
- build_zlib_ng
- fi
+ build_zlib_ng
build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto
if [[ -n "$IS_MACOS" ]]; then
@@ -265,13 +289,11 @@ function build {
--with-jpeg-include-dir=$BUILD_PREFIX/include --with-jpeg-lib-dir=$BUILD_PREFIX/lib \
--disable-webp --disable-libdeflate --disable-zstd
else
+ build_zstd
build_tiff
fi
- if [[ -z "$IOS_SDK" ]]; then
- # Short term workaround; don't build libavif on iOS
- build_libavif
- fi
+ build_libavif
build_libpng
build_lcms2
build_openjpeg
@@ -280,7 +302,11 @@ function build {
if [[ -n "$IS_MACOS" ]]; then
webp_cflags="$webp_cflags -Wl,-headerpad_max_install_names"
fi
- CFLAGS="$CFLAGS $webp_cflags" build_simple libwebp $LIBWEBP_VERSION \
+ webp_ldflags=""
+ if [[ -n "$IOS_SDK" ]]; then
+ webp_ldflags="$webp_ldflags -llzma -lz"
+ fi
+ CFLAGS="$CFLAGS $webp_cflags" LDFLAGS="$LDFLAGS $webp_ldflags" build_simple libwebp $LIBWEBP_VERSION \
https://storage.googleapis.com/downloads.webmproject.org/releases/webp tar.gz \
--enable-libwebpmux --enable-libwebpdemux
@@ -380,6 +406,15 @@ fi
wrap_wheel_builder build
+# A safety catch for iOS. iOS can't use dynamic libraries, but clang will prefer
+# to link dynamic libraries to static libraries. The only way to reliably
+# prevent this is to not have dynamic libraries available in the first place.
+# The build process *shouldn't* generate any dylibs... but just in case, purge
+# any dylibs that *have* been installed into the build prefix directory.
+if [[ -n "$IOS_SDK" ]]; then
+ find "$BUILD_PREFIX" -name "*.dylib" -exec rm -rf {} \;
+fi
+
# Return to the project root to finish the build
popd > /dev/null
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 52a3f2cdb..24e78f965 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -77,36 +77,36 @@ jobs:
platform: linux
os: ubuntu-latest
cibw_arch: x86_64
+ manylinux: "manylinux2014"
- name: "manylinux_2_28 x86_64"
platform: linux
os: ubuntu-latest
cibw_arch: x86_64
build: "*manylinux*"
- manylinux: "manylinux_2_28"
- name: "manylinux2014 and musllinux aarch64"
platform: linux
os: ubuntu-24.04-arm
cibw_arch: aarch64
+ manylinux: "manylinux2014"
- name: "manylinux_2_28 aarch64"
platform: linux
os: ubuntu-24.04-arm
cibw_arch: aarch64
build: "*manylinux*"
- manylinux: "manylinux_2_28"
- name: "iOS arm64 device"
platform: ios
os: macos-latest
cibw_arch: arm64_iphoneos
- name: "iOS arm64 simulator"
platform: ios
- os: macos-latest
+ os: macos-14
cibw_arch: arm64_iphonesimulator
- name: "iOS x86_64 simulator"
platform: ios
os: macos-13
cibw_arch: x86_64_iphonesimulator
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
submodules: true
@@ -153,12 +153,12 @@ jobs:
- cibw_arch: ARM64
os: windows-11-arm
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
- name: Checkout extra test images
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
repository: python-pillow/test-images
@@ -234,7 +234,7 @@ jobs:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
persist-credentials: false
@@ -256,7 +256,7 @@ jobs:
runs-on: ubuntu-latest
name: Upload wheels to scientific-python-nightly-wheels
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v5
with:
pattern: dist-*
path: dist
@@ -278,7 +278,7 @@ jobs:
permissions:
id-token: write
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v5
with:
pattern: dist-*
path: dist
diff --git a/.github/zizmor.yml b/.github/zizmor.yml
index 5bdc48c30..b56709781 100644
--- a/.github/zizmor.yml
+++ b/.github/zizmor.yml
@@ -1,5 +1,5 @@
# Configuration for the zizmor static analysis tool, run via pre-commit in CI
-# https://woodruffw.github.io/zizmor/configuration/
+# https://docs.zizmor.sh/configuration/
rules:
unpinned-uses:
config:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 75c7d3632..23bda1ec7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.12.2
+ rev: v0.12.11
hooks:
- id: ruff-check
args: [--exit-non-zero-on-fix]
@@ -24,7 +24,7 @@ repos:
exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$|\.patch$)
- repo: https://github.com/pre-commit/mirrors-clang-format
- rev: v20.1.7
+ rev: v21.1.0
hooks:
- id: clang-format
types: [c]
@@ -36,7 +36,7 @@ repos:
- id: rst-backticks
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v5.0.0
+ rev: v6.0.0
hooks:
- id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
@@ -51,14 +51,14 @@ repos:
exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/|\.patch$
- repo: https://github.com/python-jsonschema/check-jsonschema
- rev: 0.33.2
+ rev: 0.33.3
hooks:
- id: check-github-workflows
- id: check-readthedocs
- id: check-renovate
- - repo: https://github.com/woodruffw/zizmor-pre-commit
- rev: v1.11.0
+ - repo: https://github.com/zizmorcore/zizmor-pre-commit
+ rev: v1.12.1
hooks:
- id: zizmor
@@ -79,7 +79,7 @@ repos:
additional_dependencies: [trove-classifiers>=2024.10.12]
- repo: https://github.com/tox-dev/tox-ini-fmt
- rev: 1.5.0
+ rev: 1.6.0
hooks:
- id: tox-ini-fmt
diff --git a/MANIFEST.in b/MANIFEST.in
index 95a6b1b92..6623f227d 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -13,6 +13,7 @@ include LICENSE
include Makefile
include tox.ini
graft Tests
+graft Tests/images
graft checks
graft patches
graft src
@@ -28,8 +29,19 @@ exclude .editorconfig
exclude .readthedocs.yml
exclude codecov.yml
exclude renovate.json
+exclude Tests/images/README.md
+exclude Tests/images/crash*.tif
+exclude Tests/images/string_dimension.tiff
global-exclude .git*
global-exclude *.pyc
global-exclude *.so
prune .ci
prune wheels
+prune winbuild/build
+prune winbuild/depends
+prune Tests/errors
+prune Tests/images/jpeg2000
+prune Tests/images/msp
+prune Tests/images/picins
+prune Tests/images/sunraster
+prune Tests/test-images
diff --git a/README.md b/README.md
index 365d356a0..8585ef6cb 100644
--- a/README.md
+++ b/README.md
@@ -36,9 +36,6 @@ As of 2019, Pillow development is
-
diff --git a/Tests/helper.py b/Tests/helper.py
index 34e4d6e75..dbdd30b42 100644
--- a/Tests/helper.py
+++ b/Tests/helper.py
@@ -10,17 +10,20 @@ import shutil
import subprocess
import sys
import tempfile
-from collections.abc import Sequence
from functools import lru_cache
from io import BytesIO
-from pathlib import Path
-from typing import Any, Callable
import pytest
from packaging.version import parse as parse_version
from PIL import Image, ImageFile, ImageMath, features
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable, Sequence
+ from pathlib import Path
+ from typing import Any
+
logger = logging.getLogger(__name__)
uploader = None
@@ -172,6 +175,14 @@ def skip_unless_feature(feature: str) -> pytest.MarkDecorator:
return pytest.mark.skipif(not features.check(feature), reason=reason)
+def has_feature_version(feature: str, required: str) -> bool:
+ version = features.version(feature)
+ assert version is not None
+ version_required = parse_version(required)
+ version_available = parse_version(version)
+ return version_available >= version_required
+
+
def skip_unless_feature_version(
feature: str, required: str, reason: str | None = None
) -> pytest.MarkDecorator:
@@ -291,16 +302,6 @@ def djpeg_available() -> bool:
return False
-def cjpeg_available() -> bool:
- if shutil.which("cjpeg"):
- try:
- subprocess.check_call(["cjpeg", "-version"])
- return True
- except subprocess.CalledProcessError: # pragma: no cover
- return False
- return False
-
-
def netpbm_available() -> bool:
return bool(shutil.which("ppmquant") and shutil.which("ppmtogif"))
diff --git a/Tests/images/unimplemented_pixel_format.dds b/Tests/images/unimplemented_pixel_format.dds
new file mode 100644
index 000000000..9092df8b1
Binary files /dev/null and b/Tests/images/unimplemented_pixel_format.dds differ
diff --git a/Tests/test_features.py b/Tests/test_features.py
index 520c25b46..93d803fc1 100644
--- a/Tests/test_features.py
+++ b/Tests/test_features.py
@@ -2,7 +2,6 @@ from __future__ import annotations
import io
import re
-from typing import Callable
import pytest
@@ -10,6 +9,10 @@ from PIL import features
from .helper import skip_unless_feature
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
def test_check() -> None:
# Check the correctness of the convenience function
@@ -18,11 +21,7 @@ def test_check() -> None:
for codec in features.codecs:
assert features.check_codec(codec) == features.check(codec)
for feature in features.features:
- if "webp" in feature:
- with pytest.warns(DeprecationWarning, match="webp"):
- 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)
def test_version() -> None:
@@ -48,11 +47,7 @@ def test_version() -> None:
for codec in features.codecs:
test(codec, features.version_codec)
for feature in features.features:
- if "webp" in feature:
- with pytest.warns(DeprecationWarning, match="webp"):
- test(feature, features.version_feature)
- else:
- test(feature, features.version_feature)
+ test(feature, features.version_feature)
@skip_unless_feature("libjpeg_turbo")
@@ -112,6 +107,25 @@ def test_unsupported_module() -> None:
features.version_module(module)
+def test_unsupported_feature() -> None:
+ # Arrange
+ feature = "unsupported_feature"
+ # Act / Assert
+ with pytest.raises(ValueError):
+ features.check_feature(feature)
+ with pytest.raises(ValueError):
+ features.version_feature(feature)
+
+
+def test_unsupported_version() -> None:
+ assert features.version("unsupported_version") is None
+
+
+def test_modulenotfound(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(features, "features", {"test": ("PIL._test", "", "")})
+ assert features.check_feature("test") is None
+
+
@pytest.mark.parametrize("supported_formats", (True, False))
def test_pilinfo(supported_formats: bool) -> None:
buf = io.StringIO()
diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py
index 5c7a943b1..116dfa59c 100644
--- a/Tests/test_file_dds.py
+++ b/Tests/test_file_dds.py
@@ -380,21 +380,28 @@ def test_palette() -> None:
assert_image_equal_tofile(im, "Tests/images/transparent.gif")
+def test_unsupported_header_size() -> None:
+ with pytest.raises(OSError, match="Unsupported header size 0"):
+ with Image.open(BytesIO(b"DDS " + b"\x00" * 4)):
+ pass
+
+
def test_unsupported_bitcount() -> None:
- with pytest.raises(OSError):
+ with pytest.raises(OSError, match="Unsupported bitcount 24 for 131072"):
with Image.open("Tests/images/unsupported_bitcount.dds"):
pass
@pytest.mark.parametrize(
- "test_file",
+ "test_file, message",
(
- "Tests/images/unimplemented_dxgi_format.dds",
- "Tests/images/unimplemented_pfflags.dds",
+ ("Tests/images/unimplemented_dxgi_format.dds", "Unimplemented DXGI format 93"),
+ ("Tests/images/unimplemented_pixel_format.dds", "Unimplemented pixel format 0"),
+ ("Tests/images/unimplemented_pfflags.dds", "Unknown pixel format flags 8"),
),
)
-def test_not_implemented(test_file: str) -> None:
- with pytest.raises(NotImplementedError):
+def test_not_implemented(test_file: str, message: str) -> None:
+ with pytest.raises(NotImplementedError, match=message):
with Image.open(test_file):
pass
diff --git a/Tests/test_file_gbr.py b/Tests/test_file_gbr.py
index 1b834cd3c..b8851d82b 100644
--- a/Tests/test_file_gbr.py
+++ b/Tests/test_file_gbr.py
@@ -1,8 +1,10 @@
from __future__ import annotations
+from io import BytesIO
+
import pytest
-from PIL import GbrImagePlugin, Image
+from PIL import GbrImagePlugin, Image, _binary
from .helper import assert_image_equal_tofile
@@ -31,8 +33,49 @@ def test_multiple_load_operations() -> None:
assert_image_equal_tofile(im, "Tests/images/gbr.png")
-def test_invalid_file() -> None:
- invalid_file = "Tests/images/flower.jpg"
+def create_gbr_image(info: dict[str, int] = {}, magic_number=b"") -> BytesIO:
+ return BytesIO(
+ b"".join(
+ _binary.o32be(i)
+ for i in [
+ info.get("header_size", 20),
+ info.get("version", 1),
+ info.get("width", 1),
+ info.get("height", 1),
+ info.get("color_depth", 1),
+ ]
+ )
+ + magic_number
+ )
- with pytest.raises(SyntaxError):
+
+def test_invalid_file() -> None:
+ for f in [
+ create_gbr_image({"header_size": 0}),
+ create_gbr_image({"width": 0}),
+ create_gbr_image({"height": 0}),
+ ]:
+ with pytest.raises(SyntaxError, match="not a GIMP brush"):
+ GbrImagePlugin.GbrImageFile(f)
+
+ invalid_file = "Tests/images/flower.jpg"
+ with pytest.raises(SyntaxError, match="Unsupported GIMP brush version"):
GbrImagePlugin.GbrImageFile(invalid_file)
+
+
+def test_unsupported_gimp_brush() -> None:
+ f = create_gbr_image({"color_depth": 2})
+ with pytest.raises(SyntaxError, match="Unsupported GIMP brush color depth: 2"):
+ GbrImagePlugin.GbrImageFile(f)
+
+
+def test_bad_magic_number() -> None:
+ f = create_gbr_image({"version": 2}, magic_number=b"badm")
+ with pytest.raises(SyntaxError, match="not a GIMP brush, bad magic number"):
+ GbrImagePlugin.GbrImageFile(f)
+
+
+def test_L() -> None:
+ f = create_gbr_image()
+ with Image.open(f) as im:
+ assert im.mode == "L"
diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py
index 3c4c892c8..5a8aaa3ef 100644
--- a/Tests/test_file_iptc.py
+++ b/Tests/test_file_iptc.py
@@ -2,6 +2,8 @@ from __future__ import annotations
from io import BytesIO
+import pytest
+
from PIL import Image, IptcImagePlugin, TiffImagePlugin, TiffTags
from .helper import assert_image_equal, hopper
@@ -9,21 +11,78 @@ from .helper import assert_image_equal, hopper
TEST_FILE = "Tests/images/iptc.jpg"
+def create_iptc_image(info: dict[str, int] = {}) -> BytesIO:
+ def field(tag, value):
+ return bytes((0x1C,) + tag + (0, len(value))) + value
+
+ data = field((3, 60), bytes((info.get("layers", 1), info.get("component", 0))))
+ data += field((3, 120), bytes((info.get("compression", 1),)))
+ if "band" in info:
+ data += field((3, 65), bytes((info["band"] + 1,)))
+ data += field((3, 20), b"\x01") # width
+ data += field((3, 30), b"\x01") # height
+ data += field(
+ (8, 10),
+ bytes((info.get("data", 0),)),
+ )
+
+ return BytesIO(data)
+
+
def test_open() -> None:
expected = Image.new("L", (1, 1))
- f = BytesIO(
- b"\x1c\x03<\x00\x02\x01\x00\x1c\x03x\x00\x01\x01\x1c\x03\x14\x00\x01\x01"
- b"\x1c\x03\x1e\x00\x01\x01\x1c\x08\n\x00\x01\x00"
- )
+ f = create_iptc_image()
with Image.open(f) as im:
- assert im.tile == [("iptc", (0, 0, 1, 1), 25, "raw")]
+ assert im.tile == [("iptc", (0, 0, 1, 1), 25, ("raw", None))]
assert_image_equal(im, expected)
with Image.open(f) as im:
assert im.load() is not None
+def test_field_length() -> None:
+ f = create_iptc_image()
+ f.seek(28)
+ f.write(b"\xff")
+ with pytest.raises(OSError, match="illegal field length in IPTC/NAA file"):
+ with Image.open(f):
+ pass
+
+
+@pytest.mark.parametrize("layers, mode", ((3, "RGB"), (4, "CMYK")))
+def test_layers(layers: int, mode: str) -> None:
+ for band in range(-1, layers):
+ info = {"layers": layers, "component": 1, "data": 5}
+ if band != -1:
+ info["band"] = band
+ f = create_iptc_image(info)
+ with Image.open(f) as im:
+ assert im.mode == mode
+
+ data = [0] * layers
+ data[max(band, 0)] = 5
+ assert im.getpixel((0, 0)) == tuple(data)
+
+
+def test_unknown_compression() -> None:
+ f = create_iptc_image({"compression": 2})
+ with pytest.raises(OSError, match="Unknown IPTC image compression"):
+ with Image.open(f):
+ pass
+
+
+def test_getiptcinfo() -> None:
+ f = create_iptc_image()
+ with Image.open(f) as im:
+ assert IptcImagePlugin.getiptcinfo(im) == {
+ (3, 60): b"\x01\x00",
+ (3, 120): b"\x01",
+ (3, 20): b"\x01",
+ (3, 30): b"\x01",
+ }
+
+
def test_getiptcinfo_jpg_none() -> None:
# Arrange
with hopper() as im:
diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py
index dd665d9a0..e5a658d37 100644
--- a/Tests/test_file_jpeg.py
+++ b/Tests/test_file_jpeg.py
@@ -26,7 +26,6 @@ from .helper import (
assert_image_equal_tofile,
assert_image_similar,
assert_image_similar_tofile,
- cjpeg_available,
djpeg_available,
hopper,
is_win32,
@@ -731,14 +730,6 @@ class TestFileJpeg:
img.load_djpeg()
assert_image_similar_tofile(img, TEST_FILE, 5)
- @pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available")
- def test_save_cjpeg(self, tmp_path: Path) -> None:
- with Image.open(TEST_FILE) as img:
- tempfile = str(tmp_path / "temp.jpg")
- JpegImagePlugin._save_cjpeg(img, BytesIO(), tempfile)
- # Default save quality is 75%, so a tiny bit of difference is alright
- assert_image_similar_tofile(img, tempfile, 17)
-
def test_no_duplicate_0x1001_tag(self) -> None:
# Arrange
tag_ids = {v: k for k, v in ExifTags.TAGS.items()}
diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index c245a5a9b..f61f79f17 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -365,8 +365,7 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
- if 700 in reloaded.tag_v2:
- assert reloaded.tag_v2[700] == b"xmlpacket tag"
+ assert reloaded.tag_v2[700] == b"xmlpacket tag"
def test_int_dpi(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
# issue #1765
@@ -873,8 +872,8 @@ class TestFileLibTiff(LibTiffTestCase):
assert im.mode == "RGB"
assert im.size == (128, 128)
assert im.format == "TIFF"
- im2 = hopper()
- assert_image_similar(im, im2, 5)
+ with hopper() as im2:
+ assert_image_similar(im, im2, 5)
except OSError:
captured = capfd.readouterr()
if "LZMA compression support is not configured" in captured.err:
diff --git a/Tests/test_file_pcd.py b/Tests/test_file_pcd.py
index 81a316fc1..9bf1a75f0 100644
--- a/Tests/test_file_pcd.py
+++ b/Tests/test_file_pcd.py
@@ -1,10 +1,15 @@
from __future__ import annotations
+from io import BytesIO
+
+import pytest
+
from PIL import Image
def test_load_raw() -> None:
with Image.open("Tests/images/hopper.pcd") as im:
+ assert im.size == (768, 512)
im.load() # should not segfault.
# Note that this image was created with a resized hopper
@@ -15,3 +20,13 @@ def test_load_raw() -> None:
# target = hopper().resize((768,512))
# assert_image_similar(im, target, 10)
+
+
+@pytest.mark.parametrize("orientation", (1, 3))
+def test_rotated(orientation: int) -> None:
+ with open("Tests/images/hopper.pcd", "rb") as fp:
+ data = bytearray(fp.read())
+ data[2048 + 1538] = orientation
+ f = BytesIO(data)
+ with Image.open(f) as im:
+ assert im.size == (512, 768)
diff --git a/Tests/test_file_wal.py b/Tests/test_file_wal.py
index b15d79d61..549d47054 100644
--- a/Tests/test_file_wal.py
+++ b/Tests/test_file_wal.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from io import BytesIO
+
from PIL import WalImageFile
from .helper import assert_image_equal_tofile
@@ -13,12 +15,22 @@ def test_open() -> None:
assert im.format_description == "Quake2 Texture"
assert im.mode == "P"
assert im.size == (128, 128)
+ assert "next_name" not in im.info
assert isinstance(im, WalImageFile.WalImageFile)
assert_image_equal_tofile(im, "Tests/images/hopper_wal.png")
+def test_next_name() -> None:
+ with open(TEST_FILE, "rb") as fp:
+ data = bytearray(fp.read())
+ data[56:60] = b"Test"
+ f = BytesIO(data)
+ with WalImageFile.open(f) as im:
+ assert im.info["next_name"] == b"Test"
+
+
def test_load() -> None:
with WalImageFile.open(TEST_FILE) as im:
px = im.load()
diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py
index 503761374..600448fb9 100644
--- a/Tests/test_file_webp_animated.py
+++ b/Tests/test_file_webp_animated.py
@@ -4,13 +4,13 @@ from collections.abc import Generator
from pathlib import Path
import pytest
-from packaging.version import parse as parse_version
-from PIL import GifImagePlugin, Image, WebPImagePlugin, features
+from PIL import GifImagePlugin, Image, WebPImagePlugin
from .helper import (
assert_image_equal,
assert_image_similar,
+ has_feature_version,
is_big_endian,
skip_unless_feature,
)
@@ -53,11 +53,8 @@ def test_write_animation_L(tmp_path: Path) -> None:
im.load()
assert_image_similar(im, orig.convert("RGBA"), 32.9)
- if is_big_endian():
- version = features.version_module("webp")
- assert version is not None
- if parse_version(version) < parse_version("1.2.2"):
- pytest.skip("Fails with libwebp earlier than 1.2.2")
+ if is_big_endian() and not has_feature_version("webp", "1.2.2"):
+ pytest.skip("Fails with libwebp earlier than 1.2.2")
orig.seek(orig.n_frames - 1)
im.seek(im.n_frames - 1)
orig.load()
@@ -81,11 +78,8 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
assert_image_equal(im, frame1.convert("RGBA"))
# Compare second frame to original
- if is_big_endian():
- version = features.version_module("webp")
- assert version is not None
- if parse_version(version) < parse_version("1.2.2"):
- pytest.skip("Fails with libwebp earlier than 1.2.2")
+ if is_big_endian() and not has_feature_version("webp", "1.2.2"):
+ pytest.skip("Fails with libwebp earlier than 1.2.2")
im.seek(1)
im.load()
assert_image_equal(im, frame2.convert("RGBA"))
diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py
index dcf5f000f..906080d15 100644
--- a/Tests/test_file_wmf.py
+++ b/Tests/test_file_wmf.py
@@ -44,6 +44,18 @@ def test_load_zero_inch() -> None:
pass
+def test_load_unsupported_wmf() -> None:
+ b = BytesIO(b"\xd7\xcd\xc6\x9a\x00\x00" + b"\x01" * 10)
+ with pytest.raises(SyntaxError, match="Unsupported WMF file format"):
+ WmfImagePlugin.WmfStubImageFile(b)
+
+
+def test_load_unsupported() -> None:
+ b = BytesIO(b"\x01\x00\x00\x00")
+ with pytest.raises(SyntaxError, match="Unsupported file format"):
+ WmfImagePlugin.WmfStubImageFile(b)
+
+
def test_render() -> None:
with open("Tests/images/drawing.emf", "rb") as fp:
data = fp.read()
diff --git a/Tests/test_font_crash.py b/Tests/test_font_crash.py
index b82340ef7..54bd2d183 100644
--- a/Tests/test_font_crash.py
+++ b/Tests/test_font_crash.py
@@ -9,7 +9,8 @@ from .helper import skip_unless_feature
class TestFontCrash:
def _fuzz_font(self, font: ImageFont.FreeTypeFont) -> None:
- # from fuzzers.fuzz_font
+ # Copy of the code from fuzz_font() in Tests/oss-fuzz/fuzzers.py
+ # that triggered a problem when fuzzing
font.getbbox("ABC")
font.getmask("test text")
with Image.new(mode="RGBA", size=(200, 200)) as im:
diff --git a/Tests/test_format_hsv.py b/Tests/test_format_hsv.py
index 9cbf18566..861eccc11 100644
--- a/Tests/test_format_hsv.py
+++ b/Tests/test_format_hsv.py
@@ -2,12 +2,15 @@ from __future__ import annotations
import colorsys
import itertools
-from typing import Callable
from PIL import Image
from .helper import assert_image_similar, hopper
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
def int_to_float(i: int) -> float:
return i / 255
diff --git a/Tests/test_image.py b/Tests/test_image.py
index 35ce5ea4b..f0680ddab 100644
--- a/Tests/test_image.py
+++ b/Tests/test_image.py
@@ -19,6 +19,7 @@ from PIL import (
ImageDraw,
ImageFile,
ImagePalette,
+ ImageShow,
TiffImagePlugin,
UnidentifiedImageError,
features,
@@ -389,6 +390,37 @@ class TestImage:
assert img_colors is not None
assert sorted(img_colors) == expected_colors
+ def test_alpha_composite_la(self) -> None:
+ # Arrange
+ expected_colors = sorted(
+ [
+ (3300, (255, 255)),
+ (1156, (170, 192)),
+ (1122, (128, 255)),
+ (1089, (0, 0)),
+ (1122, (255, 128)),
+ (1122, (0, 128)),
+ (1089, (0, 255)),
+ ]
+ )
+
+ dst = Image.new("LA", size=(100, 100), color=(0, 255))
+ draw = ImageDraw.Draw(dst)
+ draw.rectangle((0, 33, 100, 66), fill=(0, 128))
+ draw.rectangle((0, 67, 100, 100), fill=(0, 0))
+ src = Image.new("LA", size=(100, 100), color=(255, 255))
+ draw = ImageDraw.Draw(src)
+ draw.rectangle((33, 0, 66, 100), fill=(255, 128))
+ draw.rectangle((67, 0, 100, 100), fill=(255, 0))
+
+ # Act
+ img = Image.alpha_composite(dst, src)
+
+ # Assert
+ img_colors = img.getcolors()
+ assert img_colors is not None
+ assert sorted(img_colors) == expected_colors
+
def test_alpha_inplace(self) -> None:
src = Image.new("RGBA", (128, 128), "blue")
@@ -994,6 +1026,17 @@ class TestImage:
reloaded_exif.load(exif.tobytes())
assert reloaded_exif.get_ifd(0x8769) == exif.get_ifd(0x8769)
+ def test_delete_ifd_tag(self) -> None:
+ with Image.open("Tests/images/flower.jpg") as im:
+ exif = im.getexif()
+ exif.get_ifd(0x8769)
+ assert 0x8769 in exif
+ del exif[0x8769]
+
+ reloaded_exif = Image.Exif()
+ reloaded_exif.load(exif.tobytes())
+ assert 0x8769 not in reloaded_exif
+
def test_exif_load_from_fp(self) -> None:
with Image.open("Tests/images/flower.jpg") as im:
data = im.info["exif"]
@@ -1077,6 +1120,13 @@ class TestImage:
with pytest.warns(DeprecationWarning, match="Image.Image.get_child_images"):
assert im.get_child_images() == []
+ def test_show(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(ImageShow, "_viewers", [])
+
+ im = Image.new("RGB", (1, 1))
+ with pytest.warns(DeprecationWarning, match="Image._show"):
+ Image._show(im)
+
@pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0)))
def test_zero_tobytes(self, size: tuple[int, int]) -> None:
im = Image.new("RGB", size)
diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py
index b3de5c13d..07c12594a 100644
--- a/Tests/test_image_access.py
+++ b/Tests/test_image_access.py
@@ -315,3 +315,10 @@ int main(int argc, char* argv[])
process = subprocess.Popen(["embed_pil.exe"], env=env)
process.communicate()
assert process.returncode == 0
+
+ def teardown_method(self) -> None:
+ try:
+ os.remove("embed_pil.c")
+ except FileNotFoundError:
+ # If the test was skipped or failed, the file won't exist
+ pass
diff --git a/Tests/test_image_histogram.py b/Tests/test_image_histogram.py
index dbd55d4c2..436eb78a2 100644
--- a/Tests/test_image_histogram.py
+++ b/Tests/test_image_histogram.py
@@ -10,9 +10,12 @@ def test_histogram() -> None:
assert histogram("1") == (256, 0, 10994)
assert histogram("L") == (256, 0, 662)
+ assert histogram("LA") == (512, 0, 16384)
+ assert histogram("La") == (512, 0, 16384)
assert histogram("I") == (256, 0, 662)
assert histogram("F") == (256, 0, 662)
assert histogram("P") == (256, 0, 1551)
+ assert histogram("PA") == (512, 0, 16384)
assert histogram("RGB") == (768, 4, 675)
assert histogram("RGBA") == (1024, 0, 16384)
assert histogram("CMYK") == (1024, 0, 16384)
diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py
index 6d313cb8c..d847c7440 100644
--- a/Tests/test_image_quantize.py
+++ b/Tests/test_image_quantize.py
@@ -1,11 +1,16 @@
from __future__ import annotations
import pytest
-from packaging.version import parse as parse_version
-from PIL import Image, features
+from PIL import Image
-from .helper import assert_image_similar, hopper, is_ppc64le, skip_unless_feature
+from .helper import (
+ assert_image_similar,
+ has_feature_version,
+ hopper,
+ is_ppc64le,
+ skip_unless_feature,
+)
def test_sanity() -> None:
@@ -23,11 +28,8 @@ def test_sanity() -> None:
@skip_unless_feature("libimagequant")
def test_libimagequant_quantize() -> None:
image = hopper()
- if is_ppc64le():
- version = features.version_feature("libimagequant")
- assert version is not None
- if parse_version(version) < parse_version("4"):
- pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
+ if is_ppc64le() and not has_feature_version("libimagequant", "4"):
+ pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT)
assert converted.mode == "P"
assert_image_similar(converted.convert("RGB"), image, 15)
diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py
index 0429eb99d..7cf52ddba 100644
--- a/Tests/test_image_transform.py
+++ b/Tests/test_image_transform.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import math
-from typing import Callable
import pytest
@@ -9,6 +8,10 @@ from PIL import Image, ImageTransform
from .helper import assert_image_equal, assert_image_similar, hopper
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
class TestImageTransform:
def test_sanity(self) -> None:
diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py
index 4309214f5..61812ca7d 100644
--- a/Tests/test_imagechops.py
+++ b/Tests/test_imagechops.py
@@ -1,11 +1,13 @@
from __future__ import annotations
-from typing import Callable
-
from PIL import Image, ImageChops
from .helper import assert_image_equal, hopper
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
BLACK = (0, 0, 0)
BROWN = (127, 64, 0)
CYAN = (0, 255, 255)
diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py
index 55a4a87fb..5fd7caa7c 100644
--- a/Tests/test_imagecms.py
+++ b/Tests/test_imagecms.py
@@ -7,7 +7,7 @@ import shutil
import sys
from io import BytesIO
from pathlib import Path
-from typing import Any, Literal, cast
+from typing import Literal, cast
import pytest
@@ -31,6 +31,9 @@ except ImportError:
# Skipped via setup_module()
pass
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from typing import Any
SRGB = "Tests/icc/sRGB_IEC61966-2-1_black_scaled.icc"
HAVE_PROFILE = os.path.exists(SRGB)
@@ -208,9 +211,10 @@ def test_exceptions() -> None:
ImageCms.getProfileName(None) # type: ignore[arg-type]
skip_missing()
- # Python <= 3.9: "an integer is required (got type NoneType)"
- # Python > 3.9: "'NoneType' object cannot be interpreted as an integer"
- with pytest.raises(ImageCms.PyCMSError, match="integer"):
+ with pytest.raises(
+ ImageCms.PyCMSError,
+ match="'NoneType' object cannot be interpreted as an integer",
+ ):
ImageCms.isIntentSupported(SRGB, None, None) # type: ignore[arg-type]
@@ -690,3 +694,17 @@ 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:
+ profile = ImageCmsProfile(ImageCms.createProfile("sRGB"))
+ with pytest.warns(
+ DeprecationWarning, match="ImageCms.ImageCmsProfile.product_name"
+ ):
+ profile.product_name
+ with pytest.warns(
+ DeprecationWarning, match="ImageCms.ImageCmsProfile.product_info"
+ ):
+ profile.product_info
+ with pytest.raises(AttributeError):
+ profile.this_attribute_does_not_exist
diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py
index e1dcbc52c..406d965b4 100644
--- a/Tests/test_imagedraw.py
+++ b/Tests/test_imagedraw.py
@@ -1,13 +1,10 @@
from __future__ import annotations
import os.path
-from collections.abc import Sequence
-from typing import Callable
import pytest
from PIL import Image, ImageColor, ImageDraw, ImageFont, features
-from PIL._typing import Coords
from .helper import (
assert_image_equal,
@@ -17,6 +14,12 @@ from .helper import (
skip_unless_feature,
)
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable, Sequence
+
+ from PIL._typing import Coords
+
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
GRAY = (190, 190, 190)
diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py
index 5954de874..95af3fda8 100644
--- a/Tests/test_imagefontctl.py
+++ b/Tests/test_imagefontctl.py
@@ -7,6 +7,7 @@ from PIL import Image, ImageDraw, ImageFont
from .helper import (
assert_image_equal_tofile,
assert_image_similar_tofile,
+ has_feature_version,
skip_unless_feature,
)
@@ -104,11 +105,9 @@ def test_text_direction_ttb() -> None:
im = Image.new(mode="RGB", size=(100, 300))
draw = ImageDraw.Draw(im)
- try:
- draw.text((0, 0), "English あい", font=ttf, fill=500, direction="ttb")
- except ValueError as ex:
- if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
- pytest.skip("libraqm 0.7 or greater not available")
+ if not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ draw.text((0, 0), "English あい", font=ttf, fill=500, direction="ttb")
target = "Tests/images/test_direction_ttb.png"
assert_image_similar_tofile(im, target, 2.8)
@@ -119,19 +118,17 @@ def test_text_direction_ttb_stroke() -> None:
im = Image.new(mode="RGB", size=(100, 300))
draw = ImageDraw.Draw(im)
- try:
- draw.text(
- (27, 27),
- "あい",
- font=ttf,
- fill=500,
- direction="ttb",
- stroke_width=2,
- stroke_fill="#0f0",
- )
- except ValueError as ex:
- if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
- pytest.skip("libraqm 0.7 or greater not available")
+ if not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ draw.text(
+ (27, 27),
+ "あい",
+ font=ttf,
+ fill=500,
+ direction="ttb",
+ stroke_width=2,
+ stroke_fill="#0f0",
+ )
target = "Tests/images/test_direction_ttb_stroke.png"
assert_image_similar_tofile(im, target, 19.4)
@@ -219,14 +216,9 @@ def test_getlength(
im = Image.new(mode, (1, 1), 0)
d = ImageDraw.Draw(im)
- try:
- assert d.textlength(text, ttf, direction) == expected
- except ValueError as ex:
- if (
- direction == "ttb"
- and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction"
- ):
- pytest.skip("libraqm 0.7 or greater not available")
+ if direction == "ttb" and not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ assert d.textlength(text, ttf, direction) == expected
@pytest.mark.parametrize("mode", ("L", "1"))
@@ -242,17 +234,12 @@ def test_getlength_combine(mode: str, direction: str, text: str) -> None:
ttf = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
- try:
- target = ttf.getlength("ii", mode, direction)
- actual = ttf.getlength(text, mode, direction)
+ if direction == "ttb" and not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ target = ttf.getlength("ii", mode, direction)
+ actual = ttf.getlength(text, mode, direction)
- assert actual == target
- except ValueError as ex:
- if (
- direction == "ttb"
- and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction"
- ):
- pytest.skip("libraqm 0.7 or greater not available")
+ assert actual == target
@pytest.mark.parametrize("anchor", ("lt", "mm", "rb", "sm"))
@@ -265,11 +252,9 @@ def test_anchor_ttb(anchor: str) -> None:
d = ImageDraw.Draw(im)
d.line(((0, 200), (200, 200)), "gray")
d.line(((100, 0), (100, 400)), "gray")
- try:
- d.text((100, 200), text, fill="black", anchor=anchor, direction="ttb", font=f)
- except ValueError as ex:
- if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
- pytest.skip("libraqm 0.7 or greater not available")
+ if not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ d.text((100, 200), text, fill="black", anchor=anchor, direction="ttb", font=f)
assert_image_similar_tofile(im, path, 1) # fails at 5
@@ -310,10 +295,12 @@ combine_tests = (
# this tests various combining characters for anchor alignment and clipping
@pytest.mark.parametrize(
- "name, text, anchor, dir, epsilon", combine_tests, ids=[r[0] for r in combine_tests]
+ "name, text, anchor, direction, epsilon",
+ combine_tests,
+ ids=[r[0] for r in combine_tests],
)
def test_combine(
- name: str, text: str, dir: str | None, anchor: str | None, epsilon: float
+ name: str, text: str, direction: str | None, anchor: str | None, epsilon: float
) -> None:
path = f"Tests/images/test_combine_{name}.png"
f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
@@ -322,11 +309,9 @@ def test_combine(
d = ImageDraw.Draw(im)
d.line(((0, 200), (400, 200)), "gray")
d.line(((200, 0), (200, 400)), "gray")
- try:
- d.text((200, 200), text, fill="black", anchor=anchor, direction=dir, font=f)
- except ValueError as ex:
- if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
- pytest.skip("libraqm 0.7 or greater not available")
+ if direction == "ttb" and not has_feature_version("raqm", "0.7"):
+ pytest.skip("libraqm 0.7 or greater not available")
+ d.text((200, 200), text, fill="black", anchor=anchor, direction=direction, font=f)
assert_image_similar_tofile(im, path, epsilon)
diff --git a/Tests/test_imagemath_lambda_eval.py b/Tests/test_imagemath_lambda_eval.py
index 26c04b9a0..ce2a32ae8 100644
--- a/Tests/test_imagemath_lambda_eval.py
+++ b/Tests/test_imagemath_lambda_eval.py
@@ -2,7 +2,9 @@ from __future__ import annotations
from typing import Any
-from PIL import Image, ImageMath
+import pytest
+
+from PIL import Image, ImageMath, _imagingmath
def pixel(im: Image.Image | int) -> str | int:
@@ -498,3 +500,31 @@ def test_logical_not_equal() -> None:
)
== "I 1"
)
+
+
+def test_reflected_operands() -> None:
+ assert pixel(ImageMath.lambda_eval(lambda args: 1 + args["A"], **images)) == "I 2"
+ assert pixel(ImageMath.lambda_eval(lambda args: 1 - args["A"], **images)) == "I 0"
+ assert pixel(ImageMath.lambda_eval(lambda args: 1 * args["A"], **images)) == "I 1"
+ assert pixel(ImageMath.lambda_eval(lambda args: 1 / args["A"], **images)) == "I 1"
+ assert pixel(ImageMath.lambda_eval(lambda args: 1 % args["A"], **images)) == "I 0"
+ assert pixel(ImageMath.lambda_eval(lambda args: 1 ** args["A"], **images)) == "I 1"
+ assert pixel(ImageMath.lambda_eval(lambda args: 1 & args["A"], **images)) == "I 1"
+ assert pixel(ImageMath.lambda_eval(lambda args: 1 | args["A"], **images)) == "I 1"
+ assert pixel(ImageMath.lambda_eval(lambda args: 1 ^ args["A"], **images)) == "I 0"
+
+
+def test_unsupported_mode() -> None:
+ im = Image.new("RGB", (1, 1))
+ with pytest.raises(ValueError, match="unsupported mode: RGB"):
+ ImageMath.lambda_eval(lambda args: args["im"] + 1, im=im)
+
+
+def test_bad_operand_type(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delattr(_imagingmath, "abs_I")
+ with pytest.raises(TypeError, match="bad operand type for 'abs'"):
+ ImageMath.lambda_eval(lambda args: abs(args["I"]), I=I)
+
+ monkeypatch.delattr(_imagingmath, "max_F")
+ with pytest.raises(TypeError, match="bad operand type for 'max'"):
+ ImageMath.lambda_eval(lambda args: args["max"](args["I"], args["F"]), I=I, F=F)
diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py
index 515e29cea..ca192a809 100644
--- a/Tests/test_imagemorph.py
+++ b/Tests/test_imagemorph.py
@@ -7,7 +7,7 @@ import pytest
from PIL import Image, ImageMorph, _imagingmorph
-from .helper import assert_image_equal_tofile, hopper
+from .helper import assert_image_equal_tofile, hopper, timeout_unless_slower_valgrind
def string_to_img(image_string: str) -> Image.Image:
@@ -266,16 +266,18 @@ def test_unknown_pattern() -> None:
ImageMorph.LutBuilder(op_name="unknown")
-def test_pattern_syntax_error() -> None:
+@pytest.mark.parametrize(
+ "pattern", ("a pattern with a syntax error", "4:(" + "X" * 30000)
+)
+@timeout_unless_slower_valgrind(1)
+def test_pattern_syntax_error(pattern: str) -> None:
# Arrange
lb = ImageMorph.LutBuilder(op_name="corner")
- new_patterns = ["a pattern with a syntax error"]
+ new_patterns = [pattern]
lb.add_patterns(new_patterns)
# Act / Assert
- with pytest.raises(
- Exception, match='Syntax error in pattern "a pattern with a syntax error"'
- ):
+ with pytest.raises(Exception, match='Syntax error in pattern "'):
lb.build_lut()
diff --git a/Tests/test_imagestat.py b/Tests/test_imagestat.py
index 0dfbc5a2a..0baab7ce2 100644
--- a/Tests/test_imagestat.py
+++ b/Tests/test_imagestat.py
@@ -57,3 +57,13 @@ def test_constant() -> None:
assert st.rms[0] == 128
assert st.var[0] == 0
assert st.stddev[0] == 0
+
+
+def test_zero_count() -> None:
+ im = Image.new("L", (0, 0))
+
+ st = ImageStat.Stat(im)
+
+ assert st.mean == [0]
+ assert st.rms == [0]
+ assert st.var == [0]
diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py
index ef54deeeb..f6acb3aff 100644
--- a/Tests/test_numpy.py
+++ b/Tests/test_numpy.py
@@ -28,15 +28,13 @@ def test_numpy_to_image() -> None:
a = numpy.array(data, dtype=dtype)
a.shape = TEST_IMAGE_SIZE
i = Image.fromarray(a)
- if list(i.getdata()) != data:
- print("data mismatch for", dtype)
+ assert list(i.getdata()) == data
else:
data = list(range(100))
a = numpy.array([[x] * bands for x in data], dtype=dtype)
a.shape = TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1], bands
i = Image.fromarray(a)
- if list(i.getchannel(0).getdata()) != list(range(100)):
- print("data mismatch for", dtype)
+ assert list(i.getchannel(0).getdata()) == list(range(100))
return i
# Check supported 1-bit integer formats
diff --git a/Tests/test_pyroma.py b/Tests/test_pyroma.py
index a161d3f05..5871a7213 100644
--- a/Tests/test_pyroma.py
+++ b/Tests/test_pyroma.py
@@ -9,9 +9,30 @@ from PIL import __version__
pyroma = pytest.importorskip("pyroma", reason="Pyroma not installed")
+def map_metadata_keys(md):
+ # Convert installed wheel metadata into canonical Core Metadata 2.4 format.
+ # This was a utility method in pyroma 4.3.3; it was removed in 5.0.
+ # This implementation is constructed from the relevant logic from
+ # Pyroma 5.0's `build_metadata()` implementation. This has been submitted
+ # upstream to Pyroma as https://github.com/regebro/pyroma/pull/116,
+ # so it may be possible to simplify this test in future.
+ data = {}
+ for key in set(md.keys()):
+ value = md.get_all(key)
+ key = pyroma.projectdata.normalize(key)
+
+ if len(value) == 1:
+ value = value[0]
+ if value.strip() == "UNKNOWN":
+ continue
+
+ data[key] = value
+ return data
+
+
def test_pyroma() -> None:
# Arrange
- data = pyroma.projectdata.map_metadata_keys(metadata("Pillow"))
+ data = map_metadata_keys(metadata("Pillow"))
# Act
rating = pyroma.ratings.rate(data)
diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py
index 82a3e0741..b31e2a4ef 100644
--- a/Tests/test_qt_image_qapplication.py
+++ b/Tests/test_qt_image_qapplication.py
@@ -1,8 +1,5 @@
from __future__ import annotations
-from pathlib import Path
-from typing import Union
-
import pytest
from PIL import Image, ImageQt
@@ -11,18 +8,8 @@ from .helper import assert_image_equal_tofile, assert_image_similar, hopper
TYPE_CHECKING = False
if TYPE_CHECKING:
- import PyQt6
- import PySide6
+ from pathlib import Path
- QApplication = Union[PyQt6.QtWidgets.QApplication, PySide6.QtWidgets.QApplication]
- QHBoxLayout = Union[PyQt6.QtWidgets.QHBoxLayout, PySide6.QtWidgets.QHBoxLayout]
- QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage]
- QLabel = Union[PyQt6.QtWidgets.QLabel, PySide6.QtWidgets.QLabel]
- QPainter = Union[PyQt6.QtGui.QPainter, PySide6.QtGui.QPainter]
- QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap]
- QPoint = Union[PyQt6.QtCore.QPoint, PySide6.QtCore.QPoint]
- QRegion = Union[PyQt6.QtGui.QRegion, PySide6.QtGui.QRegion]
- QWidget = Union[PyQt6.QtWidgets.QWidget, PySide6.QtWidgets.QWidget]
if ImageQt.qt_is_installed:
from PIL.ImageQt import QPixmap
@@ -32,11 +19,16 @@ if ImageQt.qt_is_installed:
from PyQt6.QtGui import QImage, QPainter, QRegion
from PyQt6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
elif ImageQt.qt_version == "side6":
- from PySide6.QtCore import QPoint
- from PySide6.QtGui import QImage, QPainter, QRegion
- from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
+ from PySide6.QtCore import QPoint # type: ignore[assignment]
+ from PySide6.QtGui import QImage, QPainter, QRegion # type: ignore[assignment]
+ from PySide6.QtWidgets import ( # type: ignore[assignment]
+ QApplication,
+ QHBoxLayout,
+ QLabel,
+ QWidget,
+ )
- class Example(QWidget): # type: ignore[misc]
+ class Example(QWidget):
def __init__(self) -> None:
super().__init__()
@@ -47,9 +39,9 @@ if ImageQt.qt_is_installed:
pixmap1 = getattr(ImageQt.QPixmap, "fromImage")(qimage)
# hbox
- QHBoxLayout(self) # type: ignore[operator]
+ QHBoxLayout(self)
- lbl = QLabel(self) # type: ignore[operator]
+ lbl = QLabel(self)
# Segfault in the problem
lbl.setPixmap(pixmap1.copy())
@@ -63,7 +55,7 @@ def roundtrip(expected: Image.Image) -> None:
@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed")
def test_sanity(tmp_path: Path) -> None:
# Segfault test
- app: QApplication | None = QApplication([]) # type: ignore[operator]
+ app: QApplication | None = QApplication([])
ex = Example()
assert app # Silence warning
assert ex # Silence warning
@@ -84,11 +76,11 @@ def test_sanity(tmp_path: Path) -> None:
imageqt = ImageQt.ImageQt(im)
data = getattr(QPixmap, "fromImage")(imageqt)
qt_format = getattr(QImage, "Format") if ImageQt.qt_version == "6" else QImage
- qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32")) # type: ignore[operator]
- painter = QPainter(qimage) # type: ignore[operator]
- image_label = QLabel() # type: ignore[operator]
+ qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32"))
+ painter = QPainter(qimage)
+ image_label = QLabel()
image_label.setPixmap(data)
- image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) # type: ignore[operator]
+ image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128))
painter.end()
rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png")
qimage.save(rendered_tempfile)
diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py
index 8cb7ffb9b..0004b5521 100644
--- a/Tests/test_qt_image_toqimage.py
+++ b/Tests/test_qt_image_toqimage.py
@@ -1,13 +1,15 @@
from __future__ import annotations
-from pathlib import Path
-
import pytest
from PIL import ImageQt
from .helper import assert_image_equal, assert_image_equal_tofile, hopper
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from pathlib import Path
+
pytestmark = pytest.mark.skipif(
not ImageQt.qt_is_installed, reason="Qt bindings are not installed"
)
@@ -21,7 +23,7 @@ def test_sanity(mode: str, tmp_path: Path) -> None:
src = hopper(mode)
data = ImageQt.toqimage(src)
- assert isinstance(data, QImage) # type: ignore[arg-type, misc]
+ assert isinstance(data, QImage)
assert not data.isNull()
# reload directly from the qimage
diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py
index 03e92b5b9..465517bb6 100644
--- a/Tests/test_shell_injection.py
+++ b/Tests/test_shell_injection.py
@@ -2,14 +2,18 @@ from __future__ import annotations
import shutil
from io import BytesIO
-from pathlib import Path
-from typing import IO, Callable
import pytest
from PIL import GifImagePlugin, Image, JpegImagePlugin
-from .helper import cjpeg_available, djpeg_available, is_win32, netpbm_available
+from .helper import djpeg_available, is_win32, netpbm_available
+
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from pathlib import Path
+ from typing import IO
TEST_JPG = "Tests/images/hopper.jpg"
TEST_GIF = "Tests/images/hopper.gif"
@@ -42,11 +46,6 @@ class TestShellInjection:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
im.load_djpeg()
- @pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available")
- def test_save_cjpeg_filename(self, tmp_path: Path) -> None:
- with Image.open(TEST_JPG) as im:
- self.assert_save_filename_check(tmp_path, im, JpegImagePlugin._save_cjpeg)
-
@pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available")
def test_save_netpbm_filename_bmp_mode(self, tmp_path: Path) -> None:
with Image.open(TEST_GIF) as im:
diff --git a/checks/check_imaging_leaks.py b/checks/check_imaging_leaks.py
index 231789ca0..e9f202f3d 100755
--- a/checks/check_imaging_leaks.py
+++ b/checks/check_imaging_leaks.py
@@ -1,18 +1,20 @@
#!/usr/bin/env python3
from __future__ import annotations
-from typing import Any, Callable
+import sys
+from collections.abc import Callable
+from typing import Any
import pytest
from PIL import Image
-from .helper import is_win32
-
min_iterations = 100
max_iterations = 10000
-pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS")
+pytestmark = pytest.mark.skipif(
+ sys.platform.startswith("win32"), reason="requires Unix or macOS"
+)
def _get_mem_usage() -> float:
diff --git a/checks/check_j2k_leaks.py b/checks/check_j2k_leaks.py
index bbe35b591..7103d502e 100644
--- a/checks/check_j2k_leaks.py
+++ b/checks/check_j2k_leaks.py
@@ -1,12 +1,11 @@
from __future__ import annotations
+import sys
from io import BytesIO
import pytest
-from PIL import Image
-
-from .helper import is_win32, skip_unless_feature
+from PIL import Image, features
# Limits for testing the leak
mem_limit = 1024 * 1048576
@@ -15,8 +14,10 @@ iterations = int((mem_limit / stack_size) * 2)
test_file = "Tests/images/rgb_trns_ycbc.jp2"
pytestmark = [
- pytest.mark.skipif(is_win32(), reason="requires Unix or macOS"),
- skip_unless_feature("jpg_2000"),
+ pytest.mark.skipif(
+ sys.platform.startswith("win32"), reason="requires Unix or macOS"
+ ),
+ pytest.mark.skipif(not features.check("jpg_2000"), reason="jpg_2000 not available"),
]
diff --git a/checks/check_jpeg_leaks.py b/checks/check_jpeg_leaks.py
index 2f42ad734..2c27ce1d5 100644
--- a/checks/check_jpeg_leaks.py
+++ b/checks/check_jpeg_leaks.py
@@ -1,10 +1,11 @@
from __future__ import annotations
+import sys
from io import BytesIO
import pytest
-from .helper import hopper, is_win32
+from PIL import Image
iterations = 5000
@@ -18,7 +19,9 @@ valgrind --tool=massif python test-installed.py -s -v checks/check_jpeg_leaks.py
"""
-pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS")
+pytestmark = pytest.mark.skipif(
+ sys.platform.startswith("win32"), reason="requires Unix or macOS"
+)
"""
pre patch:
@@ -112,10 +115,10 @@ standard_chrominance_qtable = (
),
)
def test_qtables_leak(qtables: tuple[tuple[int, ...]] | list[tuple[int, ...]]) -> None:
- im = hopper("RGB")
- for _ in range(iterations):
- test_output = BytesIO()
- im.save(test_output, "JPEG", qtables=qtables)
+ with Image.open("Tests/images/hopper.ppm") as im:
+ for _ in range(iterations):
+ test_output = BytesIO()
+ im.save(test_output, "JPEG", qtables=qtables)
def test_exif_leak() -> None:
@@ -173,12 +176,12 @@ def test_exif_leak() -> None:
0 +----------------------------------------------------------------------->Gi
0 11.33
"""
- im = hopper("RGB")
exif = b"12345678" * 4096
- for _ in range(iterations):
- test_output = BytesIO()
- im.save(test_output, "JPEG", exif=exif)
+ with Image.open("Tests/images/hopper.ppm") as im:
+ for _ in range(iterations):
+ test_output = BytesIO()
+ im.save(test_output, "JPEG", exif=exif)
def test_base_save() -> None:
@@ -207,8 +210,7 @@ def test_base_save() -> None:
| :@ @@ @ # : : :: :: @:: :::: :::: :::: : : : : : : :::::::::::: :::@:::
0 +----------------------------------------------------------------------->Gi
0 7.882"""
- im = hopper("RGB")
-
- for _ in range(iterations):
- test_output = BytesIO()
- im.save(test_output, "JPEG")
+ with Image.open("Tests/images/hopper.ppm") as im:
+ for _ in range(iterations):
+ test_output = BytesIO()
+ im.save(test_output, "JPEG")
diff --git a/checks/check_wheel.py b/checks/check_wheel.py
index 3d806eb71..f716c8498 100644
--- a/checks/check_wheel.py
+++ b/checks/check_wheel.py
@@ -4,7 +4,6 @@ import platform
import sys
from PIL import features
-from Tests.helper import is_pypy
def test_wheel_modules() -> None:
@@ -25,8 +24,7 @@ def test_wheel_modules() -> None:
elif sys.platform == "ios":
# tkinter is not available on iOS
- # libavif is not available on iOS (for now)
- expected_modules -= {"tkinter", "avif"}
+ expected_modules.remove("tkinter")
assert set(features.get_supported_modules()) == expected_modules
@@ -49,8 +47,6 @@ def test_wheel_features() -> None:
if sys.platform == "win32":
expected_features.remove("xcb")
- elif sys.platform == "darwin" and not is_pypy() and platform.processor() != "arm":
- expected_features.remove("zlib_ng")
elif sys.platform == "ios":
# Can't distribute raqm due to licensing, and there's no system version;
# fribidi and harfbuzz won't be available if raqm isn't available.
diff --git a/depends/install_imagequant.sh b/depends/install_imagequant.sh
index 88756f8f9..357214f1f 100755
--- a/depends/install_imagequant.sh
+++ b/depends/install_imagequant.sh
@@ -2,7 +2,7 @@
# install libimagequant
archive_name=libimagequant
-archive_version=4.3.4
+archive_version=4.4.0
archive=$archive_name-$archive_version
diff --git a/depends/install_raqm.sh b/depends/install_raqm.sh
index 5d862403e..b5a05100b 100755
--- a/depends/install_raqm.sh
+++ b/depends/install_raqm.sh
@@ -2,7 +2,7 @@
# install raqm
-archive=libraqm-0.10.2
+archive=libraqm-0.10.3
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz
diff --git a/depends/install_webp.sh b/depends/install_webp.sh
index 9d2977715..d7f3cd2f5 100755
--- a/depends/install_webp.sh
+++ b/depends/install_webp.sh
@@ -1,7 +1,7 @@
#!/bin/bash
# install webp
-archive=libwebp-1.5.0
+archive=libwebp-1.6.0
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz
diff --git a/docs/deprecations.rst b/docs/deprecations.rst
index 236554565..e31d3c31c 100644
--- a/docs/deprecations.rst
+++ b/docs/deprecations.rst
@@ -12,13 +12,6 @@ Deprecated features
Below are features which are considered deprecated. Where appropriate,
a :py:exc:`DeprecationWarning` is issued.
-ImageDraw.getdraw hints parameter
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-.. deprecated:: 10.4.0
-
-The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated.
-
ExifTags.IFD.Makernote
^^^^^^^^^^^^^^^^^^^^^^
@@ -59,6 +52,23 @@ another mode before saving::
im = Image.new("I", (1, 1))
im.convert("I;16").save("out.png")
+ImageCms.ImageCmsProfile.product_name and .product_info
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. deprecated:: 12.0.0
+
+``ImageCms.ImageCmsProfile.product_name`` and the corresponding
+``.product_info`` attributes have been deprecated, and will be removed in
+Pillow 13 (2026-10-15). They have been set to ``None`` since Pillow 2.3.0.
+
+Image._show
+~~~~~~~~~~~
+
+.. deprecated:: 12.0.0
+
+``Image._show`` has been deprecated, and will be removed in Pillow 13 (2026-10-15).
+Use :py:meth:`~PIL.ImageShow.show` instead.
+
Removed features
----------------
@@ -186,6 +196,7 @@ ICNS (width, height, scale) sizes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
+.. versionremoved:: 12.0.0
Setting an ICNS image size to ``(width, height, scale)`` before loading has been
removed. Instead, ``load(scale)`` can be used.
diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst
index c9d3f5e91..46f612be3 100644
--- a/docs/handbook/concepts.rst
+++ b/docs/handbook/concepts.rst
@@ -101,6 +101,28 @@ Palette
The palette mode (``P``) uses a color palette to define the actual color for
each pixel.
+.. _colors:
+
+Colors
+------
+
+To specify colors, you can use tuples with a value for each channel in the image, e.g.
+``Image.new("RGB", (1, 1), (255, 0, 0))``.
+
+If an image has a single channel, you can use a single number instead, e.g.
+``Image.new("L", (1, 1), 255)``. For "F" mode images, floating point values are also
+accepted. In the case of "P" mode images, these will be indexes for the color palette.
+
+If a single value is used for an image with more than one channel, it will still be
+parsed::
+
+ >>> from PIL import Image
+ >>> im = Image.new("RGBA", (1, 1), 0x04030201)
+ >>> im.getpixel((0, 0))
+ (1, 2, 3, 4)
+
+Some methods accept other forms, such as color names. See :ref:`color-names`.
+
Info
----
diff --git a/docs/handbook/third-party-plugins.rst b/docs/handbook/third-party-plugins.rst
index a189a5773..1c7dfb5e9 100644
--- a/docs/handbook/third-party-plugins.rst
+++ b/docs/handbook/third-party-plugins.rst
@@ -11,7 +11,7 @@ Here is a list of PyPI projects that offer additional plugins:
* :pypi:`heif-image-plugin`: Simple HEIF/HEIC images plugin, based on the pyheif library.
* :pypi:`jxlpy`: Introduces reading and writing support for JPEG XL.
* :pypi:`pillow-heif`: Python bindings to libheif for working with HEIF images.
-* :pypi:`pillow-jpls`: Plugin for the JPEG-LS codec, based on the Charls JPEG-LS implemetation. Python bindings implemented using pybind11.
+* :pypi:`pillow-jpls`: Plugin for the JPEG-LS codec, based on the Charls JPEG-LS implementation. Python bindings implemented using pybind11.
* :pypi:`pillow-jxl-plugin`: Plugin for JPEG-XL, using Rust for bindings.
* :pypi:`pillow-mbm`: Adds support for KSP's proprietary MBM texture format.
* :pypi:`pillow-svg`: Implements basic SVG read support. Supports basic paths, shapes, and text.
diff --git a/docs/index.rst b/docs/index.rst
index 689088d48..ee51621ac 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -29,10 +29,6 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more = 11,Yes,Yes,Yes,Yes,Yes,,,,
-Pillow 10.1 - 10.4,,Yes,Yes,Yes,Yes,Yes,,,
-Pillow 10.0,,,Yes,Yes,Yes,Yes,,,
-Pillow 9.3 - 9.5,,,Yes,Yes,Yes,Yes,Yes,,
-Pillow 9.0 - 9.2,,,,Yes,Yes,Yes,Yes,,
-Pillow 8.3.2 - 8.4,,,,Yes,Yes,Yes,Yes,Yes,
-Pillow 8.0 - 8.3.1,,,,,Yes,Yes,Yes,Yes,
-Pillow 7.0 - 7.2,,,,,,Yes,Yes,Yes,Yes
+Python,3.14,3.13,3.12,3.11,3.10,3.9,3.8,3.7,3.6,3.5
+Pillow 12,Yes,Yes,Yes,Yes,Yes,,,,,
+Pillow 11,,Yes,Yes,Yes,Yes,Yes,,,,
+Pillow 10.1 - 10.4,,,Yes,Yes,Yes,Yes,Yes,,,
+Pillow 10.0,,,,Yes,Yes,Yes,Yes,,,
+Pillow 9.3 - 9.5,,,,Yes,Yes,Yes,Yes,Yes,,
+Pillow 9.0 - 9.2,,,,,Yes,Yes,Yes,Yes,,
+Pillow 8.3.2 - 8.4,,,,,Yes,Yes,Yes,Yes,Yes,
+Pillow 8.0 - 8.3.1,,,,,,Yes,Yes,Yes,Yes,
+Pillow 7.0 - 7.2,,,,,,,Yes,Yes,Yes,Yes
diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst
index c2227f1d2..3c5e4cd51 100644
--- a/docs/installation/platform-support.rst
+++ b/docs/installation/platform-support.rst
@@ -19,45 +19,45 @@ These platforms are built and tested for every change.
+==================================+============================+=====================+
| Alpine | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+
-| Amazon Linux 2 | 3.9 | x86-64 |
+| Amazon Linux 2 | 3.10 | x86-64 |
+----------------------------------+----------------------------+---------------------+
-| Amazon Linux 2023 | 3.9 | x86-64 |
+| Amazon Linux 2023 | 3.11 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Arch | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+
-| CentOS Stream 9 | 3.9 | x86-64 |
+| CentOS Stream 9 | 3.10 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| CentOS Stream 10 | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Debian 12 Bookworm | 3.11 | x86, x86-64 |
+----------------------------------+----------------------------+---------------------+
+| Debian 13 Trixie | 3.13 | x86, x86-64 |
++----------------------------------+----------------------------+---------------------+
| Fedora 41 | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Fedora 42 | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Gentoo | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+
-| macOS 13 Ventura | 3.9 | x86-64 |
+| macOS 13 Ventura | 3.10 | x86-64 |
+----------------------------------+----------------------------+---------------------+
-| macOS 14 Sonoma | 3.10, 3.11, 3.12, 3.13, | arm64 |
-| | 3.14, PyPy3 | |
+| macOS 15 Sequoia | 3.11, 3.12, 3.13, 3.14 | arm64 |
+| | PyPy3 | |
+----------------------------------+----------------------------+---------------------+
| Ubuntu Linux 22.04 LTS (Jammy) | 3.10 | x86-64 |
+----------------------------------+----------------------------+---------------------+
-| Ubuntu Linux 24.04 LTS (Noble) | 3.9, 3.10, 3.11, | x86-64 |
-| | 3.12, 3.13, 3.14, PyPy3 | |
+| Ubuntu Linux 24.04 LTS (Noble) | 3.10, 3.11, 3.12, 3.13, | x86-64 |
+| | 3.14, PyPy3 | |
| +----------------------------+---------------------+
| | 3.12 | arm64v8, ppc64le, |
| | | s390x |
+----------------------------------+----------------------------+---------------------+
-| Windows Server 2022 | 3.9 | x86 |
+| Windows Server 2022 | 3.10 | x86 |
| +----------------------------+---------------------+
-| | 3.10, 3.11, 3.12, 3.13, | x86-64 |
-| | 3.14, PyPy3 | |
+| | 3.11, 3.12, 3.13, 3.14, | x86-64 |
+| | PyPy3 | |
| +----------------------------+---------------------+
| | 3.12 (MinGW) | x86-64 |
-| +----------------------------+---------------------+
-| | 3.9 (Cygwin) | x86-64 |
+----------------------------------+----------------------------+---------------------+
diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst
index 6e73233a1..4a2223a40 100644
--- a/docs/reference/ImageDraw.rst
+++ b/docs/reference/ImageDraw.rst
@@ -45,9 +45,7 @@ Colors
^^^^^^
To specify colors, you can use numbers or tuples just as you would use with
-:py:meth:`PIL.Image.new` or :py:meth:`PIL.Image.Image.putpixel`. For “1”,
-“L”, and “I” images, use integers. For “RGB” images, use a 3-tuple containing
-integer values. For “F” images, use integer or floating point values.
+:py:meth:`PIL.Image.new`. See :ref:`colors` for more information.
For palette images (mode “P”), use integers as color indexes. In 1.1.4 and
later, you can also use RGB 3-tuples or color names (see below). The drawing
diff --git a/docs/reference/ImageFile.rst b/docs/reference/ImageFile.rst
index 043559352..4c34ff812 100644
--- a/docs/reference/ImageFile.rst
+++ b/docs/reference/ImageFile.rst
@@ -74,5 +74,6 @@ Constants
---------
.. autodata:: PIL.ImageFile.LOAD_TRUNCATED_IMAGES
+.. autodata:: PIL.ImageFile.MAXBLOCK
.. autodata:: PIL.ImageFile.ERRORS
:annotation:
diff --git a/docs/reference/PixelAccess.rst b/docs/reference/PixelAccess.rst
index 9d7cf83b6..e4af94b9f 100644
--- a/docs/reference/PixelAccess.rst
+++ b/docs/reference/PixelAccess.rst
@@ -59,7 +59,7 @@ Access using negative indexes is also possible. ::
Modifies the pixel at x,y. The color is given as a single
numerical value for single band images, and a tuple for
- multi-band images.
+ multi-band images. See :ref:`colors` for more information.
:param xy: The pixel coordinate, given as (x, y).
:param color: The pixel value according to its mode,
diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst
index 19f78864d..41a8837b3 100644
--- a/docs/reference/internal_modules.rst
+++ b/docs/reference/internal_modules.rst
@@ -53,11 +53,6 @@ on some Python versions.
An object that supports the read method.
-.. py:data:: TypeGuard
- :value: typing.TypeGuard
-
- See :py:obj:`typing.TypeGuard`.
-
:mod:`~PIL._util` module
------------------------
diff --git a/docs/releasenotes/12.0.0.rst b/docs/releasenotes/12.0.0.rst
index 68b664443..12bf760e2 100644
--- a/docs/releasenotes/12.0.0.rst
+++ b/docs/releasenotes/12.0.0.rst
@@ -17,6 +17,12 @@ TODO
Backwards incompatible changes
==============================
+Python 3.9
+^^^^^^^^^^
+
+Pillow has dropped support for Python 3.9,
+which reached end-of-life in October 2025.
+
ImageFile.raise_oserror
^^^^^^^^^^^^^^^^^^^^^^^
@@ -110,10 +116,18 @@ vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`).
Deprecations
============
-TODO
-^^^^
+Image._show
+^^^^^^^^^^^
-TODO
+``Image._show`` has been deprecated, and will be removed in Pillow 13 (2026-10-15).
+Use :py:meth:`~PIL.ImageShow.show` instead.
+
+ImageCms.ImageCmsProfile.product_name and .product_info
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+``ImageCms.ImageCmsProfile.product_name`` and the corresponding
+``.product_info`` attributes have been deprecated, and will be removed in
+Pillow 13 (2026-10-15). They have been set to ``None`` since Pillow 2.3.0.
API changes
===========
@@ -134,7 +148,18 @@ TODO
Other changes
=============
-TODO
-^^^^
+Python 3.14
+^^^^^^^^^^^
-TODO
+Pillow 11.3.0 had wheels built against Python 3.14 beta, available as a preview to help
+others prepare for 3.14, and to ensure Pillow could be used immediately at the release
+of 3.14.0 final (2025-10-07, :pep:`745`).
+
+Pillow 12.0.0 now officially supports Python 3.14.
+
+ImageMorph operations must have length 1
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Valid ImageMorph operations are 4, N, 1 and M. By limiting the length to 1 character
+within Pillow, long execution times can be avoided if a user provided long pattern
+strings. Reported by Jang Choi.
diff --git a/patches/iOS/libwebp-1.5.0.tar.gz.patch b/patches/iOS/libwebp-1.5.0.tar.gz.patch
deleted file mode 100644
index fefb72b68..000000000
--- a/patches/iOS/libwebp-1.5.0.tar.gz.patch
+++ /dev/null
@@ -1,42 +0,0 @@
-# libwebp example binaries require dependencies that aren't available for iOS builds.
-# There's also no easy way to invoke the build to *exclude* the example builds.
-# Since we don't need the examples anyway, remove them from the Makefile.
-#
-# As a point of reference, libwebp provides an XCFramework build script that involves
-# 7 separate invocations of make to avoid building the examples. Patching the Makefile
-# to remove the examples is a simpler approach, and one that is more compatible with
-# the existing multibuild infrastructure.
-#
-# In the next release, it should be possible to pass --disable-libwebpexamples
-# instead of applying this patch.
-#
-diff -ur libwebp-1.5.0-orig/Makefile.am libwebp-1.5.0/Makefile.am
---- libwebp-1.5.0-orig/Makefile.am 2024-12-20 09:17:50
-+++ libwebp-1.5.0/Makefile.am 2025-01-09 11:24:17
-@@ -5,5 +5,3 @@
- if BUILD_EXTRAS
- SUBDIRS += extras
- endif
--
--SUBDIRS += examples
-diff -ur libwebp-1.5.0-orig/Makefile.in libwebp-1.5.0/Makefile.in
---- libwebp-1.5.0-orig/Makefile.in 2024-12-20 09:52:53
-+++ libwebp-1.5.0/Makefile.in 2025-01-09 11:24:17
-@@ -156,7 +156,7 @@
- unique=`for i in $$list; do \
- if test -f "$$i"; then echo $$i; else echo $(srcdir)/$$i; fi; \
- done | $(am__uniquify_input)`
--DIST_SUBDIRS = sharpyuv src imageio man extras examples
-+DIST_SUBDIRS = sharpyuv src imageio man extras
- am__DIST_COMMON = $(srcdir)/Makefile.in \
- $(top_srcdir)/src/webp/config.h.in AUTHORS COPYING ChangeLog \
- NEWS README.md ar-lib compile config.guess config.sub \
-@@ -351,7 +351,7 @@
- top_srcdir = @top_srcdir@
- webp_libname_prefix = @webp_libname_prefix@
- ACLOCAL_AMFLAGS = -I m4
--SUBDIRS = sharpyuv src imageio man $(am__append_1) examples
-+SUBDIRS = sharpyuv src imageio man $(am__append_1)
- EXTRA_DIST = COPYING autogen.sh
- all: all-recursive
-
diff --git a/pyproject.toml b/pyproject.toml
index abab61e6b..137726a1c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,7 @@
[build-system]
build-backend = "backend"
requires = [
+ "pybind11",
"setuptools>=77",
]
backend-path = [
@@ -19,15 +20,15 @@ license-files = [ "LICENSE" ]
authors = [
{ name = "Jeffrey A. Clark", email = "aclark@aclark.net" },
]
-requires-python = ">=3.9"
+requires-python = ">=3.10"
classifiers = [
"Development Status :: 6 - Mature",
"Programming Language :: Python :: 3 :: Only",
- "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Multimedia :: Graphics",
@@ -66,7 +67,7 @@ optional-dependencies.tests = [
"markdown2",
"olefile",
"packaging",
- "pyroma",
+ "pyroma>=5",
"pytest",
"pytest-cov",
"pytest-timeout",
@@ -74,9 +75,6 @@ optional-dependencies.tests = [
"trove-classifiers>=2024.10.12",
]
-optional-dependencies.typing = [
- "typing-extensions; python_version<'3.10'",
-]
optional-dependencies.xmp = [
"defusedxml",
]
@@ -187,8 +185,8 @@ lint.ignore = [
"PT011", # pytest-raises-too-broad
"PT012", # pytest-raises-with-multiple-statements
"PT017", # pytest-assert-in-except
- "PYI026", # flake8-pyi: typing.TypeAlias added in Python 3.10
"PYI034", # flake8-pyi: typing.Self added in Python 3.11
+ "UP038", # pyupgrade: deprecated rule
]
lint.per-file-ignores."Tests/oss-fuzz/fuzz_font.py" = [
"I002",
@@ -205,7 +203,7 @@ lint.isort.required-imports = [
]
[tool.pyproject-fmt]
-max_supported_python = "3.13"
+max_supported_python = "3.14"
[tool.pytest.ini_options]
addopts = "-ra --color=auto"
@@ -214,7 +212,7 @@ testpaths = [
]
[tool.mypy]
-python_version = "3.9"
+python_version = "3.10"
pretty = true
disallow_any_generics = true
enable_error_code = "ignore-without-code"
diff --git a/setup.py b/setup.py
index 477d187a2..df584f8df 100644
--- a/setup.py
+++ b/setup.py
@@ -17,9 +17,20 @@ import sys
import warnings
from collections.abc import Iterator
+from pybind11.setup_helpers import ParallelCompile
from setuptools import Extension, setup
from setuptools.command.build_ext import build_ext
+configuration: dict[str, list[str]] = {}
+
+# parse configuration from _custom_build/backend.py
+while sys.argv[-1].startswith("--pillow-configuration="):
+ _, key, value = sys.argv.pop().split("=", 2)
+ configuration.setdefault(key, []).append(value)
+
+default = int(configuration.get("parallel", ["0"])[-1])
+ParallelCompile("MAX_CONCURRENCY", default).install()
+
def get_version() -> str:
version_file = "src/PIL/_version.py"
@@ -27,9 +38,6 @@ def get_version() -> str:
return f.read().split('"')[1]
-configuration: dict[str, list[str]] = {}
-
-
PILLOW_VERSION = get_version()
AVIF_ROOT = None
FREETYPE_ROOT = None
@@ -386,9 +394,7 @@ class pil_build_ext(build_ext):
cpu_count = os.cpu_count()
if cpu_count is not None:
try:
- self.parallel = int(
- os.environ.get("MAX_CONCURRENCY", min(4, cpu_count))
- )
+ self.parallel = int(os.environ.get("MAX_CONCURRENCY", cpu_count))
except TypeError:
pass
for x in self.feature:
@@ -1083,11 +1089,6 @@ ext_modules = [
]
-# parse configuration from _custom_build/backend.py
-while sys.argv[-1].startswith("--pillow-configuration="):
- _, key, value = sys.argv.pop().split("=", 2)
- configuration.setdefault(key, []).append(value)
-
try:
setup(
cmdclass={"build_ext": pil_build_ext},
diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py
index 7c5bfeefa..ccb8a5953 100644
--- a/src/PIL/FliImagePlugin.py
+++ b/src/PIL/FliImagePlugin.py
@@ -30,7 +30,7 @@ from ._util import DeferredError
def _accept(prefix: bytes) -> bool:
return (
- len(prefix) >= 6
+ len(prefix) >= 16
and i16(prefix, 4) in [0xAF11, 0xAF12]
and i16(prefix, 14) in [0, 3] # flags
)
diff --git a/src/PIL/GbrImagePlugin.py b/src/PIL/GbrImagePlugin.py
index f319d7e84..d69295363 100644
--- a/src/PIL/GbrImagePlugin.py
+++ b/src/PIL/GbrImagePlugin.py
@@ -54,7 +54,7 @@ class GbrImageFile(ImageFile.ImageFile):
width = i32(self.fp.read(4))
height = i32(self.fp.read(4))
color_depth = i32(self.fp.read(4))
- if width <= 0 or height <= 0:
+ if width == 0 or height == 0:
msg = "not a GIMP brush"
raise SyntaxError(msg)
if color_depth not in (1, 4):
@@ -71,7 +71,7 @@ class GbrImageFile(ImageFile.ImageFile):
raise SyntaxError(msg)
self.info["spacing"] = i32(self.fp.read(4))
- comment = self.fp.read(comment_length)[:-1]
+ self.info["comment"] = self.fp.read(comment_length)[:-1]
if color_depth == 1:
self._mode = "L"
@@ -80,8 +80,6 @@ class GbrImageFile(ImageFile.ImageFile):
self._size = width, height
- self.info["comment"] = comment
-
# Image might not be small
Image._decompression_bomb_check(self.size)
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
index fff7ed8e2..10d915e71 100644
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -31,7 +31,7 @@ import os
import subprocess
from enum import IntEnum
from functools import cached_property
-from typing import IO, Any, Literal, NamedTuple, Union, cast
+from typing import Any, NamedTuple, cast
from . import (
Image,
@@ -49,6 +49,8 @@ from ._util import DeferredError
TYPE_CHECKING = False
if TYPE_CHECKING:
+ from typing import IO, Literal
+
from . import _imaging
from ._typing import Buffer
@@ -535,7 +537,7 @@ def _normalize_mode(im: Image.Image) -> Image.Image:
return im.convert("L")
-_Palette = Union[bytes, bytearray, list[int], ImagePalette.ImagePalette]
+_Palette = bytes | bytearray | list[int] | ImagePalette.ImagePalette
def _normalize_palette(
diff --git a/src/PIL/GimpGradientFile.py b/src/PIL/GimpGradientFile.py
index ec62f8e4e..5f2691882 100644
--- a/src/PIL/GimpGradientFile.py
+++ b/src/PIL/GimpGradientFile.py
@@ -21,10 +21,14 @@ See the GIMP distribution for more information.)
from __future__ import annotations
from math import log, pi, sin, sqrt
-from typing import IO, Callable
from ._binary import o8
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from typing import IO
+
EPSILON = 1e-10
"""""" # Enable auto-doc for data member
diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py
index 379ffd739..016257d3d 100644
--- a/src/PIL/GimpPaletteFile.py
+++ b/src/PIL/GimpPaletteFile.py
@@ -17,7 +17,10 @@ from __future__ import annotations
import re
from io import BytesIO
-from typing import IO
+
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from typing import IO
class GimpPaletteFile:
diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py
index 439fc5a3e..dfa798893 100644
--- a/src/PIL/GribStubImagePlugin.py
+++ b/src/PIL/GribStubImagePlugin.py
@@ -33,7 +33,7 @@ def register_handler(handler: ImageFile.StubHandler | None) -> None:
def _accept(prefix: bytes) -> bool:
- return prefix.startswith(b"GRIB") and prefix[7] == 1
+ return len(prefix) >= 8 and prefix.startswith(b"GRIB") and prefix[7] == 1
class GribStubImageFile(ImageFile.StubImageFile):
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
index 6d05be841..42da38fba 100644
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -38,10 +38,9 @@ import struct
import sys
import tempfile
import warnings
-from collections.abc import Callable, Iterator, MutableMapping, Sequence
+from collections.abc import MutableMapping
from enum import IntEnum
-from types import ModuleType
-from typing import IO, Any, Literal, Protocol, cast
+from typing import IO, Protocol, cast
# VERSION was removed in Pillow 6.0.0.
# PILLOW_VERSION was removed in Pillow 9.0.0.
@@ -64,6 +63,12 @@ try:
except ImportError:
ElementTree = None
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator, Sequence
+ from types import ModuleType
+ from typing import Any, Literal
+
logger = logging.getLogger(__name__)
@@ -98,7 +103,6 @@ try:
raise ImportError(msg)
except ImportError as v:
- core = DeferredError.new(ImportError("The _imaging C module is not installed."))
# Explanations for ways that we know we might have an import error
if str(v).startswith("Module use of python"):
# The _imaging C module is present, but not compiled for
@@ -1730,9 +1734,10 @@ class Image:
details).
Instead of an image, the source can be a integer or tuple
- containing pixel values. The method then fills the region
- with the given color. When creating RGB images, you can
- also use color strings as supported by the ImageColor module.
+ containing pixel values. The method then fills the region
+ with the given color. When creating RGB images, you can
+ also use color strings as supported by the ImageColor module. See
+ :ref:`colors` for more information.
If a mask is given, this method updates only the regions
indicated by the mask. You can use either "1", "L", "LA", "RGBA"
@@ -1988,7 +1993,8 @@ class Image:
sequence ends. The scale and offset values are used to adjust the
sequence values: **pixel = value*scale + offset**.
- :param data: A flattened sequence object.
+ :param data: A flattened sequence object. See :ref:`colors` for more
+ information about values.
:param scale: An optional scale value. The default is 1.0.
:param offset: An optional offset value. The default is 0.0.
"""
@@ -2047,7 +2053,7 @@ class Image:
Modifies the pixel at the given position. The color is given as
a single numerical value for single-band images, and a tuple for
multi-band images. In addition to this, RGB and RGBA tuples are
- accepted for P and PA images.
+ accepted for P and PA images. See :ref:`colors` for more information.
Note that this method is relatively slow. For more extensive changes,
use :py:meth:`~PIL.Image.Image.paste` or the :py:mod:`~PIL.ImageDraw`
@@ -2694,7 +2700,9 @@ class Image:
:param title: Optional title to use for the image window, where possible.
"""
- _show(self, title=title)
+ from . import ImageShow
+
+ ImageShow.show(self, title)
def split(self) -> tuple[Image, ...]:
"""
@@ -3123,12 +3131,12 @@ def new(
:param mode: The mode to use for the new image. See:
:ref:`concept-modes`.
:param size: A 2-tuple, containing (width, height) in pixels.
- :param color: What color to use for the image. Default is black.
- If given, this should be a single integer or floating point value
- for single-band modes, and a tuple for multi-band modes (one value
- per band). When creating RGB or HSV images, you can also use color
- strings as supported by the ImageColor module. If the color is
- None, the image is not initialised.
+ :param color: What color to use for the image. Default is black. If given,
+ this should be a single integer or floating point value for single-band
+ modes, and a tuple for multi-band modes (one value per band). When
+ creating RGB or HSV images, you can also use color strings as supported
+ by the ImageColor module. See :ref:`colors` for more information. If the
+ color is None, the image is not initialised.
:returns: An :py:class:`~PIL.Image.Image` object.
"""
@@ -3632,9 +3640,8 @@ def alpha_composite(im1: Image, im2: Image) -> Image:
"""
Alpha composite im2 over im1.
- :param im1: The first image. Must have mode RGBA.
- :param im2: The second image. Must have mode RGBA, and the same size as
- the first image.
+ :param im1: The first image. Must have mode RGBA or LA.
+ :param im2: The second image. Must have the same mode and size as the first image.
:returns: An :py:class:`~PIL.Image.Image` object.
"""
@@ -3860,6 +3867,7 @@ def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None:
def _show(image: Image, **options: Any) -> None:
from . import ImageShow
+ deprecate("Image._show", 13, "ImageShow.show")
ImageShow.show(image, **options)
@@ -4281,6 +4289,8 @@ class Exif(_ExifBase):
del self._info[tag]
else:
del self._data[tag]
+ if tag in self._ifds:
+ del self._ifds[tag]
def __iter__(self) -> Iterator[int]:
keys = set(self._data)
diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py
index d3555694a..513e28acf 100644
--- a/src/PIL/ImageCms.py
+++ b/src/PIL/ImageCms.py
@@ -23,9 +23,10 @@ import operator
import sys
from enum import IntEnum, IntFlag
from functools import reduce
-from typing import Literal, SupportsFloat, SupportsInt, Union
+from typing import Any, Literal, SupportsFloat, SupportsInt, Union
from . import Image
+from ._deprecate import deprecate
from ._typing import SupportsRead
try:
@@ -233,9 +234,7 @@ class ImageCmsProfile:
low-level profile object
"""
- self.filename = None
- self.product_name = None # profile.product_name
- self.product_info = None # profile.product_info
+ self.filename: str | None = None
if isinstance(profile, str):
if sys.platform == "win32":
@@ -256,6 +255,13 @@ class ImageCmsProfile:
msg = "Invalid type for Profile" # type: ignore[unreachable]
raise TypeError(msg)
+ def __getattr__(self, name: str) -> Any:
+ if name in ("product_name", "product_info"):
+ deprecate(f"ImageCms.ImageCmsProfile.{name}", 13)
+ return None
+ msg = f"'{self.__class__.__name__}' object has no attribute '{name}'"
+ raise AttributeError(msg)
+
def tobytes(self) -> bytes:
"""
Returns the profile in a format suitable for embedding in
diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py
index e95fa91f8..ed46899b4 100644
--- a/src/PIL/ImageDraw.py
+++ b/src/PIL/ImageDraw.py
@@ -34,20 +34,23 @@ from __future__ import annotations
import math
import struct
from collections.abc import Sequence
-from types import ModuleType
-from typing import Any, AnyStr, Callable, Union, cast
+from typing import cast
from . import Image, ImageColor
-from ._typing import Coords
+
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from types import ModuleType
+ from typing import Any, AnyStr
+
+ from . import ImageDraw2, ImageFont
+ from ._typing import Coords
# experimental access to the outline API
Outline: Callable[[], Image.core._Outline] = Image.core.outline
-TYPE_CHECKING = False
-if TYPE_CHECKING:
- from . import ImageDraw2, ImageFont
-
-_Ink = Union[float, tuple[int, ...], str]
+_Ink = float | tuple[int, ...] | str
"""
A simple 2D drawing interface for PIL images.
diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py
index 27b27127e..e33b846d4 100644
--- a/src/PIL/ImageFile.py
+++ b/src/PIL/ImageFile.py
@@ -46,6 +46,18 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
MAXBLOCK = 65536
+"""
+By default, Pillow processes image data in blocks. This helps to prevent excessive use
+of resources. Codecs may disable this behaviour with ``_pulls_fd`` or ``_pushes_fd``.
+
+When reading an image, this is the number of bytes to read at once.
+
+When writing an image, this is the number of bytes to write at once.
+If the image width times 4 is greater, then that will be used instead.
+Plugins may also set a greater number.
+
+User code may set this to another number.
+"""
SAFEBLOCK = 1024 * 1024
diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py
index b9ed54ab2..9326eeeda 100644
--- a/src/PIL/ImageFilter.py
+++ b/src/PIL/ImageFilter.py
@@ -19,11 +19,14 @@ from __future__ import annotations
import abc
import functools
from collections.abc import Sequence
-from types import ModuleType
-from typing import Any, Callable, cast
+from typing import cast
TYPE_CHECKING = False
if TYPE_CHECKING:
+ from collections.abc import Callable
+ from types import ModuleType
+ from typing import Any
+
from . import _imaging
from ._typing import NumpyArray
diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py
index bf3f471f5..a2bf9ccf9 100644
--- a/src/PIL/ImageFont.py
+++ b/src/PIL/ImageFont.py
@@ -671,11 +671,7 @@ class FreeTypeFont:
:returns: A list of the named styles in a variation font.
:exception OSError: If the font is not a variation font.
"""
- try:
- names = self.font.getvarnames()
- except AttributeError as e:
- msg = "FreeType 2.9.1 or greater is required"
- raise NotImplementedError(msg) from e
+ names = self.font.getvarnames()
return [name.replace(b"\x00", b"") for name in names]
def set_variation_by_name(self, name: str | bytes) -> None:
@@ -702,11 +698,7 @@ class FreeTypeFont:
:returns: A list of the axes in a variation font.
:exception OSError: If the font is not a variation font.
"""
- try:
- axes = self.font.getvaraxes()
- except AttributeError as e:
- msg = "FreeType 2.9.1 or greater is required"
- raise NotImplementedError(msg) from e
+ axes = self.font.getvaraxes()
for axis in axes:
if axis["name"]:
axis["name"] = axis["name"].replace(b"\x00", b"")
@@ -717,11 +709,7 @@ class FreeTypeFont:
:param axes: A list of values for each axis.
:exception OSError: If the font is not a variation font.
"""
- try:
- self.font.setvaraxes(axes)
- except AttributeError as e:
- msg = "FreeType 2.9.1 or greater is required"
- raise NotImplementedError(msg) from e
+ self.font.setvaraxes(axes)
class TransposedFont:
diff --git a/src/PIL/ImageMath.py b/src/PIL/ImageMath.py
index d2504b1ae..dfdc50c05 100644
--- a/src/PIL/ImageMath.py
+++ b/src/PIL/ImageMath.py
@@ -17,11 +17,15 @@
from __future__ import annotations
import builtins
-from types import CodeType
-from typing import Any, Callable
from . import Image, _imagingmath
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from types import CodeType
+ from typing import Any
+
class _Operand:
"""Wraps an image operand, providing standard operators"""
diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py
index f0a066b5b..bd70aff7b 100644
--- a/src/PIL/ImageMorph.py
+++ b/src/PIL/ImageMorph.py
@@ -150,7 +150,7 @@ class LutBuilder:
# Parse and create symmetries of the patterns strings
for p in self.patterns:
- m = re.search(r"(\w*):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", ""))
+ m = re.search(r"(\w):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", ""))
if not m:
msg = 'Syntax error in pattern "' + p + '"'
raise Exception(msg)
diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py
index df7a57b65..af4d0742d 100644
--- a/src/PIL/ImageQt.py
+++ b/src/PIL/ImageQt.py
@@ -19,23 +19,18 @@ from __future__ import annotations
import sys
from io import BytesIO
-from typing import Any, Callable, Union
from . import Image
from ._util import is_path
TYPE_CHECKING = False
if TYPE_CHECKING:
- import PyQt6
- import PySide6
+ from collections.abc import Callable
+ from typing import Any
from . import ImageFile
QBuffer: type
- QByteArray = Union[PyQt6.QtCore.QByteArray, PySide6.QtCore.QByteArray]
- QIODevice = Union[PyQt6.QtCore.QIODevice, PySide6.QtCore.QIODevice]
- QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage]
- QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap]
qt_version: str | None
qt_versions = [
@@ -49,11 +44,15 @@ for version, qt_module in qt_versions:
try:
qRgba: Callable[[int, int, int, int], int]
if qt_module == "PyQt6":
- from PyQt6.QtCore import QBuffer, QIODevice
+ from PyQt6.QtCore import QBuffer, QByteArray, QIODevice
from PyQt6.QtGui import QImage, QPixmap, qRgba
elif qt_module == "PySide6":
- from PySide6.QtCore import QBuffer, QIODevice
- from PySide6.QtGui import QImage, QPixmap, qRgba
+ from PySide6.QtCore import ( # type: ignore[assignment]
+ QBuffer,
+ QByteArray,
+ QIODevice,
+ )
+ from PySide6.QtGui import QImage, QPixmap, qRgba # type: ignore[assignment]
except (ImportError, RuntimeError):
continue
qt_is_installed = True
@@ -183,7 +182,7 @@ def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]:
if qt_is_installed:
- class ImageQt(QImage): # type: ignore[misc]
+ class ImageQt(QImage):
def __init__(self, im: Image.Image | str | QByteArray) -> None:
"""
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
diff --git a/src/PIL/ImageSequence.py b/src/PIL/ImageSequence.py
index a6fc340d5..361be4897 100644
--- a/src/PIL/ImageSequence.py
+++ b/src/PIL/ImageSequence.py
@@ -16,10 +16,12 @@
##
from __future__ import annotations
-from typing import Callable
-
from . import Image
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
class Iterator:
"""
diff --git a/src/PIL/ImageStat.py b/src/PIL/ImageStat.py
index 8bc504526..3a1044ba4 100644
--- a/src/PIL/ImageStat.py
+++ b/src/PIL/ImageStat.py
@@ -120,7 +120,7 @@ class Stat:
@cached_property
def mean(self) -> list[float]:
"""Average (arithmetic mean) pixel level for each band in the image."""
- return [self.sum[i] / self.count[i] for i in self.bands]
+ return [self.sum[i] / self.count[i] if self.count[i] else 0 for i in self.bands]
@cached_property
def median(self) -> list[int]:
@@ -141,13 +141,20 @@ class Stat:
@cached_property
def rms(self) -> list[float]:
"""RMS (root-mean-square) for each band in the image."""
- return [math.sqrt(self.sum2[i] / self.count[i]) for i in self.bands]
+ return [
+ math.sqrt(self.sum2[i] / self.count[i]) if self.count[i] else 0
+ for i in self.bands
+ ]
@cached_property
def var(self) -> list[float]:
"""Variance for each band in the image."""
return [
- (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i]
+ (
+ (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i]
+ if self.count[i]
+ else 0
+ )
for i in self.bands
]
diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py
index b1fbb1bf1..c28f4dcc7 100644
--- a/src/PIL/IptcImagePlugin.py
+++ b/src/PIL/IptcImagePlugin.py
@@ -34,10 +34,6 @@ def _i(c: bytes) -> int:
return i32((b"\0\0\0\0" + c)[-4:])
-def _i8(c: int | bytes) -> int:
- return c if isinstance(c, int) else c[0]
-
-
##
# Image plugin for IPTC/NAA datastreams. To read IPTC/NAA fields
# from TIFF and JPEG files, use the getiptcinfo function.
@@ -100,16 +96,18 @@ class IptcImageFile(ImageFile.ImageFile):
# mode
layers = self.info[(3, 60)][0]
component = self.info[(3, 60)][1]
- if (3, 65) in self.info:
- id = self.info[(3, 65)][0] - 1
- else:
- id = 0
if layers == 1 and not component:
self._mode = "L"
- elif layers == 3 and component:
- self._mode = "RGB"[id]
- elif layers == 4 and component:
- self._mode = "CMYK"[id]
+ band = None
+ else:
+ if layers == 3 and component:
+ self._mode = "RGB"
+ elif layers == 4 and component:
+ self._mode = "CMYK"
+ if (3, 65) in self.info:
+ band = self.info[(3, 65)][0] - 1
+ else:
+ band = 0
# size
self._size = self.getint((3, 20)), self.getint((3, 30))
@@ -124,39 +122,44 @@ class IptcImageFile(ImageFile.ImageFile):
# tile
if tag == (8, 10):
self.tile = [
- ImageFile._Tile("iptc", (0, 0) + self.size, offset, compression)
+ ImageFile._Tile("iptc", (0, 0) + self.size, offset, (compression, band))
]
def load(self) -> Image.core.PixelAccess | None:
- if len(self.tile) != 1 or self.tile[0][0] != "iptc":
- return ImageFile.ImageFile.load(self)
+ if self.tile:
+ args = self.tile[0].args
+ assert isinstance(args, tuple)
+ compression, band = args
- offset, compression = self.tile[0][2:]
+ self.fp.seek(self.tile[0].offset)
- self.fp.seek(offset)
-
- # Copy image data to temporary file
- o = BytesIO()
- if compression == "raw":
- # To simplify access to the extracted file,
- # prepend a PPM header
- o.write(b"P5\n%d %d\n255\n" % self.size)
- while True:
- type, size = self.field()
- if type != (8, 10):
- break
- while size > 0:
- s = self.fp.read(min(size, 8192))
- if not s:
+ # Copy image data to temporary file
+ o = BytesIO()
+ if compression == "raw":
+ # To simplify access to the extracted file,
+ # prepend a PPM header
+ o.write(b"P5\n%d %d\n255\n" % self.size)
+ while True:
+ type, size = self.field()
+ if type != (8, 10):
break
- o.write(s)
- size -= len(s)
+ while size > 0:
+ s = self.fp.read(min(size, 8192))
+ if not s:
+ break
+ o.write(s)
+ size -= len(s)
- with Image.open(o) as _im:
- _im.load()
- self.im = _im.im
- self.tile = []
- return Image.Image.load(self)
+ with Image.open(o) as _im:
+ if band is not None:
+ bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode)
+ bands[band] = _im
+ _im = Image.merge(self.mode, bands)
+ else:
+ _im.load()
+ self.im = _im.im
+ self.tile = []
+ return ImageFile.ImageFile.load(self)
Image.register_open(IptcImageFile.format, IptcImageFile)
diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py
index e0f4ecae5..4c85dd4e2 100644
--- a/src/PIL/Jpeg2KImagePlugin.py
+++ b/src/PIL/Jpeg2KImagePlugin.py
@@ -18,11 +18,15 @@ from __future__ import annotations
import io
import os
import struct
-from collections.abc import Callable
-from typing import IO, cast
+from typing import cast
from . import Image, ImageFile, ImagePalette, _binary
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from typing import IO
+
class BoxReader:
"""
diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py
index d638d6573..c9b805856 100644
--- a/src/PIL/JpegImagePlugin.py
+++ b/src/PIL/JpegImagePlugin.py
@@ -42,7 +42,6 @@ import subprocess
import sys
import tempfile
import warnings
-from typing import IO, Any
from . import Image, ImageFile
from ._binary import i16be as i16
@@ -53,6 +52,8 @@ from .JpegPresets import presets
TYPE_CHECKING = False
if TYPE_CHECKING:
+ from typing import IO, Any
+
from .MpoImagePlugin import MpoImageFile
#
@@ -845,16 +846,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
)
-def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
- # ALTERNATIVE: handle JPEGs via the IJG command line utilities.
- tempfile = im._dump()
- subprocess.check_call(["cjpeg", "-outfile", filename, tempfile])
- try:
- os.unlink(tempfile)
- except OSError:
- pass
-
-
##
# Factory for making JPEG and MPO instances
def jpeg_factory(
diff --git a/src/PIL/PcdImagePlugin.py b/src/PIL/PcdImagePlugin.py
index 3aa249988..7f9ab525c 100644
--- a/src/PIL/PcdImagePlugin.py
+++ b/src/PIL/PcdImagePlugin.py
@@ -32,7 +32,7 @@ class PcdImageFile(ImageFile.ImageFile):
assert self.fp is not None
self.fp.seek(2048)
- s = self.fp.read(2048)
+ s = self.fp.read(1539)
if not s.startswith(b"PCD_"):
msg = "not a PCD file"
@@ -46,14 +46,13 @@ class PcdImageFile(ImageFile.ImageFile):
self.tile_post_rotate = -90
self._mode = "RGB"
- self._size = 768, 512 # FIXME: not correct for rotated images!
+ self._size = (512, 768) if orientation in (1, 3) else (768, 512)
self.tile = [ImageFile._Tile("pcd", (0, 0) + self.size, 96 * 2048)]
def load_end(self) -> None:
if self.tile_post_rotate:
# Handle rotated PCDs
self.im = self.im.rotate(self.tile_post_rotate)
- self._size = self.im.size
#
diff --git a/src/PIL/PcfFontFile.py b/src/PIL/PcfFontFile.py
index 0d1968b14..a00e9b919 100644
--- a/src/PIL/PcfFontFile.py
+++ b/src/PIL/PcfFontFile.py
@@ -18,7 +18,6 @@
from __future__ import annotations
import io
-from typing import BinaryIO, Callable
from . import FontFile, Image
from ._binary import i8
@@ -27,6 +26,11 @@ from ._binary import i16le as l16
from ._binary import i32be as b32
from ._binary import i32le as l32
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from typing import BinaryIO
+
# --------------------------------------------------------------------
# declarations
diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py
index 458d586c4..6b16d5385 100644
--- a/src/PIL/PcxImagePlugin.py
+++ b/src/PIL/PcxImagePlugin.py
@@ -39,7 +39,7 @@ logger = logging.getLogger(__name__)
def _accept(prefix: bytes) -> bool:
- return prefix[0] == 10 and prefix[1] in [0, 2, 3, 5]
+ return len(prefix) >= 2 and prefix[0] == 10 and prefix[1] in [0, 2, 3, 5]
##
diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py
index 73d8c21c0..2c9031469 100644
--- a/src/PIL/PdfParser.py
+++ b/src/PIL/PdfParser.py
@@ -8,7 +8,15 @@ import os
import re
import time
import zlib
-from typing import IO, Any, NamedTuple, Union
+from typing import Any, NamedTuple
+
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from typing import IO
+
+ _DictBase = collections.UserDict[str | bytes, Any]
+else:
+ _DictBase = collections.UserDict
# see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set
@@ -251,13 +259,6 @@ class PdfArray(list[Any]):
return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]"
-TYPE_CHECKING = False
-if TYPE_CHECKING:
- _DictBase = collections.UserDict[Union[str, bytes], Any]
-else:
- _DictBase = collections.UserDict
-
-
class PdfDict(_DictBase):
def __setattr__(self, key: str, value: Any) -> None:
if key == "data":
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
index 39957cace..a01c2a5c0 100644
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -38,9 +38,8 @@ import re
import struct
import warnings
import zlib
-from collections.abc import Callable
from enum import IntEnum
-from typing import IO, Any, NamedTuple, NoReturn, cast
+from typing import IO, NamedTuple, cast
from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
from ._binary import i16be as i16
@@ -53,6 +52,9 @@ from ._util import DeferredError
TYPE_CHECKING = False
if TYPE_CHECKING:
+ from collections.abc import Callable
+ from typing import Any, NoReturn
+
from . import _imaging
logger = logging.getLogger(__name__)
diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py
index db34d107a..307bc97ff 100644
--- a/src/PIL/PpmImagePlugin.py
+++ b/src/PIL/PpmImagePlugin.py
@@ -47,7 +47,7 @@ MODES = {
def _accept(prefix: bytes) -> bool:
- return prefix.startswith(b"P") and prefix[1] in b"0123456fy"
+ return len(prefix) >= 2 and prefix.startswith(b"P") and prefix[1] in b"0123456fy"
##
diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py
index c1850f084..c1741284b 100644
--- a/src/PIL/TiffImagePlugin.py
+++ b/src/PIL/TiffImagePlugin.py
@@ -47,22 +47,24 @@ import math
import os
import struct
import warnings
-from collections.abc import Iterator, MutableMapping
+from collections.abc import Callable, MutableMapping
from fractions import Fraction
from numbers import Number, Rational
-from typing import IO, Any, Callable, NoReturn, cast
+from typing import IO, Any, cast
from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags
from ._binary import i16be as i16
from ._binary import i32be as i32
from ._binary import o8
-from ._typing import StrOrBytesPath
from ._util import DeferredError, is_path
from .TiffTags import TYPES
TYPE_CHECKING = False
if TYPE_CHECKING:
- from ._typing import Buffer, IntegralLike
+ from collections.abc import Iterator
+ from typing import NoReturn
+
+ from ._typing import Buffer, IntegralLike, StrOrBytesPath
logger = logging.getLogger(__name__)
diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py
index 87e32878b..5494f62e8 100644
--- a/src/PIL/WalImageFile.py
+++ b/src/PIL/WalImageFile.py
@@ -49,8 +49,7 @@ class WalImageFile(ImageFile.ImageFile):
# strings are null-terminated
self.info["name"] = header[:32].split(b"\0", 1)[0]
- next_name = header[56 : 56 + 32].split(b"\0", 1)[0]
- if next_name:
+ if next_name := header[56 : 56 + 32].split(b"\0", 1)[0]:
self.info["next_name"] = next_name
def load(self) -> Image.core.PixelAccess | None:
diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py
index 4353e7e15..203121c06 100644
--- a/src/PIL/WebPImagePlugin.py
+++ b/src/PIL/WebPImagePlugin.py
@@ -1,7 +1,6 @@
from __future__ import annotations
from io import BytesIO
-from typing import IO, Any
from . import Image, ImageFile
@@ -12,6 +11,9 @@ try:
except ImportError:
SUPPORTED = False
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from typing import IO, Any
_VP8_MODES_BY_IDENTIFIER = {
b"VP8 ": "RGB",
diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py
index d569cb4b8..de714d337 100644
--- a/src/PIL/WmfImagePlugin.py
+++ b/src/PIL/WmfImagePlugin.py
@@ -80,7 +80,7 @@ class WmfStubImageFile(ImageFile.StubImageFile):
format_description = "Windows Metafile"
def _open(self) -> None:
- # check placable header
+ # check placeable header
s = self.fp.read(44)
if s.startswith(b"\xd7\xcd\xc6\x9a\x00\x00"):
diff --git a/src/PIL/_imagingcms.pyi b/src/PIL/_imagingcms.pyi
index ddcf93ab1..4fc0d60ab 100644
--- a/src/PIL/_imagingcms.pyi
+++ b/src/PIL/_imagingcms.pyi
@@ -1,14 +1,14 @@
import datetime
import sys
-from typing import Literal, SupportsFloat, TypedDict
+from typing import Literal, SupportsFloat, TypeAlias, TypedDict
from ._typing import CapsuleType
littlecms_version: str | None
-_Tuple3f = tuple[float, float, float]
-_Tuple2x3f = tuple[_Tuple3f, _Tuple3f]
-_Tuple3x3f = tuple[_Tuple3f, _Tuple3f, _Tuple3f]
+_Tuple3f: TypeAlias = tuple[float, float, float]
+_Tuple2x3f: TypeAlias = tuple[_Tuple3f, _Tuple3f]
+_Tuple3x3f: TypeAlias = tuple[_Tuple3f, _Tuple3f, _Tuple3f]
class _IccMeasurementCondition(TypedDict):
observer: int
diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi
index 1cb1429d6..2136810ba 100644
--- a/src/PIL/_imagingft.pyi
+++ b/src/PIL/_imagingft.pyi
@@ -1,4 +1,5 @@
-from typing import Any, Callable
+from collections.abc import Callable
+from typing import Any
from . import ImageFont, _imaging
diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py
index 373938e71..979147e0c 100644
--- a/src/PIL/_typing.py
+++ b/src/PIL/_typing.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import os
import sys
from collections.abc import Sequence
-from typing import Any, Protocol, TypeVar, Union
+from typing import Any, Protocol, TypeVar
TYPE_CHECKING = False
if TYPE_CHECKING:
@@ -12,8 +12,8 @@ if TYPE_CHECKING:
try:
import numpy.typing as npt
- NumpyArray = npt.NDArray[Any] # requires numpy>=1.21
- except (ImportError, AttributeError):
+ NumpyArray = npt.NDArray[Any]
+ except ImportError:
pass
if sys.version_info >= (3, 13):
@@ -26,19 +26,8 @@ if sys.version_info >= (3, 12):
else:
Buffer = Any
-if sys.version_info >= (3, 10):
- from typing import TypeGuard
-else:
- try:
- from typing_extensions import TypeGuard
- except ImportError:
- class TypeGuard: # type: ignore[no-redef]
- def __class_getitem__(cls, item: Any) -> type[bool]:
- return bool
-
-
-Coords = Union[Sequence[float], Sequence[Sequence[float]]]
+Coords = Sequence[float] | Sequence[Sequence[float]]
_T_co = TypeVar("_T_co", covariant=True)
@@ -48,7 +37,7 @@ class SupportsRead(Protocol[_T_co]):
def read(self, length: int = ..., /) -> _T_co: ...
-StrOrBytesPath = Union[str, bytes, os.PathLike[str], os.PathLike[bytes]]
+StrOrBytesPath = str | bytes | os.PathLike[str] | os.PathLike[bytes]
-__all__ = ["Buffer", "IntegralLike", "StrOrBytesPath", "SupportsRead", "TypeGuard"]
+__all__ = ["Buffer", "IntegralLike", "StrOrBytesPath", "SupportsRead"]
diff --git a/src/PIL/_util.py b/src/PIL/_util.py
index 8ef0d36f7..b1fa6a0f3 100644
--- a/src/PIL/_util.py
+++ b/src/PIL/_util.py
@@ -1,9 +1,12 @@
from __future__ import annotations
import os
-from typing import Any, NoReturn
-from ._typing import StrOrBytesPath, TypeGuard
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from typing import Any, NoReturn, TypeGuard
+
+ from ._typing import StrOrBytesPath
def is_path(f: Any) -> TypeGuard[StrOrBytesPath]:
diff --git a/src/PIL/features.py b/src/PIL/features.py
index 984f7532c..ff32c2510 100644
--- a/src/PIL/features.py
+++ b/src/PIL/features.py
@@ -9,7 +9,6 @@ from typing import IO
import PIL
from . import Image
-from ._deprecate import deprecate
modules = {
"pil": ("PIL._imaging", "PILLOW_VERSION"),
@@ -120,7 +119,7 @@ def get_supported_codecs() -> list[str]:
return [f for f in codecs if check_codec(f)]
-features: dict[str, tuple[str, str | bool, str | None]] = {
+features: dict[str, tuple[str, str, str | None]] = {
"raqm": ("PIL._imagingft", "HAVE_RAQM", "raqm_version"),
"fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"),
"harfbuzz": ("PIL._imagingft", "HAVE_HARFBUZZ", "harfbuzz_version"),
@@ -146,12 +145,8 @@ def check_feature(feature: str) -> bool | None:
module, flag, ver = features[feature]
- if isinstance(flag, bool):
- deprecate(f'check_feature("{feature}")', 12)
try:
imported_module = __import__(module, fromlist=["PIL"])
- if isinstance(flag, bool):
- return flag
return getattr(imported_module, flag)
except ModuleNotFoundError:
return None
@@ -181,17 +176,7 @@ def get_supported_features() -> list[str]:
"""
:returns: A list of all supported features.
"""
- supported_features = []
- for f, (module, flag, _) in features.items():
- if flag is True:
- for feature, (feature_module, _) in modules.items():
- if feature_module == module:
- if check_module(feature):
- supported_features.append(f)
- break
- elif check_feature(f):
- supported_features.append(f)
- return supported_features
+ return [f for f in features if check_feature(f)]
def check(feature: str) -> bool | None:
diff --git a/src/_imagingft.c b/src/_imagingft.c
index 29d8e9e71..c9938fd3e 100644
--- a/src/_imagingft.c
+++ b/src/_imagingft.c
@@ -1221,8 +1221,6 @@ glyph_error:
return NULL;
}
-#if FREETYPE_MAJOR > 2 || (FREETYPE_MAJOR == 2 && FREETYPE_MINOR > 9) || \
- (FREETYPE_MAJOR == 2 && FREETYPE_MINOR == 9 && FREETYPE_PATCH == 1)
static PyObject *
font_getvarnames(FontObject *self) {
int error;
@@ -1432,7 +1430,6 @@ font_setvaraxes(FontObject *self, PyObject *args) {
Py_RETURN_NONE;
}
-#endif
static void
font_dealloc(FontObject *self) {
@@ -1451,13 +1448,10 @@ static PyMethodDef font_methods[] = {
{"render", (PyCFunction)font_render, METH_VARARGS},
{"getsize", (PyCFunction)font_getsize, METH_VARARGS},
{"getlength", (PyCFunction)font_getlength, METH_VARARGS},
-#if FREETYPE_MAJOR > 2 || (FREETYPE_MAJOR == 2 && FREETYPE_MINOR > 9) || \
- (FREETYPE_MAJOR == 2 && FREETYPE_MINOR == 9 && FREETYPE_PATCH == 1)
{"getvarnames", (PyCFunction)font_getvarnames, METH_NOARGS},
{"getvaraxes", (PyCFunction)font_getvaraxes, METH_NOARGS},
{"setvarname", (PyCFunction)font_setvarname, METH_VARARGS},
{"setvaraxes", (PyCFunction)font_setvaraxes, METH_VARARGS},
-#endif
{NULL, NULL}
};
diff --git a/src/decode.c b/src/decode.c
index 03db1ce35..e7a6e6323 100644
--- a/src/decode.c
+++ b/src/decode.c
@@ -870,8 +870,6 @@ PyImaging_Jpeg2KDecoderNew(PyObject *self, PyObject *args) {
if (strcmp(format, "j2k") == 0) {
codec_format = OPJ_CODEC_J2K;
- } else if (strcmp(format, "jpt") == 0) {
- codec_format = OPJ_CODEC_JPT;
} else if (strcmp(format, "jp2") == 0) {
codec_format = OPJ_CODEC_JP2;
} else {
diff --git a/src/libImaging/AlphaComposite.c b/src/libImaging/AlphaComposite.c
index 6d728f908..44c451679 100644
--- a/src/libImaging/AlphaComposite.c
+++ b/src/libImaging/AlphaComposite.c
@@ -25,13 +25,12 @@ ImagingAlphaComposite(Imaging imDst, Imaging imSrc) {
int x, y;
/* Check arguments */
- if (!imDst || !imSrc || strcmp(imDst->mode, "RGBA") ||
- imDst->type != IMAGING_TYPE_UINT8 || imDst->bands != 4) {
+ if (!imDst || !imSrc ||
+ (strcmp(imDst->mode, "RGBA") && strcmp(imDst->mode, "LA"))) {
return ImagingError_ModeError();
}
- if (strcmp(imDst->mode, imSrc->mode) || imDst->type != imSrc->type ||
- imDst->bands != imSrc->bands || imDst->xsize != imSrc->xsize ||
+ if (strcmp(imDst->mode, imSrc->mode) || imDst->xsize != imSrc->xsize ||
imDst->ysize != imSrc->ysize) {
return ImagingError_Mismatch();
}
diff --git a/src/libImaging/Histo.c b/src/libImaging/Histo.c
index c5a547a64..87c09d3d4 100644
--- a/src/libImaging/Histo.c
+++ b/src/libImaging/Histo.c
@@ -132,11 +132,15 @@ ImagingGetHistogram(Imaging im, Imaging imMask, void *minmax) {
ImagingSectionEnter(&cookie);
for (y = 0; y < im->ysize; y++) {
UINT8 *in = (UINT8 *)im->image[y];
- for (x = 0; x < im->xsize; x++) {
- h->histogram[(*in++)]++;
- h->histogram[(*in++) + 256]++;
- h->histogram[(*in++) + 512]++;
- h->histogram[(*in++) + 768]++;
+ for (x = 0; x < im->xsize; x++, in += 4) {
+ h->histogram[*in]++;
+ if (im->bands == 2) {
+ h->histogram[*(in + 3) + 256]++;
+ } else {
+ h->histogram[*(in + 1) + 256]++;
+ h->histogram[*(in + 2) + 512]++;
+ h->histogram[*(in + 3) + 768]++;
+ }
}
}
ImagingSectionLeave(&cookie);
diff --git a/src/libImaging/Palette.c b/src/libImaging/Palette.c
index 78916bca5..da1d80504 100644
--- a/src/libImaging/Palette.c
+++ b/src/libImaging/Palette.c
@@ -148,7 +148,7 @@ ImagingPaletteDelete(ImagingPalette palette) {
#define BOX 8
-#define BOXVOLUME BOX *BOX *BOX
+#define BOXVOLUME BOX * BOX * BOX
void
ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) {
diff --git a/src/thirdparty/raqm/COPYING b/src/thirdparty/raqm/COPYING
index 97e2489b7..964318a8a 100644
--- a/src/thirdparty/raqm/COPYING
+++ b/src/thirdparty/raqm/COPYING
@@ -1,7 +1,7 @@
The MIT License (MIT)
Copyright © 2015 Information Technology Authority (ITA)
-Copyright © 2016-2023 Khaled Hosny
+Copyright © 2016-2025 Khaled Hosny
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/thirdparty/raqm/NEWS b/src/thirdparty/raqm/NEWS
index e8bf32e0b..fb432cffb 100644
--- a/src/thirdparty/raqm/NEWS
+++ b/src/thirdparty/raqm/NEWS
@@ -1,3 +1,19 @@
+Overview of changes leading to 0.10.3
+Tuesday, August 5, 2025
+====================================
+
+Fix raqm_set_text_utf8/utf16 reading beyond len for multibyte.
+
+Support building against SheenBidi 2.9.
+
+Fix deprecation warning with latest HarfBuzz.
+
+Overview of changes leading to 0.10.2
+Sunday, September 22, 2024
+====================================
+
+Fix Unicode codepoint conversion from UTF-16.
+
Overview of changes leading to 0.10.1
Wednesday, April 12, 2023
====================================
diff --git a/src/thirdparty/raqm/raqm-version.h b/src/thirdparty/raqm/raqm-version.h
index 62d2d2064..f2dd61cf6 100644
--- a/src/thirdparty/raqm/raqm-version.h
+++ b/src/thirdparty/raqm/raqm-version.h
@@ -33,9 +33,9 @@
#define RAQM_VERSION_MAJOR 0
#define RAQM_VERSION_MINOR 10
-#define RAQM_VERSION_MICRO 1
+#define RAQM_VERSION_MICRO 3
-#define RAQM_VERSION_STRING "0.10.1"
+#define RAQM_VERSION_STRING "0.10.3"
#define RAQM_VERSION_ATLEAST(major,minor,micro) \
((major)*10000+(minor)*100+(micro) <= \
diff --git a/src/thirdparty/raqm/raqm.c b/src/thirdparty/raqm/raqm.c
index 2b331e1af..9ecc5cac8 100644
--- a/src/thirdparty/raqm/raqm.c
+++ b/src/thirdparty/raqm/raqm.c
@@ -30,7 +30,11 @@
#include
#ifdef RAQM_SHEENBIDI
+#ifdef RAQM_SHEENBIDI_GT_2_9
+#include
+#else
#include
+#endif
#else
#ifdef HAVE_FRIBIDI_SYSTEM
#include
@@ -546,34 +550,32 @@ raqm_set_text (raqm_t *rq,
return true;
}
-static void *
-_raqm_get_utf8_codepoint (const void *str,
+static const char *
+_raqm_get_utf8_codepoint (const char *str,
uint32_t *out_codepoint)
{
- const char *s = (const char *)str;
-
- if (0xf0 == (0xf8 & s[0]))
+ if (0xf0 == (0xf8 & str[0]))
{
- *out_codepoint = ((0x07 & s[0]) << 18) | ((0x3f & s[1]) << 12) | ((0x3f & s[2]) << 6) | (0x3f & s[3]);
- s += 4;
+ *out_codepoint = ((0x07 & str[0]) << 18) | ((0x3f & str[1]) << 12) | ((0x3f & str[2]) << 6) | (0x3f & str[3]);
+ str += 4;
}
- else if (0xe0 == (0xf0 & s[0]))
+ else if (0xe0 == (0xf0 & str[0]))
{
- *out_codepoint = ((0x0f & s[0]) << 12) | ((0x3f & s[1]) << 6) | (0x3f & s[2]);
- s += 3;
+ *out_codepoint = ((0x0f & str[0]) << 12) | ((0x3f & str[1]) << 6) | (0x3f & str[2]);
+ str += 3;
}
- else if (0xc0 == (0xe0 & s[0]))
+ else if (0xc0 == (0xe0 & str[0]))
{
- *out_codepoint = ((0x1f & s[0]) << 6) | (0x3f & s[1]);
- s += 2;
+ *out_codepoint = ((0x1f & str[0]) << 6) | (0x3f & str[1]);
+ str += 2;
}
else
{
- *out_codepoint = s[0];
- s += 1;
+ *out_codepoint = str[0];
+ str += 1;
}
- return (void *)s;
+ return str;
}
static size_t
@@ -585,42 +587,41 @@ _raqm_u8_to_u32 (const char *text, size_t len, uint32_t *unicode)
while ((*in_utf8 != '\0') && (in_len < len))
{
- in_utf8 = _raqm_get_utf8_codepoint (in_utf8, out_utf32);
+ const char *out_utf8 = _raqm_get_utf8_codepoint (in_utf8, out_utf32);
+ in_len += out_utf8 - in_utf8;
+ in_utf8 = out_utf8;
++out_utf32;
- ++in_len;
}
return (out_utf32 - unicode);
}
-static void *
-_raqm_get_utf16_codepoint (const void *str,
- uint32_t *out_codepoint)
+static const uint16_t *
+_raqm_get_utf16_codepoint (const uint16_t *str,
+ uint32_t *out_codepoint)
{
- const uint16_t *s = (const uint16_t *)str;
-
- if (s[0] > 0xD800 && s[0] < 0xDBFF)
+ if (str[0] >= 0xD800 && str[0] <= 0xDBFF)
{
- if (s[1] > 0xDC00 && s[1] < 0xDFFF)
+ if (str[1] >= 0xDC00 && str[1] <= 0xDFFF)
{
- uint32_t X = ((s[0] & ((1 << 6) -1)) << 10) | (s[1] & ((1 << 10) -1));
- uint32_t W = (s[0] >> 6) & ((1 << 5) - 1);
+ uint32_t X = ((str[0] & ((1 << 6) -1)) << 10) | (str[1] & ((1 << 10) -1));
+ uint32_t W = (str[0] >> 6) & ((1 << 5) - 1);
*out_codepoint = (W+1) << 16 | X;
- s += 2;
+ str += 2;
}
else
{
/* A single high surrogate, this is an error. */
- *out_codepoint = s[0];
- s += 1;
+ *out_codepoint = str[0];
+ str += 1;
}
}
else
{
- *out_codepoint = s[0];
- s += 1;
+ *out_codepoint = str[0];
+ str += 1;
}
- return (void *)s;
+ return str;
}
static size_t
@@ -632,9 +633,10 @@ _raqm_u16_to_u32 (const uint16_t *text, size_t len, uint32_t *unicode)
while ((*in_utf16 != '\0') && (in_len < len))
{
- in_utf16 = _raqm_get_utf16_codepoint (in_utf16, out_utf32);
+ const uint16_t *out_utf16 = _raqm_get_utf16_codepoint (in_utf16, out_utf32);
+ in_len += (out_utf16 - in_utf16);
+ in_utf16 = out_utf16;
++out_utf32;
- ++in_len;
}
return (out_utf32 - unicode);
@@ -1114,12 +1116,12 @@ _raqm_set_spacing (raqm_t *rq,
{
if (_raqm_allowed_grapheme_boundary (rq->text[i], rq->text[i+1]))
{
- /* CSS word seperators, word spacing is only applied on these.*/
+ /* CSS word separators, word spacing is only applied on these.*/
if (rq->text[i] == 0x0020 || /* Space */
rq->text[i] == 0x00A0 || /* No Break Space */
rq->text[i] == 0x1361 || /* Ethiopic Word Space */
- rq->text[i] == 0x10100 || /* Aegean Word Seperator Line */
- rq->text[i] == 0x10101 || /* Aegean Word Seperator Dot */
+ rq->text[i] == 0x10100 || /* Aegean Word Separator Line */
+ rq->text[i] == 0x10101 || /* Aegean Word Separator Dot */
rq->text[i] == 0x1039F || /* Ugaric Word Divider */
rq->text[i] == 0x1091F) /* Phoenician Word Separator */
{
@@ -2167,6 +2169,10 @@ _raqm_ft_transform (int *x,
*y = vector.y;
}
+#if !HB_VERSION_ATLEAST (10, 4, 0)
+# define hb_ft_font_get_ft_face hb_ft_font_get_face
+#endif
+
static bool
_raqm_shape (raqm_t *rq)
{
@@ -2199,7 +2205,7 @@ _raqm_shape (raqm_t *rq)
hb_glyph_position_t *pos;
unsigned int len;
- FT_Get_Transform (hb_ft_font_get_face (run->font), &matrix, NULL);
+ FT_Get_Transform (hb_ft_font_get_ft_face (run->font), &matrix, NULL);
pos = hb_buffer_get_glyph_positions (run->buffer, &len);
info = hb_buffer_get_glyph_infos (run->buffer, &len);
diff --git a/tox.ini b/tox.ini
index 967d4b537..d58fd67b6 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,7 +3,7 @@ requires =
tox>=4.2
env_list =
lint
- py{py3, 314, 313, 312, 311, 310, 39}
+ py{py3, 314, 313, 312, 311, 310}
[testenv]
deps =
@@ -29,7 +29,5 @@ commands =
skip_install = true
deps =
-r .ci/requirements-mypy.txt
-extras =
- typing
commands =
- mypy conftest.py selftest.py setup.py docs src winbuild Tests {posargs}
+ mypy conftest.py selftest.py setup.py checks docs src winbuild Tests {posargs}
diff --git a/wheels/dependency_licenses/ZSTD.txt b/wheels/dependency_licenses/ZSTD.txt
new file mode 100644
index 000000000..75800288c
--- /dev/null
+++ b/wheels/dependency_licenses/ZSTD.txt
@@ -0,0 +1,30 @@
+BSD License
+
+For Zstandard software
+
+Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+ * Neither the name Facebook, nor Meta, nor the names of its contributors may
+ be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/wheels/multibuild b/wheels/multibuild
index 42d761728..647393271 160000
--- a/wheels/multibuild
+++ b/wheels/multibuild
@@ -1 +1 @@
-Subproject commit 42d761728d141d8462cd9943f4329f12fe62b155
+Subproject commit 64739327166fcad1fa41ad9b23fa910fa244c84f
diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py
index 187d07b20..4ba683801 100644
--- a/winbuild/build_prepare.py
+++ b/winbuild/build_prepare.py
@@ -116,17 +116,17 @@ V = {
"BROTLI": "1.1.0",
"FREETYPE": "2.13.3",
"FRIBIDI": "1.0.16",
- "HARFBUZZ": "11.2.1",
- "JPEGTURBO": "3.1.1",
+ "HARFBUZZ": "11.3.3",
+ "JPEGTURBO": "3.1.2",
"LCMS2": "2.17",
"LIBAVIF": "1.3.0",
- "LIBIMAGEQUANT": "4.3.4",
- "LIBPNG": "1.6.49",
- "LIBWEBP": "1.5.0",
+ "LIBIMAGEQUANT": "4.4.0",
+ "LIBPNG": "1.6.50",
+ "LIBWEBP": "1.6.0",
"OPENJPEG": "2.5.3",
"TIFF": "4.7.0",
"XZ": "5.8.1",
- "ZLIBNG": "2.2.4",
+ "ZLIBNG": "2.2.5",
}
V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2])
@@ -149,18 +149,17 @@ DEPS: dict[str, dict[str, Any]] = {
},
"build": [
*cmds_cmake(
- ("jpeg-static", "cjpeg-static", "djpeg-static"),
+ ("jpeg-static", "djpeg-static"),
"-DENABLE_SHARED:BOOL=FALSE",
"-DWITH_JPEG8:BOOL=TRUE",
"-DWITH_CRT_DLL:BOOL=TRUE",
),
cmd_copy("jpeg-static.lib", "libjpeg.lib"),
- cmd_copy("cjpeg-static.exe", "cjpeg.exe"),
cmd_copy("djpeg-static.exe", "djpeg.exe"),
],
"headers": ["jconfig.h", r"src\j*.h"],
"libs": ["libjpeg.lib"],
- "bins": ["cjpeg.exe", "djpeg.exe"],
+ "bins": ["djpeg.exe"],
},
"zlib": {
"url": f"https://github.com/zlib-ng/zlib-ng/archive/refs/tags/{V['ZLIBNG']}.tar.gz",