Merge branch 'main' into bitmap_buffer

This commit is contained in:
Andrew Murray 2024-09-30 19:47:11 +10:00 committed by GitHub
commit 94c3ee6944
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
133 changed files with 1751 additions and 930 deletions

View File

@ -34,8 +34,8 @@ install:
- xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images - xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images
- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip - curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip
- 7z x nasm-win64.zip -oc:\ - 7z x nasm-win64.zip -oc:\
- choco install ghostscript --version=10.3.1 - choco install ghostscript --version=10.4.0
- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.03.1\bin;%PATH% - path c:\nasm-2.16.03;C:\Program Files\gs\gs10.04.0\bin;%PATH%
- cd c:\pillow\winbuild\ - cd c:\pillow\winbuild\
- ps: | - ps: |
c:\python39\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ c:\python39\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\
@ -51,11 +51,10 @@ build_script:
test_script: test_script:
- cd c:\pillow - cd c:\pillow
- '%PYTHON%\%EXECUTABLE% -m pip install pytest pytest-cov pytest-timeout defusedxml numpy olefile pyroma' - '%PYTHON%\%EXECUTABLE% -m pip install pytest pytest-cov pytest-timeout defusedxml ipython numpy olefile pyroma'
- c:\"Program Files (x86)"\"Windows Kits"\10\Debuggers\x86\gflags.exe /p /enable %PYTHON%\%EXECUTABLE% - c:\"Program Files (x86)"\"Windows Kits"\10\Debuggers\x86\gflags.exe /p /enable %PYTHON%\%EXECUTABLE%
- '%PYTHON%\%EXECUTABLE% -c "from PIL import Image"' - path %PYTHON%;%PATH%
- '%PYTHON%\%EXECUTABLE% -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests' - .ci\test.cmd
#- '%PYTHON%\%EXECUTABLE% test-installed.py -v -s %TEST_OPTIONS%' TODO TEST_OPTIONS with pytest?
after_test: after_test:
- curl -Os https://uploader.codecov.io/latest/windows/codecov.exe - curl -Os https://uploader.codecov.io/latest/windows/codecov.exe

View File

@ -21,7 +21,7 @@ set -e
if [[ $(uname) != CYGWIN* ]]; then if [[ $(uname) != CYGWIN* ]]; then
sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\
ghostscript libffi-dev libjpeg-turbo-progs libopenjp2-7-dev\ ghostscript libjpeg-turbo-progs libopenjp2-7-dev\
cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ cmake meson imagemagick libharfbuzz-dev libfribidi-dev\
sway wl-clipboard libopenblas-dev sway wl-clipboard libopenblas-dev
fi fi
@ -30,6 +30,7 @@ python3 -m pip install --upgrade pip
python3 -m pip install --upgrade wheel python3 -m pip install --upgrade wheel
python3 -m pip install coverage python3 -m pip install coverage
python3 -m pip install defusedxml python3 -m pip install defusedxml
python3 -m pip install ipython
python3 -m pip install olefile python3 -m pip install olefile
python3 -m pip install -U pytest python3 -m pip install -U pytest
python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-cov
@ -37,12 +38,7 @@ python3 -m pip install -U pytest-timeout
python3 -m pip install pyroma python3 -m pip install pyroma
if [[ $(uname) != CYGWIN* ]]; then if [[ $(uname) != CYGWIN* ]]; then
# TODO Update condition when NumPy supports free-threading
if [[ "$PYTHON_GIL" == "0" ]]; then
python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple
else
python3 -m pip install numpy python3 -m pip install numpy
fi
# PyQt6 doesn't support PyPy3 # PyQt6 doesn't support PyPy3
if [[ $GHA_PYTHON_VERSION == 3.* ]]; then if [[ $GHA_PYTHON_VERSION == 3.* ]]; then
@ -52,10 +48,7 @@ if [[ $(uname) != CYGWIN* ]]; then
fi fi
# Pyroma uses non-isolated build and fails with old setuptools # Pyroma uses non-isolated build and fails with old setuptools
if [[ if [[ $GHA_PYTHON_VERSION == 3.9 ]]; then
$GHA_PYTHON_VERSION == pypy3.9
|| $GHA_PYTHON_VERSION == 3.9
]]; then
# To match pyproject.toml # To match pyproject.toml
python3 -m pip install "setuptools>=67.8" python3 -m pip install "setuptools>=67.8"
fi fi

View File

@ -1 +1 @@
cibuildwheel==2.20.0 cibuildwheel==2.21.1

View File

@ -1,4 +1,4 @@
mypy==1.11.1 mypy==1.11.2
IceSpringPySideStubs-PyQt6 IceSpringPySideStubs-PyQt6
IceSpringPySideStubs-PySide6 IceSpringPySideStubs-PySide6
ipython ipython
@ -6,6 +6,7 @@ numpy
packaging packaging
pytest pytest
sphinx sphinx
types-atheris
types-defusedxml types-defusedxml
types-olefile types-olefile
types-setuptools types-setuptools

3
.ci/test.cmd Normal file
View File

@ -0,0 +1,3 @@
python.exe -c "from PIL import Image"
IF ERRORLEVEL 1 EXIT /B
python.exe -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests

View File

@ -4,4 +4,4 @@ set -e
python3 -c "from PIL import Image" python3 -c "from PIL import Image"
python3 -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term Tests $REVERSE python3 -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests $REVERSE

View File

@ -24,8 +24,6 @@ concurrency:
jobs: jobs:
Fuzzing: Fuzzing:
# Disabled until google/oss-fuzz#11419 upgrades Python to 3.9+
if: false
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Build Fuzzers - name: Build Fuzzers

View File

@ -23,6 +23,7 @@ export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig"
python3 -m pip install coverage python3 -m pip install coverage
python3 -m pip install defusedxml python3 -m pip install defusedxml
python3 -m pip install ipython
python3 -m pip install olefile python3 -m pip install olefile
python3 -m pip install -U pytest python3 -m pip install -U pytest
python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-cov

View File

@ -74,6 +74,7 @@ jobs:
perl perl
python3${{ matrix.python-minor-version }}-cython python3${{ matrix.python-minor-version }}-cython
python3${{ matrix.python-minor-version }}-devel python3${{ matrix.python-minor-version }}-devel
python3${{ matrix.python-minor-version }}-ipython
python3${{ matrix.python-minor-version }}-numpy python3${{ matrix.python-minor-version }}-numpy
python3${{ matrix.python-minor-version }}-sip python3${{ matrix.python-minor-version }}-sip
python3${{ matrix.python-minor-version }}-tkinter python3${{ matrix.python-minor-version }}-tkinter

View File

@ -80,8 +80,7 @@ jobs:
- name: Test Pillow - name: Test Pillow
run: | run: |
python3 selftest.py --installed python3 selftest.py --installed
python3 -c "from PIL import Image" .ci/test.sh
python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v4

View File

@ -35,7 +35,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["pypy3.10", "pypy3.9", "3.9", "3.10", "3.11", "3.12", "3.13"] python-version: ["pypy3.10", "3.9", "3.10", "3.11", "3.12", "3.13"]
timeout-minutes: 30 timeout-minutes: 30
@ -86,8 +86,8 @@ jobs:
choco install nasm --no-progress choco install nasm --no-progress
echo "C:\Program Files\NASM" >> $env:GITHUB_PATH echo "C:\Program Files\NASM" >> $env:GITHUB_PATH
choco install ghostscript --version=10.3.1 --no-progress choco install ghostscript --version=10.4.0 --no-progress
echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH echo "C:\Program Files\gs\gs10.04.0\bin" >> $env:GITHUB_PATH
# Install extra test images # Install extra test images
xcopy /S /Y Tests\test-images\* Tests\images xcopy /S /Y Tests\test-images\* Tests\images
@ -190,8 +190,8 @@ jobs:
- name: Test Pillow - name: Test Pillow
run: | run: |
path %GITHUB_WORKSPACE%\\winbuild\\build\\bin;%PATH% path %GITHUB_WORKSPACE%\winbuild\build\bin;%PATH%
python.exe -m pytest -vx -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests .ci\test.cmd
shell: cmd shell: cmd
- name: Prepare to upload errors - name: Prepare to upload errors

View File

@ -37,12 +37,11 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ os: [
"macos-14", "macos-latest",
"ubuntu-latest", "ubuntu-latest",
] ]
python-version: [ python-version: [
"pypy3.10", "pypy3.10",
"pypy3.9",
"3.13", "3.13",
"3.12", "3.12",
"3.11", "3.11",
@ -57,7 +56,7 @@ jobs:
# M1 only available for 3.10+ # M1 only available for 3.10+
- { os: "macos-13", python-version: "3.9" } - { os: "macos-13", python-version: "3.9" }
exclude: exclude:
- { os: "macos-14", python-version: "3.9" } - { os: "macos-latest", python-version: "3.9" }
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }}
@ -77,7 +76,7 @@ jobs:
"pyproject.toml" "pyproject.toml"
- name: Set up Python ${{ matrix.python-version }} (free-threaded) - name: Set up Python ${{ matrix.python-version }} (free-threaded)
uses: deadsnakes/action@v3.1.0 uses: deadsnakes/action@v3.2.0
if: "${{ matrix.disable-gil }}" if: "${{ matrix.disable-gil }}"
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}

View File

@ -16,11 +16,11 @@ ARCHIVE_SDIR=pillow-depends-main
# Package versions for fresh source builds # Package versions for fresh source builds
FREETYPE_VERSION=2.13.2 FREETYPE_VERSION=2.13.2
HARFBUZZ_VERSION=8.5.0 HARFBUZZ_VERSION=10.0.1
LIBPNG_VERSION=1.6.43 LIBPNG_VERSION=1.6.44
JPEGTURBO_VERSION=3.0.3 JPEGTURBO_VERSION=3.0.4
OPENJPEG_VERSION=2.5.2 OPENJPEG_VERSION=2.5.2
XZ_VERSION=5.4.5 XZ_VERSION=5.6.2
TIFF_VERSION=4.6.0 TIFF_VERSION=4.6.0
LCMS2_VERSION=2.16 LCMS2_VERSION=2.16
if [[ -n "$IS_MACOS" ]]; then if [[ -n "$IS_MACOS" ]]; then
@ -40,7 +40,7 @@ BROTLI_VERSION=1.1.0
if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then
function build_openjpeg { function build_openjpeg {
local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v${OPENJPEG_VERSION}.tar.gz openjpeg-${OPENJPEG_VERSION}.tar.gz) local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v$OPENJPEG_VERSION.tar.gz openjpeg-$OPENJPEG_VERSION.tar.gz)
(cd $out_dir \ (cd $out_dir \
&& cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \
&& make install) && make install)
@ -50,7 +50,7 @@ fi
function build_brotli { function build_brotli {
local cmake=$(get_modern_cmake) local cmake=$(get_modern_cmake)
local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-1.1.0.tar.gz) local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-$BROTLI_VERSION.tar.gz)
(cd $out_dir \ (cd $out_dir \
&& $cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ && $cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \
&& make install) && make install)
@ -60,6 +60,19 @@ function build_brotli {
fi fi
} }
function build_harfbuzz {
python3 -m pip install meson ninja
local out_dir=$(fetch_unpack https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION/$HARFBUZZ_VERSION.tar.xz harfbuzz-$HARFBUZZ_VERSION.tar.xz)
(cd $out_dir \
&& meson setup build --buildtype=release -Dfreetype=enabled -Dglib=disabled)
(cd $out_dir/build \
&& meson install)
if [[ "$MB_ML_LIBC" == "manylinux" ]]; then
cp /usr/local/lib64/libharfbuzz* /usr/local/lib
fi
}
function build { function build {
if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then
sudo chown -R runner /usr/local sudo chown -R runner /usr/local
@ -109,15 +122,7 @@ function build {
build_freetype build_freetype
fi fi
if [ -z "$IS_MACOS" ]; then build_harfbuzz
export FREETYPE_LIBS=-lfreetype
export FREETYPE_CFLAGS=-I/usr/local/include/freetype2/
fi
build_simple harfbuzz $HARFBUZZ_VERSION https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION tar.xz --with-freetype=yes --with-glib=no
if [ -z "$IS_MACOS" ]; then
export FREETYPE_LIBS=""
export FREETYPE_CFLAGS=""
fi
} }
# Any stuff that you need to do before you start building the wheels # Any stuff that you need to do before you start building the wheels

View File

@ -13,14 +13,7 @@ else
yum install -y fribidi yum install -y fribidi
fi fi
if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then python3 -m pip install numpy
# TODO Update condition when NumPy supports free-threading
if [ $(python3 -c "import sysconfig;print(sysconfig.get_config_var('Py_GIL_DISABLED'))") == "1" ]; then
python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple
else
python3 -m pip install numpy
fi
fi
if [ ! -d "test-images-main" ]; then if [ ! -d "test-images-main" ]; then
curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip

View File

@ -48,7 +48,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: python-version:
- pp39
- pp310 - pp310
- cp3{9,10,11} - cp3{9,10,11}
- cp3{12,13} - cp3{12,13}
@ -57,7 +56,6 @@ jobs:
- manylinux_2_28 - manylinux_2_28
- musllinux - musllinux
exclude: exclude:
- { python-version: pp39, spec: musllinux }
- { python-version: pp310, spec: musllinux } - { python-version: pp310, spec: musllinux }
steps: steps:
@ -104,12 +102,23 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- name: "macOS x86_64" - name: "macOS 10.10 x86_64"
os: macos-13 os: macos-13
cibw_arch: x86_64 cibw_arch: x86_64
build: "cp3{9,10,11}*"
macosx_deployment_target: "10.10" macosx_deployment_target: "10.10"
- name: "macOS 10.13 x86_64"
os: macos-13
cibw_arch: x86_64
build: "cp3{12,13}*"
macosx_deployment_target: "10.13"
- name: "macOS 10.15 x86_64"
os: macos-13
cibw_arch: x86_64
build: "pp310*"
macosx_deployment_target: "10.15"
- name: "macOS arm64" - name: "macOS arm64"
os: macos-14 os: macos-latest
cibw_arch: arm64 cibw_arch: arm64
macosx_deployment_target: "11.0" macosx_deployment_target: "11.0"
- name: "manylinux2014 and musllinux x86_64" - name: "manylinux2014 and musllinux x86_64"
@ -147,7 +156,7 @@ jobs:
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: dist-${{ matrix.os }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }} name: dist-${{ matrix.os }}${{ matrix.macosx_deployment_target && format('-{0}', matrix.macosx_deployment_target) }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }}
path: ./wheelhouse/*.whl path: ./wheelhouse/*.whl
windows: windows:
@ -269,7 +278,7 @@ jobs:
path: dist path: dist
merge-multiple: true merge-multiple: true
- name: Upload wheels to scientific-python-nightly-wheels - name: Upload wheels to scientific-python-nightly-wheels
uses: scientific-python/upload-nightly-action@b67d7fcc0396e1128a474d1ab2b48aa94680f9fc # 0.5.0 uses: scientific-python/upload-nightly-action@ccf29c805b5d0c1dc31fa97fcdb962be074cade3 # 0.6.0
with: with:
artifacts_path: dist artifacts_path: dist
anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }}

View File

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0 rev: v0.6.3
hooks: hooks:
- id: ruff - id: ruff
args: [--exit-non-zero-on-fix] args: [--exit-non-zero-on-fix]
@ -50,7 +50,7 @@ repos:
exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/
- repo: https://github.com/python-jsonschema/check-jsonschema - repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.29.1 rev: 0.29.2
hooks: hooks:
- id: check-github-workflows - id: check-github-workflows
- id: check-readthedocs - id: check-readthedocs

View File

@ -5,6 +5,87 @@ Changelog (Pillow)
11.0.0 (unreleased) 11.0.0 (unreleased)
------------------- -------------------
- Improved copying imagequant libraries #8420
[radarhere]
- Use Capsule for WebP saving #8386
[homm, radarhere]
- Fixed writing multiple StripOffsets to TIFF #8317
[Yay295, radarhere]
- Fix dereference before checking for NULL in ImagingTransformAffine #8398
[PavlNekrasov]
- Use transposed size after opening for TIFF images #8390
[radarhere, homm]
- Improve ImageFont error messages #8338
[yngvem, radarhere, hugovk]
- Mention MAX_TEXT_CHUNK limit in PNG error message #8391
[radarhere]
- Cast Dib handle to int #8385
[radarhere]
- Accept float stroke widths #8369
[radarhere]
- Deprecate ICNS (width, height, scale) sizes in favour of load(scale) #8352
[radarhere]
- Improved handling of RGBA palettes when saving GIF images #8366
[radarhere]
- Deprecate isImageType #8364
[radarhere]
- Support converting more modes to LAB by converting to RGBA first #8358
[radarhere]
- Deprecate support for FreeType 2.9.0 #8356
[hugovk, radarhere]
- Removed unused TiffImagePlugin IFD_LEGACY_API #8355
[radarhere]
- Handle duplicate EXIF header #8350
[zakajd, radarhere]
- Return early from BoxBlur if either width or height is zero #8347
[radarhere]
- Check text is either string or bytes #8308
[radarhere]
- Added writing XMP bytes to JPEG #8286
[radarhere]
- Support JPEG2000 RGBA palettes #8256
[radarhere]
- Expand C image to match GIF frame image size #8237
[radarhere]
- Allow saving I;16 images as PPM #8231
[radarhere]
- When IFD is missing, connect get_ifd() dictionary to Exif #8230
[radarhere]
- Skip truncated ICO mask if LOAD_TRUNCATED_IMAGES is enabled #8180
[radarhere]
- Treat unknown JPEG2000 colorspace as unspecified #8343
[radarhere]
- Updated error message when saving WebP with invalid width or height #8322
[radarhere, hugovk]
- Remove warning if NumPy failed to raise an error during conversion #8326
[radarhere]
- If left and right sides meet in ImageDraw.rounded_rectangle(), do not draw rectangle to fill gap #8304 - If left and right sides meet in ImageDraw.rounded_rectangle(), do not draw rectangle to fill gap #8304
[radarhere] [radarhere]

View File

@ -117,7 +117,7 @@ lint-fix:
python3 -c "import black" > /dev/null 2>&1 || python3 -m pip install black python3 -c "import black" > /dev/null 2>&1 || python3 -m pip install black
python3 -m black . python3 -m black .
python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff
python3 -m ruff --fix . python3 -m ruff check --fix .
.PHONY: mypy .PHONY: mypy
mypy: mypy:

View File

@ -51,7 +51,7 @@ As of 2019, Pillow development is
<a href="https://app.codecov.io/gh/python-pillow/Pillow"><img <a href="https://app.codecov.io/gh/python-pillow/Pillow"><img
alt="Code coverage" alt="Code coverage"
src="https://codecov.io/gh/python-pillow/Pillow/branch/main/graph/badge.svg"></a> src="https://codecov.io/gh/python-pillow/Pillow/branch/main/graph/badge.svg"></a>
<a href="https://bugs.chromium.org/p/oss-fuzz/issues/list?sort=-opened&can=1&q=proj:pillow"><img <a href="https://issues.oss-fuzz.com/issues?q=title:pillow"><img
alt="Fuzzing Status" alt="Fuzzing Status"
src="https://oss-fuzz-build-logs.storage.googleapis.com/badges/pillow.svg"></a> src="https://oss-fuzz-build-logs.storage.googleapis.com/badges/pillow.svg"></a>
</td> </td>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

View File

@ -16,8 +16,9 @@
import atheris import atheris
from atheris.import_hook import instrument_imports
with atheris.instrument_imports(): with instrument_imports():
import sys import sys
import fuzzers import fuzzers

View File

@ -14,8 +14,9 @@
import atheris import atheris
from atheris.import_hook import instrument_imports
with atheris.instrument_imports(): with instrument_imports():
import sys import sys
import fuzzers import fuzzers

View File

@ -1,5 +1,5 @@
{ {
<py3_8_encode_current_locale> <py3_10_encode_current_locale>
Memcheck:Cond Memcheck:Cond
... ...
fun:encode_current_locale fun:encode_current_locale

View File

@ -71,6 +71,11 @@ def test_color_modes() -> None:
box_blur(sample.convert("YCbCr")) box_blur(sample.convert("YCbCr"))
@pytest.mark.parametrize("size", ((0, 1), (1, 0)))
def test_zero_dimension(size: tuple[int, int]) -> None:
assert box_blur(Image.new("L", size)).size == size
def test_radius_0() -> None: def test_radius_0() -> None:
assert_blur( assert_blur(
sample, sample,

View File

@ -120,7 +120,6 @@ class TestColorLut3DCoreAPI:
self, lut_mode: str, table_channels: int, table_size: int | tuple[int, int, int] self, lut_mode: str, table_channels: int, table_size: int | tuple[int, int, int]
) -> None: ) -> None:
im = Image.new("RGB", (10, 10), 0) im = Image.new("RGB", (10, 10), 0)
assert im.im is not None
im.im.color_lut_3d( im.im.color_lut_3d(
lut_mode, lut_mode,
Image.Resampling.BILINEAR, Image.Resampling.BILINEAR,
@ -142,7 +141,6 @@ class TestColorLut3DCoreAPI:
) -> None: ) -> None:
with pytest.raises(ValueError, match="wrong mode"): with pytest.raises(ValueError, match="wrong mode"):
im = Image.new(image_mode, (10, 10), 0) im = Image.new(image_mode, (10, 10), 0)
assert im.im is not None
im.im.color_lut_3d( im.im.color_lut_3d(
lut_mode, lut_mode,
Image.Resampling.BILINEAR, Image.Resampling.BILINEAR,
@ -162,7 +160,6 @@ class TestColorLut3DCoreAPI:
self, image_mode: str, lut_mode: str, table_channels: int, table_size: int self, image_mode: str, lut_mode: str, table_channels: int, table_size: int
) -> None: ) -> None:
im = Image.new(image_mode, (10, 10), 0) im = Image.new(image_mode, (10, 10), 0)
assert im.im is not None
im.im.color_lut_3d( im.im.color_lut_3d(
lut_mode, lut_mode,
Image.Resampling.BILINEAR, Image.Resampling.BILINEAR,

View File

@ -1378,8 +1378,26 @@ def test_lzw_bits() -> None:
im.load() im.load()
def test_extents() -> None: @pytest.mark.parametrize(
with Image.open("Tests/images/test_extents.gif") as im: "test_file, loading_strategy",
(
("test_extents.gif", GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST),
(
"test_extents.gif",
GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY,
),
(
"test_extents_transparency.gif",
GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST,
),
),
)
def test_extents(
test_file: str, loading_strategy: GifImagePlugin.LoadingStrategy
) -> None:
GifImagePlugin.LOADING_STRATEGY = loading_strategy
try:
with Image.open("Tests/images/" + test_file) as im:
assert im.size == (100, 100) assert im.size == (100, 100)
# Check that n_frames does not change the size # Check that n_frames does not change the size
@ -1389,6 +1407,11 @@ def test_extents() -> None:
im.seek(1) im.seek(1)
assert im.size == (150, 150) assert im.size == (150, 150)
im.load()
assert im.im.size == (150, 150)
finally:
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST
def test_missing_background() -> None: def test_missing_background() -> None:
# The Global Color Table Flag isn't set, so there is no background color index, # The Global Color Table Flag isn't set, so there is no background color index,
@ -1406,3 +1429,21 @@ def test_saving_rgba(tmp_path: Path) -> None:
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
reloaded_rgba = reloaded.convert("RGBA") reloaded_rgba = reloaded.convert("RGBA")
assert reloaded_rgba.load()[0, 0][3] == 0 assert reloaded_rgba.load()[0, 0][3] == 0
def test_optimizing_p_rgba(tmp_path: Path) -> None:
out = str(tmp_path / "temp.gif")
im1 = Image.new("P", (100, 100))
d = ImageDraw.Draw(im1)
d.ellipse([(40, 40), (60, 60)], fill=1)
data = [0, 0, 0, 0, 0, 0, 0, 255] + [0, 0, 0, 0] * 254
im1.putpalette(data, "RGBA")
im2 = Image.new("P", (100, 100))
im2.putpalette(data, "RGBA")
im1.save(out, save_all=True, append_images=[im2])
with Image.open(out) as reloaded:
assert reloaded.n_frames == 2

View File

@ -63,8 +63,8 @@ def test_save_append_images(tmp_path: Path) -> None:
assert_image_similar_tofile(im, temp_file, 1) assert_image_similar_tofile(im, temp_file, 1)
with Image.open(temp_file) as reread: with Image.open(temp_file) as reread:
reread.size = (16, 16, 2) reread.size = (16, 16)
reread.load() reread.load(2)
assert_image_equal(reread, provided_im) assert_image_equal(reread, provided_im)
@ -87,14 +87,21 @@ def test_sizes() -> None:
for w, h, r in im.info["sizes"]: for w, h, r in im.info["sizes"]:
wr = w * r wr = w * r
hr = h * r hr = h * r
with pytest.warns(DeprecationWarning):
im.size = (w, h, r) im.size = (w, h, r)
im.load() im.load()
assert im.mode == "RGBA" assert im.mode == "RGBA"
assert im.size == (wr, hr) assert im.size == (wr, hr)
# Test using load() with scale
im.size = (w, h)
im.load(scale=r)
assert im.mode == "RGBA"
assert im.size == (wr, hr)
# Check that we cannot load an incorrect size # Check that we cannot load an incorrect size
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.size = (1, 1) im.size = (1, 2)
def test_older_icon() -> None: def test_older_icon() -> None:
@ -105,8 +112,8 @@ def test_older_icon() -> None:
wr = w * r wr = w * r
hr = h * r hr = h * r
with Image.open("Tests/images/pillow2.icns") as im2: with Image.open("Tests/images/pillow2.icns") as im2:
im2.size = (w, h, r) im2.size = (w, h)
im2.load() im2.load(r)
assert im2.mode == "RGBA" assert im2.mode == "RGBA"
assert im2.size == (wr, hr) assert im2.size == (wr, hr)
@ -122,8 +129,8 @@ def test_jp2_icon() -> None:
wr = w * r wr = w * r
hr = h * r hr = h * r
with Image.open("Tests/images/pillow3.icns") as im2: with Image.open("Tests/images/pillow3.icns") as im2:
im2.size = (w, h, r) im2.size = (w, h)
im2.load() im2.load(r)
assert im2.mode == "RGBA" assert im2.mode == "RGBA"
assert im2.size == (wr, hr) assert im2.size == (wr, hr)

View File

@ -6,7 +6,7 @@ from pathlib import Path
import pytest import pytest
from PIL import IcoImagePlugin, Image, ImageDraw from PIL import IcoImagePlugin, Image, ImageDraw, ImageFile
from .helper import assert_image_equal, assert_image_equal_tofile, hopper from .helper import assert_image_equal, assert_image_equal_tofile, hopper
@ -241,3 +241,29 @@ def test_draw_reloaded(tmp_path: Path) -> None:
with Image.open(outfile) as im: with Image.open(outfile) as im:
assert_image_equal_tofile(im, "Tests/images/hopper_draw.ico") assert_image_equal_tofile(im, "Tests/images/hopper_draw.ico")
def test_truncated_mask() -> None:
# 1 bpp
with open("Tests/images/hopper_mask.ico", "rb") as fp:
data = fp.read()
ImageFile.LOAD_TRUNCATED_IMAGES = True
data = data[:-3]
try:
with Image.open(io.BytesIO(data)) as im:
with Image.open("Tests/images/hopper_mask.png") as expected:
assert im.mode == "1"
# 32 bpp
output = io.BytesIO()
expected = hopper("RGBA")
expected.save(output, "ico", bitmap_format="bmp")
data = output.getvalue()[:-1]
with Image.open(io.BytesIO(data)) as im:
assert im.mode == "RGB"
finally:
ImageFile.LOAD_TRUNCATED_IMAGES = False

View File

@ -991,12 +991,29 @@ class TestFileJpeg:
else: else:
assert im.getxmp() == {"xmpmeta": None} assert im.getxmp() == {"xmpmeta": None}
def test_save_xmp(self, tmp_path: Path) -> None:
f = str(tmp_path / "temp.jpg")
im = hopper()
im.save(f, xmp=b"XMP test")
with Image.open(f) as reloaded:
assert reloaded.info["xmp"] == b"XMP test"
im.info["xmp"] = b"1" * 65504
im.save(f)
with Image.open(f) as reloaded:
assert reloaded.info["xmp"] == b"1" * 65504
with pytest.raises(ValueError):
im.save(f, xmp=b"1" * 65505)
@pytest.mark.timeout(timeout=1) @pytest.mark.timeout(timeout=1)
def test_eof(self) -> None: def test_eof(self) -> None:
# Even though this decoder never says that it is finished # Even though this decoder never says that it is finished
# the image should still end when there is no new data # the image should still end when there is no new data
class InfiniteMockPyDecoder(ImageFile.PyDecoder): class InfiniteMockPyDecoder(ImageFile.PyDecoder):
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(
self, buffer: bytes | Image.SupportsArrayInterface
) -> tuple[int, int]:
return 0, 0 return 0, 0
Image.register_decoder("INFINITE", InfiniteMockPyDecoder) Image.register_decoder("INFINITE", InfiniteMockPyDecoder)

View File

@ -182,6 +182,15 @@ def test_restricted_icc_profile() -> None:
ImageFile.LOAD_TRUNCATED_IMAGES = False ImageFile.LOAD_TRUNCATED_IMAGES = False
@pytest.mark.skipif(
not os.path.exists(EXTRA_DIR), reason="Extra image files not installed"
)
def test_unknown_colorspace() -> None:
with Image.open(f"{EXTRA_DIR}/file8.jp2") as im:
im.load()
assert im.mode == "L"
def test_header_errors() -> None: def test_header_errors() -> None:
for path in ( for path in (
"Tests/images/invalid_header_length.jp2", "Tests/images/invalid_header_length.jp2",
@ -391,6 +400,13 @@ def test_pclr() -> None:
assert len(im.palette.colors) == 256 assert len(im.palette.colors) == 256
assert im.palette.colors[(255, 255, 255)] == 0 assert im.palette.colors[(255, 255, 255)] == 0
with Image.open(
f"{EXTRA_DIR}/147af3f1083de4393666b7d99b01b58b_signal_sigsegv_130c531_6155_5136.jp2"
) as im:
assert im.mode == "P"
assert len(im.palette.colors) == 139
assert im.palette.colors[(0, 0, 0, 0)] == 0
def test_comment() -> None: def test_comment() -> None:
with Image.open("Tests/images/comment.jp2") as im: with Image.open("Tests/images/comment.jp2") as im:

View File

@ -95,7 +95,9 @@ def test_16bit_pgm_write(tmp_path: Path) -> None:
with Image.open("Tests/images/16_bit_binary.pgm") as im: with Image.open("Tests/images/16_bit_binary.pgm") as im:
filename = str(tmp_path / "temp.pgm") filename = str(tmp_path / "temp.pgm")
im.save(filename, "PPM") im.save(filename, "PPM")
assert_image_equal_tofile(im, filename)
im.convert("I;16").save(filename, "PPM")
assert_image_equal_tofile(im, filename) assert_image_equal_tofile(im, filename)

View File

@ -108,7 +108,8 @@ class TestFileTiff:
assert_image_equal_tofile(im, "Tests/images/hopper.tif") assert_image_equal_tofile(im, "Tests/images/hopper.tif")
with Image.open("Tests/images/hopper_bigtiff.tif") as im: with Image.open("Tests/images/hopper_bigtiff.tif") as im:
# multistrip support not yet implemented # The data type of this file's StripOffsets tag is LONG8,
# which is not yet supported for offset data when saving multiple frames.
del im.tag_v2[273] del im.tag_v2[273]
outfile = str(tmp_path / "temp.tif") outfile = str(tmp_path / "temp.tif")
@ -684,6 +685,13 @@ class TestFileTiff:
with Image.open(outfile) as reloaded: with Image.open(outfile) as reloaded:
assert_image_equal_tofile(reloaded, infile) assert_image_equal_tofile(reloaded, infile)
def test_invalid_tiled_dimensions(self) -> None:
with open("Tests/images/tiff_tiled_planar_raw.tif", "rb") as fp:
data = fp.read()
b = BytesIO(data[:144] + b"\x02" + data[145:])
with pytest.raises(ValueError):
Image.open(b)
@pytest.mark.parametrize("mode", ("P", "PA")) @pytest.mark.parametrize("mode", ("P", "PA"))
def test_palette(self, mode: str, tmp_path: Path) -> None: def test_palette(self, mode: str, tmp_path: Path) -> None:
outfile = str(tmp_path / "temp.tif") outfile = str(tmp_path / "temp.tif")

View File

@ -181,6 +181,29 @@ def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None:
assert reloaded.tag_v2.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] == TiffTags.LONG assert reloaded.tag_v2.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] == TiffTags.LONG
def test_save_multiple_stripoffsets() -> None:
ifd = TiffImagePlugin.ImageFileDirectory_v2()
ifd[TiffImagePlugin.STRIPOFFSETS] = (123, 456)
assert ifd.tagtype[TiffImagePlugin.STRIPOFFSETS] == TiffTags.LONG
# all values are in little-endian
assert ifd.tobytes() == (
# number of tags == 1
b"\x01\x00"
# tag id (2 bytes), type (2 bytes), count (4 bytes), value (4 bytes)
# TiffImagePlugin.STRIPOFFSETS, TiffTags.LONG, 2, 18
# where STRIPOFFSETS is 273, LONG is 4
# and 18 is the offset of the tag data
b"\x11\x01\x04\x00\x02\x00\x00\x00\x12\x00\x00\x00"
# end of entries
b"\x00\x00\x00\x00"
# 26 is the total number of bytes output,
# the offset for any auxiliary strip data that will then be appended
# (123 + 26, 456 + 26) == (149, 482)
b"\x95\x00\x00\x00\xe2\x01\x00\x00"
)
def test_no_duplicate_50741_tag() -> None: def test_no_duplicate_50741_tag() -> None:
assert TAG_IDS["MakerNoteSafety"] == 50741 assert TAG_IDS["MakerNoteSafety"] == 50741
assert TAG_IDS["BestQualityScale"] == 50780 assert TAG_IDS["BestQualityScale"] == 50780

View File

@ -72,7 +72,7 @@ class TestFileWebp:
def _roundtrip( def _roundtrip(
self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {} self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {}
) -> None: ) -> None:
temp_file = str(tmp_path / "temp.webp") temp_file = tmp_path / "temp.webp"
hopper(mode).save(temp_file, **args) hopper(mode).save(temp_file, **args)
with Image.open(temp_file) as image: with Image.open(temp_file) as image:
@ -116,7 +116,7 @@ class TestFileWebp:
assert buffer_no_args.getbuffer() != buffer_method.getbuffer() assert buffer_no_args.getbuffer() != buffer_method.getbuffer()
def test_save_all(self, tmp_path: Path) -> None: def test_save_all(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.webp") temp_file = tmp_path / "temp.webp"
im = Image.new("RGB", (1, 1)) im = Image.new("RGB", (1, 1))
im2 = Image.new("RGB", (1, 1), "#f00") im2 = Image.new("RGB", (1, 1), "#f00")
im.save(temp_file, save_all=True, append_images=[im2]) im.save(temp_file, save_all=True, append_images=[im2])
@ -127,6 +127,11 @@ class TestFileWebp:
reloaded.seek(1) reloaded.seek(1)
assert_image_similar(im2, reloaded, 1) assert_image_similar(im2, reloaded, 1)
def test_unsupported_image_mode(self) -> None:
im = Image.new("1", (1, 1))
with pytest.raises(ValueError):
_webp.WebPEncode(im.getim(), False, 0, 0, "", 4, 0, b"", "")
def test_icc_profile(self, tmp_path: Path) -> None: def test_icc_profile(self, tmp_path: Path) -> None:
self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None})
self._roundtrip( self._roundtrip(
@ -151,12 +156,21 @@ class TestFileWebp:
@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system")
def test_write_encoding_error_message(self, tmp_path: Path) -> None: def test_write_encoding_error_message(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.webp")
im = Image.new("RGB", (15000, 15000)) im = Image.new("RGB", (15000, 15000))
with pytest.raises(ValueError) as e: with pytest.raises(ValueError) as e:
im.save(temp_file, method=0) im.save(tmp_path / "temp.webp", method=0)
assert str(e.value) == "encoding error 6" assert str(e.value) == "encoding error 6"
@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system")
def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None:
im = Image.new("L", (16384, 16384))
with pytest.raises(ValueError) as e:
im.save(tmp_path / "temp.webp")
assert (
str(e.value)
== "encoding error 5: Image size exceeds WebP limit of 16383 pixels"
)
def test_WebPEncode_with_invalid_args(self) -> None: def test_WebPEncode_with_invalid_args(self) -> None:
""" """
Calling encoder functions with no arguments should result in an error. Calling encoder functions with no arguments should result in an error.
@ -176,9 +190,8 @@ class TestFileWebp:
def test_no_resource_warning(self, tmp_path: Path) -> None: def test_no_resource_warning(self, tmp_path: Path) -> None:
file_path = "Tests/images/hopper.webp" file_path = "Tests/images/hopper.webp"
with Image.open(file_path) as image: with Image.open(file_path) as image:
temp_file = str(tmp_path / "temp.webp")
with warnings.catch_warnings(): with warnings.catch_warnings():
image.save(temp_file) image.save(tmp_path / "temp.webp")
def test_file_pointer_could_be_reused(self) -> None: def test_file_pointer_could_be_reused(self) -> None:
file_path = "Tests/images/hopper.webp" file_path = "Tests/images/hopper.webp"
@ -193,15 +206,16 @@ class TestFileWebp:
def test_invalid_background( def test_invalid_background(
self, background: int | tuple[int, ...], tmp_path: Path self, background: int | tuple[int, ...], tmp_path: Path
) -> None: ) -> None:
temp_file = str(tmp_path / "temp.webp") temp_file = tmp_path / "temp.webp"
im = hopper() im = hopper()
with pytest.raises(OSError): with pytest.raises(OSError):
im.save(temp_file, save_all=True, append_images=[im], background=background) im.save(temp_file, save_all=True, append_images=[im], background=background)
def test_background_from_gif(self, tmp_path: Path) -> None: def test_background_from_gif(self, tmp_path: Path) -> None:
out_webp = tmp_path / "temp.webp"
# Save L mode GIF with background # Save L mode GIF with background
with Image.open("Tests/images/no_palette_with_background.gif") as im: with Image.open("Tests/images/no_palette_with_background.gif") as im:
out_webp = str(tmp_path / "temp.webp")
im.save(out_webp, save_all=True) im.save(out_webp, save_all=True)
# Save P mode GIF with background # Save P mode GIF with background
@ -209,11 +223,10 @@ class TestFileWebp:
original_value = im.convert("RGB").getpixel((1, 1)) original_value = im.convert("RGB").getpixel((1, 1))
# Save as WEBP # Save as WEBP
out_webp = str(tmp_path / "temp.webp")
im.save(out_webp, save_all=True) im.save(out_webp, save_all=True)
# Save as GIF # Save as GIF
out_gif = str(tmp_path / "temp.gif") out_gif = tmp_path / "temp.gif"
with Image.open(out_webp) as im: with Image.open(out_webp) as im:
im.save(out_gif) im.save(out_gif)
@ -223,10 +236,10 @@ class TestFileWebp:
assert difference < 5 assert difference < 5
def test_duration(self, tmp_path: Path) -> None: def test_duration(self, tmp_path: Path) -> None:
out_webp = tmp_path / "temp.webp"
with Image.open("Tests/images/dispose_bgnd.gif") as im: with Image.open("Tests/images/dispose_bgnd.gif") as im:
assert im.info["duration"] == 1000 assert im.info["duration"] == 1000
out_webp = str(tmp_path / "temp.webp")
im.save(out_webp, save_all=True) im.save(out_webp, save_all=True)
with Image.open(out_webp) as reloaded: with Image.open(out_webp) as reloaded:
@ -234,9 +247,10 @@ class TestFileWebp:
assert reloaded.info["duration"] == 1000 assert reloaded.info["duration"] == 1000
def test_roundtrip_rgba_palette(self, tmp_path: Path) -> None: def test_roundtrip_rgba_palette(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.webp") temp_file = tmp_path / "temp.webp"
im = Image.new("RGBA", (1, 1)).convert("P") im = Image.new("RGBA", (1, 1)).convert("P")
assert im.mode == "P" assert im.mode == "P"
assert im.palette is not None
assert im.palette.mode == "RGBA" assert im.palette.mode == "RGBA"
im.save(temp_file) im.save(temp_file)

View File

@ -116,7 +116,15 @@ def test_read_no_exif() -> None:
def test_getxmp() -> None: def test_getxmp() -> None:
with Image.open("Tests/images/flower.webp") as im: with Image.open("Tests/images/flower.webp") as im:
assert "xmp" not in im.info assert "xmp" not in im.info
assert im.getxmp() == {} if ElementTree is None:
with pytest.warns(
UserWarning,
match="XMP data cannot be read without defusedxml dependency",
):
xmp = im.getxmp()
else:
xmp = im.getxmp()
assert xmp == {}
with Image.open("Tests/images/flower2.webp") as im: with Image.open("Tests/images/flower2.webp") as im:
if ElementTree is None: if ElementTree is None:

View File

@ -42,6 +42,12 @@ try:
except ImportError: except ImportError:
ElementTree = None ElementTree = None
PrettyPrinter: type | None
try:
from IPython.lib.pretty import PrettyPrinter
except ImportError:
PrettyPrinter = None
# Deprecation helper # Deprecation helper
def helper_image_new(mode: str, size: tuple[int, int]) -> Image.Image: def helper_image_new(mode: str, size: tuple[int, int]) -> Image.Image:
@ -91,16 +97,15 @@ class TestImage:
# with pytest.raises(MemoryError): # with pytest.raises(MemoryError):
# Image.new("L", (1000000, 1000000)) # Image.new("L", (1000000, 1000000))
@pytest.mark.skipif(PrettyPrinter is None, reason="IPython is not installed")
def test_repr_pretty(self) -> None: def test_repr_pretty(self) -> None:
class Pretty:
def text(self, text: str) -> None:
self.pretty_output = text
im = Image.new("L", (100, 100)) im = Image.new("L", (100, 100))
p = Pretty() output = io.StringIO()
assert PrettyPrinter is not None
p = PrettyPrinter(output)
im._repr_pretty_(p, False) im._repr_pretty_(p, False)
assert p.pretty_output == "<PIL.Image.Image image mode=L size=100x100>" assert output.getvalue() == "<PIL.Image.Image image mode=L size=100x100>"
def test_open_formats(self) -> None: def test_open_formats(self) -> None:
PNGFILE = "Tests/images/hopper.png" PNGFILE = "Tests/images/hopper.png"
@ -700,6 +705,7 @@ class TestImage:
assert new_image.size == image.size assert new_image.size == image.size
assert new_image.info == base_image.info assert new_image.info == base_image.info
if palette_result is not None: if palette_result is not None:
assert new_image.palette is not None
assert new_image.palette.tobytes() == palette_result.tobytes() assert new_image.palette.tobytes() == palette_result.tobytes()
else: else:
assert new_image.palette is None assert new_image.palette is None
@ -769,6 +775,22 @@ class TestImage:
exif.load(b"Exif\x00\x00") exif.load(b"Exif\x00\x00")
assert not dict(exif) assert not dict(exif)
def test_duplicate_exif_header(self) -> None:
with Image.open("Tests/images/exif.png") as im:
im.load()
im.info["exif"] = b"Exif\x00\x00" + im.info["exif"]
exif = im.getexif()
assert exif[274] == 1
def test_empty_get_ifd(self) -> None:
exif = Image.Exif()
ifd = exif.get_ifd(0x8769)
assert ifd == {}
ifd[36864] = b"0220"
assert exif.get_ifd(0x8769) == {36864: b"0220"}
@mark_if_feature_version( @mark_if_feature_version(
pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing"
) )
@ -938,7 +960,15 @@ class TestImage:
def test_empty_xmp(self) -> None: def test_empty_xmp(self) -> None:
with Image.open("Tests/images/hopper.gif") as im: with Image.open("Tests/images/hopper.gif") as im:
assert im.getxmp() == {} if ElementTree is None:
with pytest.warns(
UserWarning,
match="XMP data cannot be read without defusedxml dependency",
):
xmp = im.getxmp()
else:
xmp = im.getxmp()
assert xmp == {}
def test_getxmp_padded(self) -> None: def test_getxmp_padded(self) -> None:
im = Image.new("RGB", (1, 1)) im = Image.new("RGB", (1, 1))
@ -989,12 +1019,14 @@ class TestImage:
# P mode with RGBA palette # P mode with RGBA palette
im = Image.new("RGBA", (1, 1)).convert("P") im = Image.new("RGBA", (1, 1)).convert("P")
assert im.mode == "P" assert im.mode == "P"
assert im.palette is not None
assert im.palette.mode == "RGBA" assert im.palette.mode == "RGBA"
assert im.has_transparency_data assert im.has_transparency_data
def test_apply_transparency(self) -> None: def test_apply_transparency(self) -> None:
im = Image.new("P", (1, 1)) im = Image.new("P", (1, 1))
im.putpalette((0, 0, 0, 1, 1, 1)) im.putpalette((0, 0, 0, 1, 1, 1))
assert im.palette is not None
assert im.palette.colors == {(0, 0, 0): 0, (1, 1, 1): 1} assert im.palette.colors == {(0, 0, 0): 0, (1, 1, 1): 1}
# Test that no transformation is applied without transparency # Test that no transformation is applied without transparency
@ -1012,13 +1044,16 @@ class TestImage:
im.putpalette((0, 0, 0, 255, 1, 1, 1, 128), "RGBA") im.putpalette((0, 0, 0, 255, 1, 1, 1, 128), "RGBA")
im.info["transparency"] = 0 im.info["transparency"] = 0
im.apply_transparency() im.apply_transparency()
assert im.palette is not None
assert im.palette.colors == {(0, 0, 0, 0): 0, (1, 1, 1, 128): 1} assert im.palette.colors == {(0, 0, 0, 0): 0, (1, 1, 1, 128): 1}
# Test that transparency bytes are applied # Test that transparency bytes are applied
with Image.open("Tests/images/pil123p.png") as im: with Image.open("Tests/images/pil123p.png") as im:
assert isinstance(im.info["transparency"], bytes) assert isinstance(im.info["transparency"], bytes)
assert im.palette is not None
assert im.palette.colors[(27, 35, 6)] == 24 assert im.palette.colors[(27, 35, 6)] == 24
im.apply_transparency() im.apply_transparency()
assert im.palette is not None
assert im.palette.colors[(27, 35, 6, 214)] == 24 assert im.palette.colors[(27, 35, 6, 214)] == 24
def test_constants(self) -> None: def test_constants(self) -> None:
@ -1051,22 +1086,17 @@ class TestImage:
valgrind pytest -qq Tests/test_image.py::TestImage::test_overrun | grep decode.c valgrind pytest -qq Tests/test_image.py::TestImage::test_overrun | grep decode.c
""" """
with Image.open(os.path.join("Tests/images", path)) as im: with Image.open(os.path.join("Tests/images", path)) as im:
try: with pytest.raises(OSError) as e:
im.load() im.load()
pytest.fail() buffer_overrun = str(e.value) == "buffer overrun when reading image file"
except OSError as e: truncated = "image file is truncated" in str(e.value)
buffer_overrun = str(e) == "buffer overrun when reading image file"
truncated = "image file is truncated" in str(e)
assert buffer_overrun or truncated assert buffer_overrun or truncated
def test_fli_overrun2(self) -> None: def test_fli_overrun2(self) -> None:
with Image.open("Tests/images/fli_overrun2.bin") as im: with Image.open("Tests/images/fli_overrun2.bin") as im:
try: with pytest.raises(OSError, match="buffer overrun when reading image file"):
im.seek(1) im.seek(1)
pytest.fail()
except OSError as e:
assert str(e) == "buffer overrun when reading image file"
def test_exit_fp(self) -> None: def test_exit_fp(self) -> None:
with Image.new("L", (1, 1)) as im: with Image.new("L", (1, 1)) as im:
@ -1082,6 +1112,10 @@ class TestImage:
assert len(caplog.records) == 0 assert len(caplog.records) == 0
assert im.fp is None assert im.fp is None
def test_deprecation(self) -> None:
with pytest.warns(DeprecationWarning):
assert not Image.isImageType(None)
class TestImageBytes: class TestImageBytes:
@pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"]) @pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"])

View File

@ -24,7 +24,7 @@ def test_toarray() -> None:
def test_with_dtype(dtype: npt.DTypeLike) -> None: def test_with_dtype(dtype: npt.DTypeLike) -> None:
ai = numpy.array(im, dtype=dtype) ai = numpy.array(im, dtype=dtype)
assert ai.dtype == dtype assert ai.dtype.type is dtype
# assert test("1") == ((100, 128), '|b1', 1600)) # assert test("1") == ((100, 128), '|b1', 1600))
assert test("L") == ((100, 128), "|u1", 12800) assert test("L") == ((100, 128), "|u1", 12800)
@ -47,7 +47,7 @@ def test_toarray() -> None:
with pytest.raises(OSError): with pytest.raises(OSError):
numpy.array(im_truncated) numpy.array(im_truncated)
else: else:
with pytest.warns(UserWarning): with pytest.warns(DeprecationWarning):
numpy.array(im_truncated) numpy.array(im_truncated)
@ -113,4 +113,5 @@ def test_fromarray_palette() -> None:
out = Image.fromarray(a, "P") out = Image.fromarray(a, "P")
# Assert that the Python and C palettes match # Assert that the Python and C palettes match
assert out.palette is not None
assert len(out.palette.colors) == len(out.im.getpalette()) / 3 assert len(out.palette.colors) == len(out.im.getpalette()) / 3

View File

@ -218,6 +218,7 @@ def test_trns_RGB(tmp_path: Path) -> None:
def test_l_macro_rounding(convert_mode: str) -> None: def test_l_macro_rounding(convert_mode: str) -> None:
for mode in ("P", "PA"): for mode in ("P", "PA"):
im = Image.new(mode, (1, 1)) im = Image.new(mode, (1, 1))
assert im.palette is not None
im.palette.getcolor((0, 1, 2)) im.palette.getcolor((0, 1, 2))
converted_im = im.convert(convert_mode) converted_im = im.convert(convert_mode)

View File

@ -49,5 +49,7 @@ def test_copy_zero() -> None:
@skip_unless_feature("libtiff") @skip_unless_feature("libtiff")
def test_deepcopy() -> None: def test_deepcopy() -> None:
with Image.open("Tests/images/g4_orientation_5.tif") as im: with Image.open("Tests/images/g4_orientation_5.tif") as im:
assert im.size == (590, 88)
out = copy.deepcopy(im) out = copy.deepcopy(im)
assert out.size == (590, 88) assert out.size == (590, 88)

View File

@ -86,6 +86,7 @@ def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None:
im = Image.new("P", (1, 1)) im = Image.new("P", (1, 1))
im.putpalette(palette, mode) im.putpalette(palette, mode)
assert im.getpalette() == [1, 2, 3] assert im.getpalette() == [1, 2, 3]
assert im.palette is not None
assert im.palette.colors == {(1, 2, 3, 4): 0} assert im.palette.colors == {(1, 2, 3, 4): 0}

View File

@ -69,6 +69,7 @@ def test_quantize_no_dither() -> None:
converted = image.quantize(dither=Image.Dither.NONE, palette=palette) converted = image.quantize(dither=Image.Dither.NONE, palette=palette)
assert converted.mode == "P" assert converted.mode == "P"
assert converted.palette is not None
assert converted.palette.palette == palette.palette.palette assert converted.palette.palette == palette.palette.palette
@ -81,6 +82,7 @@ def test_quantize_no_dither2() -> None:
palette.putpalette(data) palette.putpalette(data)
quantized = im.quantize(dither=Image.Dither.NONE, palette=palette) quantized = im.quantize(dither=Image.Dither.NONE, palette=palette)
assert quantized.palette is not None
assert tuple(quantized.palette.palette) == data assert tuple(quantized.palette.palette) == data
px = quantized.load() px = quantized.load()
@ -117,6 +119,7 @@ def test_colors() -> None:
im = hopper() im = hopper()
colors = 2 colors = 2
converted = im.quantize(colors) converted = im.quantize(colors)
assert converted.palette is not None
assert len(converted.palette.palette) == colors * len("RGB") assert len(converted.palette.palette) == colors * len("RGB")
@ -147,6 +150,7 @@ def test_palette(method: Image.Quantize, color: tuple[int, ...]) -> None:
converted = im.quantize(method=method) converted = im.quantize(method=method)
converted_px = converted.load() converted_px = converted.load()
assert converted_px is not None assert converted_px is not None
assert converted.palette is not None
assert converted_px[0, 0] == converted.palette.colors[color] assert converted_px[0, 0] == converted.palette.colors[color]

View File

@ -300,9 +300,7 @@ class TestImageResize:
im.resize((10, 10), "unknown") im.resize((10, 10), "unknown")
@skip_unless_feature("libtiff") @skip_unless_feature("libtiff")
def test_load_first(self) -> None: def test_transposed(self) -> None:
# load() may change the size of the image
# Test that resize() is calling it before getting the size
with Image.open("Tests/images/g4_orientation_5.tif") as im: with Image.open("Tests/images/g4_orientation_5.tif") as im:
im = im.resize((64, 64)) im = im.resize((64, 64))
assert im.size == (64, 64) assert im.size == (64, 64)

View File

@ -92,15 +92,13 @@ def test_no_resize() -> None:
@skip_unless_feature("libtiff") @skip_unless_feature("libtiff")
def test_load_first() -> None: def test_transposed() -> None:
# load() may change the size of the image
# Test that thumbnail() is calling it before performing size calculations
with Image.open("Tests/images/g4_orientation_5.tif") as im: with Image.open("Tests/images/g4_orientation_5.tif") as im:
assert im.size == (590, 88)
im.thumbnail((64, 64)) im.thumbnail((64, 64))
assert im.size == (64, 10) assert im.size == (64, 10)
# Test thumbnail(), without draft(),
# on an image that is large enough once load() has changed the size
with Image.open("Tests/images/g4_orientation_5.tif") as im: with Image.open("Tests/images/g4_orientation_5.tif") as im:
im.thumbnail((590, 88), reducing_gap=None) im.thumbnail((590, 88), reducing_gap=None)
assert im.size == (590, 88) assert im.size == (590, 88)

View File

@ -696,6 +696,12 @@ def test_rgb_lab(mode: str) -> None:
assert value[:3] == (0, 255, 255) assert value[:3] == (0, 255, 255)
def test_cmyk_lab() -> None:
im = Image.new("CMYK", (1, 1))
converted_im = im.convert("LAB")
assert converted_im.getpixel((0, 0)) == (255, 128, 128)
def test_deprecation() -> None: def test_deprecation() -> None:
with pytest.warns(DeprecationWarning): with pytest.warns(DeprecationWarning):
assert ImageCms.DESCRIPTION.strip().startswith("pyCMS") assert ImageCms.DESCRIPTION.strip().startswith("pyCMS")

View File

@ -1368,6 +1368,20 @@ def test_stroke() -> None:
) )
@skip_unless_feature("freetype2")
def test_stroke_float() -> None:
# Arrange
im = Image.new("RGB", (120, 130))
draw = ImageDraw.Draw(im)
font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 120)
# Act
draw.text((12, 12), "A", "#f00", font, stroke_width=0.5)
# Assert
assert_image_similar_tofile(im, "Tests/images/imagedraw_stroke_float.png", 3.1)
@skip_unless_feature("freetype2") @skip_unless_feature("freetype2")
def test_stroke_descender() -> None: def test_stroke_descender() -> None:
# Arrange # Arrange

View File

@ -210,7 +210,7 @@ class MockPyDecoder(ImageFile.PyDecoder):
super().__init__(mode, *args) super().__init__(mode, *args)
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
# eof # eof
return -1, 0 return -1, 0
@ -238,7 +238,9 @@ class MockImageFile(ImageFile.ImageFile):
self.rawmode = "RGBA" self.rawmode = "RGBA"
self._mode = "RGBA" self._mode = "RGBA"
self._size = (200, 200) self._size = (200, 200)
self.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None)] self.tile = [
ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None)
]
class CodecsTest: class CodecsTest:
@ -268,7 +270,7 @@ class TestPyDecoder(CodecsTest):
buf = BytesIO(b"\x00" * 255) buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf) im = MockImageFile(buf)
im.tile = [("MOCK", None, 32, None)] im.tile = [ImageFile._Tile("MOCK", None, 32, None)]
im.load() im.load()
@ -281,12 +283,12 @@ class TestPyDecoder(CodecsTest):
buf = BytesIO(b"\x00" * 255) buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf) im = MockImageFile(buf)
im.tile = [("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)] im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)]
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.load() im.load()
im.tile = [("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)] im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)]
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.load() im.load()
@ -294,12 +296,20 @@ class TestPyDecoder(CodecsTest):
buf = BytesIO(b"\x00" * 255) buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf) im = MockImageFile(buf)
im.tile = [("MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None)] im.tile = [
ImageFile._Tile(
"MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None
)
]
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.load() im.load()
im.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None)] im.tile = [
ImageFile._Tile(
"MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None
)
]
with pytest.raises(ValueError): with pytest.raises(ValueError):
im.load() im.load()
@ -336,7 +346,7 @@ class TestPyEncoder(CodecsTest):
buf = BytesIO(b"\x00" * 255) buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf) im = MockImageFile(buf)
im.tile = [("MOCK", None, 32, None)] im.tile = [ImageFile._Tile("MOCK", None, 32, None)]
fp = BytesIO() fp = BytesIO()
ImageFile._save(im, fp, [ImageFile._Tile("MOCK", None, 0, "RGB")]) ImageFile._save(im, fp, [ImageFile._Tile("MOCK", None, 0, "RGB")])
@ -412,9 +422,8 @@ class TestPyEncoder(CodecsTest):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
encoder.encode_to_pyfd() encoder.encode_to_pyfd()
fh = BytesIO()
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
encoder.encode_to_file(fh, 0) encoder.encode_to_file(0, 0)
def test_zero_height(self) -> None: def test_zero_height(self) -> None:
with pytest.raises(UnidentifiedImageError): with pytest.raises(UnidentifiedImageError):

View File

@ -5,6 +5,7 @@ import os
import re import re
import shutil import shutil
import sys import sys
import tempfile
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any, BinaryIO from typing import Any, BinaryIO
@ -460,17 +461,43 @@ def test_free_type_font_get_mask(font: ImageFont.FreeTypeFont) -> None:
assert mask.size == (108, 13) assert mask.size == (108, 13)
def test_load_when_image_not_found() -> None:
with tempfile.NamedTemporaryFile(delete=False) as tmp:
pass
with pytest.raises(OSError) as e:
ImageFont.load(tmp.name)
os.unlink(tmp.name)
root = os.path.splitext(tmp.name)[0]
assert str(e.value) == f"cannot find glyph data file {root}.{{gif|pbm|png}}"
def test_load_path_not_found() -> None: def test_load_path_not_found() -> None:
# Arrange # Arrange
filename = "somefilenamethatdoesntexist.ttf" filename = "somefilenamethatdoesntexist.ttf"
# Act/Assert # Act/Assert
with pytest.raises(OSError): with pytest.raises(OSError) as e:
ImageFont.load_path(filename) ImageFont.load_path(filename)
# The file doesn't exist, so don't suggest `load`
assert filename in str(e.value)
assert "did you mean" not in str(e.value)
with pytest.raises(OSError): with pytest.raises(OSError):
ImageFont.truetype(filename) ImageFont.truetype(filename)
def test_load_path_existing_path() -> None:
with tempfile.NamedTemporaryFile() as tmp:
with pytest.raises(OSError) as e:
ImageFont.load_path(tmp.name)
# The file exists, so the error message suggests to use `load` instead
assert tmp.name in str(e.value)
assert " did you mean" in str(e.value)
def test_load_non_font_bytes() -> None: def test_load_non_font_bytes() -> None:
with open("Tests/images/hopper.jpg", "rb") as f: with open("Tests/images/hopper.jpg", "rb") as f:
with pytest.raises(OSError): with pytest.raises(OSError):
@ -1113,6 +1140,9 @@ def test_bytes(font: ImageFont.FreeTypeFont) -> None:
) )
assert font.getmask2(b"test")[1] == font.getmask2("test")[1] assert font.getmask2(b"test")[1] == font.getmask2("test")[1]
with pytest.raises(TypeError):
font.getlength((0, 0)) # type: ignore[arg-type]
@pytest.mark.parametrize( @pytest.mark.parametrize(
"test_file", "test_file",
@ -1147,3 +1177,15 @@ def test_invalid_truetype_sizes_raise_valueerror(
) -> None: ) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine) ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine)
def test_freetype_deprecation(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange: mock features.version_module to return fake FreeType version
def fake_version_module(module: str) -> str:
return "2.9.0"
monkeypatch.setattr(features, "version_module", fake_version_module)
# Act / Assert
with pytest.warns(DeprecationWarning):
ImageFont.truetype(FONT_PATH, FONT_SIZE)

View File

@ -115,7 +115,7 @@ def test_ipythonviewer() -> None:
test_viewer = viewer test_viewer = viewer
break break
else: else:
pytest.fail() pytest.fail("IPythonViewer not found")
im = hopper() im = hopper()
assert test_viewer.show(im) == 1 assert test_viewer.show(im) == 1

View File

@ -60,6 +60,18 @@ class TestImageWinDib:
with pytest.raises(ValueError): with pytest.raises(ValueError):
ImageWin.Dib(mode) ImageWin.Dib(mode)
def test_dib_hwnd(self) -> None:
mode = "RGBA"
size = (128, 128)
wnd = 0
dib = ImageWin.Dib(mode, size)
hwnd = ImageWin.HWND(wnd)
dib.expose(hwnd)
dib.draw(hwnd, (0, 0) + size)
assert isinstance(dib.query_palette(hwnd), int)
def test_dib_paste(self) -> None: def test_dib_paste(self) -> None:
# Arrange # Arrange
im = hopper() im = hopper()

View File

@ -238,8 +238,10 @@ def test_zero_size() -> None:
@skip_unless_feature("libtiff") @skip_unless_feature("libtiff")
def test_load_first() -> None: def test_transposed() -> None:
with Image.open("Tests/images/g4_orientation_5.tif") as im: with Image.open("Tests/images/g4_orientation_5.tif") as im:
assert im.size == (590, 88)
a = numpy.array(im) a = numpy.array(im)
assert a.shape == (88, 590) assert a.shape == (88, 590)

View File

@ -5,8 +5,6 @@ import sys
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
import pytest
from PIL import Image, PSDraw from PIL import Image, PSDraw
@ -49,15 +47,14 @@ def test_draw_postscript(tmp_path: Path) -> None:
assert os.path.getsize(tempfile) > 0 assert os.path.getsize(tempfile) > 0
@pytest.mark.parametrize("buffer", (True, False)) def test_stdout() -> None:
def test_stdout(buffer: bool) -> None:
# Temporarily redirect stdout # Temporarily redirect stdout
old_stdout = sys.stdout old_stdout = sys.stdout
class MyStdOut: class MyStdOut:
buffer = BytesIO() buffer = BytesIO()
mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() mystdout = MyStdOut()
sys.stdout = mystdout sys.stdout = mystdout
@ -67,6 +64,4 @@ def test_stdout(buffer: bool) -> None:
# Reset stdout # Reset stdout
sys.stdout = old_stdout sys.stdout = old_stdout
if isinstance(mystdout, MyStdOut): assert mystdout.buffer.getvalue() != b""
mystdout = mystdout.buffer
assert mystdout.getvalue() != b""

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Union
import pytest import pytest
@ -8,6 +9,20 @@ from PIL import Image, ImageQt
from .helper import assert_image_equal_tofile, assert_image_similar, hopper from .helper import assert_image_equal_tofile, assert_image_similar, hopper
if TYPE_CHECKING:
import PyQt6
import PySide6
QApplication = Union[PyQt6.QtWidgets.QApplication, PySide6.QtWidgets.QApplication]
QHBoxLayout = Union[PyQt6.QtWidgets.QHBoxLayout, PySide6.QtWidgets.QHBoxLayout]
QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage]
QLabel = Union[PyQt6.QtWidgets.QLabel, PySide6.QtWidgets.QLabel]
QPainter = Union[PyQt6.QtGui.QPainter, PySide6.QtGui.QPainter]
QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap]
QPoint = Union[PyQt6.QtCore.QPoint, PySide6.QtCore.QPoint]
QRegion = Union[PyQt6.QtGui.QRegion, PySide6.QtGui.QRegion]
QWidget = Union[PyQt6.QtWidgets.QWidget, PySide6.QtWidgets.QWidget]
if ImageQt.qt_is_installed: if ImageQt.qt_is_installed:
from PIL.ImageQt import QPixmap from PIL.ImageQt import QPixmap
@ -20,7 +35,7 @@ if ImageQt.qt_is_installed:
from PySide6.QtGui import QImage, QPainter, QRegion from PySide6.QtGui import QImage, QPainter, QRegion
from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
class Example(QWidget): class Example(QWidget): # type: ignore[misc]
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -28,11 +43,12 @@ if ImageQt.qt_is_installed:
qimage = ImageQt.ImageQt(img) qimage = ImageQt.ImageQt(img)
pixmap1 = ImageQt.QPixmap.fromImage(qimage) pixmap1 = getattr(ImageQt.QPixmap, "fromImage")(qimage)
QHBoxLayout(self) # hbox # hbox
QHBoxLayout(self) # type: ignore[operator]
lbl = QLabel(self) lbl = QLabel(self) # type: ignore[operator]
# Segfault in the problem # Segfault in the problem
lbl.setPixmap(pixmap1.copy()) lbl.setPixmap(pixmap1.copy())
@ -46,7 +62,7 @@ def roundtrip(expected: Image.Image) -> None:
@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed")
def test_sanity(tmp_path: Path) -> None: def test_sanity(tmp_path: Path) -> None:
# Segfault test # Segfault test
app: QApplication | None = QApplication([]) app: QApplication | None = QApplication([]) # type: ignore[operator]
ex = Example() ex = Example()
assert app # Silence warning assert app # Silence warning
assert ex # Silence warning assert ex # Silence warning
@ -56,7 +72,7 @@ def test_sanity(tmp_path: Path) -> None:
im = hopper(mode) im = hopper(mode)
data = ImageQt.toqpixmap(im) data = ImageQt.toqpixmap(im)
assert isinstance(data, QPixmap) assert data.__class__.__name__ == "QPixmap"
assert not data.isNull() assert not data.isNull()
# Test saving the file # Test saving the file
@ -64,14 +80,14 @@ def test_sanity(tmp_path: Path) -> None:
data.save(tempfile) data.save(tempfile)
# Render the image # Render the image
qimage = ImageQt.ImageQt(im) imageqt = ImageQt.ImageQt(im)
data = QPixmap.fromImage(qimage) data = getattr(QPixmap, "fromImage")(imageqt)
qt_format = QImage.Format if ImageQt.qt_version == "6" else QImage qt_format = getattr(QImage, "Format") if ImageQt.qt_version == "6" else QImage
qimage = QImage(128, 128, qt_format.Format_ARGB32) qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32")) # type: ignore[operator]
painter = QPainter(qimage) painter = QPainter(qimage) # type: ignore[operator]
image_label = QLabel() image_label = QLabel() # type: ignore[operator]
image_label.setPixmap(data) image_label.setPixmap(data)
image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) # type: ignore[operator]
painter.end() painter.end()
rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png") rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png")
qimage.save(rendered_tempfile) qimage.save(rendered_tempfile)

View File

@ -21,7 +21,7 @@ def test_sanity(mode: str, tmp_path: Path) -> None:
src = hopper(mode) src = hopper(mode)
data = ImageQt.toqimage(src) data = ImageQt.toqimage(src)
assert isinstance(data, QImage) assert isinstance(data, QImage) # type: ignore[arg-type, misc]
assert not data.isNull() assert not data.isNull()
# reload directly from the qimage # reload directly from the qimage

View File

@ -2,7 +2,7 @@
# install libimagequant # install libimagequant
archive_name=libimagequant archive_name=libimagequant
archive_version=4.3.1 archive_version=4.3.3
archive=$archive_name-$archive_version archive=$archive_name-$archive_version
@ -23,14 +23,14 @@ else
cargo cinstall --prefix=/usr --destdir=. cargo cinstall --prefix=/usr --destdir=.
# Copy into place # Copy into place
sudo cp usr/lib/libimagequant.so* /usr/lib/ sudo find usr -name libimagequant.so* -exec cp {} /usr/lib/ \;
sudo cp usr/include/libimagequant.h /usr/include/ sudo cp usr/include/libimagequant.h /usr/include/
if [ -n "$GITHUB_ACTIONS" ]; then if [ -n "$GITHUB_ACTIONS" ]; then
# Copy to cache # Copy to cache
rm -rf ~/cache-$archive_name rm -rf ~/cache-$archive_name
mkdir ~/cache-$archive_name mkdir ~/cache-$archive_name
cp usr/lib/libimagequant.so* ~/cache-$archive_name/ find usr -name libimagequant.so* -exec cp {} ~/cache-$archive_name/ \;
cp usr/include/libimagequant.h ~/cache-$archive_name/ cp usr/include/libimagequant.h ~/cache-$archive_name/
fi fi

View File

@ -2,7 +2,7 @@
# install raqm # install raqm
archive=libraqm-0.10.1 archive=libraqm-0.10.2
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz

View File

@ -121,7 +121,7 @@ nitpicky = True
# generating warnings in “nitpicky mode”. Note that type should include the domain name # generating warnings in “nitpicky mode”. Note that type should include the domain name
# if present. Example entries would be ('py:func', 'int') or # if present. Example entries would be ('py:func', 'int') or
# ('envvar', 'LD_LIBRARY_PATH'). # ('envvar', 'LD_LIBRARY_PATH').
# nitpick_ignore = [] nitpick_ignore = [("py:class", "_io.BytesIO")]
# -- Options for HTML output ---------------------------------------------- # -- Options for HTML output ----------------------------------------------

View File

@ -109,6 +109,35 @@ ImageDraw.getdraw hints parameter
The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated.
FreeType 2.9.0
^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
Support for FreeType 2.9.0 is deprecated and will be removed in Pillow 12.0.0
(2025-10-15), when FreeType 2.9.1 will be the minimum supported.
We recommend upgrading to at least FreeType `2.10.4`_, which fixed a severe
vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`).
.. _2.10.4: https://sourceforge.net/projects/freetype/files/freetype2/2.10.4/
ICNS (width, height, scale) sizes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
Setting an ICNS image size to ``(width, height, scale)`` before loading has been
deprecated. Instead, ``load(scale)`` can be used.
Image isImageType()
^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
``Image.isImageType(im)`` has been deprecated. Use ``isinstance(im, Image.Image)``
instead.
ImageMath.lambda_eval and ImageMath.unsafe_eval options parameter ImageMath.lambda_eval and ImageMath.unsafe_eval options parameter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -142,6 +171,13 @@ Removed features
Deprecated features are only removed in major releases after an appropriate Deprecated features are only removed in major releases after an appropriate
period of deprecation has passed. period of deprecation has passed.
TiffImagePlugin IFD_LEGACY_API
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. versionremoved:: 11.0.0
``TiffImagePlugin.IFD_LEGACY_API`` was removed, as it was an unused setting.
PSFile PSFile
~~~~~~ ~~~~~~

View File

@ -246,7 +246,9 @@ class DdsImageFile(ImageFile.ImageFile):
msg = f"Unimplemented pixel format {repr(fourcc)}" msg = f"Unimplemented pixel format {repr(fourcc)}"
raise NotImplementedError(msg) raise NotImplementedError(msg)
self.tile = [(self.decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))] self.tile = [
ImageFile._Tile(self.decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))
]
def load_seek(self, pos: int) -> None: def load_seek(self, pos: int) -> None:
pass pass
@ -255,7 +257,7 @@ class DdsImageFile(ImageFile.ImageFile):
class DXT1Decoder(ImageFile.PyDecoder): class DXT1Decoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None assert self.fd is not None
try: try:
self.set_as_raw(_dxt1(self.fd, self.state.xsize, self.state.ysize)) self.set_as_raw(_dxt1(self.fd, self.state.xsize, self.state.ysize))
@ -268,7 +270,7 @@ class DXT1Decoder(ImageFile.PyDecoder):
class DXT5Decoder(ImageFile.PyDecoder): class DXT5Decoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None assert self.fd is not None
try: try:
self.set_as_raw(_dxt5(self.fd, self.state.xsize, self.state.ysize)) self.set_as_raw(_dxt5(self.fd, self.state.xsize, self.state.ysize))

View File

@ -324,12 +324,19 @@ sets the following :py:attr:`~PIL.Image.Image.info` property:
**sizes** **sizes**
A list of supported sizes found in this icon file; these are a A list of supported sizes found in this icon file; these are a
3-tuple, ``(width, height, scale)``, where ``scale`` is 2 for a retina 3-tuple, ``(width, height, scale)``, where ``scale`` is 2 for a retina
icon and 1 for a standard icon. You *are* permitted to use this 3-tuple icon and 1 for a standard icon.
format for the :py:attr:`~PIL.Image.Image.size` property if you set it
before calling :py:meth:`~PIL.Image.Image.load`; after loading, the size .. _icns-loading:
will be reset to a 2-tuple containing pixel dimensions (so, e.g. if you
ask for ``(512, 512, 2)``, the final value of Loading
:py:attr:`~PIL.Image.Image.size` will be ``(1024, 1024)``). ~~~~~~~
You can call the :py:meth:`~PIL.Image.Image.load` method with the following parameter.
**scale**
Affects the scale of the resultant image. If the size is set to ``(512, 512)``,
after loading at scale 2, the final value of :py:attr:`~PIL.Image.Image.size` will
be ``(1024, 1024)``.
.. _icns-saving: .. _icns-saving:

View File

@ -54,7 +54,7 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more <h
:alt: Tidelift :alt: Tidelift
.. image:: https://oss-fuzz-build-logs.storage.googleapis.com/badges/pillow.svg .. image:: https://oss-fuzz-build-logs.storage.googleapis.com/badges/pillow.svg
:target: https://bugs.chromium.org/p/oss-fuzz/issues/list?sort=-opened&can=1&q=proj:pillow :target: https://issues.oss-fuzz.com/issues?q=title:pillow
:alt: Fuzzing Status :alt: Fuzzing Status
.. image:: https://img.shields.io/pypi/v/pillow.svg .. image:: https://img.shields.io/pypi/v/pillow.svg

View File

@ -64,7 +64,7 @@ Many of Pillow's features require external libraries:
* **libimagequant** provides improved color quantization * **libimagequant** provides improved color quantization
* Pillow has been tested with libimagequant **2.6-4.3.1** * Pillow has been tested with libimagequant **2.6-4.3.3**
* Libimagequant is licensed GPLv3, which is more restrictive than * Libimagequant is licensed GPLv3, which is more restrictive than
the Pillow license, therefore we will not be distributing binaries the Pillow license, therefore we will not be distributing binaries
with libimagequant support enabled. with libimagequant support enabled.

View File

@ -362,6 +362,7 @@ Classes
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
.. autoclass:: PIL.Image.ImagePointHandler .. autoclass:: PIL.Image.ImagePointHandler
.. autoclass:: PIL.Image.ImagePointTransform
.. autoclass:: PIL.Image.ImageTransformHandler .. autoclass:: PIL.Image.ImageTransformHandler
Protocols Protocols

View File

@ -33,6 +33,14 @@ Internal Modules
Provides a convenient way to import type hints that are not available Provides a convenient way to import type hints that are not available
on some Python versions. on some Python versions.
.. py:class:: Buffer
Typing alias.
.. py:class:: IntegralLike
Typing alias.
.. py:class:: NumpyArray .. py:class:: NumpyArray
Typing alias. Typing alias.

View File

@ -23,6 +23,13 @@ Python 3.8
Pillow has dropped support for Python 3.8, Pillow has dropped support for Python 3.8,
which reached end-of-life in October 2024. which reached end-of-life in October 2024.
Python 3.12 on macOS <= 10.12
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The latest version of Python 3.12 only supports macOS versions 10.13 and later,
and so Pillow has also updated the deployment target for its prebuilt Python 3.12
wheels.
PSFile PSFile
^^^^^^ ^^^^^^
@ -40,12 +47,53 @@ removed. Pillow's C API will now be used on PyPy instead.
``Image.USE_CFFI_ACCESS``, for switching from the C API to PyAccess, was ``Image.USE_CFFI_ACCESS``, for switching from the C API to PyAccess, was
similarly removed. similarly removed.
TiffImagePlugin IFD_LEGACY_API
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
An unused setting, ``TiffImagePlugin.IFD_LEGACY_API``, has been removed.
WebP 0.4
^^^^^^^^
Support for WebP 0.4 and earlier has been removed; WebP 0.5 is the minimum supported.
Deprecations Deprecations
============ ============
FreeType 2.9.0
^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
Support for FreeType 2.9.0 is deprecated and will be removed in Pillow 12.0.0
(2025-10-15), when FreeType 2.9.1 will be the minimum supported.
We recommend upgrading to at least FreeType `2.10.4`_, which fixed a severe
vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`).
.. _2.10.4: https://sourceforge.net/projects/freetype/files/freetype2/2.10.4/
ICNS (width, height, scale) sizes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
Setting an ICNS image size to ``(width, height, scale)`` before loading has been
deprecated. Instead, ``load(scale)`` can be used.
Image isImageType()
^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
``Image.isImageType(im)`` has been deprecated. Use ``isinstance(im, Image.Image)``
instead.
ImageMath.lambda_eval and ImageMath.unsafe_eval options parameter ImageMath.lambda_eval and ImageMath.unsafe_eval options parameter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
The ``options`` parameter in :py:meth:`~PIL.ImageMath.lambda_eval()` and The ``options`` parameter in :py:meth:`~PIL.ImageMath.lambda_eval()` and
:py:meth:`~PIL.ImageMath.unsafe_eval()` has been deprecated. One or more :py:meth:`~PIL.ImageMath.unsafe_eval()` has been deprecated. One or more
keyword arguments can be used instead. keyword arguments can be used instead.
@ -61,6 +109,8 @@ have been deprecated, and will be removed in Pillow 12 (2025-10-15).
Specific WebP Feature Checks Specific WebP Feature Checks
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
``features.check("transp_webp")``, ``features.check("webp_mux")`` and ``features.check("transp_webp")``, ``features.check("webp_mux")`` and
``features.check("webp_anim")`` are now deprecated. They will always return ``features.check("webp_anim")`` are now deprecated. They will always return
``True`` if the WebP module is installed, until they are removed in Pillow ``True`` if the WebP module is installed, until they are removed in Pillow
@ -77,10 +127,18 @@ TODO
API Additions API Additions
============= =============
TODO Writing XMP bytes to JPEG and MPO
^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TODO XMP data can now be saved to JPEG files using an ``xmp`` argument::
im.save("out.jpg", xmp=b"test")
The data can also be set through :py:attr:`~PIL.Image.Image.info`, for use when saving
either JPEG or MPO images::
im.info["xmp"] = b"test"
im.save("out.jpg")
Other Changes Other Changes
============= =============
@ -94,6 +152,10 @@ of 3.13.0 final (2024-10-01, :pep:`719`).
Pillow 11.0.0 now officially supports Python 3.13. Pillow 11.0.0 now officially supports Python 3.13.
Support has also been added for the experimental free-threaded mode of :pep:`703`.
Python 3.13 only supports macOS versions 10.13 and later.
C-level Flags C-level Flags
^^^^^^^^^^^^^ ^^^^^^^^^^^^^

View File

@ -97,9 +97,13 @@ config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable"
test-command = "cd {project} && .github/workflows/wheels-test.sh" test-command = "cd {project} && .github/workflows/wheels-test.sh"
test-extras = "tests" test-extras = "tests"
[tool.ruff] [tool.black]
fix = true exclude = "wheels/multibuild"
[tool.ruff]
exclude = [ "wheels/multibuild" ]
fix = true
lint.select = [ lint.select = [
"C4", # flake8-comprehensions "C4", # flake8-comprehensions
"E", # pycodestyle errors "E", # pycodestyle errors
@ -125,7 +129,6 @@ lint.ignore = [
"PT007", # pytest-parametrize-values-wrong-type "PT007", # pytest-parametrize-values-wrong-type
"PT011", # pytest-raises-too-broad "PT011", # pytest-raises-too-broad
"PT012", # pytest-raises-with-multiple-statements "PT012", # pytest-raises-with-multiple-statements
"PT016", # pytest-fail-without-message
"PT017", # pytest-assert-in-except "PT017", # pytest-assert-in-except
"PYI026", # flake8-pyi: typing.TypeAlias added in Python 3.10 "PYI026", # flake8-pyi: typing.TypeAlias added in Python 3.10
"PYI034", # flake8-pyi: typing.Self added in Python 3.11 "PYI034", # flake8-pyi: typing.Self added in Python 3.11
@ -163,8 +166,3 @@ follow_imports = "silent"
warn_redundant_casts = true warn_redundant_casts = true
warn_unreachable = true warn_unreachable = true
warn_unused_ignores = true warn_unused_ignores = true
exclude = [
'^Tests/oss-fuzz/fuzz_font.py$',
'^Tests/oss-fuzz/fuzz_pillow.py$',
'^Tests/test_qt_image_qapplication.py$',
]

View File

@ -52,7 +52,7 @@ def testimage() -> None:
or you call the "load" method: or you call the "load" method:
>>> im = Image.open("Tests/images/hopper.ppm") >>> im = Image.open("Tests/images/hopper.ppm")
>>> print(im.im) # internal image attribute >>> print(im._im) # internal image attribute
None None
>>> a = im.load() >>> a = im.load()
>>> type(im.im) # doctest: +ELLIPSIS >>> type(im.im) # doctest: +ELLIPSIS

249
setup.py
View File

@ -15,18 +15,20 @@ import struct
import subprocess import subprocess
import sys import sys
import warnings import warnings
from collections.abc import Iterator
from typing import Any
from setuptools import Extension, setup from setuptools import Extension, setup
from setuptools.command.build_ext import build_ext from setuptools.command.build_ext import build_ext
def get_version(): def get_version() -> str:
version_file = "src/PIL/_version.py" version_file = "src/PIL/_version.py"
with open(version_file, encoding="utf-8") as f: with open(version_file, encoding="utf-8") as f:
return f.read().split('"')[1] return f.read().split('"')[1]
configuration = {} configuration: dict[str, list[str]] = {}
PILLOW_VERSION = get_version() PILLOW_VERSION = get_version()
@ -143,7 +145,7 @@ class RequiredDependencyException(Exception):
PLATFORM_MINGW = os.name == "nt" and "GCC" in sys.version PLATFORM_MINGW = os.name == "nt" and "GCC" in sys.version
def _dbg(s, tp=None): def _dbg(s: str, tp: Any = None) -> None:
if DEBUG: if DEBUG:
if tp: if tp:
print(s % tp) print(s % tp)
@ -151,10 +153,13 @@ def _dbg(s, tp=None):
print(s) print(s)
def _find_library_dirs_ldconfig(): def _find_library_dirs_ldconfig() -> list[str]:
# Based on ctypes.util from Python 2 # Based on ctypes.util from Python 2
ldconfig = "ldconfig" if shutil.which("ldconfig") else "/sbin/ldconfig" ldconfig = "ldconfig" if shutil.which("ldconfig") else "/sbin/ldconfig"
args: list[str]
env: dict[str, str]
expr: str
if sys.platform.startswith("linux") or sys.platform.startswith("gnu"): if sys.platform.startswith("linux") or sys.platform.startswith("gnu"):
if struct.calcsize("l") == 4: if struct.calcsize("l") == 4:
machine = os.uname()[4] + "-32" machine = os.uname()[4] + "-32"
@ -184,13 +189,11 @@ def _find_library_dirs_ldconfig():
try: try:
p = subprocess.Popen( p = subprocess.Popen(
args, stderr=subprocess.DEVNULL, stdout=subprocess.PIPE, env=env args, stderr=subprocess.DEVNULL, stdout=subprocess.PIPE, env=env, text=True
) )
except OSError: # E.g. command not found except OSError: # E.g. command not found
return [] return []
[data, _] = p.communicate() data = p.communicate()[0]
if isinstance(data, bytes):
data = data.decode("latin1")
dirs = [] dirs = []
for dll in re.findall(expr, data): for dll in re.findall(expr, data):
@ -200,7 +203,9 @@ def _find_library_dirs_ldconfig():
return dirs return dirs
def _add_directory(path, subdir, where=None): def _add_directory(
path: list[str], subdir: str | None, where: int | None = None
) -> None:
if subdir is None: if subdir is None:
return return
subdir = os.path.realpath(subdir) subdir = os.path.realpath(subdir)
@ -216,7 +221,7 @@ def _add_directory(path, subdir, where=None):
path.insert(where, subdir) path.insert(where, subdir)
def _find_include_file(self, include): def _find_include_file(self: pil_build_ext, include: str) -> int:
for directory in self.compiler.include_dirs: for directory in self.compiler.include_dirs:
_dbg("Checking for include file %s in %s", (include, directory)) _dbg("Checking for include file %s in %s", (include, directory))
if os.path.isfile(os.path.join(directory, include)): if os.path.isfile(os.path.join(directory, include)):
@ -225,7 +230,7 @@ def _find_include_file(self, include):
return 0 return 0
def _find_library_file(self, library): def _find_library_file(self: pil_build_ext, library: str) -> str | None:
ret = self.compiler.find_library_file(self.compiler.library_dirs, library) ret = self.compiler.find_library_file(self.compiler.library_dirs, library)
if ret: if ret:
_dbg("Found library %s at %s", (library, ret)) _dbg("Found library %s at %s", (library, ret))
@ -234,7 +239,7 @@ def _find_library_file(self, library):
return ret return ret
def _find_include_dir(self, dirname, include): def _find_include_dir(self: pil_build_ext, dirname: str, include: str) -> bool | str:
for directory in self.compiler.include_dirs: for directory in self.compiler.include_dirs:
_dbg("Checking for include file %s in %s", (include, directory)) _dbg("Checking for include file %s in %s", (include, directory))
if os.path.isfile(os.path.join(directory, include)): if os.path.isfile(os.path.join(directory, include)):
@ -245,6 +250,7 @@ def _find_include_dir(self, dirname, include):
if os.path.isfile(os.path.join(subdir, include)): if os.path.isfile(os.path.join(subdir, include)):
_dbg("Found %s in %s", (include, subdir)) _dbg("Found %s in %s", (include, subdir))
return subdir return subdir
return False
def _cmd_exists(cmd: str) -> bool: def _cmd_exists(cmd: str) -> bool:
@ -256,7 +262,7 @@ def _cmd_exists(cmd: str) -> bool:
) )
def _pkg_config(name): def _pkg_config(name: str) -> tuple[list[str], list[str]] | None:
command = os.environ.get("PKG_CONFIG", "pkg-config") command = os.environ.get("PKG_CONFIG", "pkg-config")
for keep_system in (True, False): for keep_system in (True, False):
try: try:
@ -283,10 +289,11 @@ def _pkg_config(name):
return libs, cflags return libs, cflags
except Exception: except Exception:
pass pass
return None
class pil_build_ext(build_ext): class pil_build_ext(build_ext):
class feature: class ext_feature:
features = [ features = [
"zlib", "zlib",
"jpeg", "jpeg",
@ -301,25 +308,32 @@ class pil_build_ext(build_ext):
] ]
required = {"jpeg", "zlib"} required = {"jpeg", "zlib"}
vendor = set() vendor: set[str] = set()
def __init__(self): def __init__(self) -> None:
self._settings: dict[str, str | bool | None] = {}
for f in self.features: for f in self.features:
setattr(self, f, None) self.set(f, None)
def require(self, feat): def require(self, feat: str) -> bool:
return feat in self.required return feat in self.required
def want(self, feat): def get(self, feat: str) -> str | bool | None:
return getattr(self, feat) is None return self._settings[feat]
def want_vendor(self, feat): def set(self, feat: str, value: str | bool | None) -> None:
self._settings[feat] = value
def want(self, feat: str) -> bool:
return self._settings[feat] is None
def want_vendor(self, feat: str) -> bool:
return feat in self.vendor return feat in self.vendor
def __iter__(self): def __iter__(self) -> Iterator[str]:
yield from self.features yield from self.features
feature = feature() feature = ext_feature()
user_options = ( user_options = (
build_ext.user_options build_ext.user_options
@ -337,10 +351,10 @@ class pil_build_ext(build_ext):
) )
@staticmethod @staticmethod
def check_configuration(option, value): def check_configuration(option: str, value: str) -> bool | None:
return True if value in configuration.get(option, []) else None return True if value in configuration.get(option, []) else None
def initialize_options(self): def initialize_options(self) -> None:
self.disable_platform_guessing = self.check_configuration( self.disable_platform_guessing = self.check_configuration(
"platform-guessing", "disable" "platform-guessing", "disable"
) )
@ -355,7 +369,7 @@ class pil_build_ext(build_ext):
self.debug = True self.debug = True
self.parallel = configuration.get("parallel", [None])[-1] self.parallel = configuration.get("parallel", [None])[-1]
def finalize_options(self): def finalize_options(self) -> None:
build_ext.finalize_options(self) build_ext.finalize_options(self)
if self.debug: if self.debug:
global DEBUG global DEBUG
@ -363,12 +377,16 @@ class pil_build_ext(build_ext):
if not self.parallel: if not self.parallel:
# If --parallel (or -j) wasn't specified, we want to reproduce the same # If --parallel (or -j) wasn't specified, we want to reproduce the same
# behavior as before, that is, auto-detect the number of jobs. # behavior as before, that is, auto-detect the number of jobs.
self.parallel = None
cpu_count = os.cpu_count()
if cpu_count is not None:
try: try:
self.parallel = int( self.parallel = int(
os.environ.get("MAX_CONCURRENCY", min(4, os.cpu_count())) os.environ.get("MAX_CONCURRENCY", min(4, cpu_count))
) )
except TypeError: except TypeError:
self.parallel = None pass
for x in self.feature: for x in self.feature:
if getattr(self, f"disable_{x}"): if getattr(self, f"disable_{x}"):
setattr(self.feature, x, False) setattr(self.feature, x, False)
@ -402,7 +420,13 @@ class pil_build_ext(build_ext):
_dbg("Using vendored version of %s", x) _dbg("Using vendored version of %s", x)
self.feature.vendor.add(x) self.feature.vendor.add(x)
def _update_extension(self, name, libraries, define_macros=None, sources=None): def _update_extension(
self,
name: str,
libraries: list[str] | list[str | bool | None],
define_macros: list[tuple[str, str | None]] | None = None,
sources: list[str] | None = None,
) -> None:
for extension in self.extensions: for extension in self.extensions:
if extension.name == name: if extension.name == name:
extension.libraries += libraries extension.libraries += libraries
@ -415,13 +439,13 @@ class pil_build_ext(build_ext):
extension.extra_link_args = ["--stdlib=libc++"] extension.extra_link_args = ["--stdlib=libc++"]
break break
def _remove_extension(self, name): def _remove_extension(self, name: str) -> None:
for extension in self.extensions: for extension in self.extensions:
if extension.name == name: if extension.name == name:
self.extensions.remove(extension) self.extensions.remove(extension)
break break
def get_macos_sdk_path(self): def get_macos_sdk_path(self) -> str | None:
try: try:
sdk_path = ( sdk_path = (
subprocess.check_output(["xcrun", "--show-sdk-path"]) subprocess.check_output(["xcrun", "--show-sdk-path"])
@ -442,9 +466,9 @@ class pil_build_ext(build_ext):
sdk_path = commandlinetools_sdk_path sdk_path = commandlinetools_sdk_path
return sdk_path return sdk_path
def build_extensions(self): def build_extensions(self) -> None:
library_dirs = [] library_dirs: list[str] = []
include_dirs = [] include_dirs: list[str] = []
pkg_config = None pkg_config = None
if _cmd_exists(os.environ.get("PKG_CONFIG", "pkg-config")): if _cmd_exists(os.environ.get("PKG_CONFIG", "pkg-config")):
@ -468,19 +492,22 @@ class pil_build_ext(build_ext):
root = globals()[root_name] root = globals()[root_name]
if root is None and root_name in os.environ: if root is None and root_name in os.environ:
prefix = os.environ[root_name] root_prefix = os.environ[root_name]
root = (os.path.join(prefix, "lib"), os.path.join(prefix, "include")) root = (
os.path.join(root_prefix, "lib"),
os.path.join(root_prefix, "include"),
)
if root is None and pkg_config: if root is None and pkg_config:
if isinstance(lib_name, tuple): if isinstance(lib_name, str):
_dbg(f"Looking for `{lib_name}` using pkg-config.")
root = pkg_config(lib_name)
else:
for lib_name2 in lib_name: for lib_name2 in lib_name:
_dbg(f"Looking for `{lib_name2}` using pkg-config.") _dbg(f"Looking for `{lib_name2}` using pkg-config.")
root = pkg_config(lib_name2) root = pkg_config(lib_name2)
if root: if root:
break break
else:
_dbg(f"Looking for `{lib_name}` using pkg-config.")
root = pkg_config(lib_name)
if isinstance(root, tuple): if isinstance(root, tuple):
lib_root, include_root = root lib_root, include_root = root
@ -660,22 +687,22 @@ class pil_build_ext(build_ext):
_dbg("Looking for zlib") _dbg("Looking for zlib")
if _find_include_file(self, "zlib.h"): if _find_include_file(self, "zlib.h"):
if _find_library_file(self, "z"): if _find_library_file(self, "z"):
feature.zlib = "z" feature.set("zlib", "z")
elif sys.platform == "win32" and _find_library_file(self, "zlib"): elif sys.platform == "win32" and _find_library_file(self, "zlib"):
feature.zlib = "zlib" # alternative name feature.set("zlib", "zlib") # alternative name
if feature.want("jpeg"): if feature.want("jpeg"):
_dbg("Looking for jpeg") _dbg("Looking for jpeg")
if _find_include_file(self, "jpeglib.h"): if _find_include_file(self, "jpeglib.h"):
if _find_library_file(self, "jpeg"): if _find_library_file(self, "jpeg"):
feature.jpeg = "jpeg" feature.set("jpeg", "jpeg")
elif sys.platform == "win32" and _find_library_file(self, "libjpeg"): elif sys.platform == "win32" and _find_library_file(self, "libjpeg"):
feature.jpeg = "libjpeg" # alternative name feature.set("jpeg", "libjpeg") # alternative name
feature.openjpeg_version = None feature.set("openjpeg_version", None)
if feature.want("jpeg2000"): if feature.want("jpeg2000"):
_dbg("Looking for jpeg2000") _dbg("Looking for jpeg2000")
best_version = None best_version: tuple[int, ...] | None = None
best_path = None best_path = None
# Find the best version # Find the best version
@ -705,26 +732,26 @@ class pil_build_ext(build_ext):
# <openjpeg.h> rather than having to cope with the versioned # <openjpeg.h> rather than having to cope with the versioned
# include path # include path
_add_directory(self.compiler.include_dirs, best_path, 0) _add_directory(self.compiler.include_dirs, best_path, 0)
feature.jpeg2000 = "openjp2" feature.set("jpeg2000", "openjp2")
feature.openjpeg_version = ".".join(str(x) for x in best_version) feature.set("openjpeg_version", ".".join(str(x) for x in best_version))
if feature.want("imagequant"): if feature.want("imagequant"):
_dbg("Looking for imagequant") _dbg("Looking for imagequant")
if _find_include_file(self, "libimagequant.h"): if _find_include_file(self, "libimagequant.h"):
if _find_library_file(self, "imagequant"): if _find_library_file(self, "imagequant"):
feature.imagequant = "imagequant" feature.set("imagequant", "imagequant")
elif _find_library_file(self, "libimagequant"): elif _find_library_file(self, "libimagequant"):
feature.imagequant = "libimagequant" feature.set("imagequant", "libimagequant")
if feature.want("tiff"): if feature.want("tiff"):
_dbg("Looking for tiff") _dbg("Looking for tiff")
if _find_include_file(self, "tiff.h"): if _find_include_file(self, "tiff.h"):
if _find_library_file(self, "tiff"): if _find_library_file(self, "tiff"):
feature.tiff = "tiff" feature.set("tiff", "tiff")
if sys.platform in ["win32", "darwin"] and _find_library_file( if sys.platform in ["win32", "darwin"] and _find_library_file(
self, "libtiff" self, "libtiff"
): ):
feature.tiff = "libtiff" feature.set("tiff", "libtiff")
if feature.want("freetype"): if feature.want("freetype"):
_dbg("Looking for freetype") _dbg("Looking for freetype")
@ -745,31 +772,31 @@ class pil_build_ext(build_ext):
freetype_version = 21 freetype_version = 21
break break
if freetype_version: if freetype_version:
feature.freetype = "freetype" feature.set("freetype", "freetype")
if subdir: if subdir:
_add_directory(self.compiler.include_dirs, subdir, 0) _add_directory(self.compiler.include_dirs, subdir, 0)
if feature.freetype and feature.want("raqm"): if feature.get("freetype") and feature.want("raqm"):
if not feature.want_vendor("raqm"): # want system Raqm if not feature.want_vendor("raqm"): # want system Raqm
_dbg("Looking for Raqm") _dbg("Looking for Raqm")
if _find_include_file(self, "raqm.h"): if _find_include_file(self, "raqm.h"):
if _find_library_file(self, "raqm"): if _find_library_file(self, "raqm"):
feature.raqm = "raqm" feature.set("raqm", "raqm")
elif _find_library_file(self, "libraqm"): elif _find_library_file(self, "libraqm"):
feature.raqm = "libraqm" feature.set("raqm", "libraqm")
else: # want to build Raqm from src/thirdparty else: # want to build Raqm from src/thirdparty
_dbg("Looking for HarfBuzz") _dbg("Looking for HarfBuzz")
feature.harfbuzz = None feature.set("harfbuzz", None)
hb_dir = _find_include_dir(self, "harfbuzz", "hb.h") hb_dir = _find_include_dir(self, "harfbuzz", "hb.h")
if hb_dir: if hb_dir:
if isinstance(hb_dir, str): if isinstance(hb_dir, str):
_add_directory(self.compiler.include_dirs, hb_dir, 0) _add_directory(self.compiler.include_dirs, hb_dir, 0)
if _find_library_file(self, "harfbuzz"): if _find_library_file(self, "harfbuzz"):
feature.harfbuzz = "harfbuzz" feature.set("harfbuzz", "harfbuzz")
if feature.harfbuzz: if feature.get("harfbuzz"):
if not feature.want_vendor("fribidi"): # want system FriBiDi if not feature.want_vendor("fribidi"): # want system FriBiDi
_dbg("Looking for FriBiDi") _dbg("Looking for FriBiDi")
feature.fribidi = None feature.set("fribidi", None)
fribidi_dir = _find_include_dir(self, "fribidi", "fribidi.h") fribidi_dir = _find_include_dir(self, "fribidi", "fribidi.h")
if fribidi_dir: if fribidi_dir:
if isinstance(fribidi_dir, str): if isinstance(fribidi_dir, str):
@ -777,19 +804,19 @@ class pil_build_ext(build_ext):
self.compiler.include_dirs, fribidi_dir, 0 self.compiler.include_dirs, fribidi_dir, 0
) )
if _find_library_file(self, "fribidi"): if _find_library_file(self, "fribidi"):
feature.fribidi = "fribidi" feature.set("fribidi", "fribidi")
feature.raqm = True feature.set("raqm", True)
else: # want to build FriBiDi shim from src/thirdparty else: # want to build FriBiDi shim from src/thirdparty
feature.raqm = True feature.set("raqm", True)
if feature.want("lcms"): if feature.want("lcms"):
_dbg("Looking for lcms") _dbg("Looking for lcms")
if _find_include_file(self, "lcms2.h"): if _find_include_file(self, "lcms2.h"):
if _find_library_file(self, "lcms2"): if _find_library_file(self, "lcms2"):
feature.lcms = "lcms2" feature.set("lcms", "lcms2")
elif _find_library_file(self, "lcms2_static"): elif _find_library_file(self, "lcms2_static"):
# alternate Windows name. # alternate Windows name.
feature.lcms = "lcms2_static" feature.set("lcms", "lcms2_static")
if feature.want("webp"): if feature.want("webp"):
_dbg("Looking for webp") _dbg("Looking for webp")
@ -803,17 +830,17 @@ class pil_build_ext(build_ext):
_find_library_file(self, prefix + library) _find_library_file(self, prefix + library)
for library in ("webp", "webpmux", "webpdemux") for library in ("webp", "webpmux", "webpdemux")
): ):
feature.webp = prefix + "webp" feature.set("webp", prefix + "webp")
break break
if feature.want("xcb"): if feature.want("xcb"):
_dbg("Looking for xcb") _dbg("Looking for xcb")
if _find_include_file(self, "xcb/xcb.h"): if _find_include_file(self, "xcb/xcb.h"):
if _find_library_file(self, "xcb"): if _find_library_file(self, "xcb"):
feature.xcb = "xcb" feature.set("xcb", "xcb")
for f in feature: for f in feature:
if not getattr(feature, f) and feature.require(f): if not feature.get(f) and feature.require(f):
if f in ("jpeg", "zlib"): if f in ("jpeg", "zlib"):
raise RequiredDependencyException(f) raise RequiredDependencyException(f)
raise DependencyException(f) raise DependencyException(f)
@ -821,10 +848,11 @@ class pil_build_ext(build_ext):
# #
# core library # core library
libs = self.add_imaging_libs.split() libs: list[str | bool | None] = []
defs = [] libs.extend(self.add_imaging_libs.split())
if feature.tiff: defs: list[tuple[str, str | None]] = []
libs.append(feature.tiff) if feature.get("tiff"):
libs.append(feature.get("tiff"))
defs.append(("HAVE_LIBTIFF", None)) defs.append(("HAVE_LIBTIFF", None))
if sys.platform == "win32": if sys.platform == "win32":
# This define needs to be defined if-and-only-if it was defined # This define needs to be defined if-and-only-if it was defined
@ -832,22 +860,22 @@ class pil_build_ext(build_ext):
# so we have to guess; by default it is defined in all Windows builds. # so we have to guess; by default it is defined in all Windows builds.
# See #4237, #5243, #5359 for more information. # See #4237, #5243, #5359 for more information.
defs.append(("USE_WIN32_FILEIO", None)) defs.append(("USE_WIN32_FILEIO", None))
if feature.jpeg: if feature.get("jpeg"):
libs.append(feature.jpeg) libs.append(feature.get("jpeg"))
defs.append(("HAVE_LIBJPEG", None)) defs.append(("HAVE_LIBJPEG", None))
if feature.jpeg2000: if feature.get("jpeg2000"):
libs.append(feature.jpeg2000) libs.append(feature.get("jpeg2000"))
defs.append(("HAVE_OPENJPEG", None)) defs.append(("HAVE_OPENJPEG", None))
if sys.platform == "win32" and not PLATFORM_MINGW: if sys.platform == "win32" and not PLATFORM_MINGW:
defs.append(("OPJ_STATIC", None)) defs.append(("OPJ_STATIC", None))
if feature.zlib: if feature.get("zlib"):
libs.append(feature.zlib) libs.append(feature.get("zlib"))
defs.append(("HAVE_LIBZ", None)) defs.append(("HAVE_LIBZ", None))
if feature.imagequant: if feature.get("imagequant"):
libs.append(feature.imagequant) libs.append(feature.get("imagequant"))
defs.append(("HAVE_LIBIMAGEQUANT", None)) defs.append(("HAVE_LIBIMAGEQUANT", None))
if feature.xcb: if feature.get("xcb"):
libs.append(feature.xcb) libs.append(feature.get("xcb"))
defs.append(("HAVE_XCB", None)) defs.append(("HAVE_XCB", None))
if sys.platform == "win32": if sys.platform == "win32":
libs.extend(["kernel32", "user32", "gdi32"]) libs.extend(["kernel32", "user32", "gdi32"])
@ -861,22 +889,22 @@ class pil_build_ext(build_ext):
# #
# additional libraries # additional libraries
if feature.freetype: if feature.get("freetype"):
srcs = [] srcs = []
libs = ["freetype"] libs = ["freetype"]
defs = [] defs = []
if feature.raqm: if feature.get("raqm"):
if not feature.want_vendor("raqm"): # using system Raqm if not feature.want_vendor("raqm"): # using system Raqm
defs.append(("HAVE_RAQM", None)) defs.append(("HAVE_RAQM", None))
defs.append(("HAVE_RAQM_SYSTEM", None)) defs.append(("HAVE_RAQM_SYSTEM", None))
libs.append(feature.raqm) libs.append(feature.get("raqm"))
else: # building Raqm from src/thirdparty else: # building Raqm from src/thirdparty
defs.append(("HAVE_RAQM", None)) defs.append(("HAVE_RAQM", None))
srcs.append("src/thirdparty/raqm/raqm.c") srcs.append("src/thirdparty/raqm/raqm.c")
libs.append(feature.harfbuzz) libs.append(feature.get("harfbuzz"))
if not feature.want_vendor("fribidi"): # using system FriBiDi if not feature.want_vendor("fribidi"): # using system FriBiDi
defs.append(("HAVE_FRIBIDI_SYSTEM", None)) defs.append(("HAVE_FRIBIDI_SYSTEM", None))
libs.append(feature.fribidi) libs.append(feature.get("fribidi"))
else: # building FriBiDi shim from src/thirdparty else: # building FriBiDi shim from src/thirdparty
srcs.append("src/thirdparty/fribidi-shim/fribidi.c") srcs.append("src/thirdparty/fribidi-shim/fribidi.c")
self._update_extension("PIL._imagingft", libs, defs, srcs) self._update_extension("PIL._imagingft", libs, defs, srcs)
@ -884,16 +912,17 @@ class pil_build_ext(build_ext):
else: else:
self._remove_extension("PIL._imagingft") self._remove_extension("PIL._imagingft")
if feature.lcms: if feature.get("lcms"):
extra = [] libs = [feature.get("lcms")]
if sys.platform == "win32": if sys.platform == "win32":
extra.extend(["user32", "gdi32"]) libs.extend(["user32", "gdi32"])
self._update_extension("PIL._imagingcms", [feature.lcms] + extra) self._update_extension("PIL._imagingcms", libs)
else: else:
self._remove_extension("PIL._imagingcms") self._remove_extension("PIL._imagingcms")
if feature.webp: webp = feature.get("webp")
libs = [feature.webp, feature.webp + "mux", feature.webp + "demux"] if isinstance(webp, str):
libs = [webp, webp + "mux", webp + "demux"]
self._update_extension("PIL._webp", libs) self._update_extension("PIL._webp", libs)
else: else:
self._remove_extension("PIL._webp") self._remove_extension("PIL._webp")
@ -908,14 +937,14 @@ class pil_build_ext(build_ext):
self.summary_report(feature) self.summary_report(feature)
def summary_report(self, feature): def summary_report(self, feature: ext_feature) -> None:
print("-" * 68) print("-" * 68)
print("PIL SETUP SUMMARY") print("PIL SETUP SUMMARY")
print("-" * 68) print("-" * 68)
print(f"version Pillow {PILLOW_VERSION}") print(f"version Pillow {PILLOW_VERSION}")
v = sys.version.split("[") version = sys.version.split("[")
print(f"platform {sys.platform} {v[0].strip()}") print(f"platform {sys.platform} {version[0].strip()}")
for v in v[1:]: for v in version[1:]:
print(f" [{v.strip()}") print(f" [{v.strip()}")
print("-" * 68) print("-" * 68)
@ -926,16 +955,20 @@ class pil_build_ext(build_ext):
raqm_extra_info += ", FriBiDi shim" raqm_extra_info += ", FriBiDi shim"
options = [ options = [
(feature.jpeg, "JPEG"), (feature.get("jpeg"), "JPEG"),
(feature.jpeg2000, "OPENJPEG (JPEG2000)", feature.openjpeg_version), (
(feature.zlib, "ZLIB (PNG/ZIP)"), feature.get("jpeg2000"),
(feature.imagequant, "LIBIMAGEQUANT"), "OPENJPEG (JPEG2000)",
(feature.tiff, "LIBTIFF"), feature.get("openjpeg_version"),
(feature.freetype, "FREETYPE2"), ),
(feature.raqm, "RAQM (Text shaping)", raqm_extra_info), (feature.get("zlib"), "ZLIB (PNG/ZIP)"),
(feature.lcms, "LITTLECMS2"), (feature.get("imagequant"), "LIBIMAGEQUANT"),
(feature.webp, "WEBP"), (feature.get("tiff"), "LIBTIFF"),
(feature.xcb, "XCB (X protocol)"), (feature.get("freetype"), "FREETYPE2"),
(feature.get("raqm"), "RAQM (Text shaping)", raqm_extra_info),
(feature.get("lcms"), "LITTLECMS2"),
(feature.get("webp"), "WEBP"),
(feature.get("xcb"), "XCB (X protocol)"),
] ]
all = 1 all = 1
@ -964,7 +997,7 @@ class pil_build_ext(build_ext):
print("") print("")
def debug_build(): def debug_build() -> bool:
return hasattr(sys, "gettotalrefcount") or FUZZING_BUILD return hasattr(sys, "gettotalrefcount") or FUZZING_BUILD

View File

@ -273,13 +273,13 @@ class BlpImageFile(ImageFile.ImageFile):
raise BLPFormatError(msg) raise BLPFormatError(msg)
self._mode = "RGBA" if self._blp_alpha_depth else "RGB" self._mode = "RGBA" if self._blp_alpha_depth else "RGB"
self.tile = [(decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))] self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))]
class _BLPBaseDecoder(ImageFile.PyDecoder): class _BLPBaseDecoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
try: try:
self._read_blp_header() self._read_blp_header()
self._load() self._load()
@ -372,7 +372,10 @@ class BLP1Decoder(_BLPBaseDecoder):
Image._decompression_bomb_check(image.size) Image._decompression_bomb_check(image.size)
if image.mode == "CMYK": if image.mode == "CMYK":
decoder_name, extents, offset, args = image.tile[0] decoder_name, extents, offset, args = image.tile[0]
image.tile = [(decoder_name, extents, offset, (args[0], "CMYK"))] assert isinstance(args, tuple)
image.tile = [
ImageFile._Tile(decoder_name, extents, offset, (args[0], "CMYK"))
]
r, g, b = image.convert("RGB").split() r, g, b = image.convert("RGB").split()
reversed_image = Image.merge("RGB", (b, g, r)) reversed_image = Image.merge("RGB", (b, g, r))
self.set_as_raw(reversed_image.tobytes()) self.set_as_raw(reversed_image.tobytes())
@ -467,6 +470,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2" magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2"
fp.write(magic) fp.write(magic)
assert im.palette is not None
fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED)) fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
fp.write(struct.pack("<b", 1 if im.palette.mode == "RGBA" else 0)) fp.write(struct.pack("<b", 1 if im.palette.mode == "RGBA" else 0))

View File

@ -170,6 +170,8 @@ class BmpImageFile(ImageFile.ImageFile):
# ------------------ Special case : header is reported 40, which # ------------------ Special case : header is reported 40, which
# ---------------------- is shorter than real size for bpp >= 16 # ---------------------- is shorter than real size for bpp >= 16
assert isinstance(file_info["width"], int)
assert isinstance(file_info["height"], int)
self._size = file_info["width"], file_info["height"] self._size = file_info["width"], file_info["height"]
# ------- If color count was not found in the header, compute from bits # ------- If color count was not found in the header, compute from bits
@ -294,7 +296,7 @@ class BmpImageFile(ImageFile.ImageFile):
args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3)) args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3))
args.append(file_info["direction"]) args.append(file_info["direction"])
self.tile = [ self.tile = [
( ImageFile._Tile(
decoder_name, decoder_name,
(0, 0, file_info["width"], file_info["height"]), (0, 0, file_info["width"], file_info["height"]),
offset or self.fp.tell(), offset or self.fp.tell(),
@ -319,7 +321,7 @@ class BmpImageFile(ImageFile.ImageFile):
class BmpRleDecoder(ImageFile.PyDecoder): class BmpRleDecoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None assert self.fd is not None
rle4 = self.args[1] rle4 = self.args[1]
data = bytearray() data = bytearray()
@ -385,7 +387,7 @@ class BmpRleDecoder(ImageFile.PyDecoder):
if self.fd.tell() % 2 != 0: if self.fd.tell() % 2 != 0:
self.fd.seek(1, os.SEEK_CUR) self.fd.seek(1, os.SEEK_CUR)
rawmode = "L" if self.mode == "L" else "P" rawmode = "L" if self.mode == "L" else "P"
self.set_as_raw(bytes(data), (rawmode, 0, self.args[-1])) self.set_as_raw(bytes(data), rawmode, (0, self.args[-1]))
return -1, 0 return -1, 0

View File

@ -17,7 +17,7 @@
# #
from __future__ import annotations from __future__ import annotations
from . import BmpImagePlugin, Image from . import BmpImagePlugin, Image, ImageFile
from ._binary import i16le as i16 from ._binary import i16le as i16
from ._binary import i32le as i32 from ._binary import i32le as i32
@ -64,7 +64,7 @@ class CurImageFile(BmpImagePlugin.BmpImageFile):
# patch up the bitmap height # patch up the bitmap height
self._size = self.size[0], self.size[1] // 2 self._size = self.size[0], self.size[1] // 2
d, e, o, a = self.tile[0] d, e, o, a = self.tile[0]
self.tile[0] = d, (0, 0) + self.size, o, a self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a)
# #

View File

@ -367,7 +367,7 @@ class DdsImageFile(ImageFile.ImageFile):
mask_count = 3 mask_count = 3
masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4)) masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4))
self.tile = [("dds_rgb", extents, 0, (bitcount, masks))] self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))]
return return
elif pfflags & DDPF.LUMINANCE: elif pfflags & DDPF.LUMINANCE:
if bitcount == 8: if bitcount == 8:
@ -481,7 +481,7 @@ class DdsImageFile(ImageFile.ImageFile):
class DdsRgbDecoder(ImageFile.PyDecoder): class DdsRgbDecoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None assert self.fd is not None
bitcount, masks = self.args bitcount, masks = self.args

View File

@ -71,7 +71,7 @@ def Ghostscript(
fp: IO[bytes], fp: IO[bytes],
scale: int = 1, scale: int = 1,
transparency: bool = False, transparency: bool = False,
) -> Image.Image: ) -> Image.core.ImagingCore:
"""Render an image using Ghostscript""" """Render an image using Ghostscript"""
global gs_binary global gs_binary
if not has_ghostscript(): if not has_ghostscript():
@ -190,7 +190,6 @@ class EpsImageFile(ImageFile.ImageFile):
self.fp.seek(offset) self.fp.seek(offset)
self._mode = "RGB" self._mode = "RGB"
self._size = None
byte_arr = bytearray(255) byte_arr = bytearray(255)
bytes_mv = memoryview(byte_arr) bytes_mv = memoryview(byte_arr)
@ -228,7 +227,7 @@ class EpsImageFile(ImageFile.ImageFile):
if k == "BoundingBox": if k == "BoundingBox":
if v == "(atend)": if v == "(atend)":
reading_trailer_comments = True reading_trailer_comments = True
elif not self._size or (trailer_reached and reading_trailer_comments): elif not self.tile or (trailer_reached and reading_trailer_comments):
try: try:
# Note: The DSC spec says that BoundingBox # Note: The DSC spec says that BoundingBox
# fields should be integers, but some drivers # fields should be integers, but some drivers
@ -346,7 +345,7 @@ class EpsImageFile(ImageFile.ImageFile):
trailer_reached = True trailer_reached = True
bytes_read = 0 bytes_read = 0
if not self._size: if not self.tile:
msg = "cannot determine EPS bounding box" msg = "cannot determine EPS bounding box"
raise OSError(msg) raise OSError(msg)

View File

@ -67,7 +67,7 @@ class FitsImageFile(ImageFile.ImageFile):
raise ValueError(msg) raise ValueError(msg)
offset += self.fp.tell() - 80 offset += self.fp.tell() - 80
self.tile = [(decoder_name, (0, 0) + self.size, offset, args)] self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)]
def _get_size( def _get_size(
self, headers: dict[bytes, bytes], prefix: bytes self, headers: dict[bytes, bytes], prefix: bytes
@ -126,7 +126,7 @@ class FitsImageFile(ImageFile.ImageFile):
class FitsGzipDecoder(ImageFile.PyDecoder): class FitsGzipDecoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None assert self.fd is not None
value = gzip.decompress(self.fd.read()) value = gzip.decompress(self.fd.read())

View File

@ -159,7 +159,7 @@ class FliImageFile(ImageFile.ImageFile):
framesize = i32(s) framesize = i32(s)
self.decodermaxblock = framesize self.decodermaxblock = framesize
self.tile = [("fli", (0, 0) + self.size, self.__offset, None)] self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset, None)]
self.__offset += framesize self.__offset += framesize

View File

@ -81,6 +81,8 @@ class FpxImageFile(ImageFile.ImageFile):
# size (highest resolution) # size (highest resolution)
assert isinstance(prop[0x1000002], int)
assert isinstance(prop[0x1000003], int)
self._size = prop[0x1000002], prop[0x1000003] self._size = prop[0x1000002], prop[0x1000003]
size = max(self.size) size = max(self.size)
@ -164,7 +166,7 @@ class FpxImageFile(ImageFile.ImageFile):
if compression == 0: if compression == 0:
self.tile.append( self.tile.append(
( ImageFile._Tile(
"raw", "raw",
(x, y, x1, y1), (x, y, x1, y1),
i32(s, i) + 28, i32(s, i) + 28,
@ -175,7 +177,7 @@ class FpxImageFile(ImageFile.ImageFile):
elif compression == 1: elif compression == 1:
# FIXME: the fill decoder is not implemented # FIXME: the fill decoder is not implemented
self.tile.append( self.tile.append(
( ImageFile._Tile(
"fill", "fill",
(x, y, x1, y1), (x, y, x1, y1),
i32(s, i) + 28, i32(s, i) + 28,
@ -203,7 +205,7 @@ class FpxImageFile(ImageFile.ImageFile):
jpegmode = rawmode jpegmode = rawmode
self.tile.append( self.tile.append(
( ImageFile._Tile(
"jpeg", "jpeg",
(x, y, x1, y1), (x, y, x1, y1),
i32(s, i) + 28, i32(s, i) + 28,

View File

@ -93,9 +93,9 @@ class FtexImageFile(ImageFile.ImageFile):
if format == Format.DXT1: if format == Format.DXT1:
self._mode = "RGBA" self._mode = "RGBA"
self.tile = [("bcn", (0, 0) + self.size, 0, 1)] self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
elif format == Format.UNCOMPRESSED: elif format == Format.UNCOMPRESSED:
self.tile = [("raw", (0, 0) + self.size, 0, ("RGB", 0, 1))] self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, ("RGB", 0, 1))]
else: else:
msg = f"Invalid texture compression format: {repr(format)}" msg = f"Invalid texture compression format: {repr(format)}"
raise ValueError(msg) raise ValueError(msg)

View File

@ -89,7 +89,7 @@ class GbrImageFile(ImageFile.ImageFile):
self._data_size = width * height * color_depth self._data_size = width * height * color_depth
def load(self) -> Image.core.PixelAccess | None: def load(self) -> Image.core.PixelAccess | None:
if not self.im: if self._im is None:
self.im = Image.core.new(self.mode, self.size) self.im = Image.core.new(self.mode, self.size)
self.frombytes(self.fp.read(self._data_size)) self.frombytes(self.fp.read(self._data_size))
return Image.Image.load(self) return Image.Image.load(self)

View File

@ -72,7 +72,7 @@ class GdImageFile(ImageFile.ImageFile):
) )
self.tile = [ self.tile = [
( ImageFile._Tile(
"raw", "raw",
(0, 0) + self.size, (0, 0) + self.size,
7 + true_color_offset + 4 + 256 * 4, 7 + true_color_offset + 4 + 256 * 4,

View File

@ -29,7 +29,6 @@ import itertools
import math import math
import os import os
import subprocess import subprocess
import sys
from enum import IntEnum from enum import IntEnum
from functools import cached_property from functools import cached_property
from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, Union from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, Union
@ -49,6 +48,7 @@ from ._binary import o16le as o16
if TYPE_CHECKING: if TYPE_CHECKING:
from . import _imaging from . import _imaging
from ._typing import Buffer
class LoadingStrategy(IntEnum): class LoadingStrategy(IntEnum):
@ -155,7 +155,7 @@ class GifImageFile(ImageFile.ImageFile):
if not self._seek_check(frame): if not self._seek_check(frame):
return return
if frame < self.__frame: if frame < self.__frame:
self.im = None self._im = None
self._seek(0) self._seek(0)
last_frame = self.__frame last_frame = self.__frame
@ -320,11 +320,14 @@ class GifImageFile(ImageFile.ImageFile):
else: else:
self._mode = "L" self._mode = "L"
if not palette and self.global_palette: if palette:
self.palette = palette
elif self.global_palette:
from copy import copy from copy import copy
palette = copy(self.global_palette) self.palette = copy(self.global_palette)
self.palette = palette else:
self.palette = None
else: else:
if self.mode == "P": if self.mode == "P":
if ( if (
@ -376,7 +379,7 @@ class GifImageFile(ImageFile.ImageFile):
self.dispose = Image.core.fill(dispose_mode, dispose_size, color) self.dispose = Image.core.fill(dispose_mode, dispose_size, color)
else: else:
# replace with previous contents # replace with previous contents
if self.im is not None: if self._im is not None:
# only dispose the extent in this frame # only dispose the extent in this frame
self.dispose = self._crop(self.im, self.dispose_extent) self.dispose = self._crop(self.im, self.dispose_extent)
elif frame_transparency is not None: elif frame_transparency is not None:
@ -404,7 +407,7 @@ class GifImageFile(ImageFile.ImageFile):
elif self.mode not in ("RGB", "RGBA"): elif self.mode not in ("RGB", "RGBA"):
transparency = frame_transparency transparency = frame_transparency
self.tile = [ self.tile = [
( ImageFile._Tile(
"gif", "gif",
(x0, y0, x1, y1), (x0, y0, x1, y1),
self.__offset, self.__offset,
@ -434,7 +437,14 @@ class GifImageFile(ImageFile.ImageFile):
self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
self.im.putpalette("RGB", *self._frame_palette.getdata()) self.im.putpalette("RGB", *self._frame_palette.getdata())
else: else:
self.im = None self._im = None
if not self._prev_im and self._im is not None and self.size != self.im.size:
expanded_im = Image.core.fill(self.im.mode, self.size)
if self._frame_palette:
expanded_im.putpalette("RGB", *self._frame_palette.getdata())
expanded_im.paste(self.im, (0, 0) + self.im.size)
self.im = expanded_im
self._mode = temp_mode self._mode = temp_mode
self._frame_palette = None self._frame_palette = None
@ -452,6 +462,17 @@ class GifImageFile(ImageFile.ImageFile):
return return
if not self._prev_im: if not self._prev_im:
return return
if self.size != self._prev_im.size:
if self._frame_transparency is not None:
expanded_im = Image.core.fill("RGBA", self.size)
else:
expanded_im = Image.core.fill("P", self.size)
expanded_im.putpalette("RGB", "RGB", self.im.getpalette())
expanded_im = expanded_im.convert("RGB")
expanded_im.paste(self._prev_im, (0, 0) + self._prev_im.size)
self._prev_im = expanded_im
assert self._prev_im is not None
if self._frame_transparency is not None: if self._frame_transparency is not None:
self.im.putpalettealpha(self._frame_transparency, 0) self.im.putpalettealpha(self._frame_transparency, 0)
frame_im = self.im.convert("RGBA") frame_im = self.im.convert("RGBA")
@ -495,6 +516,7 @@ def _normalize_mode(im: Image.Image) -> Image.Image:
return im return im
if Image.getmodebase(im.mode) == "RGB": if Image.getmodebase(im.mode) == "RGB":
im = im.convert("P", palette=Image.Palette.ADAPTIVE) im = im.convert("P", palette=Image.Palette.ADAPTIVE)
assert im.palette is not None
if im.palette.mode == "RGBA": if im.palette.mode == "RGBA":
for rgba in im.palette.colors: for rgba in im.palette.colors:
if rgba[3] == 0: if rgba[3] == 0:
@ -531,16 +553,18 @@ def _normalize_palette(
if im.mode == "P": if im.mode == "P":
if not source_palette: if not source_palette:
source_palette = im.im.getpalette("RGB")[:768] im_palette = im.getpalette(None)
assert im_palette is not None
source_palette = bytearray(im_palette)
else: # L-mode else: # L-mode
if not source_palette: if not source_palette:
source_palette = bytearray(i // 3 for i in range(768)) source_palette = bytearray(i // 3 for i in range(768))
im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette)
used_palette_colors: list[int] | None
if palette:
used_palette_colors = []
assert source_palette is not None assert source_palette is not None
if palette:
used_palette_colors: list[int | None] = []
assert im.palette is not None
for i in range(0, len(source_palette), 3): for i in range(0, len(source_palette), 3):
source_color = tuple(source_palette[i : i + 3]) source_color = tuple(source_palette[i : i + 3])
index = im.palette.colors.get(source_color) index = im.palette.colors.get(source_color)
@ -553,20 +577,25 @@ def _normalize_palette(
if j not in used_palette_colors: if j not in used_palette_colors:
used_palette_colors[i] = j used_palette_colors[i] = j
break break
im = im.remap_palette(used_palette_colors) dest_map: list[int] = []
for index in used_palette_colors:
assert index is not None
dest_map.append(index)
im = im.remap_palette(dest_map)
else: else:
used_palette_colors = _get_optimize(im, info) optimized_palette_colors = _get_optimize(im, info)
if used_palette_colors is not None: if optimized_palette_colors is not None:
im = im.remap_palette(used_palette_colors, source_palette) im = im.remap_palette(optimized_palette_colors, source_palette)
if "transparency" in info: if "transparency" in info:
try: try:
info["transparency"] = used_palette_colors.index( info["transparency"] = optimized_palette_colors.index(
info["transparency"] info["transparency"]
) )
except ValueError: except ValueError:
del info["transparency"] del info["transparency"]
return im return im
assert im.palette is not None
im.palette.palette = source_palette im.palette.palette = source_palette
return im return im
@ -578,6 +607,7 @@ def _write_single_frame(
) -> None: ) -> None:
im_out = _normalize_mode(im) im_out = _normalize_mode(im)
for k, v in im_out.info.items(): for k, v in im_out.info.items():
if isinstance(k, str):
im.encoderinfo.setdefault(k, v) im.encoderinfo.setdefault(k, v)
im_out = _normalize_palette(im_out, palette, im.encoderinfo) im_out = _normalize_palette(im_out, palette, im.encoderinfo)
@ -601,7 +631,10 @@ def _write_single_frame(
def _getbbox( def _getbbox(
base_im: Image.Image, im_frame: Image.Image base_im: Image.Image, im_frame: Image.Image
) -> tuple[Image.Image, tuple[int, int, int, int] | None]: ) -> tuple[Image.Image, tuple[int, int, int, int] | None]:
if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): palette_bytes = [
bytes(im.palette.palette) if im.palette else b"" for im in (base_im, im_frame)
]
if palette_bytes[0] != palette_bytes[1]:
im_frame = im_frame.convert("RGBA") im_frame = im_frame.convert("RGBA")
base_im = base_im.convert("RGBA") base_im = base_im.convert("RGBA")
delta = ImageChops.subtract_modulo(im_frame, base_im) delta = ImageChops.subtract_modulo(im_frame, base_im)
@ -632,6 +665,7 @@ def _write_multiple_frames(
for k, v in im_frame.info.items(): for k, v in im_frame.info.items():
if k == "transparency": if k == "transparency":
continue continue
if isinstance(k, str):
im.encoderinfo.setdefault(k, v) im.encoderinfo.setdefault(k, v)
encoderinfo = im.encoderinfo.copy() encoderinfo = im.encoderinfo.copy()
@ -662,10 +696,12 @@ def _write_multiple_frames(
) )
background = _get_background(im_frame, color) background = _get_background(im_frame, color)
background_im = Image.new("P", im_frame.size, background) background_im = Image.new("P", im_frame.size, background)
assert im_frames[0].im.palette is not None
background_im.putpalette(im_frames[0].im.palette) background_im.putpalette(im_frames[0].im.palette)
bbox = _getbbox(background_im, im_frame)[1] bbox = _getbbox(background_im, im_frame)[1]
elif encoderinfo.get("optimize") and im_frame.mode != "1": elif encoderinfo.get("optimize") and im_frame.mode != "1":
if "transparency" not in encoderinfo: if "transparency" not in encoderinfo:
assert im_frame.palette is not None
try: try:
encoderinfo["transparency"] = ( encoderinfo["transparency"] = (
im_frame.palette._new_color_index(im_frame) im_frame.palette._new_color_index(im_frame)
@ -903,6 +939,7 @@ def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None:
if optimise or max(used_palette_colors) >= len(used_palette_colors): if optimise or max(used_palette_colors) >= len(used_palette_colors):
return used_palette_colors return used_palette_colors
assert im.palette is not None
num_palette_colors = len(im.palette.palette) // Image.getmodebands( num_palette_colors = len(im.palette.palette) // Image.getmodebands(
im.palette.mode im.palette.mode
) )
@ -952,7 +989,13 @@ def _get_palette_bytes(im: Image.Image) -> bytes:
:param im: Image object :param im: Image object
:returns: Bytes, len<=768 suitable for inclusion in gif header :returns: Bytes, len<=768 suitable for inclusion in gif header
""" """
return im.palette.palette if im.palette else b"" if not im.palette:
return b""
palette = bytes(im.palette.palette)
if im.palette.mode == "RGBA":
palette = b"".join(palette[i * 4 : i * 4 + 3] for i in range(len(palette) // 3))
return palette
def _get_background( def _get_background(
@ -965,6 +1008,7 @@ def _get_background(
# WebPImagePlugin stores an RGBA value in info["background"] # WebPImagePlugin stores an RGBA value in info["background"]
# So it must be converted to the same format as GifImagePlugin's # So it must be converted to the same format as GifImagePlugin's
# info["background"] - a global color table index # info["background"] - a global color table index
assert im.palette is not None
try: try:
background = im.palette.getcolor(info_background, im) background = im.palette.getcolor(info_background, im)
except ValueError as e: except ValueError as e:
@ -1124,19 +1168,10 @@ def getdata(
class Collector(BytesIO): class Collector(BytesIO):
data = [] data = []
if sys.version_info >= (3, 12):
from collections.abc import Buffer
def write(self, data: Buffer) -> int: def write(self, data: Buffer) -> int:
self.data.append(data) self.data.append(data)
return len(data) return len(data)
else:
def write(self, data: Any) -> int:
self.data.append(data)
return len(data)
im.load() # make sure raster data is available im.load() # make sure raster data is available
fp = Collector() fp = Collector()

View File

@ -25,6 +25,7 @@ import sys
from typing import IO from typing import IO
from . import Image, ImageFile, PngImagePlugin, features from . import Image, ImageFile, PngImagePlugin, features
from ._deprecate import deprecate
enable_jpeg2k = features.check_codec("jpg_2000") enable_jpeg2k = features.check_codec("jpg_2000")
if enable_jpeg2k: if enable_jpeg2k:
@ -275,40 +276,40 @@ class IcnsImageFile(ImageFile.ImageFile):
self.best_size[1] * self.best_size[2], self.best_size[1] * self.best_size[2],
) )
@property @property # type: ignore[override]
def size(self): def size(self) -> tuple[int, int] | tuple[int, int, int]:
return self._size return self._size
@size.setter @size.setter
def size(self, value) -> None: def size(self, value: tuple[int, int] | tuple[int, int, int]) -> None:
info_size = value if len(value) == 3:
if info_size not in self.info["sizes"] and len(info_size) == 2: deprecate("Setting size to (width, height, scale)", 12, "load(scale)")
info_size = (info_size[0], info_size[1], 1) if value in self.info["sizes"]:
if ( self._size = value # type: ignore[assignment]
info_size not in self.info["sizes"] return
and len(info_size) == 3 else:
and info_size[2] == 1 # Check that a matching size exists,
): # or that there is a scale that would create a size that matches
simple_sizes = [ for size in self.info["sizes"]:
(size[0] * size[2], size[1] * size[2]) for size in self.info["sizes"] simple_size = size[0] * size[2], size[1] * size[2]
] scale = simple_size[0] // value[0]
if value in simple_sizes: if simple_size[1] / value[1] == scale:
info_size = self.info["sizes"][simple_sizes.index(value)] self._size = value
if info_size not in self.info["sizes"]: return
msg = "This is not one of the allowed sizes of this image" msg = "This is not one of the allowed sizes of this image"
raise ValueError(msg) raise ValueError(msg)
self._size = value
def load(self) -> Image.core.PixelAccess | None: def load(self, scale: int | None = None) -> Image.core.PixelAccess | None:
if len(self.size) == 3: if scale is not None or len(self.size) == 3:
self.best_size = self.size if scale is None and len(self.size) == 3:
self.size = ( scale = self.size[2]
self.best_size[0] * self.best_size[2], assert scale is not None
self.best_size[1] * self.best_size[2], width, height = self.size[:2]
) self.size = width * scale, height * scale
self.best_size = width, height, scale
px = Image.Image.load(self) px = Image.Image.load(self)
if self.im is not None and self.im.size == self.size: if self._im is not None and self.im.size == self.size:
# Already loaded # Already loaded
return px return px
self.load_prepare() self.load_prepare()

View File

@ -228,7 +228,7 @@ class IcoFile:
# change tile dimension to only encompass XOR image # change tile dimension to only encompass XOR image
im._size = (im.size[0], int(im.size[1] / 2)) im._size = (im.size[0], int(im.size[1] / 2))
d, e, o, a = im.tile[0] d, e, o, a = im.tile[0]
im.tile[0] = d, (0, 0) + im.size, o, a im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a)
# figure out where AND mask image starts # figure out where AND mask image starts
if header.bpp == 32: if header.bpp == 32:
@ -243,6 +243,7 @@ class IcoFile:
alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4] alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4]
# convert to an 8bpp grayscale image # convert to an 8bpp grayscale image
try:
mask = Image.frombuffer( mask = Image.frombuffer(
"L", # 8bpp "L", # 8bpp
im.size, # (w, h) im.size, # (w, h)
@ -250,6 +251,11 @@ class IcoFile:
"raw", # raw decoder "raw", # raw decoder
("L", 0, -1), # 8bpp inverted, unpadded, reversed ("L", 0, -1), # 8bpp inverted, unpadded, reversed
) )
except ValueError:
if ImageFile.LOAD_TRUNCATED_IMAGES:
mask = None
else:
raise
else: else:
# get AND image from end of bitmap # get AND image from end of bitmap
w = im.size[0] w = im.size[0]
@ -267,6 +273,7 @@ class IcoFile:
mask_data = self.buf.read(total_bytes) mask_data = self.buf.read(total_bytes)
# convert raw data to image # convert raw data to image
try:
mask = Image.frombuffer( mask = Image.frombuffer(
"1", # 1 bpp "1", # 1 bpp
im.size, # (w, h) im.size, # (w, h)
@ -274,10 +281,16 @@ class IcoFile:
"raw", # raw decoder "raw", # raw decoder
("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed
) )
except ValueError:
if ImageFile.LOAD_TRUNCATED_IMAGES:
mask = None
else:
raise
# now we have two images, im is XOR image and mask is AND image # now we have two images, im is XOR image and mask is AND image
# apply mask image as alpha channel # apply mask image as alpha channel
if mask:
im = im.convert("RGBA") im = im.convert("RGBA")
im.putalpha(mask) im.putalpha(mask)
@ -319,18 +332,18 @@ class IcoImageFile(ImageFile.ImageFile):
self.load() self.load()
@property @property
def size(self): def size(self) -> tuple[int, int]:
return self._size return self._size
@size.setter @size.setter
def size(self, value): def size(self, value: tuple[int, int]) -> None:
if value not in self.info["sizes"]: if value not in self.info["sizes"]:
msg = "This is not one of the allowed sizes of this image" msg = "This is not one of the allowed sizes of this image"
raise ValueError(msg) raise ValueError(msg)
self._size = value self._size = value
def load(self) -> Image.core.PixelAccess | None: def load(self) -> Image.core.PixelAccess | None:
if self.im is not None and self.im.size == self.size: if self._im is not None and self.im.size == self.size:
# Already loaded # Already loaded
return Image.Image.load(self) return Image.Image.load(self)
im = self.ico.getimage(self.size) im = self.ico.getimage(self.size)

View File

@ -253,7 +253,11 @@ class ImImageFile(ImageFile.ImageFile):
# use bit decoder (if necessary) # use bit decoder (if necessary)
bits = int(self.rawmode[2:]) bits = int(self.rawmode[2:])
if bits not in [8, 16, 32]: if bits not in [8, 16, 32]:
self.tile = [("bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1))] self.tile = [
ImageFile._Tile(
"bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1)
)
]
return return
except ValueError: except ValueError:
pass pass
@ -263,13 +267,17 @@ class ImImageFile(ImageFile.ImageFile):
# ever stumbled upon such a file ;-) # ever stumbled upon such a file ;-)
size = self.size[0] * self.size[1] size = self.size[0] * self.size[1]
self.tile = [ self.tile = [
("raw", (0, 0) + self.size, offs, ("G", 0, -1)), ImageFile._Tile("raw", (0, 0) + self.size, offs, ("G", 0, -1)),
("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), ImageFile._Tile("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)),
("raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1)), ImageFile._Tile(
"raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1)
),
] ]
else: else:
# LabEye/IFUNC files # LabEye/IFUNC files
self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] self.tile = [
ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))
]
@property @property
def n_frames(self) -> int: def n_frames(self) -> int:
@ -295,7 +303,9 @@ class ImImageFile(ImageFile.ImageFile):
self.fp = self._fp self.fp = self._fp
self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] self.tile = [
ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))
]
def tell(self) -> int: def tell(self) -> int:
return self.frame return self.frame

View File

@ -133,6 +133,7 @@ def isImageType(t: Any) -> TypeGuard[Image]:
:param t: object to check if it's an image :param t: object to check if it's an image
:returns: True if the object is an image :returns: True if the object is an image
""" """
deprecate("Image.isImageType(im)", 12, "isinstance(im, Image.Image)")
return hasattr(t, "im") return hasattr(t, "im")
@ -221,8 +222,15 @@ if TYPE_CHECKING:
import mmap import mmap
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
from . import ImageFile, ImageFilter, ImagePalette, TiffImagePlugin from IPython.lib.pretty import PrettyPrinter
from . import ImageFile, ImageFilter, ImagePalette, ImageQt, TiffImagePlugin
from ._typing import NumpyArray, StrOrBytesPath, TypeGuard from ._typing import NumpyArray, StrOrBytesPath, TypeGuard
if sys.version_info >= (3, 13):
from types import CapsuleType
else:
CapsuleType = object
ID: list[str] = [] ID: list[str] = []
OPEN: dict[ OPEN: dict[
str, str,
@ -468,43 +476,53 @@ def _getencoder(
# Simple expression analyzer # Simple expression analyzer
class _E: class ImagePointTransform:
"""
Used with :py:meth:`~PIL.Image.Image.point` for single band images with more than
8 bits, this represents an affine transformation, where the value is multiplied by
``scale`` and ``offset`` is added.
"""
def __init__(self, scale: float, offset: float) -> None: def __init__(self, scale: float, offset: float) -> None:
self.scale = scale self.scale = scale
self.offset = offset self.offset = offset
def __neg__(self) -> _E: def __neg__(self) -> ImagePointTransform:
return _E(-self.scale, -self.offset) return ImagePointTransform(-self.scale, -self.offset)
def __add__(self, other: _E | float) -> _E: def __add__(self, other: ImagePointTransform | float) -> ImagePointTransform:
if isinstance(other, _E): if isinstance(other, ImagePointTransform):
return _E(self.scale + other.scale, self.offset + other.offset) return ImagePointTransform(
return _E(self.scale, self.offset + other) self.scale + other.scale, self.offset + other.offset
)
return ImagePointTransform(self.scale, self.offset + other)
__radd__ = __add__ __radd__ = __add__
def __sub__(self, other: _E | float) -> _E: def __sub__(self, other: ImagePointTransform | float) -> ImagePointTransform:
return self + -other return self + -other
def __rsub__(self, other: _E | float) -> _E: def __rsub__(self, other: ImagePointTransform | float) -> ImagePointTransform:
return other + -self return other + -self
def __mul__(self, other: _E | float) -> _E: def __mul__(self, other: ImagePointTransform | float) -> ImagePointTransform:
if isinstance(other, _E): if isinstance(other, ImagePointTransform):
return NotImplemented return NotImplemented
return _E(self.scale * other, self.offset * other) return ImagePointTransform(self.scale * other, self.offset * other)
__rmul__ = __mul__ __rmul__ = __mul__
def __truediv__(self, other: _E | float) -> _E: def __truediv__(self, other: ImagePointTransform | float) -> ImagePointTransform:
if isinstance(other, _E): if isinstance(other, ImagePointTransform):
return NotImplemented return NotImplemented
return _E(self.scale / other, self.offset / other) return ImagePointTransform(self.scale / other, self.offset / other)
def _getscaleoffset(expr) -> tuple[float, float]: def _getscaleoffset(
a = expr(_E(1, 0)) expr: Callable[[ImagePointTransform], ImagePointTransform | float]
return (a.scale, a.offset) if isinstance(a, _E) else (0, a) ) -> tuple[float, float]:
a = expr(ImagePointTransform(1, 0))
return (a.scale, a.offset) if isinstance(a, ImagePointTransform) else (0, a)
# -------------------------------------------------------------------- # --------------------------------------------------------------------
@ -533,16 +551,27 @@ class Image:
format_description: str | None = None format_description: str | None = None
_close_exclusive_fp_after_loading = True _close_exclusive_fp_after_loading = True
def __init__(self): def __init__(self) -> None:
# FIXME: take "new" parameters / other image? # FIXME: take "new" parameters / other image?
# FIXME: turn mode and size into delegating properties? # FIXME: turn mode and size into delegating properties?
self.im = None self._im: core.ImagingCore | DeferredError | None = None
self._mode = "" self._mode = ""
self._size = (0, 0) self._size = (0, 0)
self.palette = None self.palette: ImagePalette.ImagePalette | None = None
self.info = {} self.info: dict[str | tuple[int, int], Any] = {}
self.readonly = 0 self.readonly = 0
self._exif = None self._exif: Exif | None = None
@property
def im(self) -> core.ImagingCore:
if isinstance(self._im, DeferredError):
raise self._im.ex
assert self._im is not None
return self._im
@im.setter
def im(self, im: core.ImagingCore) -> None:
self._im = im
@property @property
def width(self) -> int: def width(self) -> int:
@ -618,7 +647,7 @@ class Image:
# Instead of simply setting to None, we're setting up a # Instead of simply setting to None, we're setting up a
# deferred error that will better explain that the core image # deferred error that will better explain that the core image
# object is gone. # object is gone.
self.im = DeferredError(ValueError("Operation on closed image")) self._im = DeferredError(ValueError("Operation on closed image"))
def _copy(self) -> None: def _copy(self) -> None:
self.load() self.load()
@ -677,7 +706,7 @@ class Image:
id(self), id(self),
) )
def _repr_pretty_(self, p, cycle: bool) -> None: def _repr_pretty_(self, p: PrettyPrinter, cycle: bool) -> None:
"""IPython plain text display support""" """IPython plain text display support"""
# Same as __repr__ but without unpredictable id(self), # Same as __repr__ but without unpredictable id(self),
@ -724,24 +753,12 @@ class Image:
def __array_interface__(self) -> dict[str, str | bytes | int | tuple[int, ...]]: def __array_interface__(self) -> dict[str, str | bytes | int | tuple[int, ...]]:
# numpy array interface support # numpy array interface support
new: dict[str, str | bytes | int | tuple[int, ...]] = {"version": 3} new: dict[str, str | bytes | int | tuple[int, ...]] = {"version": 3}
try:
if self.mode == "1": if self.mode == "1":
# Binary images need to be extended from bits to bytes # Binary images need to be extended from bits to bytes
# See: https://github.com/python-pillow/Pillow/issues/350 # See: https://github.com/python-pillow/Pillow/issues/350
new["data"] = self.tobytes("raw", "L") new["data"] = self.tobytes("raw", "L")
else: else:
new["data"] = self.tobytes() new["data"] = self.tobytes()
except Exception as e:
if not isinstance(e, (MemoryError, RecursionError)):
try:
import numpy
from packaging.version import parse as parse_version
except ImportError:
pass
else:
if parse_version(numpy.__version__) < parse_version("1.23"):
warnings.warn(str(e))
raise
new["shape"], new["typestr"] = _conv_type_shape(self) new["shape"], new["typestr"] = _conv_type_shape(self)
return new return new
@ -840,7 +857,10 @@ class Image:
) )
def frombytes( def frombytes(
self, data: bytes | bytearray, decoder_name: str = "raw", *args: Any self,
data: bytes | bytearray | SupportsArrayInterface,
decoder_name: str = "raw",
*args: Any,
) -> None: ) -> None:
""" """
Loads this image with pixel data from a bytes object. Loads this image with pixel data from a bytes object.
@ -888,7 +908,7 @@ class Image:
:returns: An image access object. :returns: An image access object.
:rtype: :py:class:`.PixelAccess` :rtype: :py:class:`.PixelAccess`
""" """
if self.im is not None and self.palette and self.palette.dirty: if self._im is not None and self.palette and self.palette.dirty:
# realize palette # realize palette
mode, arr = self.palette.getdata() mode, arr = self.palette.getdata()
self.im.putpalette(self.palette.mode, mode, arr) self.im.putpalette(self.palette.mode, mode, arr)
@ -905,7 +925,7 @@ class Image:
self.palette.mode, self.palette.mode self.palette.mode, self.palette.mode
) )
if self.im is not None: if self._im is not None:
return self.im.pixel_access(self.readonly) return self.im.pixel_access(self.readonly)
return None return None
@ -1046,9 +1066,11 @@ class Image:
# use existing conversions # use existing conversions
trns_im = new(self.mode, (1, 1)) trns_im = new(self.mode, (1, 1))
if self.mode == "P": if self.mode == "P":
trns_im.putpalette(self.palette) assert self.palette is not None
trns_im.putpalette(self.palette, self.palette.mode)
if isinstance(t, tuple): if isinstance(t, tuple):
err = "Couldn't allocate a palette color for transparency" err = "Couldn't allocate a palette color for transparency"
assert trns_im.palette is not None
try: try:
t = trns_im.palette.getcolor(t, self) t = trns_im.palette.getcolor(t, self)
except ValueError as e: except ValueError as e:
@ -1109,17 +1131,23 @@ class Image:
return new_im return new_im
if "LAB" in (self.mode, mode): if "LAB" in (self.mode, mode):
other_mode = mode if self.mode == "LAB" else self.mode im = self
if mode == "LAB":
if im.mode not in ("RGB", "RGBA", "RGBX"):
im = im.convert("RGBA")
other_mode = im.mode
else:
other_mode = mode
if other_mode in ("RGB", "RGBA", "RGBX"): if other_mode in ("RGB", "RGBA", "RGBX"):
from . import ImageCms from . import ImageCms
srgb = ImageCms.createProfile("sRGB") srgb = ImageCms.createProfile("sRGB")
lab = ImageCms.createProfile("LAB") lab = ImageCms.createProfile("LAB")
profiles = [lab, srgb] if self.mode == "LAB" else [srgb, lab] profiles = [lab, srgb] if im.mode == "LAB" else [srgb, lab]
transform = ImageCms.buildTransform( transform = ImageCms.buildTransform(
profiles[0], profiles[1], self.mode, mode profiles[0], profiles[1], im.mode, mode
) )
return transform.apply(self) return transform.apply(im)
# colorspace conversion # colorspace conversion
if dither is None: if dither is None:
@ -1150,7 +1178,9 @@ class Image:
if trns is not None: if trns is not None:
if new_im.mode == "P" and new_im.palette: if new_im.mode == "P" and new_im.palette:
try: try:
new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) new_im.info["transparency"] = new_im.palette.getcolor(
cast(tuple[int, ...], trns), new_im # trns was converted to RGB
)
except ValueError as e: except ValueError as e:
del new_im.info["transparency"] del new_im.info["transparency"]
if str(e) != "cannot allocate more than 256 colors": if str(e) != "cannot allocate more than 256 colors":
@ -1228,6 +1258,7 @@ class Image:
raise ValueError(msg) raise ValueError(msg)
im = self.im.convert("P", dither, palette.im) im = self.im.convert("P", dither, palette.im)
new_im = self._new(im) new_im = self._new(im)
assert palette.palette is not None
new_im.palette = palette.palette.copy() new_im.palette = palette.palette.copy()
return new_im return new_im
@ -1582,7 +1613,7 @@ class Image:
self.fp.seek(offset) self.fp.seek(offset)
return child_images return child_images
def getim(self): def getim(self) -> CapsuleType:
""" """
Returns a capsule that points to the internal image memory. Returns a capsule that points to the internal image memory.
@ -1626,11 +1657,15 @@ class Image:
:returns: A boolean. :returns: A boolean.
""" """
return ( if (
self.mode in ("LA", "La", "PA", "RGBA", "RGBa") self.mode in ("LA", "La", "PA", "RGBA", "RGBa")
or (self.mode == "P" and self.palette.mode.endswith("A"))
or "transparency" in self.info or "transparency" in self.info
) ):
return True
if self.mode == "P":
assert self.palette is not None
return self.palette.mode.endswith("A")
return False
def apply_transparency(self) -> None: def apply_transparency(self) -> None:
""" """
@ -1789,23 +1824,22 @@ class Image:
:param mask: An optional mask image. :param mask: An optional mask image.
""" """
if isImageType(box): if isinstance(box, Image):
if mask is not None: if mask is not None:
msg = "If using second argument as mask, third argument must be None" msg = "If using second argument as mask, third argument must be None"
raise ValueError(msg) raise ValueError(msg)
# abbreviated paste(im, mask) syntax # abbreviated paste(im, mask) syntax
mask = box mask = box
box = None box = None
assert not isinstance(box, Image)
if box is None: if box is None:
box = (0, 0) box = (0, 0)
if len(box) == 2: if len(box) == 2:
# upper left corner given; get size from image or mask # upper left corner given; get size from image or mask
if isImageType(im): if isinstance(im, Image):
size = im.size size = im.size
elif isImageType(mask): elif isinstance(mask, Image):
size = mask.size size = mask.size
else: else:
# FIXME: use self.size here? # FIXME: use self.size here?
@ -1813,26 +1847,28 @@ class Image:
raise ValueError(msg) raise ValueError(msg)
box += (box[0] + size[0], box[1] + size[1]) box += (box[0] + size[0], box[1] + size[1])
source: core.ImagingCore | str | float | tuple[float, ...]
if isinstance(im, str): if isinstance(im, str):
from . import ImageColor from . import ImageColor
im = ImageColor.getcolor(im, self.mode) source = ImageColor.getcolor(im, self.mode)
elif isinstance(im, Image):
elif isImageType(im):
im.load() im.load()
if self.mode != im.mode: if self.mode != im.mode:
if self.mode != "RGB" or im.mode not in ("LA", "RGBA", "RGBa"): if self.mode != "RGB" or im.mode not in ("LA", "RGBA", "RGBa"):
# should use an adapter for this! # should use an adapter for this!
im = im.convert(self.mode) im = im.convert(self.mode)
im = im.im source = im.im
else:
source = im
self._ensure_mutable() self._ensure_mutable()
if mask: if mask:
mask.load() mask.load()
self.im.paste(im, box, mask.im) self.im.paste(source, box, mask.im)
else: else:
self.im.paste(im, box) self.im.paste(source, box)
def alpha_composite( def alpha_composite(
self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0) self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0)
@ -1892,7 +1928,13 @@ class Image:
def point( def point(
self, self,
lut: Sequence[float] | NumpyArray | Callable[[int], float] | ImagePointHandler, lut: (
Sequence[float]
| NumpyArray
| Callable[[int], float]
| Callable[[ImagePointTransform], ImagePointTransform | float]
| ImagePointHandler
),
mode: str | None = None, mode: str | None = None,
) -> Image: ) -> Image:
""" """
@ -1909,7 +1951,7 @@ class Image:
object:: object::
class Example(Image.ImagePointHandler): class Example(Image.ImagePointHandler):
def point(self, data): def point(self, im: Image) -> Image:
# Return result # Return result
:param mode: Output mode (default is same as input). This can only be used if :param mode: Output mode (default is same as input). This can only be used if
the source image has mode "L" or "P", and the output has mode "1" or the the source image has mode "L" or "P", and the output has mode "1" or the
@ -1928,10 +1970,10 @@ class Image:
# check if the function can be used with point_transform # check if the function can be used with point_transform
# UNDONE wiredfool -- I think this prevents us from ever doing # UNDONE wiredfool -- I think this prevents us from ever doing
# a gamma function point transform on > 8bit images. # a gamma function point transform on > 8bit images.
scale, offset = _getscaleoffset(lut) scale, offset = _getscaleoffset(lut) # type: ignore[arg-type]
return self._new(self.im.point_transform(scale, offset)) return self._new(self.im.point_transform(scale, offset))
# for other modes, convert the function to a table # for other modes, convert the function to a table
flatLut = [lut(i) for i in range(256)] * self.im.bands flatLut = [lut(i) for i in range(256)] * self.im.bands # type: ignore[arg-type]
else: else:
flatLut = lut flatLut = lut
@ -1979,7 +2021,7 @@ class Image:
else: else:
band = 3 band = 3
if isImageType(alpha): if isinstance(alpha, Image):
# alpha layer # alpha layer
if alpha.mode not in ("1", "L"): if alpha.mode not in ("1", "L"):
msg = "illegal image mode" msg = "illegal image mode"
@ -1989,7 +2031,6 @@ class Image:
alpha = alpha.convert("L") alpha = alpha.convert("L")
else: else:
# constant alpha # constant alpha
alpha = cast(int, alpha) # see python/typing#1013
try: try:
self.im.fillband(band, alpha) self.im.fillband(band, alpha)
except (AttributeError, ValueError): except (AttributeError, ValueError):
@ -2103,7 +2144,8 @@ class Image:
if self.mode == "PA": if self.mode == "PA":
alpha = value[3] if len(value) == 4 else 255 alpha = value[3] if len(value) == 4 else 255
value = value[:3] value = value[:3]
palette_index = self.palette.getcolor(value, self) assert self.palette is not None
palette_index = self.palette.getcolor(tuple(value), self)
value = (palette_index, alpha) if self.mode == "PA" else palette_index value = (palette_index, alpha) if self.mode == "PA" else palette_index
return self.im.putpixel(xy, value) return self.im.putpixel(xy, value)
@ -2137,6 +2179,9 @@ class Image:
source_palette = self.im.getpalette(palette_mode, palette_mode) source_palette = self.im.getpalette(palette_mode, palette_mode)
else: # L-mode else: # L-mode
source_palette = bytearray(i // 3 for i in range(768)) source_palette = bytearray(i // 3 for i in range(768))
elif len(source_palette) > 768:
bands = 4
palette_mode = "RGBA"
palette_bytes = b"" palette_bytes = b""
new_positions = [0] * 256 new_positions = [0] * 256
@ -2287,7 +2332,6 @@ class Image:
msg = "reducing_gap must be 1.0 or greater" msg = "reducing_gap must be 1.0 or greater"
raise ValueError(msg) raise ValueError(msg)
self.load()
if box is None: if box is None:
box = (0, 0) + self.size box = (0, 0) + self.size
@ -2736,27 +2780,18 @@ class Image:
) )
return x, y return x, y
box = None
final_size: tuple[int, int]
if reducing_gap is not None:
preserved_size = preserve_aspect_ratio() preserved_size = preserve_aspect_ratio()
if preserved_size is None: if preserved_size is None:
return return
final_size = preserved_size final_size = preserved_size
box = None
if reducing_gap is not None:
res = self.draft( res = self.draft(
None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap))
) )
if res is not None: if res is not None:
box = res[1] box = res[1]
if box is None:
self.load()
# load() may have changed the size of the image
preserved_size = preserve_aspect_ratio()
if preserved_size is None:
return
final_size = preserved_size
if self.size != final_size: if self.size != final_size:
im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap) im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap)
@ -2867,11 +2902,11 @@ class Image:
self, self,
box: tuple[int, int, int, int], box: tuple[int, int, int, int],
image: Image, image: Image,
method, method: Transform,
data, data: Sequence[float],
resample: int = Resampling.NEAREST, resample: int = Resampling.NEAREST,
fill: bool = True, fill: bool = True,
): ) -> None:
w = box[2] - box[0] w = box[2] - box[0]
h = box[3] - box[1] h = box[3] - box[1]
@ -2972,7 +3007,7 @@ class Image:
self.load() self.load()
return self._new(self.im.effect_spread(distance)) return self._new(self.im.effect_spread(distance))
def toqimage(self): def toqimage(self) -> ImageQt.ImageQt:
"""Returns a QImage copy of this image""" """Returns a QImage copy of this image"""
from . import ImageQt from . import ImageQt
@ -2981,7 +3016,7 @@ class Image:
raise ImportError(msg) raise ImportError(msg)
return ImageQt.toqimage(self) return ImageQt.toqimage(self)
def toqpixmap(self): def toqpixmap(self) -> ImageQt.QPixmap:
"""Returns a QPixmap copy of this image""" """Returns a QPixmap copy of this image"""
from . import ImageQt from . import ImageQt
@ -3109,7 +3144,7 @@ def new(
def frombytes( def frombytes(
mode: str, mode: str,
size: tuple[int, int], size: tuple[int, int],
data: bytes | bytearray, data: bytes | bytearray | SupportsArrayInterface,
decoder_name: str = "raw", decoder_name: str = "raw",
*args: Any, *args: Any,
) -> Image: ) -> Image:
@ -3153,7 +3188,11 @@ def frombytes(
def frombuffer( def frombuffer(
mode: str, size: tuple[int, int], data, decoder_name: str = "raw", *args: Any mode: str,
size: tuple[int, int],
data: bytes | SupportsArrayInterface,
decoder_name: str = "raw",
*args: Any,
) -> Image: ) -> Image:
""" """
Creates an image memory referencing pixel data in a byte buffer. Creates an image memory referencing pixel data in a byte buffer.
@ -3308,7 +3347,7 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image:
return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) return frombuffer(mode, size, obj, "raw", rawmode, 0, 1)
def fromqimage(im) -> ImageFile.ImageFile: def fromqimage(im: ImageQt.QImage) -> ImageFile.ImageFile:
"""Creates an image instance from a QImage image""" """Creates an image instance from a QImage image"""
from . import ImageQt from . import ImageQt
@ -3318,7 +3357,7 @@ def fromqimage(im) -> ImageFile.ImageFile:
return ImageQt.fromqimage(im) return ImageQt.fromqimage(im)
def fromqpixmap(im) -> ImageFile.ImageFile: def fromqpixmap(im: ImageQt.QPixmap) -> ImageFile.ImageFile:
"""Creates an image instance from a QPixmap image""" """Creates an image instance from a QPixmap image"""
from . import ImageQt from . import ImageQt
@ -3610,7 +3649,10 @@ def merge(mode: str, bands: Sequence[Image]) -> Image:
def register_open( def register_open(
id: str, id: str,
factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], factory: (
Callable[[IO[bytes], str | bytes], ImageFile.ImageFile]
| type[ImageFile.ImageFile]
),
accept: Callable[[bytes], bool | str] | None = None, accept: Callable[[bytes], bool | str] | None = None,
) -> None: ) -> None:
""" """
@ -3929,7 +3971,7 @@ class Exif(_ExifBase):
self._data.clear() self._data.clear()
self._hidden_data.clear() self._hidden_data.clear()
self._ifds.clear() self._ifds.clear()
if data and data.startswith(b"Exif\x00\x00"): while data and data.startswith(b"Exif\x00\x00"):
data = data[6:] data = data[6:]
if not data: if not data:
self._info = None self._info = None
@ -4006,15 +4048,19 @@ class Exif(_ExifBase):
ifd[tag] = value ifd[tag] = value
return b"Exif\x00\x00" + head + ifd.tobytes(offset) return b"Exif\x00\x00" + head + ifd.tobytes(offset)
def get_ifd(self, tag): def get_ifd(self, tag: int) -> dict[int, Any]:
if tag not in self._ifds: if tag not in self._ifds:
if tag == ExifTags.IFD.IFD1: if tag == ExifTags.IFD.IFD1:
if self._info is not None and self._info.next != 0: if self._info is not None and self._info.next != 0:
self._ifds[tag] = self._get_ifd_dict(self._info.next) ifd = self._get_ifd_dict(self._info.next)
if ifd is not None:
self._ifds[tag] = ifd
elif tag in [ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo]: elif tag in [ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo]:
offset = self._hidden_data.get(tag, self.get(tag)) offset = self._hidden_data.get(tag, self.get(tag))
if offset is not None: if offset is not None:
self._ifds[tag] = self._get_ifd_dict(offset, tag) ifd = self._get_ifd_dict(offset, tag)
if ifd is not None:
self._ifds[tag] = ifd
elif tag in [ExifTags.IFD.Interop, ExifTags.IFD.Makernote]: elif tag in [ExifTags.IFD.Interop, ExifTags.IFD.Makernote]:
if ExifTags.IFD.Exif not in self._ifds: if ExifTags.IFD.Exif not in self._ifds:
self.get_ifd(ExifTags.IFD.Exif) self.get_ifd(ExifTags.IFD.Exif)
@ -4071,7 +4117,9 @@ class Exif(_ExifBase):
(offset,) = struct.unpack(">L", data) (offset,) = struct.unpack(">L", data)
self.fp.seek(offset) self.fp.seek(offset)
camerainfo = {"ModelID": self.fp.read(4)} camerainfo: dict[str, int | bytes] = {
"ModelID": self.fp.read(4)
}
self.fp.read(4) self.fp.read(4)
# Seconds since 2000 # Seconds since 2000
@ -4087,17 +4135,19 @@ class Exif(_ExifBase):
][1] ][1]
camerainfo["Parallax"] = handler( camerainfo["Parallax"] = handler(
ImageFileDirectory_v2(), parallax, False ImageFileDirectory_v2(), parallax, False
) )[0]
self.fp.read(4) self.fp.read(4)
camerainfo["Category"] = self.fp.read(2) camerainfo["Category"] = self.fp.read(2)
makernote = {0x1101: dict(self._fixup_dict(camerainfo))} makernote = {0x1101: camerainfo}
self._ifds[tag] = makernote self._ifds[tag] = makernote
else: else:
# Interop # Interop
self._ifds[tag] = self._get_ifd_dict(tag_data, tag) ifd = self._get_ifd_dict(tag_data, tag)
ifd = self._ifds.get(tag, {}) if ifd is not None:
self._ifds[tag] = ifd
ifd = self._ifds.setdefault(tag, {})
if tag == ExifTags.IFD.Exif and self._hidden_data: if tag == ExifTags.IFD.Exif and self._hidden_data:
ifd = { ifd = {
k: v k: v

View File

@ -32,7 +32,6 @@
from __future__ import annotations from __future__ import annotations
import math import math
import numbers
import struct import struct
from collections.abc import Sequence from collections.abc import Sequence
from types import ModuleType from types import ModuleType
@ -160,13 +159,13 @@ class ImageDraw:
if ink is not None: if ink is not None:
if isinstance(ink, str): if isinstance(ink, str):
ink = ImageColor.getcolor(ink, self.mode) ink = ImageColor.getcolor(ink, self.mode)
if self.palette and not isinstance(ink, numbers.Number): if self.palette and isinstance(ink, tuple):
ink = self.palette.getcolor(ink, self._image) ink = self.palette.getcolor(ink, self._image)
result_ink = self.draw.draw_ink(ink) result_ink = self.draw.draw_ink(ink)
if fill is not None: if fill is not None:
if isinstance(fill, str): if isinstance(fill, str):
fill = ImageColor.getcolor(fill, self.mode) fill = ImageColor.getcolor(fill, self.mode)
if self.palette and not isinstance(fill, numbers.Number): if self.palette and isinstance(fill, tuple):
fill = self.palette.getcolor(fill, self._image) fill = self.palette.getcolor(fill, self._image)
result_fill = self.draw.draw_ink(fill) result_fill = self.draw.draw_ink(fill)
return result_ink, result_fill return result_ink, result_fill

View File

@ -31,14 +31,18 @@ from __future__ import annotations
import abc import abc
import io import io
import itertools import itertools
import os
import struct import struct
import sys import sys
from typing import IO, Any, NamedTuple from typing import IO, TYPE_CHECKING, Any, NamedTuple, cast
from . import Image from . import Image
from ._deprecate import deprecate from ._deprecate import deprecate
from ._util import is_path from ._util import is_path
if TYPE_CHECKING:
from ._typing import StrOrBytesPath
MAXBLOCK = 65536 MAXBLOCK = 65536
SAFEBLOCK = 1024 * 1024 SAFEBLOCK = 1024 * 1024
@ -106,32 +110,34 @@ class _Tile(NamedTuple):
class ImageFile(Image.Image): class ImageFile(Image.Image):
"""Base class for image file format handlers.""" """Base class for image file format handlers."""
def __init__(self, fp=None, filename=None): def __init__(
self, fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None
) -> None:
super().__init__() super().__init__()
self._min_frame = 0 self._min_frame = 0
self.custom_mimetype = None self.custom_mimetype: str | None = None
self.tile = None self.tile: list[_Tile] = []
""" A list of tile descriptors, or ``None`` """ """ A list of tile descriptors, or ``None`` """
self.readonly = 1 # until we know better self.readonly = 1 # until we know better
self.decoderconfig = () self.decoderconfig: tuple[Any, ...] = ()
self.decodermaxblock = MAXBLOCK self.decodermaxblock = MAXBLOCK
if is_path(fp): if is_path(fp):
# filename # filename
self.fp = open(fp, "rb") self.fp = open(fp, "rb")
self.filename = fp self.filename = os.path.realpath(os.fspath(fp))
self._exclusive_fp = True self._exclusive_fp = True
else: else:
# stream # stream
self.fp = fp self.fp = cast(IO[bytes], fp)
self.filename = filename self.filename = filename if filename is not None else ""
# can be overridden # can be overridden
self._exclusive_fp = None self._exclusive_fp = False
try: try:
try: try:
@ -154,6 +160,9 @@ class ImageFile(Image.Image):
self.fp.close() self.fp.close()
raise raise
def _open(self) -> None:
pass
def get_format_mimetype(self) -> str | None: def get_format_mimetype(self) -> str | None:
if self.custom_mimetype: if self.custom_mimetype:
return self.custom_mimetype return self.custom_mimetype
@ -177,7 +186,7 @@ class ImageFile(Image.Image):
def load(self) -> Image.core.PixelAccess | None: def load(self) -> Image.core.PixelAccess | None:
"""Load image data based on tile list""" """Load image data based on tile list"""
if self.tile is None: if not self.tile and self._im is None:
msg = "cannot load this image" msg = "cannot load this image"
raise OSError(msg) raise OSError(msg)
@ -213,6 +222,7 @@ class ImageFile(Image.Image):
args = (args, 0, 1) args = (args, 0, 1)
if ( if (
decoder_name == "raw" decoder_name == "raw"
and isinstance(args, tuple)
and len(args) >= 3 and len(args) >= 3
and args[0] == self.mode and args[0] == self.mode
and args[0] in Image._MAPMODES and args[0] in Image._MAPMODES
@ -312,7 +322,7 @@ class ImageFile(Image.Image):
def load_prepare(self) -> None: def load_prepare(self) -> None:
# create image memory if necessary # create image memory if necessary
if not self.im or self.im.mode != self.mode or self.im.size != self.size: if self._im is None:
self.im = Image.core.new(self.mode, self.size) self.im = Image.core.new(self.mode, self.size)
# create palette (optional) # create palette (optional)
if self.mode == "P": if self.mode == "P":
@ -555,7 +565,7 @@ def _encode_tile(
fp: IO[bytes], fp: IO[bytes],
tile: list[_Tile], tile: list[_Tile],
bufsize: int, bufsize: int,
fh, fh: int | None,
exc: BaseException | None = None, exc: BaseException | None = None,
) -> None: ) -> None:
for encoder_name, extents, offset, args in tile: for encoder_name, extents, offset, args in tile:
@ -577,6 +587,7 @@ def _encode_tile(
break break
else: else:
# slight speedup: compress to real file object # slight speedup: compress to real file object
assert fh is not None
errcode = encoder.encode_to_file(fh, bufsize) errcode = encoder.encode_to_file(fh, bufsize)
if errcode < 0: if errcode < 0:
raise _get_oserror(errcode, encoder=True) from exc raise _get_oserror(errcode, encoder=True) from exc
@ -722,7 +733,7 @@ class PyDecoder(PyCodec):
def pulls_fd(self) -> bool: def pulls_fd(self) -> bool:
return self._pulls_fd return self._pulls_fd
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
""" """
Override to perform the decoding process. Override to perform the decoding process.
@ -734,19 +745,22 @@ class PyDecoder(PyCodec):
msg = "unavailable in base decoder" msg = "unavailable in base decoder"
raise NotImplementedError(msg) raise NotImplementedError(msg)
def set_as_raw(self, data: bytes, rawmode=None) -> None: def set_as_raw(
self, data: bytes, rawmode: str | None = None, extra: tuple[Any, ...] = ()
) -> None:
""" """
Convenience method to set the internal image from a stream of raw data Convenience method to set the internal image from a stream of raw data
:param data: Bytes to be set :param data: Bytes to be set
:param rawmode: The rawmode to be used for the decoder. :param rawmode: The rawmode to be used for the decoder.
If not specified, it will default to the mode of the image If not specified, it will default to the mode of the image
:param extra: Extra arguments for the decoder.
:returns: None :returns: None
""" """
if not rawmode: if not rawmode:
rawmode = self.mode rawmode = self.mode
d = Image._getdecoder(self.mode, "raw", rawmode) d = Image._getdecoder(self.mode, "raw", rawmode, extra)
assert self.im is not None assert self.im is not None
d.setimage(self.im, self.state.extents()) d.setimage(self.im, self.state.extents())
s = d.decode(data) s = d.decode(data)
@ -801,7 +815,7 @@ class PyEncoder(PyCodec):
self.fd.write(data) self.fd.write(data)
return bytes_consumed, errcode return bytes_consumed, errcode
def encode_to_file(self, fh: IO[bytes], bufsize: int) -> int: def encode_to_file(self, fh: int, bufsize: int) -> int:
""" """
:param fh: File handle. :param fh: File handle.
:param bufsize: Buffer size. :param bufsize: Buffer size.
@ -814,5 +828,5 @@ class PyEncoder(PyCodec):
while errcode == 0: while errcode == 0:
status, errcode, buf = self.encode(bufsize) status, errcode, buf = self.encode(bufsize)
if status > 0: if status > 0:
fh.write(buf[status:]) os.write(fh, buf[status:])
return errcode return errcode

View File

@ -34,9 +34,9 @@ import warnings
from enum import IntEnum from enum import IntEnum
from io import BytesIO from io import BytesIO
from types import ModuleType from types import ModuleType
from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict, cast
from . import Image from . import Image, features
from ._typing import StrOrBytesPath from ._typing import StrOrBytesPath
from ._util import DeferredError, is_path from ._util import DeferredError, is_path
@ -98,11 +98,13 @@ class ImageFont:
def _load_pilfont(self, filename: str) -> None: def _load_pilfont(self, filename: str) -> None:
with open(filename, "rb") as fp: with open(filename, "rb") as fp:
image: ImageFile.ImageFile | None = None image: ImageFile.ImageFile | None = None
root = os.path.splitext(filename)[0]
for ext in (".png", ".gif", ".pbm"): for ext in (".png", ".gif", ".pbm"):
if image: if image:
image.close() image.close()
try: try:
fullname = os.path.splitext(filename)[0] + ext fullname = root + ext
image = Image.open(fullname) image = Image.open(fullname)
except Exception: except Exception:
pass pass
@ -112,7 +114,8 @@ class ImageFont:
else: else:
if image: if image:
image.close() image.close()
msg = "cannot find glyph data file"
msg = f"cannot find glyph data file {root}.{{gif|pbm|png}}"
raise OSError(msg) raise OSError(msg)
self.file = fullname self.file = fullname
@ -212,7 +215,7 @@ class FreeTypeFont:
def __init__( def __init__(
self, self,
font: StrOrBytesPath | BinaryIO | None = None, font: StrOrBytesPath | BinaryIO,
size: float = 10, size: float = 10,
index: int = 0, index: int = 0,
encoding: str = "", encoding: str = "",
@ -224,7 +227,7 @@ class FreeTypeFont:
raise core.ex raise core.ex
if size <= 0: if size <= 0:
msg = "font size must be greater than 0" msg = f"font size must be greater than 0, not {size}"
raise ValueError(msg) raise ValueError(msg)
self.path = font self.path = font
@ -232,6 +235,21 @@ class FreeTypeFont:
self.index = index self.index = index
self.encoding = encoding self.encoding = encoding
try:
from packaging.version import parse as parse_version
except ImportError:
pass
else:
if freetype_version := features.version_module("freetype2"):
if parse_version(freetype_version) < parse_version("2.9.1"):
warnings.warn(
"Support for FreeType 2.9.0 is deprecated and will be removed "
"in Pillow 12 (2025-10-15). Please upgrade to FreeType 2.9.1 "
"or newer, preferably FreeType 2.10.4 which fixes "
"CVE-2020-15999.",
DeprecationWarning,
)
if layout_engine not in (Layout.BASIC, Layout.RAQM): if layout_engine not in (Layout.BASIC, Layout.RAQM):
layout_engine = Layout.BASIC layout_engine = Layout.BASIC
if core.HAVE_RAQM: if core.HAVE_RAQM:
@ -245,7 +263,7 @@ class FreeTypeFont:
self.layout_engine = layout_engine self.layout_engine = layout_engine
def load_from_bytes(f) -> None: def load_from_bytes(f: IO[bytes]) -> None:
self.font_bytes = f.read() self.font_bytes = f.read()
self.font = core.getfont( self.font = core.getfont(
"", size, index, encoding, self.font_bytes, layout_engine "", size, index, encoding, self.font_bytes, layout_engine
@ -267,7 +285,7 @@ class FreeTypeFont:
font, size, index, encoding, layout_engine=layout_engine font, size, index, encoding, layout_engine=layout_engine
) )
else: else:
load_from_bytes(font) load_from_bytes(cast(IO[bytes], font))
def __getstate__(self) -> list[Any]: def __getstate__(self) -> list[Any]:
return [self.path, self.size, self.index, self.encoding, self.layout_engine] return [self.path, self.size, self.index, self.encoding, self.layout_engine]
@ -769,7 +787,8 @@ class TransposedFont:
def load(filename: str) -> ImageFont: def load(filename: str) -> ImageFont:
""" """
Load a font file. This function loads a font object from the given Load a font file. This function loads a font object from the given
bitmap font file, and returns the corresponding font object. bitmap font file, and returns the corresponding font object. For loading TrueType
or OpenType fonts instead, see :py:func:`~PIL.ImageFont.truetype`.
:param filename: Name of font file. :param filename: Name of font file.
:return: A font object. :return: A font object.
@ -781,7 +800,7 @@ def load(filename: str) -> ImageFont:
def truetype( def truetype(
font: StrOrBytesPath | BinaryIO | None = None, font: StrOrBytesPath | BinaryIO,
size: float = 10, size: float = 10,
index: int = 0, index: int = 0,
encoding: str = "", encoding: str = "",
@ -789,9 +808,10 @@ def truetype(
) -> FreeTypeFont: ) -> FreeTypeFont:
""" """
Load a TrueType or OpenType font from a file or file-like object, Load a TrueType or OpenType font from a file or file-like object,
and create a font object. and create a font object. This function loads a font object from the given
This function loads a font object from the given file or file-like file or file-like object, and creates a font object for a font of the given
object, and creates a font object for a font of the given size. size. For loading bitmap fonts instead, see :py:func:`~PIL.ImageFont.load`
and :py:func:`~PIL.ImageFont.load_path`.
Pillow uses FreeType to open font files. On Windows, be aware that FreeType Pillow uses FreeType to open font files. On Windows, be aware that FreeType
will keep the file open as long as the FreeTypeFont object exists. Windows will keep the file open as long as the FreeTypeFont object exists. Windows
@ -852,7 +872,7 @@ def truetype(
:exception ValueError: If the font size is not greater than zero. :exception ValueError: If the font size is not greater than zero.
""" """
def freetype(font: StrOrBytesPath | BinaryIO | None) -> FreeTypeFont: def freetype(font: StrOrBytesPath | BinaryIO) -> FreeTypeFont:
return FreeTypeFont(font, size, index, encoding, layout_engine) return FreeTypeFont(font, size, index, encoding, layout_engine)
try: try:
@ -927,7 +947,10 @@ def load_path(filename: str | bytes) -> ImageFont:
return load(os.path.join(directory, filename)) return load(os.path.join(directory, filename))
except OSError: except OSError:
pass pass
msg = "cannot find font file" msg = f'cannot find font file "{filename}" in sys.path'
if os.path.exists(filename):
msg += f', did you mean ImageFont.load("{filename}") instead?'
raise OSError(msg) raise OSError(msg)

View File

@ -268,7 +268,7 @@ def lambda_eval(
args.update(options) args.update(options)
args.update(kw) args.update(kw)
for k, v in args.items(): for k, v in args.items():
if hasattr(v, "im"): if isinstance(v, Image.Image):
args[k] = _Operand(v) args[k] = _Operand(v)
out = expression(args) out = expression(args)
@ -319,7 +319,7 @@ def unsafe_eval(
args.update(options) args.update(options)
args.update(kw) args.update(kw)
for k, v in args.items(): for k, v in args.items():
if hasattr(v, "im"): if isinstance(v, Image.Image):
args[k] = _Operand(v) args[k] = _Operand(v)
compiled_code = compile(expression, "<string>", "eval") compiled_code = compile(expression, "<string>", "eval")

View File

@ -19,14 +19,23 @@ from __future__ import annotations
import sys import sys
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Callable from typing import TYPE_CHECKING, Any, Callable, Union
from . import Image from . import Image
from ._util import is_path from ._util import is_path
if TYPE_CHECKING: if TYPE_CHECKING:
import PyQt6
import PySide6
from . import ImageFile 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_version: str | None
qt_versions = [ qt_versions = [
["6", "PyQt6"], ["6", "PyQt6"],
@ -37,10 +46,6 @@ qt_versions = [
qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True) qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True)
for version, qt_module in qt_versions: for version, qt_module in qt_versions:
try: try:
QBuffer: type
QIODevice: type
QImage: type
QPixmap: type
qRgba: Callable[[int, int, int, int], int] qRgba: Callable[[int, int, int, int], int]
if qt_module == "PyQt6": if qt_module == "PyQt6":
from PyQt6.QtCore import QBuffer, QIODevice from PyQt6.QtCore import QBuffer, QIODevice
@ -65,19 +70,20 @@ def rgb(r: int, g: int, b: int, a: int = 255) -> int:
return qRgba(r, g, b, a) & 0xFFFFFFFF return qRgba(r, g, b, a) & 0xFFFFFFFF
def fromqimage(im): def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile:
""" """
:param im: QImage or PIL ImageQt object :param im: QImage or PIL ImageQt object
""" """
buffer = QBuffer() buffer = QBuffer()
qt_openmode: object
if qt_version == "6": if qt_version == "6":
try: try:
qt_openmode = QIODevice.OpenModeFlag qt_openmode = getattr(QIODevice, "OpenModeFlag")
except AttributeError: except AttributeError:
qt_openmode = QIODevice.OpenMode qt_openmode = getattr(QIODevice, "OpenMode")
else: else:
qt_openmode = QIODevice qt_openmode = QIODevice
buffer.open(qt_openmode.ReadWrite) buffer.open(getattr(qt_openmode, "ReadWrite"))
# preserve alpha channel with png # preserve alpha channel with png
# otherwise ppm is more friendly with Image.open # otherwise ppm is more friendly with Image.open
if im.hasAlphaChannel(): if im.hasAlphaChannel():
@ -93,7 +99,7 @@ def fromqimage(im):
return Image.open(b) return Image.open(b)
def fromqpixmap(im) -> ImageFile.ImageFile: def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile:
return fromqimage(im) return fromqimage(im)
@ -123,7 +129,7 @@ def align8to32(bytes: bytes, width: int, mode: str) -> bytes:
return b"".join(new_data) return b"".join(new_data)
def _toqclass_helper(im): def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]:
data = None data = None
colortable = None colortable = None
exclusive_fp = False exclusive_fp = False
@ -135,30 +141,32 @@ def _toqclass_helper(im):
if is_path(im): if is_path(im):
im = Image.open(im) im = Image.open(im)
exclusive_fp = True exclusive_fp = True
assert isinstance(im, Image.Image)
qt_format = QImage.Format if qt_version == "6" else QImage qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage
if im.mode == "1": if im.mode == "1":
format = qt_format.Format_Mono format = getattr(qt_format, "Format_Mono")
elif im.mode == "L": elif im.mode == "L":
format = qt_format.Format_Indexed8 format = getattr(qt_format, "Format_Indexed8")
colortable = [rgb(i, i, i) for i in range(256)] colortable = [rgb(i, i, i) for i in range(256)]
elif im.mode == "P": elif im.mode == "P":
format = qt_format.Format_Indexed8 format = getattr(qt_format, "Format_Indexed8")
palette = im.getpalette() palette = im.getpalette()
assert palette is not None
colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)] colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)]
elif im.mode == "RGB": elif im.mode == "RGB":
# Populate the 4th channel with 255 # Populate the 4th channel with 255
im = im.convert("RGBA") im = im.convert("RGBA")
data = im.tobytes("raw", "BGRA") data = im.tobytes("raw", "BGRA")
format = qt_format.Format_RGB32 format = getattr(qt_format, "Format_RGB32")
elif im.mode == "RGBA": elif im.mode == "RGBA":
data = im.tobytes("raw", "BGRA") data = im.tobytes("raw", "BGRA")
format = qt_format.Format_ARGB32 format = getattr(qt_format, "Format_ARGB32")
elif im.mode == "I;16": elif im.mode == "I;16":
im = im.point(lambda i: i * 256) im = im.point(lambda i: i * 256)
format = qt_format.Format_Grayscale16 format = getattr(qt_format, "Format_Grayscale16")
else: else:
if exclusive_fp: if exclusive_fp:
im.close() im.close()
@ -174,8 +182,8 @@ def _toqclass_helper(im):
if qt_is_installed: if qt_is_installed:
class ImageQt(QImage): class ImageQt(QImage): # type: ignore[misc]
def __init__(self, im) -> None: def __init__(self, im: Image.Image | str | QByteArray) -> None:
""" """
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
class. class.
@ -199,10 +207,10 @@ if qt_is_installed:
self.setColorTable(im_data["colortable"]) self.setColorTable(im_data["colortable"])
def toqimage(im) -> ImageQt: def toqimage(im: Image.Image | str | QByteArray) -> ImageQt:
return ImageQt(im) return ImageQt(im)
def toqpixmap(im): def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap:
qimage = toqimage(im) qimage = toqimage(im)
return QPixmap.fromImage(qimage) return getattr(QPixmap, "fromImage")(qimage)

View File

@ -127,10 +127,7 @@ class PhotoImage:
# palette mapped data # palette mapped data
image.apply_transparency() image.apply_transparency()
image.load() image.load()
try: mode = image.palette.mode if image.palette else "RGB"
mode = image.palette.mode
except AttributeError:
mode = "RGB" # default
size = image.size size = image.size
kw["width"], kw["height"] = size kw["width"], kw["height"] = size

View File

@ -98,14 +98,15 @@ class Dib:
HDC or HWND instance. In PythonWin, you can use HDC or HWND instance. In PythonWin, you can use
``CDC.GetHandleAttrib()`` to get a suitable handle. ``CDC.GetHandleAttrib()`` to get a suitable handle.
""" """
handle_int = int(handle)
if isinstance(handle, HWND): if isinstance(handle, HWND):
dc = self.image.getdc(handle) dc = self.image.getdc(handle_int)
try: try:
self.image.expose(dc) self.image.expose(dc)
finally: finally:
self.image.releasedc(handle, dc) self.image.releasedc(handle_int, dc)
else: else:
self.image.expose(handle) self.image.expose(handle_int)
def draw( def draw(
self, self,
@ -124,14 +125,15 @@ class Dib:
""" """
if src is None: if src is None:
src = (0, 0) + self.size src = (0, 0) + self.size
handle_int = int(handle)
if isinstance(handle, HWND): if isinstance(handle, HWND):
dc = self.image.getdc(handle) dc = self.image.getdc(handle_int)
try: try:
self.image.draw(dc, dst, src) self.image.draw(dc, dst, src)
finally: finally:
self.image.releasedc(handle, dc) self.image.releasedc(handle_int, dc)
else: else:
self.image.draw(handle, dst, src) self.image.draw(handle_int, dst, src)
def query_palette(self, handle: int | HDC | HWND) -> int: def query_palette(self, handle: int | HDC | HWND) -> int:
""" """
@ -148,14 +150,15 @@ class Dib:
:return: The number of entries that were changed (if one or more entries, :return: The number of entries that were changed (if one or more entries,
this indicates that the image should be redrawn). this indicates that the image should be redrawn).
""" """
handle_int = int(handle)
if isinstance(handle, HWND): if isinstance(handle, HWND):
handle = self.image.getdc(handle) handle = self.image.getdc(handle_int)
try: try:
result = self.image.query_palette(handle) result = self.image.query_palette(handle)
finally: finally:
self.image.releasedc(handle, handle) self.image.releasedc(handle, handle)
else: else:
result = self.image.query_palette(handle) result = self.image.query_palette(handle_int)
return result return result
def paste( def paste(

View File

@ -58,7 +58,7 @@ class ImtImageFile(ImageFile.ImageFile):
if s == b"\x0C": if s == b"\x0C":
# image data begins # image data begins
self.tile = [ self.tile = [
( ImageFile._Tile(
"raw", "raw",
(0, 0) + self.size, (0, 0) + self.size,
self.fp.tell() - len(buffer), self.fp.tell() - len(buffer),

View File

@ -147,7 +147,9 @@ class IptcImageFile(ImageFile.ImageFile):
# tile # tile
if tag == (8, 10): if tag == (8, 10):
self.tile = [("iptc", (0, 0) + self.size, offset, compression)] self.tile = [
ImageFile._Tile("iptc", (0, 0) + self.size, offset, compression)
]
def load(self) -> Image.core.PixelAccess | None: def load(self) -> Image.core.PixelAccess | None:
if len(self.tile) != 1 or self.tile[0][0] != "iptc": if len(self.tile) != 1 or self.tile[0][0] != "iptc":
@ -199,9 +201,13 @@ def getiptcinfo(
data = None data = None
info: dict[tuple[int, int], bytes | list[bytes]] = {}
if isinstance(im, IptcImageFile): if isinstance(im, IptcImageFile):
# return info dictionary right away # return info dictionary right away
return im.info for k, v in im.info.items():
if isinstance(k, tuple):
info[k] = v
return info
elif isinstance(im, JpegImagePlugin.JpegImageFile): elif isinstance(im, JpegImagePlugin.JpegImageFile):
# extract the IPTC/NAA resource # extract the IPTC/NAA resource
@ -214,7 +220,7 @@ def getiptcinfo(
# as 4-byte integers, so we cannot use the get method...) # as 4-byte integers, so we cannot use the get method...)
try: try:
data = im.tag_v2[TiffImagePlugin.IPTC_NAA_CHUNK] data = im.tag_v2[TiffImagePlugin.IPTC_NAA_CHUNK]
except (AttributeError, KeyError): except KeyError:
pass pass
if data is None: if data is None:
@ -237,4 +243,7 @@ def getiptcinfo(
except (IndexError, KeyError): except (IndexError, KeyError):
pass # expected failure pass # expected failure
return iptc_im.info for k, v in iptc_im.info.items():
if isinstance(k, tuple):
info[k] = v
return info

View File

@ -18,6 +18,7 @@ from __future__ import annotations
import io import io
import os import os
import struct import struct
from collections.abc import Callable
from typing import IO, cast from typing import IO, cast
from . import Image, ImageFile, ImagePalette, _binary from . import Image, ImageFile, ImagePalette, _binary
@ -205,7 +206,7 @@ def _parse_jp2_header(
if bitdepth > max_bitdepth: if bitdepth > max_bitdepth:
max_bitdepth = bitdepth max_bitdepth = bitdepth
if max_bitdepth <= 8: if max_bitdepth <= 8:
palette = ImagePalette.ImagePalette() palette = ImagePalette.ImagePalette("RGBA" if npc == 4 else "RGB")
for i in range(ne): for i in range(ne):
color: list[int] = [] color: list[int] = []
for value in header.read_fields(">" + ("B" * npc)): for value in header.read_fields(">" + ("B" * npc)):
@ -286,7 +287,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
length = -1 length = -1
self.tile = [ self.tile = [
( ImageFile._Tile(
"jpeg2k", "jpeg2k",
(0, 0) + self.size, (0, 0) + self.size,
0, 0,
@ -316,8 +317,13 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
else: else:
self.fp.seek(length - 2, os.SEEK_CUR) self.fp.seek(length - 2, os.SEEK_CUR)
@property @property # type: ignore[override]
def reduce(self): def reduce(
self,
) -> (
Callable[[int | tuple[int, int], tuple[int, int, int, int] | None], Image.Image]
| int
):
# https://github.com/python-pillow/Pillow/issues/4343 found that the # https://github.com/python-pillow/Pillow/issues/4343 found that the
# new Image 'reduce' method was shadowed by this plugin's 'reduce' # new Image 'reduce' method was shadowed by this plugin's 'reduce'
# property. This attempts to allow for both scenarios # property. This attempts to allow for both scenarios
@ -338,8 +344,9 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
# Update the reduce and layers settings # Update the reduce and layers settings
t = self.tile[0] t = self.tile[0]
assert isinstance(t[3], tuple)
t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4])
self.tile = [(t[0], (0, 0) + self.size, t[2], t3)] self.tile = [ImageFile._Tile(t[0], (0, 0) + self.size, t[2], t3)]
return ImageFile.ImageFile.load(self) return ImageFile.ImageFile.load(self)

View File

@ -372,7 +372,9 @@ class JpegImageFile(ImageFile.ImageFile):
rawmode = self.mode rawmode = self.mode
if self.mode == "CMYK": if self.mode == "CMYK":
rawmode = "CMYK;I" # assume adobe conventions rawmode = "CMYK;I" # assume adobe conventions
self.tile = [("jpeg", (0, 0) + self.size, 0, (rawmode, ""))] self.tile = [
ImageFile._Tile("jpeg", (0, 0) + self.size, 0, (rawmode, ""))
]
# self.__offset = self.fp.tell() # self.__offset = self.fp.tell()
break break
s = self.fp.read(1) s = self.fp.read(1)
@ -423,6 +425,7 @@ class JpegImageFile(ImageFile.ImageFile):
scale = 1 scale = 1
original_size = self.size original_size = self.size
assert isinstance(a, tuple)
if a[0] == "RGB" and mode in ["L", "YCbCr"]: if a[0] == "RGB" and mode in ["L", "YCbCr"]:
self._mode = mode self._mode = mode
a = mode, "" a = mode, ""
@ -432,6 +435,7 @@ class JpegImageFile(ImageFile.ImageFile):
for s in [8, 4, 2, 1]: for s in [8, 4, 2, 1]:
if scale >= s: if scale >= s:
break break
assert e is not None
e = ( e = (
e[0], e[0],
e[1], e[1],
@ -441,7 +445,7 @@ class JpegImageFile(ImageFile.ImageFile):
self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s) self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s)
scale = s scale = s
self.tile = [(d, e, o, a)] self.tile = [ImageFile._Tile(d, e, o, a)]
self.decoderconfig = (scale, 0) self.decoderconfig = (scale, 0)
box = (0, 0, original_size[0] / scale, original_size[1] / scale) box = (0, 0, original_size[0] / scale, original_size[1] / scale)
@ -747,17 +751,27 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
extra = info.get("extra", b"") extra = info.get("extra", b"")
MAX_BYTES_IN_MARKER = 65533 MAX_BYTES_IN_MARKER = 65533
xmp = info.get("xmp", im.info.get("xmp"))
if xmp:
overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00"
max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len
if len(xmp) > max_data_bytes_in_marker:
msg = "XMP data is too long"
raise ValueError(msg)
size = o16(2 + overhead_len + len(xmp))
extra += b"\xFF\xE1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp
icc_profile = info.get("icc_profile") icc_profile = info.get("icc_profile")
if icc_profile: if icc_profile:
ICC_OVERHEAD_LEN = 14 overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers))
MAX_DATA_BYTES_IN_MARKER = MAX_BYTES_IN_MARKER - ICC_OVERHEAD_LEN max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len
markers = [] markers = []
while icc_profile: while icc_profile:
markers.append(icc_profile[:MAX_DATA_BYTES_IN_MARKER]) markers.append(icc_profile[:max_data_bytes_in_marker])
icc_profile = icc_profile[MAX_DATA_BYTES_IN_MARKER:] icc_profile = icc_profile[max_data_bytes_in_marker:]
i = 1 i = 1
for marker in markers: for marker in markers:
size = o16(2 + ICC_OVERHEAD_LEN + len(marker)) size = o16(2 + overhead_len + len(marker))
extra += ( extra += (
b"\xFF\xE2" b"\xFF\xE2"
+ size + size
@ -844,7 +858,7 @@ def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
## ##
# Factory for making JPEG and MPO instances # Factory for making JPEG and MPO instances
def jpeg_factory( def jpeg_factory(
fp: IO[bytes] | None = None, filename: str | bytes | None = None fp: IO[bytes], filename: str | bytes | None = None
) -> JpegImageFile | MpoImageFile: ) -> JpegImageFile | MpoImageFile:
im = JpegImageFile(fp, filename) im = JpegImageFile(fp, filename)
try: try:

View File

@ -67,7 +67,9 @@ class McIdasImageFile(ImageFile.ImageFile):
offset = w[34] + w[15] offset = w[34] + w[15]
stride = w[15] + w[10] * w[11] * w[14] stride = w[15] + w[10] * w[11] * w[14]
self.tile = [("raw", (0, 0) + self.size, offset, (rawmode, stride, 1))] self.tile = [
ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1))
]
# -------------------------------------------------------------------- # --------------------------------------------------------------------

View File

@ -26,6 +26,7 @@ from typing import IO, Any, cast
from . import ( from . import (
Image, Image,
ImageFile,
ImageSequence, ImageSequence,
JpegImagePlugin, JpegImagePlugin,
TiffImagePlugin, TiffImagePlugin,
@ -145,7 +146,9 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
if self.info.get("exif") != original_exif: if self.info.get("exif") != original_exif:
self._reload_exif() self._reload_exif()
self.tile = [("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])] self.tile = [
ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])
]
self.__frame = frame self.__frame = frame
def tell(self) -> int: def tell(self) -> int:

View File

@ -70,9 +70,9 @@ class MspImageFile(ImageFile.ImageFile):
self._size = i16(s, 4), i16(s, 6) self._size = i16(s, 4), i16(s, 6)
if s[:4] == b"DanM": if s[:4] == b"DanM":
self.tile = [("raw", (0, 0) + self.size, 32, ("1", 0, 1))] self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, ("1", 0, 1))]
else: else:
self.tile = [("MSP", (0, 0) + self.size, 32, None)] self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32, None)]
class MspDecoder(ImageFile.PyDecoder): class MspDecoder(ImageFile.PyDecoder):
@ -112,7 +112,7 @@ class MspDecoder(ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer: bytes) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None assert self.fd is not None
img = io.BytesIO() img = io.BytesIO()
@ -152,7 +152,7 @@ class MspDecoder(ImageFile.PyDecoder):
msg = f"Corrupted MSP file in row {x}" msg = f"Corrupted MSP file in row {x}"
raise OSError(msg) from e raise OSError(msg) from e
self.set_as_raw(img.getvalue(), ("1", 0, 1)) self.set_as_raw(img.getvalue(), "1")
return -1, 0 return -1, 0

View File

@ -17,7 +17,7 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from typing import TYPE_CHECKING from typing import IO, TYPE_CHECKING
from . import EpsImagePlugin from . import EpsImagePlugin
@ -28,15 +28,12 @@ from . import EpsImagePlugin
class PSDraw: class PSDraw:
""" """
Sets up printing to the given file. If ``fp`` is omitted, Sets up printing to the given file. If ``fp`` is omitted,
``sys.stdout.buffer`` or ``sys.stdout`` is assumed. ``sys.stdout.buffer`` is assumed.
""" """
def __init__(self, fp=None): def __init__(self, fp: IO[bytes] | None = None) -> None:
if not fp: if not fp:
try:
fp = sys.stdout.buffer fp = sys.stdout.buffer
except AttributeError:
fp = sys.stdout
self.fp = fp self.fp = fp
def begin_document(self, id: str | None = None) -> None: def begin_document(self, id: str | None = None) -> None:

View File

@ -173,6 +173,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
flags = 0 flags = 0
if im.mode == "P" and "custom-colormap" in im.info: if im.mode == "P" and "custom-colormap" in im.info:
assert im.palette is not None
flags = flags & _FLAGS["custom-colormap"] flags = flags & _FLAGS["custom-colormap"]
colormapsize = 4 * 256 + 2 colormapsize = 4 * 256 + 2
colormapmode = im.palette.mode colormapmode = im.palette.mode

Some files were not shown because too many files have changed in this diff Show More