diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index 781ad4a4b..000000000 --- a/.appveyor.yml +++ /dev/null @@ -1,99 +0,0 @@ -skip_commits: - files: - - ".github/**/*" - - ".gitmodules" - - "docs/**/*" - - "wheels/**/*" - -version: '{build}' -clone_folder: c:\pillow -init: -- ECHO %PYTHON% -#- ps: iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) -# Uncomment previous line to get RDP access during the build. - -environment: - COVERAGE_CORE: sysmon - EXECUTABLE: python.exe - TEST_OPTIONS: - DEPLOY: YES - matrix: - - PYTHON: C:/Python313 - ARCHITECTURE: x86 - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 - - PYTHON: C:/Python39-x64 - ARCHITECTURE: AMD64 - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 - - -install: -- '%PYTHON%\%EXECUTABLE% --version' -- '%PYTHON%\%EXECUTABLE% -m pip install --upgrade pip' -- curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip -- 7z x pillow-test-images.zip -oc:\ -- xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images -- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip -- 7z x nasm-win64.zip -oc:\ -- choco install ghostscript --version=10.4.0 -- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.04.0\bin;%PATH% -- cd c:\pillow\winbuild\ -- ps: | - c:\python39\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ - c:\pillow\winbuild\build\build_dep_all.cmd - $host.SetShouldExit(0) -- path C:\pillow\winbuild\build\bin;%PATH% - -build_script: -- cd c:\pillow -- winbuild\build\build_env.cmd -- '%PYTHON%\%EXECUTABLE% -m pip install -v -C raqm=vendor -C fribidi=vendor .' -- '%PYTHON%\%EXECUTABLE% selftest.py --installed' - -test_script: -- cd c:\pillow -- '%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% -- path %PYTHON%;%PATH% -- .ci\test.cmd - -after_test: -- curl -Os https://uploader.codecov.io/latest/windows/codecov.exe -- .\codecov.exe --file coverage.xml --name %PYTHON% --flags AppVeyor - -matrix: - fast_finish: true - -cache: -- '%LOCALAPPDATA%\pip\Cache' - -artifacts: -- path: pillow\*.egg - name: egg -- path: pillow\*.whl - name: wheel - -before_deploy: - - cd c:\pillow - - '%PYTHON%\%EXECUTABLE% -m pip wheel -v -C raqm=vendor -C fribidi=vendor .' - - ps: Get-ChildItem .\*.whl | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } - -deploy: - provider: S3 - region: us-west-2 - access_key_id: AKIAIRAXC62ZNTVQJMOQ - secret_access_key: - secure: Hwb6klTqtBeMgxAjRoDltiiqpuH8xbwD4UooDzBSiCWXjuFj1lyl4kHgHwTCCGqi - bucket: pillow-nightly - folder: win/$(APPVEYOR_BUILD_NUMBER)/ - artifact: /.*egg|wheel/ - on: - APPVEYOR_REPO_NAME: python-pillow/Pillow - branch: main - deploy: YES - - -# Uncomment the following lines to get RDP access after the build/test and block for -# up to the timeout limit (~1hr) -# -#on_finish: -#- ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) diff --git a/.ci/after_success.sh b/.ci/after_success.sh index c71546f00..6da27b975 100755 --- a/.ci/after_success.sh +++ b/.ci/after_success.sh @@ -2,8 +2,4 @@ # gather the coverage data python3 -m pip install coverage -if [[ $MATRIX_DOCKER ]]; then - python3 -m coverage xml --ignore-errors -else - python3 -m coverage xml -fi +python3 -m coverage xml diff --git a/.ci/build.sh b/.ci/build.sh index e678f68ec..ae10cb671 100755 --- a/.ci/build.sh +++ b/.ci/build.sh @@ -3,8 +3,5 @@ set -e python3 -m coverage erase -if [ $(uname) == "Darwin" ]; then - export CPPFLAGS="-I/usr/local/miniconda/include"; -fi make clean make install-coverage diff --git a/.ci/install.sh b/.ci/install.sh index e85e6bdc5..62677005e 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -2,12 +2,12 @@ aptget_update() { - if [ ! -z $1 ]; then + if [ -n "$1" ]; then echo "" echo "Retrying apt-get update..." echo "" fi - output=`sudo apt-get update 2>&1` + output=$(sudo apt-get update 2>&1) echo "$output" if [[ $output == *[WE]:\ * ]]; then return 1 @@ -20,8 +20,8 @@ fi set -e if [[ $(uname) != CYGWIN* ]]; then - sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ - ghostscript libjpeg-turbo-progs libopenjp2-7-dev\ + sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\ + ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\ cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ sway wl-clipboard libopenblas-dev fi diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index c4511439c..db5f89c9a 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.21.3 +cibuildwheel==2.23.2 diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index c84a3533b..2e3610478 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.13.0 +mypy==1.15.0 IceSpringPySideStubs-PyQt6 IceSpringPySideStubs-PySide6 ipython diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d03fcf0d9..c098e32eb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -9,7 +9,7 @@ Please send a pull request to the `main` branch. Please include [documentation]( - Fork the Pillow repository. - Create a branch from `main`. - Develop bug fixes, features, tests, etc. -- Run the test suite. You can enable GitHub Actions (https://github.com/MY-USERNAME/Pillow/actions) and [AppVeyor](https://ci.appveyor.com/projects/new) on your repo to catch test failures prior to the pull request, and [Codecov](https://codecov.io/gh) to see if the changed code is covered by tests. +- Run the test suite. You can enable GitHub Actions (https://github.com/MY-USERNAME/Pillow/actions) on your repo to catch test failures prior to the pull request, and [Codecov](https://codecov.io/gh) to see if the changed code is covered by tests. - Create a pull request to pull the changes from your branch to the Pillow `main`. ### Guidelines @@ -17,9 +17,8 @@ Please send a pull request to the `main` branch. Please include [documentation]( - Separate code commits from reformatting commits. - Provide tests for any newly added code. - Follow PEP 8. -- When committing only documentation changes please include `[ci skip]` in the commit message to avoid running tests on AppVeyor. +- When committing only documentation changes please include `[ci skip]` in the commit message to avoid running extra tests. - Include [release notes](https://github.com/python-pillow/Pillow/tree/main/docs/releasenotes) as needed or appropriate with your bug fixes, feature additions and tests. -- Do not add to the [changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) for proposed changes, as that is updated after changes are merged. ## Reporting Issues diff --git a/.github/mergify.yml b/.github/mergify.yml index 3c2066137..9bb089615 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -9,7 +9,6 @@ pull_request_rules: - status-success=Windows Test Successful - status-success=MinGW - status-success=Cygwin Test Successful - - status-success=continuous-integration/appveyor/pr actions: merge: method: merge diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 3711d91f0..de0ab4805 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -3,18 +3,19 @@ tag-template: "$NEXT_MINOR_VERSION" change-template: '- $TITLE #$NUMBER [@$AUTHOR]' categories: - - title: "Dependencies" - label: "Dependency" + - title: "Removals" + label: "Removal" - title: "Deprecations" label: "Deprecation" - title: "Documentation" label: "Documentation" - - title: "Removals" - label: "Removal" + - title: "Dependencies" + label: "Dependency" - title: "Testing" label: "Testing" - title: "Type hints" label: "Type hints" + - title: "Other changes" exclude-labels: - "changelog: skip" @@ -23,6 +24,4 @@ template: | https://pillow.readthedocs.io/en/stable/releasenotes/$NEXT_MINOR_VERSION.html - ## Changes - $CHANGES diff --git a/.github/renovate.json b/.github/renovate.json index f48b670ec..91fa0426f 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -16,6 +16,6 @@ } ], "schedule": [ - "on the 3rd day of the month" + "* * 3 * *" ] } diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index ddb421230..6aa59a4ac 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -8,17 +8,13 @@ fi brew install \ freetype \ ghostscript \ + jpeg-turbo \ libimagequant \ - libjpeg \ + libraqm \ libtiff \ little-cms2 \ openjpeg \ webp -if [[ "$ImageOS" == "macos13" ]]; then - brew install --ignore-dependencies libraqm -else - brew install libraqm -fi export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" python3 -m pip install coverage diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 656054e89..abfeaa77f 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -52,7 +52,7 @@ jobs: persist-credentials: false - name: Install Cygwin - uses: cygwin/cygwin-install-action@v4 + uses: cygwin/cygwin-install-action@v5 with: packages: > gcc-g++ @@ -133,11 +133,12 @@ jobs: - name: After success run: | bash.exe .ci/after_success.sh + rm C:\cygwin\bin\bash.EXE - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - file: ./coverage.xml + files: ./coverage.xml flags: GHA_Cygwin name: Cygwin Python 3.${{ matrix.python-minor-version }} token: ${{ secrets.CODECOV_ORG_TOKEN }} diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 03608319a..25aef55fb 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -29,13 +29,13 @@ concurrency: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: ["ubuntu-latest"] docker: [ # Run slower jobs first to give them a headstart and reduce waiting time - ubuntu-22.04-jammy-arm64v8, ubuntu-24.04-noble-ppc64le, ubuntu-24.04-noble-s390x, # Then run the remainder @@ -44,6 +44,7 @@ jobs: amazon-2023-amd64, arch, centos-stream-9-amd64, + centos-stream-10-amd64, debian-12-bookworm-x86, debian-12-bookworm-amd64, fedora-40-amd64, @@ -54,12 +55,13 @@ jobs: ] dockerTag: [main] include: - - docker: "ubuntu-22.04-jammy-arm64v8" - qemu-arch: "aarch64" - docker: "ubuntu-24.04-noble-ppc64le" qemu-arch: "ppc64le" - docker: "ubuntu-24.04-noble-s390x" qemu-arch: "s390x" + - docker: "ubuntu-24.04-noble-arm64v8" + os: "ubuntu-24.04-arm" + dockerTag: main name: ${{ matrix.docker }} @@ -73,8 +75,9 @@ jobs: - name: Set up QEMU if: "matrix.qemu-arch" - run: | - docker run --rm --privileged aptman/qus -s -- -p ${{ matrix.qemu-arch }} + uses: docker/setup-qemu-action@v3 + with: + platforms: ${{ matrix.qemu-arch }} - name: Docker pull run: | @@ -89,18 +92,18 @@ jobs: - name: After success run: | - PATH="$PATH:~/.local/bin" docker start pillow_container + sudo docker cp pillow_container:/Pillow /Pillow + sudo chown -R runner /Pillow pil_path=`docker exec pillow_container /vpy3/bin/python -c 'import os, PIL;print(os.path.realpath(os.path.dirname(PIL.__file__)))'` docker stop pillow_container sudo mkdir -p $pil_path sudo cp src/PIL/*.py $pil_path + cd /Pillow .ci/after_success.sh - env: - MATRIX_DOCKER: ${{ matrix.docker }} - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: flags: GHA_Docker name: ${{ matrix.docker }} diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index bfd393db5..bb6d7dc37 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -66,18 +66,18 @@ jobs: mingw-w64-x86_64-libtiff \ mingw-w64-x86_64-libwebp \ mingw-w64-x86_64-openjpeg2 \ - mingw-w64-x86_64-python3-numpy \ - mingw-w64-x86_64-python3-olefile \ - mingw-w64-x86_64-python3-setuptools \ + mingw-w64-x86_64-python-numpy \ + mingw-w64-x86_64-python-olefile \ + mingw-w64-x86_64-python-pip \ + mingw-w64-x86_64-python-pytest \ + mingw-w64-x86_64-python-pytest-cov \ + mingw-w64-x86_64-python-pytest-timeout \ mingw-w64-x86_64-python-pyqt6 - python3 -m ensurepip - python3 -m pip install pyroma pytest pytest-cov pytest-timeout - pushd depends && ./install_extra_test_images.sh && popd - name: Build Pillow - run: SETUPTOOLS_USE_DISTUTILS="stdlib" CFLAGS="-coverage" python3 -m pip install . + run: CFLAGS="-coverage" python3 -m pip install . - name: Test Pillow run: | @@ -85,9 +85,9 @@ jobs: .ci/test.sh - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - file: ./coverage.xml + files: ./coverage.xml flags: GHA_Windows name: "MSYS2 MinGW" token: ${{ secrets.CODECOV_ORG_TOKEN }} diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index c1ba52719..a780c7835 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -31,15 +31,20 @@ env: jobs: build: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["pypy3.10", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["pypy3.11", "pypy3.10", "3.10", "3.11", "3.12", "3.13", "3.14"] + architecture: ["x64"] + os: ["windows-latest"] + include: + # Test the oldest Python on 32-bit + - { python-version: "3.9", architecture: "x86", os: "windows-2019" } timeout-minutes: 30 - name: Python ${{ matrix.python-version }} + name: Python ${{ matrix.python-version }} (${{ matrix.architecture }}) steps: - name: Checkout Pillow @@ -67,28 +72,21 @@ jobs: with: python-version: ${{ matrix.python-version }} allow-prereleases: true + architecture: ${{ matrix.architecture }} cache: pip cache-dependency-path: ".github/workflows/test-windows.yml" - name: Print build system information run: python3 .github/workflows/system-info.py - - name: Install Python dependencies - run: > - python3 -m pip install - coverage>=7.4.2 - defusedxml - olefile - pyroma - pytest - pytest-cov - pytest-timeout + - name: Upgrade pip + run: | + python3 -m pip install --upgrade pip - name: Install CPython dependencies - if: "!contains(matrix.python-version, 'pypy')" - run: > - python3 -m pip install - PyQt6 + if: "!contains(matrix.python-version, 'pypy') && matrix.architecture != 'x86'" + run: | + python3 -m pip install PyQt6 - name: Install dependencies id: install @@ -96,8 +94,8 @@ jobs: choco install nasm --no-progress echo "C:\Program Files\NASM" >> $env:GITHUB_PATH - choco install ghostscript --version=10.4.0 --no-progress - echo "C:\Program Files\gs\gs10.04.0\bin" >> $env:GITHUB_PATH + choco install ghostscript --version=10.5.0 --no-progress + echo "C:\Program Files\gs\gs10.05.0\bin" >> $env:GITHUB_PATH # Install extra test images xcopy /S /Y Tests\test-images\* Tests\images @@ -188,7 +186,7 @@ jobs: - name: Build Pillow run: | $FLAGS="-C raqm=vendor -C fribidi=vendor" - cmd /c "winbuild\build\build_env.cmd && $env:pythonLocation\python.exe -m pip install -v $FLAGS ." + cmd /c "winbuild\build\build_env.cmd && $env:pythonLocation\python.exe -m pip install -v $FLAGS .[tests]" & $env:pythonLocation\python.exe selftest.py --installed shell: pwsh @@ -223,9 +221,9 @@ jobs: shell: pwsh - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - file: ./coverage.xml + files: ./coverage.xml flags: GHA_Windows name: ${{ runner.os }} Python ${{ matrix.python-version }} token: ${{ secrets.CODECOV_ORG_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 87acd7ddb..006d574f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,10 @@ jobs: "ubuntu-latest", ] python-version: [ + "pypy3.11", "pypy3.10", + "3.14", + "3.13t", "3.13", "3.12", "3.11", @@ -52,14 +55,14 @@ jobs: - { python-version: "3.11", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } - { python-version: "3.10", PYTHONOPTIMIZE: 2 } # Free-threaded - - { os: "ubuntu-latest", python-version: "3.13-dev", disable-gil: true } + - { python-version: "3.13t", disable-gil: true } # M1 only available for 3.10+ - { os: "macos-13", python-version: "3.9" } exclude: - { os: "macos-latest", python-version: "3.9" } runs-on: ${{ matrix.os }} - name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }} + name: ${{ matrix.os }} Python ${{ matrix.python-version }} steps: - uses: actions/checkout@v4 @@ -68,7 +71,6 @@ jobs: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 - if: "${{ !matrix.disable-gil }}" with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -77,13 +79,6 @@ jobs: ".ci/*.sh" "pyproject.toml" - - name: Set up Python ${{ matrix.python-version }} (free-threaded) - uses: deadsnakes/action@v3.2.0 - if: "${{ matrix.disable-gil }}" - with: - python-version: ${{ matrix.python-version }} - nogil: ${{ matrix.disable-gil }} - - name: Set PYTHON_GIL if: "${{ matrix.disable-gil }}" run: | @@ -116,7 +111,7 @@ jobs: GHA_PYTHON_VERSION: ${{ matrix.python-version }} - name: Register gcc problem matcher - if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'" + if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'" run: echo "::add-matcher::.github/problem-matchers/gcc.json" - name: Build @@ -156,7 +151,7 @@ jobs: .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 3a80a7e74..461729a74 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -1,11 +1,33 @@ #!/bin/bash -# Define custom utilities -# Test for macOS with [ -n "$IS_MACOS" ] -if [ -z "$IS_MACOS" ]; then - export MB_ML_LIBC=${AUDITWHEEL_POLICY::9} - export MB_ML_VER=${AUDITWHEEL_POLICY:9} + +# Setup that needs to be done before multibuild utils are invoked +PROJECTDIR=$(pwd) +if [[ "$(uname -s)" == "Darwin" ]]; then + # Safety check - macOS builds require that CIBW_ARCHS is set, and that it + # only contains a single value (even though cibuildwheel allows multiple + # values in CIBW_ARCHS). + if [[ -z "$CIBW_ARCHS" ]]; then + echo "ERROR: Pillow macOS builds require CIBW_ARCHS be defined." + exit 1 + fi + if [[ "$CIBW_ARCHS" == *" "* ]]; then + echo "ERROR: Pillow macOS builds only support a single architecture in CIBW_ARCHS." + exit 1 + fi + + # Build macOS dependencies in `build/darwin` + # Install them into `build/deps/darwin` + WORKDIR=$(pwd)/build/darwin + BUILD_PREFIX=$(pwd)/build/deps/darwin +else + # Build prefix will default to /usr/local + WORKDIR=$(pwd)/build + MB_ML_LIBC=${AUDITWHEEL_POLICY::9} + MB_ML_VER=${AUDITWHEEL_POLICY:9} fi -export PLAT=$CIBW_ARCHS +PLAT=$CIBW_ARCHS + +# Define custom utilities source wheels/multibuild/common_utils.sh source wheels/multibuild/library_builders.sh if [ -z "$IS_MACOS" ]; then @@ -15,90 +37,115 @@ fi ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds -FREETYPE_VERSION=2.13.2 -HARFBUZZ_VERSION=10.0.1 -LIBPNG_VERSION=1.6.44 -JPEGTURBO_VERSION=3.0.4 -OPENJPEG_VERSION=2.5.2 -XZ_VERSION=5.6.3 -TIFF_VERSION=4.6.0 -LCMS2_VERSION=2.16 -if [[ -n "$IS_MACOS" ]]; then - GIFLIB_VERSION=5.2.2 +FREETYPE_VERSION=2.13.3 +HARFBUZZ_VERSION=11.0.0 +LIBPNG_VERSION=1.6.47 +JPEGTURBO_VERSION=3.1.0 +OPENJPEG_VERSION=2.5.3 +if [[ $MB_ML_VER == 2014 ]]; then + XZ_VERSION=5.6.4 else - GIFLIB_VERSION=5.2.1 + XZ_VERSION=5.8.0 fi -if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then - ZLIB_VERSION=1.3.1 -else - ZLIB_VERSION=1.2.8 -fi -LIBWEBP_VERSION=1.4.0 +TIFF_VERSION=4.7.0 +LCMS2_VERSION=2.17 +ZLIB_NG_VERSION=2.2.4 +LIBWEBP_VERSION=1.5.0 BZIP2_VERSION=1.0.8 LIBXCB_VERSION=1.17.0 BROTLI_VERSION=1.1.0 +function build_pkg_config { + if [ -e pkg-config-stamp ]; then return; fi + # This essentially duplicates the Homebrew recipe + CFLAGS="$CFLAGS -Wno-int-conversion" build_simple pkg-config 0.29.2 https://pkg-config.freedesktop.org/releases tar.gz \ + --disable-debug --disable-host-tool --with-internal-glib \ + --with-pc-path=$BUILD_PREFIX/share/pkgconfig:$BUILD_PREFIX/lib/pkgconfig \ + --with-system-include-path=$(xcrun --show-sdk-path --sdk macosx)/usr/include + export PKG_CONFIG=$BUILD_PREFIX/bin/pkg-config + touch pkg-config-stamp +} + +function build_zlib_ng { + if [ -e zlib-stamp ]; then return; fi + fetch_unpack https://github.com/zlib-ng/zlib-ng/archive/$ZLIB_NG_VERSION.tar.gz zlib-ng-$ZLIB_NG_VERSION.tar.gz + (cd zlib-ng-$ZLIB_NG_VERSION \ + && ./configure --prefix=$BUILD_PREFIX --zlib-compat \ + && make -j4 \ + && make install) + + if [ -n "$IS_MACOS" ]; then + # Ensure that on macOS, the library name is an absolute path, not an + # @rpath, so that delocate picks up the right library (and doesn't need + # DYLD_LIBRARY_PATH to be set). The default Makefile doesn't have an + # option to control the install_name. + install_name_tool -id $BUILD_PREFIX/lib/libz.1.dylib $BUILD_PREFIX/lib/libz.1.dylib + fi + touch zlib-stamp +} + function build_brotli { - local cmake=$(get_modern_cmake) + if [ -e brotli-stamp ]; then return; fi local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-$BROTLI_VERSION.tar.gz) (cd $out_dir \ - && $cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ + && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ && make install) - if [[ "$MB_ML_LIBC" == "manylinux" ]]; then - cp /usr/local/lib64/libbrotli* /usr/local/lib - cp /usr/local/lib64/pkgconfig/libbrotli* /usr/local/lib/pkgconfig - fi + touch brotli-stamp } function build_harfbuzz { + if [ -e harfbuzz-stamp ]; then return; fi 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) + local out_dir=$(fetch_unpack https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION/harfbuzz-$HARFBUZZ_VERSION.tar.xz harfbuzz-$HARFBUZZ_VERSION.tar.xz) (cd $out_dir \ - && meson setup build --buildtype=release -Dfreetype=enabled -Dglib=disabled) + && meson setup build --prefix=$BUILD_PREFIX --libdir=$BUILD_PREFIX/lib --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 + touch harfbuzz-stamp } function build { - if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then - sudo chown -R runner /usr/local - fi build_xz - if [ -z "$IS_ALPINE" ] && [ -z "$IS_MACOS" ]; then + if [ -z "$IS_ALPINE" ] && [ -z "$SANITIZER" ] && [ -z "$IS_MACOS" ]; then yum remove -y zlib-devel fi - build_new_zlib + build_zlib_ng build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto if [ -n "$IS_MACOS" ]; then build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto - build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib + build_simple libXau 1.0.12 https://www.x.org/pub/individual/lib build_simple libpthread-stubs 0.5 https://xcb.freedesktop.org/dist - if [[ "$CIBW_ARCHS" == "arm64" ]]; then - cp /usr/local/share/pkgconfig/xcb-proto.pc /usr/local/lib/pkgconfig - fi else - sed s/\${pc_sysrootdir\}// /usr/local/share/pkgconfig/xcb-proto.pc > /usr/local/lib/pkgconfig/xcb-proto.pc + sed s/\${pc_sysrootdir\}// $BUILD_PREFIX/share/pkgconfig/xcb-proto.pc > $BUILD_PREFIX/lib/pkgconfig/xcb-proto.pc fi build_simple libxcb $LIBXCB_VERSION https://www.x.org/releases/individual/lib build_libjpeg_turbo - build_tiff + if [ -n "$IS_MACOS" ]; then + # Custom tiff build to include jpeg; by default, configure won't include + # headers/libs in the custom macOS prefix. Explicitly disable webp, + # libdeflate and zstd, because on x86_64 macs, it will pick up the + # Homebrew versions of those libraries from /usr/local. + build_simple tiff $TIFF_VERSION https://download.osgeo.org/libtiff tar.gz \ + --with-jpeg-include-dir=$BUILD_PREFIX/include --with-jpeg-lib-dir=$BUILD_PREFIX/lib \ + --disable-webp --disable-libdeflate --disable-zstd + else + build_tiff + fi + build_libpng build_lcms2 build_openjpeg - ORIGINAL_CFLAGS=$CFLAGS - CFLAGS="$CFLAGS -O3 -DNDEBUG" + webp_cflags="-O3 -DNDEBUG" if [[ -n "$IS_MACOS" ]]; then - CFLAGS="$CFLAGS -Wl,-headerpad_max_install_names" + webp_cflags="$webp_cflags -Wl,-headerpad_max_install_names" fi - build_libwebp - CFLAGS=$ORIGINAL_CFLAGS + CFLAGS="$CFLAGS $webp_cflags" build_simple libwebp $LIBWEBP_VERSION \ + https://storage.googleapis.com/downloads.webmproject.org/releases/webp tar.gz \ + --enable-libwebpmux --enable-libwebpdemux build_brotli @@ -112,32 +159,47 @@ function build { build_harfbuzz } +# Perform all dependency builds in the build subfolder. +mkdir -p $WORKDIR +pushd $WORKDIR > /dev/null + # Any stuff that you need to do before you start building the wheels # Runs in the root directory of this repository. -curl -fsSL -o pillow-depends-main.zip https://github.com/python-pillow/pillow-depends/archive/main.zip -untar pillow-depends-main.zip +if [[ ! -d $WORKDIR/pillow-depends-main ]]; then + if [[ ! -f $PROJECTDIR/pillow-depends-main.zip ]]; then + echo "Download pillow dependency sources..." + curl -fSL -o $PROJECTDIR/pillow-depends-main.zip https://github.com/python-pillow/pillow-depends/archive/main.zip + fi + echo "Unpacking pillow dependency sources..." + untar $PROJECTDIR/pillow-depends-main.zip +fi if [[ -n "$IS_MACOS" ]]; then - # libdeflate may cause a minimum target error when repairing the wheel - # libtiff and libxcb cause a conflict with building libtiff and libxcb - # libxau and libxdmcp cause an issue on macOS < 11 - # remove cairo to fix building harfbuzz on arm64 - # remove lcms2 and libpng to fix building openjpeg on arm64 - # remove jpeg-turbo to avoid inclusion on arm64 - # remove webp and zstd to avoid inclusion on x86_64 - # curl from brew requires zstd, use system curl - brew remove --ignore-dependencies libpng libtiff libxcb libxau libxdmcp curl cairo lcms2 zstd - if [[ "$CIBW_ARCHS" == "arm64" ]]; then - brew remove --ignore-dependencies jpeg-turbo - else - brew remove --ignore-dependencies libdeflate webp - fi + # Homebrew (or similar packaging environments) install can contain some of + # the libraries that we're going to build. However, they may be compiled + # with a MACOSX_DEPLOYMENT_TARGET that doesn't match what we want to use, + # and they may bring in other dependencies that we don't want. The same will + # be true of any other locations on the path. To avoid conflicts, strip the + # path down to the bare minimum (which, on macOS, won't include any + # development dependencies). + export PATH="$BUILD_PREFIX/bin:$(dirname $(which python3)):/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin" + export CMAKE_PREFIX_PATH=$BUILD_PREFIX - brew install pkg-config + # Ensure the basic structure of the build prefix directory exists. + mkdir -p "$BUILD_PREFIX/bin" + mkdir -p "$BUILD_PREFIX/lib" + + # Ensure pkg-config is available + build_pkg_config + # Ensure cmake is available + python3 -m pip install cmake fi wrap_wheel_builder build +# Return to the project root to finish the build +popd > /dev/null + # Append licenses for filename in wheels/dependency_licenses/*; do echo -e "\n\n----\n\n$(basename $filename | cut -f 1 -d '.')\n" | cat >> LICENSE diff --git a/.github/workflows/wheels-test.ps1 b/.github/workflows/wheels-test.ps1 index f593c7228..a1edc14ef 100644 --- a/.github/workflows/wheels-test.ps1 +++ b/.github/workflows/wheels-test.ps1 @@ -11,6 +11,9 @@ if ("$venv" -like "*\cibw-run-*\pp*-win_amd64\*") { $env:path += ";$pillow\winbuild\build\bin\" & "$venv\Scripts\activate.ps1" & reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\python.exe" /v "GlobalFlag" /t REG_SZ /d "0x02000000" /f +if ("$venv" -like "*\cibw-run-*-win_amd64\*") { + & python -m pip install numpy +} cd $pillow & python -VV if (!$?) { exit $LASTEXITCODE } diff --git a/.github/workflows/wheels-test.sh b/.github/workflows/wheels-test.sh index b30b1725f..ce83a4278 100755 --- a/.github/workflows/wheels-test.sh +++ b/.github/workflows/wheels-test.sh @@ -1,12 +1,24 @@ #!/bin/bash set -e +# Ensure fribidi is installed by the system. if [[ "$OSTYPE" == "darwin"* ]]; then - brew install fribidi - export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" - if [ -f /opt/homebrew/lib/libfribidi.dylib ]; then - sudo cp /opt/homebrew/lib/libfribidi.dylib /usr/local/lib + # If Homebrew is on the path during the build, it may leak into the wheels. + # However, we *do* need Homebrew to provide a copy of fribidi for + # testing purposes so that we can verify the fribidi shim works as expected. + if [[ "$(uname -m)" == "x86_64" ]]; then + HOMEBREW_PREFIX=/usr/local + else + HOMEBREW_PREFIX=/opt/homebrew fi + $HOMEBREW_PREFIX/bin/brew install fribidi + + # Add the lib folder for fribidi so that the vendored library can be found. + # Don't use $HOMEWBREW_PREFIX/lib directly - use the lib folder where the + # installed copy of fribidi is cellared. This ensures we don't pick up the + # Homebrew version of any other library that we're dependent on (most notably, + # freetype). + export DYLD_LIBRARY_PATH=$(dirname $(realpath $HOMEBREW_PREFIX/lib/libfribidi.dylib)) elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then apk add curl fribidi else diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 45f186341..1fe6badae 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -13,6 +13,7 @@ on: paths: - ".ci/requirements-cibw.txt" - ".github/workflows/wheel*" + - "pyproject.toml" - "setup.py" - "wheels/*" - "winbuild/build_prepare.py" @@ -23,6 +24,7 @@ on: paths: - ".ci/requirements-cibw.txt" - ".github/workflows/wheel*" + - "pyproject.toml" - "setup.py" - "wheels/*" - "winbuild/build_prepare.py" @@ -40,62 +42,7 @@ env: FORCE_COLOR: 1 jobs: - build-1-QEMU-emulated-wheels: - if: github.event_name != 'schedule' - name: aarch64 ${{ matrix.python-version }} ${{ matrix.spec }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: - - pp310 - - cp3{9,10,11} - - cp3{12,13} - spec: - - manylinux2014 - - manylinux_2_28 - - musllinux - exclude: - - { python-version: pp310, spec: musllinux } - - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - submodules: true - - - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - # https://github.com/docker/setup-qemu-action - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Install cibuildwheel - run: | - python3 -m pip install -r .ci/requirements-cibw.txt - - - name: Build wheels - run: | - python3 -m cibuildwheel --output-dir wheelhouse - env: - # Build only the currently selected Linux architecture (so we can - # parallelise for speed). - CIBW_ARCHS: "aarch64" - # Likewise, select only one Python version per job to speed this up. - CIBW_BUILD: "${{ matrix.python-version }}-${{ matrix.spec == 'musllinux' && 'musllinux' || 'manylinux' }}*" - CIBW_PRERELEASE_PYTHONS: True - # Extra options for manylinux. - CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.spec }} - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.spec }} - - - uses: actions/upload-artifact@v4 - with: - name: dist-qemu-${{ matrix.python-version }}-${{ matrix.spec }} - path: ./wheelhouse/*.whl - - build-2-native-wheels: + build-native-wheels: if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' name: ${{ matrix.name }} runs-on: ${{ matrix.os }} @@ -116,7 +63,7 @@ jobs: - name: "macOS 10.15 x86_64" os: macos-13 cibw_arch: x86_64 - build: "pp310*" + build: "pp3*" macosx_deployment_target: "10.15" - name: "macOS arm64" os: macos-latest @@ -130,6 +77,14 @@ jobs: cibw_arch: x86_64 build: "*manylinux*" manylinux: "manylinux_2_28" + - name: "manylinux2014 and musllinux aarch64" + os: ubuntu-24.04-arm + cibw_arch: aarch64 + - name: "manylinux_2_28 aarch64" + os: ubuntu-24.04-arm + cibw_arch: aarch64 + build: "*manylinux*" + manylinux: "manylinux_2_28" steps: - uses: actions/checkout@v4 with: @@ -150,10 +105,11 @@ jobs: env: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BUILD: ${{ matrix.build }} - CIBW_FREE_THREADED_SUPPORT: True + CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy + CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.manylinux }} + CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} - CIBW_PRERELEASE_PYTHONS: True CIBW_SKIP: pp39-* MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} @@ -228,8 +184,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_CACHE_PATH: "C:\\cibw" - CIBW_FREE_THREADED_SUPPORT: True - CIBW_PRERELEASE_PYTHONS: True + CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy CIBW_SKIP: pp39-* CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_COMMAND: 'docker run --rm @@ -265,8 +220,6 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.x" - cache: pip - cache-dependency-path: "Makefile" - run: make sdist @@ -277,7 +230,7 @@ jobs: scientific-python-nightly-wheels-publish: if: github.repository_owner == 'python-pillow' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - needs: [build-2-native-wheels, windows] + needs: [build-native-wheels, windows] runs-on: ubuntu-latest name: Upload wheels to scientific-python-nightly-wheels steps: @@ -294,7 +247,7 @@ jobs: pypi-publish: if: github.repository_owner == 'python-pillow' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - needs: [build-1-QEMU-emulated-wheels, build-2-native-wheels, windows, sdist] + needs: [build-native-wheels, windows, sdist] runs-on: ubuntu-latest name: Upload release to PyPI environment: diff --git a/.gitignore b/.gitignore index 1dd6c9175..3033c2ea7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ lib64/ parts/ sdist/ var/ +wheelhouse/ *.egg-info/ .installed.cfg *.egg @@ -90,5 +91,9 @@ Tests/images/msp Tests/images/picins Tests/images/sunraster +# Test and dependency downloads +pillow-depends-main.zip +pillow-test-images.zip + # pyinstaller *.spec diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ddc98fdc3..5ff947d41 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,17 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.2 + rev: v0.9.9 hooks: - id: ruff args: [--exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.10.0 + rev: 25.1.0 hooks: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.7.10 + rev: 1.8.3 hooks: - id: bandit args: [--severity-level=high] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v19.1.3 + rev: v19.1.7 hooks: - id: clang-format types: [c] @@ -50,30 +50,35 @@ repos: exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.29.4 + rev: 0.31.2 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate + - repo: https://github.com/woodruffw/zizmor-pre-commit + rev: v1.4.1 + hooks: + - id: zizmor + - repo: https://github.com/sphinx-contrib/sphinx-lint rev: v1.0.0 hooks: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.5.0 + rev: v2.5.1 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.22 + rev: v0.23 hooks: - id: validate-pyproject additional_dependencies: [trove-classifiers>=2024.10.12] - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.4.1 + rev: 1.5.0 hooks: - id: tox-ini-fmt diff --git a/.readthedocs.yml b/.readthedocs.yml index def6282dd..3e03c76ea 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,5 +1,8 @@ version: 2 +sphinx: + configuration: docs/conf.py + formats: [pdf] build: diff --git a/CHANGES.rst b/CHANGES.rst index 9d45e2214..dfbbd24b3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,20 +2,12 @@ Changelog (Pillow) ================== -11.1.0 (unreleased) -------------------- +11.1.0 and newer +---------------- -- Detach PyQt6 QPixmap instance before returning #8509 - [radarhere] +See GitHub Releases: -- Corrected EMF DPI #8485 - [radarhere] - -- Fix IFDRational with a zero denominator #8474 - [radarhere] - -- Fixed disabling a feature during install #8469 - [radarhere] +- https://github.com/python-pillow/Pillow/releases 11.0.0 (2024-10-15) ------------------- diff --git a/LICENSE b/LICENSE index 8837c290c..10dd42d9e 100644 --- a/LICENSE +++ b/LICENSE @@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is Pillow is the friendly PIL fork. It is - Copyright © 2010-2024 by Jeffrey A. Clark and contributors + Copyright © 2010 by Jeffrey A. Clark and contributors Like PIL, Pillow is licensed under the open source MIT-CMU License: diff --git a/MANIFEST.in b/MANIFEST.in index af25dfd2d..48085b82e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -20,7 +20,6 @@ graft docs graft _custom_build # build/src control detritus -exclude .appveyor.yml exclude .clang-format exclude .coveragerc exclude .editorconfig diff --git a/README.md b/README.md index 5bbebaccb..1cae558ad 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,6 @@ As of 2019, Pillow development is GitHub Actions build status (Test Docker) - AppVeyor CI build status (Windows) GitHub Actions build status (Wheels) @@ -107,7 +104,7 @@ The core image library is designed for fast access to data stored in a few basic - [Issues](https://github.com/python-pillow/Pillow/issues) - [Pull requests](https://github.com/python-pillow/Pillow/pulls) - [Release notes](https://pillow.readthedocs.io/en/stable/releasenotes/index.html) -- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) +- [Changelog](https://github.com/python-pillow/Pillow/releases) - [Pre-fork](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst#pre-fork) ## Report a Vulnerability diff --git a/RELEASING.md b/RELEASING.md index 9e6ec5dd4..932beb2c2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,10 +9,9 @@ Released quarterly on January 2nd, April 1st, July 1st and October 15th. * [ ] Open a release ticket e.g. https://github.com/python-pillow/Pillow/issues/3154 * [ ] Develop and prepare release in `main` branch. -* [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) and [AppVeyor](https://ci.appveyor.com/project/python-pillow/Pillow) to confirm passing tests in `main` branch. +* [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) to confirm passing tests in `main` branch. * [ ] Check that all the wheel builds pass the tests in the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) jobs by manually triggering them. * [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py` -* [ ] Update `CHANGES.rst`. * [ ] Run pre-release check via `make release-test` in a freshly cloned repo. * [ ] Create branch and tag for release e.g.: ```bash @@ -34,13 +33,12 @@ Released quarterly on January 2nd, April 1st, July 1st and October 15th. Released as needed for security, installation or critical bug fixes. * [ ] Make necessary changes in `main` branch. -* [ ] Update `CHANGES.rst`. * [ ] Check out release branch e.g.: ```bash git checkout -t remotes/origin/5.2.x ``` * [ ] Cherry pick individual commits from `main` branch to release branch e.g. `5.2.x`, then `git push`. -* [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) and [AppVeyor](https://ci.appveyor.com/project/python-pillow/Pillow) to confirm passing tests in release branch e.g. `5.2.x`. +* [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) to confirm passing tests in release branch e.g. `5.2.x`. * [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py` * [ ] Run pre-release check via `make release-test`. * [ ] Create tag for release e.g.: diff --git a/Tests/check_j2k_overflow.py b/Tests/check_j2k_overflow.py index dbdd5a4f5..58566c4b2 100644 --- a/Tests/check_j2k_overflow.py +++ b/Tests/check_j2k_overflow.py @@ -9,6 +9,6 @@ from PIL import Image def test_j2k_overflow(tmp_path: Path) -> None: im = Image.new("RGBA", (1024, 131584)) - target = str(tmp_path / "temp.jpc") + target = tmp_path / "temp.jpc" with pytest.raises(OSError): im.save(target) diff --git a/Tests/check_large_memory.py b/Tests/check_large_memory.py index a9ce79e57..c9feda3b1 100644 --- a/Tests/check_large_memory.py +++ b/Tests/check_large_memory.py @@ -32,7 +32,7 @@ pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit sy def _write_png(tmp_path: Path, xdim: int, ydim: int) -> None: - f = str(tmp_path / "temp.png") + f = tmp_path / "temp.png" im = Image.new("L", (xdim, ydim), 0) im.save(f) diff --git a/Tests/check_large_memory_numpy.py b/Tests/check_large_memory_numpy.py index f4ca8d0aa..458b0ab72 100644 --- a/Tests/check_large_memory_numpy.py +++ b/Tests/check_large_memory_numpy.py @@ -28,7 +28,7 @@ pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit sy def _write_png(tmp_path: Path, xdim: int, ydim: int) -> None: dtype = np.uint8 a = np.zeros((xdim, ydim), dtype=dtype) - f = str(tmp_path / "temp.png") + f = tmp_path / "temp.png" im = Image.fromarray(a, "L") im.save(f) diff --git a/Tests/check_png_dos.py b/Tests/check_png_dos.py index 63d6657bc..1bfb94ab7 100644 --- a/Tests/check_png_dos.py +++ b/Tests/check_png_dos.py @@ -3,26 +3,25 @@ from __future__ import annotations import zlib from io import BytesIO +import pytest + from PIL import Image, ImageFile, PngImagePlugin TEST_FILE = "Tests/images/png_decompression_dos.png" -def test_ignore_dos_text() -> None: - ImageFile.LOAD_TRUNCATED_IMAGES = True +def test_ignore_dos_text(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) - try: - im = Image.open(TEST_FILE) + with Image.open(TEST_FILE) as im: im.load() - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False - assert isinstance(im, PngImagePlugin.PngImageFile) - for s in im.text.values(): - assert len(s) < 1024 * 1024, "Text chunk larger than 1M" + assert isinstance(im, PngImagePlugin.PngImageFile) + for s in im.text.values(): + assert len(s) < 1024 * 1024, "Text chunk larger than 1M" - for s in im.info.values(): - assert len(s) < 1024 * 1024, "Text chunk larger than 1M" + for s in im.info.values(): + assert len(s) < 1024 * 1024, "Text chunk larger than 1M" def test_dos_text() -> None: diff --git a/Tests/check_wheel.py b/Tests/check_wheel.py index 4b91984f5..563be0b74 100644 --- a/Tests/check_wheel.py +++ b/Tests/check_wheel.py @@ -34,6 +34,7 @@ def test_wheel_features() -> None: "fribidi", "harfbuzz", "libjpeg_turbo", + "zlib_ng", "xcb", } diff --git a/Tests/helper.py b/Tests/helper.py index d6a93a803..909fff879 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -9,11 +9,11 @@ import os import shutil import subprocess import sys -import sysconfig import tempfile from collections.abc import Sequence from functools import lru_cache from io import BytesIO +from pathlib import Path from typing import Any, Callable import pytest @@ -96,7 +96,10 @@ def assert_image_equal(a: Image.Image, b: Image.Image, msg: str | None = None) - def assert_image_equal_tofile( - a: Image.Image, filename: str, msg: str | None = None, mode: str | None = None + a: Image.Image, + filename: str | Path, + msg: str | None = None, + mode: str | None = None, ) -> None: with Image.open(filename) as img: if mode: @@ -137,21 +140,14 @@ def assert_image_similar( def assert_image_similar_tofile( a: Image.Image, - filename: str, + filename: str | Path, epsilon: float, msg: str | None = None, - mode: str | None = None, ) -> None: with Image.open(filename) as img: - if mode: - img = img.convert(mode) assert_image_similar(a, img, epsilon, msg) -def assert_all_same(items: Sequence[Any], msg: str | None = None) -> None: - assert items.count(items[0]) == len(items), msg - - def assert_not_all_same(items: Sequence[Any], msg: str | None = None) -> None: assert items.count(items[0]) != len(items), msg @@ -327,16 +323,7 @@ def magick_command() -> list[str] | None: return None -def on_appveyor() -> bool: - return "APPVEYOR" in os.environ - - -def on_github_actions() -> bool: - return "GITHUB_ACTIONS" in os.environ - - def on_ci() -> bool: - # GitHub Actions and AppVeyor have "CI" return "CI" in os.environ @@ -358,10 +345,6 @@ def is_pypy() -> bool: return hasattr(sys, "pypy_translation_info") -def is_mingw() -> bool: - return sysconfig.get_platform() == "mingw" - - class CachedProperty: def __init__(self, func: Callable[[Any], Any]) -> None: self.func = func diff --git a/Tests/images/drawing_emf_ref_72_144.png b/Tests/images/drawing_emf_ref_72_144.png new file mode 100644 index 000000000..000377b6c Binary files /dev/null and b/Tests/images/drawing_emf_ref_72_144.png differ diff --git a/Tests/images/full_gimp_palette.gpl b/Tests/images/full_gimp_palette.gpl new file mode 100644 index 000000000..004217210 --- /dev/null +++ b/Tests/images/full_gimp_palette.gpl @@ -0,0 +1,260 @@ +GIMP Palette +Name: fullpalette +Columns: 4 +# + 0 0 0 Index 0 + 1 1 1 Index 1 + 2 2 2 Index 2 + 3 3 3 Index 3 + 4 4 4 Index 4 + 5 5 5 Index 5 + 6 6 6 Index 6 + 7 7 7 Index 7 + 8 8 8 Index 8 + 9 9 9 Index 9 + 10 10 10 Index 10 + 11 11 11 Index 11 + 12 12 12 Index 12 + 13 13 13 Index 13 + 14 14 14 Index 14 + 15 15 15 Index 15 + 16 16 16 Index 16 + 17 17 17 Index 17 + 18 18 18 Index 18 + 19 19 19 Index 19 + 20 20 20 Index 20 + 21 21 21 Index 21 + 22 22 22 Index 22 + 23 23 23 Index 23 + 24 24 24 Index 24 + 25 25 25 Index 25 + 26 26 26 Index 26 + 27 27 27 Index 27 + 28 28 28 Index 28 + 29 29 29 Index 29 + 30 30 30 Index 30 + 31 31 31 Index 31 + 32 32 32 Index 32 + 33 33 33 Index 33 + 34 34 34 Index 34 + 35 35 35 Index 35 + 36 36 36 Index 36 + 37 37 37 Index 37 + 38 38 38 Index 38 + 39 39 39 Index 39 + 40 40 40 Index 40 + 41 41 41 Index 41 + 42 42 42 Index 42 + 43 43 43 Index 43 + 44 44 44 Index 44 + 45 45 45 Index 45 + 46 46 46 Index 46 + 47 47 47 Index 47 + 48 48 48 Index 48 + 49 49 49 Index 49 + 50 50 50 Index 50 + 51 51 51 Index 51 + 52 52 52 Index 52 + 53 53 53 Index 53 + 54 54 54 Index 54 + 55 55 55 Index 55 + 56 56 56 Index 56 + 57 57 57 Index 57 + 58 58 58 Index 58 + 59 59 59 Index 59 + 60 60 60 Index 60 + 61 61 61 Index 61 + 62 62 62 Index 62 + 63 63 63 Index 63 + 64 64 64 Index 64 + 65 65 65 Index 65 + 66 66 66 Index 66 + 67 67 67 Index 67 + 68 68 68 Index 68 + 69 69 69 Index 69 + 70 70 70 Index 70 + 71 71 71 Index 71 + 72 72 72 Index 72 + 73 73 73 Index 73 + 74 74 74 Index 74 + 75 75 75 Index 75 + 76 76 76 Index 76 + 77 77 77 Index 77 + 78 78 78 Index 78 + 79 79 79 Index 79 + 80 80 80 Index 80 + 81 81 81 Index 81 + 82 82 82 Index 82 + 83 83 83 Index 83 + 84 84 84 Index 84 + 85 85 85 Index 85 + 86 86 86 Index 86 + 87 87 87 Index 87 + 88 88 88 Index 88 + 89 89 89 Index 89 + 90 90 90 Index 90 + 91 91 91 Index 91 + 92 92 92 Index 92 + 93 93 93 Index 93 + 94 94 94 Index 94 + 95 95 95 Index 95 + 96 96 96 Index 96 + 97 97 97 Index 97 + 98 98 98 Index 98 + 99 99 99 Index 99 +100 100 100 Index 100 +101 101 101 Index 101 +102 102 102 Index 102 +103 103 103 Index 103 +104 104 104 Index 104 +105 105 105 Index 105 +106 106 106 Index 106 +107 107 107 Index 107 +108 108 108 Index 108 +109 109 109 Index 109 +110 110 110 Index 110 +111 111 111 Index 111 +112 112 112 Index 112 +113 113 113 Index 113 +114 114 114 Index 114 +115 115 115 Index 115 +116 116 116 Index 116 +117 117 117 Index 117 +118 118 118 Index 118 +119 119 119 Index 119 +120 120 120 Index 120 +121 121 121 Index 121 +122 122 122 Index 122 +123 123 123 Index 123 +124 124 124 Index 124 +125 125 125 Index 125 +126 126 126 Index 126 +127 127 127 Index 127 +128 128 128 Index 128 +129 129 129 Index 129 +130 130 130 Index 130 +131 131 131 Index 131 +132 132 132 Index 132 +133 133 133 Index 133 +134 134 134 Index 134 +135 135 135 Index 135 +136 136 136 Index 136 +137 137 137 Index 137 +138 138 138 Index 138 +139 139 139 Index 139 +140 140 140 Index 140 +141 141 141 Index 141 +142 142 142 Index 142 +143 143 143 Index 143 +144 144 144 Index 144 +145 145 145 Index 145 +146 146 146 Index 146 +147 147 147 Index 147 +148 148 148 Index 148 +149 149 149 Index 149 +150 150 150 Index 150 +151 151 151 Index 151 +152 152 152 Index 152 +153 153 153 Index 153 +154 154 154 Index 154 +155 155 155 Index 155 +156 156 156 Index 156 +157 157 157 Index 157 +158 158 158 Index 158 +159 159 159 Index 159 +160 160 160 Index 160 +161 161 161 Index 161 +162 162 162 Index 162 +163 163 163 Index 163 +164 164 164 Index 164 +165 165 165 Index 165 +166 166 166 Index 166 +167 167 167 Index 167 +168 168 168 Index 168 +169 169 169 Index 169 +170 170 170 Index 170 +171 171 171 Index 171 +172 172 172 Index 172 +173 173 173 Index 173 +174 174 174 Index 174 +175 175 175 Index 175 +176 176 176 Index 176 +177 177 177 Index 177 +178 178 178 Index 178 +179 179 179 Index 179 +180 180 180 Index 180 +181 181 181 Index 181 +182 182 182 Index 182 +183 183 183 Index 183 +184 184 184 Index 184 +185 185 185 Index 185 +186 186 186 Index 186 +187 187 187 Index 187 +188 188 188 Index 188 +189 189 189 Index 189 +190 190 190 Index 190 +191 191 191 Index 191 +192 192 192 Index 192 +193 193 193 Index 193 +194 194 194 Index 194 +195 195 195 Index 195 +196 196 196 Index 196 +197 197 197 Index 197 +198 198 198 Index 198 +199 199 199 Index 199 +200 200 200 Index 200 +201 201 201 Index 201 +202 202 202 Index 202 +203 203 203 Index 203 +204 204 204 Index 204 +205 205 205 Index 205 +206 206 206 Index 206 +207 207 207 Index 207 +208 208 208 Index 208 +209 209 209 Index 209 +210 210 210 Index 210 +211 211 211 Index 211 +212 212 212 Index 212 +213 213 213 Index 213 +214 214 214 Index 214 +215 215 215 Index 215 +216 216 216 Index 216 +217 217 217 Index 217 +218 218 218 Index 218 +219 219 219 Index 219 +220 220 220 Index 220 +221 221 221 Index 221 +222 222 222 Index 222 +223 223 223 Index 223 +224 224 224 Index 224 +225 225 225 Index 225 +226 226 226 Index 226 +227 227 227 Index 227 +228 228 228 Index 228 +229 229 229 Index 229 +230 230 230 Index 230 +231 231 231 Index 231 +232 232 232 Index 232 +233 233 233 Index 233 +234 234 234 Index 234 +235 235 235 Index 235 +236 236 236 Index 236 +237 237 237 Index 237 +238 238 238 Index 238 +239 239 239 Index 239 +240 240 240 Index 240 +241 241 241 Index 241 +242 242 242 Index 242 +243 243 243 Index 243 +244 244 244 Index 244 +245 245 245 Index 245 +246 246 246 Index 246 +247 247 247 Index 247 +248 248 248 Index 248 +249 249 249 Index 249 +250 250 250 Index 250 +251 251 251 Index 251 +252 252 252 Index 252 +253 253 253 Index 253 +254 254 254 Index 254 +255 255 255 Index 255 diff --git a/Tests/images/imagedraw/discontiguous_corners_polygon.png b/Tests/images/imagedraw/discontiguous_corners_polygon.png index 509c42b26..8992a1657 100644 Binary files a/Tests/images/imagedraw/discontiguous_corners_polygon.png and b/Tests/images/imagedraw/discontiguous_corners_polygon.png differ diff --git a/Tests/images/jfif_unit_cm.jpg b/Tests/images/jfif_unit_cm.jpg new file mode 100644 index 000000000..78b50e60a Binary files /dev/null and b/Tests/images/jfif_unit_cm.jpg differ diff --git a/Tests/images/multiline_text_justify.png b/Tests/images/multiline_text_justify.png new file mode 100644 index 000000000..32eed34cd Binary files /dev/null and b/Tests/images/multiline_text_justify.png differ diff --git a/Tests/oss-fuzz/test_fuzzers.py b/Tests/oss-fuzz/test_fuzzers.py index 90eb8713a..e42ec90aa 100644 --- a/Tests/oss-fuzz/test_fuzzers.py +++ b/Tests/oss-fuzz/test_fuzzers.py @@ -7,7 +7,7 @@ import fuzzers import packaging import pytest -from PIL import Image, UnidentifiedImageError, features +from PIL import Image, features from Tests.helper import skip_unless_feature if sys.platform.startswith("win32"): @@ -32,21 +32,17 @@ def test_fuzz_images(path: str) -> None: fuzzers.fuzz_image(f.read()) assert True except ( + # Known exceptions from Pillow OSError, SyntaxError, MemoryError, ValueError, NotImplementedError, OverflowError, - ): - # Known exceptions that are through from Pillow - assert True - except ( + # Known Image.* exceptions Image.DecompressionBombError, Image.DecompressionBombWarning, - UnidentifiedImageError, ): - # Known Image.* exceptions assert True finally: fuzzers.disable_decompressionbomb_error() diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index 36ab187f2..26945ae1a 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -19,7 +19,7 @@ except ImportError: class TestColorLut3DCoreAPI: def generate_identity_table( self, channels: int, size: int | tuple[int, int, int] - ) -> tuple[int, int, int, int, list[float]]: + ) -> tuple[int, tuple[int, int, int], list[float]]: if isinstance(size, tuple): size_1d, size_2d, size_3d = size else: @@ -39,9 +39,7 @@ class TestColorLut3DCoreAPI: ] return ( channels, - size_1d, - size_2d, - size_3d, + (size_1d, size_2d, size_3d), [item for sublist in table for item in sublist], ) @@ -89,21 +87,21 @@ class TestColorLut3DCoreAPI: with pytest.raises(ValueError, match=r"size1D \* size2D \* size3D"): im.im.color_lut_3d( - "RGB", Image.Resampling.BILINEAR, 3, 2, 2, 2, [0, 0, 0] * 7 + "RGB", Image.Resampling.BILINEAR, 3, (2, 2, 2), [0, 0, 0] * 7 ) with pytest.raises(ValueError, match=r"size1D \* size2D \* size3D"): im.im.color_lut_3d( - "RGB", Image.Resampling.BILINEAR, 3, 2, 2, 2, [0, 0, 0] * 9 + "RGB", Image.Resampling.BILINEAR, 3, (2, 2, 2), [0, 0, 0] * 9 ) with pytest.raises(TypeError): im.im.color_lut_3d( - "RGB", Image.Resampling.BILINEAR, 3, 2, 2, 2, [0, 0, "0"] * 8 + "RGB", Image.Resampling.BILINEAR, 3, (2, 2, 2), [0, 0, "0"] * 8 ) with pytest.raises(TypeError): - im.im.color_lut_3d("RGB", Image.Resampling.BILINEAR, 3, 2, 2, 2, 16) + im.im.color_lut_3d("RGB", Image.Resampling.BILINEAR, 3, (2, 2, 2), 16) @pytest.mark.parametrize( "lut_mode, table_channels, table_size", @@ -264,7 +262,7 @@ class TestColorLut3DCoreAPI: assert_image_equal( Image.merge('RGB', im.split()[::-1]), im._new(im.im.color_lut_3d('RGB', Image.Resampling.BILINEAR, - 3, 2, 2, 2, [ + 3, (2, 2, 2), [ 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, @@ -286,7 +284,7 @@ class TestColorLut3DCoreAPI: # fmt: off transformed = im._new(im.im.color_lut_3d('RGB', Image.Resampling.BILINEAR, - 3, 2, 2, 2, + 3, (2, 2, 2), [ -1, -1, -1, 2, -1, -1, -1, 2, -1, 2, 2, -1, @@ -307,7 +305,7 @@ class TestColorLut3DCoreAPI: # fmt: off transformed = im._new(im.im.color_lut_3d('RGB', Image.Resampling.BILINEAR, - 3, 2, 2, 2, + 3, (2, 2, 2), [ -3, -3, -3, 5, -3, -3, -3, 5, -3, 5, 5, -3, @@ -388,10 +386,12 @@ class TestColorLut3DFilter: table = numpy.ones((7 * 6 * 5, 3), dtype=numpy.float16) lut = ImageFilter.Color3DLUT((5, 6, 7), table) + assert isinstance(lut.table, numpy.ndarray) assert lut.table.shape == (table.size,) table = numpy.ones((7 * 6 * 5 * 3), dtype=numpy.float16) lut = ImageFilter.Color3DLUT((5, 6, 7), table) + assert isinstance(lut.table, numpy.ndarray) assert lut.table.shape == (table.size,) # Check application diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index c140156f9..98d833736 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -12,19 +12,16 @@ ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS class TestDecompressionBomb: - def teardown_method(self) -> None: - Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT - def test_no_warning_small_file(self) -> None: # Implicit assert: no warning. # A warning would cause a failure. with Image.open(TEST_FILE): pass - def test_no_warning_no_limit(self) -> None: + def test_no_warning_no_limit(self, monkeypatch: pytest.MonkeyPatch) -> None: # Arrange # Turn limit off - Image.MAX_IMAGE_PIXELS = None + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", None) assert Image.MAX_IMAGE_PIXELS is None # Act / Assert @@ -33,18 +30,18 @@ class TestDecompressionBomb: with Image.open(TEST_FILE): pass - def test_warning(self) -> None: + def test_warning(self, monkeypatch: pytest.MonkeyPatch) -> None: # Set limit to trigger warning on the test file - Image.MAX_IMAGE_PIXELS = 128 * 128 - 1 + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 128 * 128 - 1) assert Image.MAX_IMAGE_PIXELS == 128 * 128 - 1 with pytest.warns(Image.DecompressionBombWarning): with Image.open(TEST_FILE): pass - def test_exception(self) -> None: + def test_exception(self, monkeypatch: pytest.MonkeyPatch) -> None: # Set limit to trigger exception on the test file - Image.MAX_IMAGE_PIXELS = 64 * 128 - 1 + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 64 * 128 - 1) assert Image.MAX_IMAGE_PIXELS == 64 * 128 - 1 with pytest.raises(Image.DecompressionBombError): @@ -66,9 +63,9 @@ class TestDecompressionBomb: with pytest.raises(Image.DecompressionBombError): im.seek(1) - def test_exception_gif_zero_width(self) -> None: + def test_exception_gif_zero_width(self, monkeypatch: pytest.MonkeyPatch) -> None: # Set limit to trigger exception on the test file - Image.MAX_IMAGE_PIXELS = 4 * 64 * 128 + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 4 * 64 * 128) assert Image.MAX_IMAGE_PIXELS == 4 * 64 * 128 with pytest.raises(Image.DecompressionBombError): diff --git a/Tests/test_features.py b/Tests/test_features.py index ed7929973..f8f7f6eec 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -36,10 +36,11 @@ def test_version() -> None: else: assert function(name) == version if name != "PIL": - if name == "zlib" and version is not None: - version = re.sub(".zlib-ng$", "", version) - elif name == "libtiff" and version is not None: - version = re.sub("t$", "", version) + if version is not None: + if name == "zlib" and features.check_feature("zlib_ng"): + version = re.sub(".zlib-ng$", "", version) + elif name == "libtiff": + version = re.sub("t$", "", version) assert version is None or re.search(r"\d+(\.\d+)*$", version) for module in features.modules: diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index ee6c867c3..a5734c202 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -12,6 +12,7 @@ from PIL import Image, ImageSequence, PngImagePlugin # (referenced from https://wiki.mozilla.org/APNG_Specification) def test_apng_basic() -> None: with Image.open("Tests/images/apng/single_frame.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated assert im.n_frames == 1 assert im.get_format_mimetype() == "image/apng" @@ -20,6 +21,7 @@ def test_apng_basic() -> None: assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/single_frame_default.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.is_animated assert im.n_frames == 2 assert im.get_format_mimetype() == "image/apng" @@ -34,8 +36,11 @@ def test_apng_basic() -> None: with pytest.raises(EOFError): im.seek(2) - # test rewind support im.seek(0) + with pytest.raises(ValueError, match="cannot seek to frame 2"): + im._seek(2) + + # test rewind support assert im.getpixel((0, 0)) == (255, 0, 0, 255) assert im.getpixel((64, 32)) == (255, 0, 0, 255) im.seek(1) @@ -49,6 +54,7 @@ def test_apng_basic() -> None: ) def test_apng_fdat(filename: str) -> None: with Image.open(filename) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -56,31 +62,37 @@ def test_apng_fdat(filename: str) -> None: def test_apng_dispose() -> None: with Image.open("Tests/images/apng/dispose_op_none.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_background.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0) with Image.open("Tests/images/apng/dispose_op_background_final.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_previous.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_previous_final.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_previous_first.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0) @@ -88,21 +100,25 @@ def test_apng_dispose() -> None: def test_apng_dispose_region() -> None: with Image.open("Tests/images/apng/dispose_op_none_region.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_background_before_region.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0) with Image.open("Tests/images/apng/dispose_op_background_region.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 255, 255) assert im.getpixel((64, 32)) == (0, 0, 0, 0) with Image.open("Tests/images/apng/dispose_op_previous_region.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -129,6 +145,7 @@ def test_apng_dispose_op_previous_frame() -> None: # ], # ) with Image.open("Tests/images/apng/dispose_op_previous_frame.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (255, 0, 0, 255) @@ -142,26 +159,31 @@ def test_apng_dispose_op_background_p_mode() -> None: def test_apng_blend() -> None: with Image.open("Tests/images/apng/blend_op_source_solid.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/blend_op_source_transparent.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0) with Image.open("Tests/images/apng/blend_op_source_near_transparent.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 2) assert im.getpixel((64, 32)) == (0, 255, 0, 2) with Image.open("Tests/images/apng/blend_op_over.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/blend_op_over_near_transparent.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 97) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -175,6 +197,7 @@ def test_apng_blend_transparency() -> None: def test_apng_chunk_order() -> None: with Image.open("Tests/images/apng/fctl_actl.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -230,24 +253,28 @@ def test_apng_num_plays() -> None: def test_apng_mode() -> None: with Image.open("Tests/images/apng/mode_16bit.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "RGBA" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 128, 191) assert im.getpixel((64, 32)) == (0, 0, 128, 191) with Image.open("Tests/images/apng/mode_grayscale.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "L" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == 128 assert im.getpixel((64, 32)) == 255 with Image.open("Tests/images/apng/mode_grayscale_alpha.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "LA" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (128, 191) assert im.getpixel((64, 32)) == (128, 191) with Image.open("Tests/images/apng/mode_palette.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) im = im.convert("RGB") @@ -255,6 +282,7 @@ def test_apng_mode() -> None: assert im.getpixel((64, 32)) == (0, 255, 0) with Image.open("Tests/images/apng/mode_palette_alpha.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) im = im.convert("RGBA") @@ -262,6 +290,7 @@ def test_apng_mode() -> None: assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) im = im.convert("RGBA") @@ -271,25 +300,31 @@ def test_apng_mode() -> None: def test_apng_chunk_errors() -> None: with Image.open("Tests/images/apng/chunk_no_actl.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated with pytest.warns(UserWarning): with Image.open("Tests/images/apng/chunk_multi_actl.png") as im: im.load() + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated with Image.open("Tests/images/apng/chunk_actl_after_idat.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated with Image.open("Tests/images/apng/chunk_no_fctl.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) with pytest.raises(SyntaxError): im.seek(im.n_frames - 1) with Image.open("Tests/images/apng/chunk_repeat_fctl.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) with pytest.raises(SyntaxError): im.seek(im.n_frames - 1) with Image.open("Tests/images/apng/chunk_no_fdat.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) with pytest.raises(SyntaxError): im.seek(im.n_frames - 1) @@ -297,31 +332,31 @@ def test_apng_chunk_errors() -> None: def test_apng_syntax_errors() -> None: with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated with pytest.raises(OSError): im.load() with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated im.load() # we can handle this case gracefully - exception = None with Image.open("Tests/images/apng/syntax_num_frames_low.png") as im: - try: - im.seek(im.n_frames - 1) - except Exception as e: - exception = e - assert exception is None + assert isinstance(im, PngImagePlugin.PngImageFile) + im.seek(im.n_frames - 1) with pytest.raises(OSError): with Image.open("Tests/images/apng/syntax_num_frames_high.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) im.load() with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_invalid.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated im.load() @@ -341,16 +376,18 @@ def test_apng_syntax_errors() -> None: def test_apng_sequence_errors(test_file: str) -> None: with pytest.raises(SyntaxError): with Image.open(f"Tests/images/apng/{test_file}") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) im.load() def test_apng_save(tmp_path: Path) -> None: with Image.open("Tests/images/apng/single_frame.png") as im: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im.save(test_file, save_all=True) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.load() assert not im.is_animated assert im.n_frames == 1 @@ -366,6 +403,7 @@ def test_apng_save(tmp_path: Path) -> None: ) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.load() assert im.is_animated assert im.n_frames == 2 @@ -377,7 +415,7 @@ def test_apng_save(tmp_path: Path) -> None: def test_apng_save_alpha(tmp_path: Path) -> None: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im = Image.new("RGBA", (1, 1), (255, 0, 0, 255)) im2 = Image.new("RGBA", (1, 1), (255, 0, 0, 127)) @@ -395,7 +433,7 @@ def test_apng_save_split_fdat(tmp_path: Path) -> None: # frames with image data spanning multiple fdAT chunks (in this case # both the default image and first animation frame will span multiple # data chunks) - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" with Image.open("Tests/images/old-style-jpeg-compression.png") as im: frames = [im.copy(), Image.new("RGBA", im.size, (255, 0, 0, 255))] im.save( @@ -405,17 +443,13 @@ def test_apng_save_split_fdat(tmp_path: Path) -> None: append_images=frames, ) with Image.open(test_file) as im: - exception = None - try: - im.seek(im.n_frames - 1) - im.load() - except Exception as e: - exception = e - assert exception is None + assert isinstance(im, PngImagePlugin.PngImageFile) + im.seek(im.n_frames - 1) + im.load() def test_apng_save_duration_loop(tmp_path: Path) -> None: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" with Image.open("Tests/images/apng/delay.png") as im: frames = [] durations = [] @@ -452,6 +486,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None: test_file, save_all=True, append_images=[frame, frame], duration=[500, 100, 150] ) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.n_frames == 1 assert "duration" not in im.info @@ -463,6 +498,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None: duration=[500, 100, 150], ) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.n_frames == 2 assert im.info["duration"] == 600 @@ -473,12 +509,13 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None: frame.info["duration"] = 300 frame.save(test_file, save_all=True, append_images=[frame, different_frame]) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.n_frames == 2 assert im.info["duration"] == 600 def test_apng_save_disposal(tmp_path: Path) -> None: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" size = (128, 64) red = Image.new("RGBA", size, (255, 0, 0, 255)) green = Image.new("RGBA", size, (0, 255, 0, 255)) @@ -579,7 +616,7 @@ def test_apng_save_disposal(tmp_path: Path) -> None: def test_apng_save_disposal_previous(tmp_path: Path) -> None: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" size = (128, 64) blue = Image.new("RGBA", size, (0, 0, 255, 255)) red = Image.new("RGBA", size, (255, 0, 0, 255)) @@ -601,7 +638,7 @@ def test_apng_save_disposal_previous(tmp_path: Path) -> None: def test_apng_save_blend(tmp_path: Path) -> None: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" size = (128, 64) red = Image.new("RGBA", size, (255, 0, 0, 255)) green = Image.new("RGBA", size, (0, 255, 0, 255)) @@ -669,7 +706,7 @@ def test_apng_save_blend(tmp_path: Path) -> None: def test_apng_save_size(tmp_path: Path) -> None: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im = Image.new("L", (100, 100)) im.save(test_file, save_all=True, append_images=[Image.new("L", (200, 200))]) @@ -693,7 +730,7 @@ def test_seek_after_close() -> None: def test_different_modes_in_later_frames( mode: str, default_image: bool, duplicate: bool, tmp_path: Path ) -> None: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im = Image.new("L", (1, 1)) im.save( @@ -707,7 +744,7 @@ def test_different_modes_in_later_frames( def test_different_durations(tmp_path: Path) -> None: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" with Image.open("Tests/images/apng/different_durations.png") as im: for _ in range(3): diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 1e2f20c40..9f50df22d 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from PIL import Image +from PIL import BlpImagePlugin, Image from .helper import ( assert_image_equal, @@ -19,6 +19,7 @@ def test_load_blp1() -> None: assert_image_equal_tofile(im, "Tests/images/blp/blp1_jpeg.png") with Image.open("Tests/images/blp/blp1_jpeg2.blp") as im: + assert im.mode == "RGBA" im.load() @@ -37,8 +38,15 @@ def test_load_blp2_dxt1a() -> None: assert_image_equal_tofile(im, "Tests/images/blp/blp2_dxt1a.png") +def test_invalid_file() -> None: + invalid_file = "Tests/images/flower.jpg" + + with pytest.raises(BlpImagePlugin.BLPFormatError): + BlpImagePlugin.BlpImageFile(invalid_file) + + def test_save(tmp_path: Path) -> None: - f = str(tmp_path / "temp.blp") + f = tmp_path / "temp.blp" for version in ("BLP1", "BLP2"): im = hopper("P") @@ -48,7 +56,7 @@ def test_save(tmp_path: Path) -> None: assert_image_equal(im.convert("RGB"), reloaded) with Image.open("Tests/images/transparent.png") as im: - f = str(tmp_path / "temp.blp") + f = tmp_path / "temp.blp" im.convert("P").save(f, blp_version=version) with Image.open(f) as reloaded: diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 2ff4160bd..757650711 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -15,25 +15,19 @@ from .helper import ( ) -def test_sanity(tmp_path: Path) -> None: - def roundtrip(im: Image.Image) -> None: - outfile = str(tmp_path / "temp.bmp") +@pytest.mark.parametrize("mode", ("1", "L", "P", "RGB")) +def test_sanity(mode: str, tmp_path: Path) -> None: + outfile = tmp_path / "temp.bmp" - im.save(outfile, "BMP") + im = hopper(mode) + im.save(outfile, "BMP") - with Image.open(outfile) as reloaded: - reloaded.load() - assert im.mode == reloaded.mode - assert im.size == reloaded.size - assert reloaded.format == "BMP" - assert reloaded.get_format_mimetype() == "image/bmp" - - roundtrip(hopper()) - - roundtrip(hopper("1")) - roundtrip(hopper("L")) - roundtrip(hopper("P")) - roundtrip(hopper("RGB")) + with Image.open(outfile) as reloaded: + reloaded.load() + assert im.mode == reloaded.mode + assert im.size == reloaded.size + assert reloaded.format == "BMP" + assert reloaded.get_format_mimetype() == "image/bmp" def test_invalid_file() -> None: @@ -66,7 +60,7 @@ def test_small_palette(tmp_path: Path) -> None: colors = [0, 0, 0, 125, 125, 125, 255, 255, 255] im.putpalette(colors) - out = str(tmp_path / "temp.bmp") + out = tmp_path / "temp.bmp" im.save(out) with Image.open(out) as reloaded: @@ -74,7 +68,7 @@ def test_small_palette(tmp_path: Path) -> None: def test_save_too_large(tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.bmp") + outfile = tmp_path / "temp.bmp" with Image.new("RGB", (1, 1)) as im: im._size = (37838, 37838) with pytest.raises(ValueError): @@ -96,7 +90,7 @@ def test_dpi() -> None: def test_save_bmp_with_dpi(tmp_path: Path) -> None: # Test for #1301 # Arrange - outfile = str(tmp_path / "temp.jpg") + outfile = tmp_path / "temp.jpg" with Image.open("Tests/images/hopper.bmp") as im: assert im.info["dpi"] == (95.98654816726399, 95.98654816726399) @@ -112,7 +106,7 @@ def test_save_bmp_with_dpi(tmp_path: Path) -> None: def test_save_float_dpi(tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.bmp") + outfile = tmp_path / "temp.bmp" with Image.open("Tests/images/hopper.bmp") as im: im.save(outfile, dpi=(72.21216100543306, 72.21216100543306)) with Image.open(outfile) as reloaded: @@ -152,7 +146,7 @@ def test_dib_header_size(header_size: int, path: str) -> None: def test_save_dib(tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.dib") + outfile = tmp_path / "temp.dib" with Image.open("Tests/images/clipboard.dib") as im: im.save(outfile) @@ -230,3 +224,13 @@ def test_offset() -> None: # to exclude the palette size from the pixel data offset with Image.open("Tests/images/pal8_offset.bmp") as im: assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp") + + +def test_use_raw_alpha(monkeypatch: pytest.MonkeyPatch) -> None: + with Image.open("Tests/images/bmp/g/rgb32.bmp") as im: + assert im.info["compression"] == BmpImagePlugin.BmpImageFile.COMPRESSIONS["RAW"] + assert im.mode == "RGB" + + monkeypatch.setattr(BmpImagePlugin, "USE_RAW_ALPHA", True) + with Image.open("Tests/images/bmp/g/rgb32.bmp") as im: + assert im.mode == "RGBA" diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 77ee5b0ea..362578c56 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -43,7 +43,7 @@ def test_load() -> None: def test_save(tmp_path: Path) -> None: # Arrange im = hopper() - tmpfile = str(tmp_path / "temp.bufr") + tmpfile = tmp_path / "temp.bufr" # Act / Assert: stub cannot save without an implemented handler with pytest.raises(OSError): @@ -79,8 +79,8 @@ def test_handler(tmp_path: Path) -> None: im.load() assert handler.is_loaded() - temp_file = str(tmp_path / "temp.bufr") + temp_file = tmp_path / "temp.bufr" im.save(temp_file) assert handler.saved - BufrStubImagePlugin._handler = None + BufrStubImagePlugin.register_handler(None) diff --git a/Tests/test_file_container.py b/Tests/test_file_container.py index 237045acc..597ab5083 100644 --- a/Tests/test_file_container.py +++ b/Tests/test_file_container.py @@ -4,8 +4,6 @@ import pytest from PIL import ContainerIO, Image -from .helper import hopper - TEST_FILE = "Tests/images/dummy.container" @@ -15,15 +13,15 @@ def test_sanity() -> None: def test_isatty() -> None: - with hopper() as im: - container = ContainerIO.ContainerIO(im, 0, 0) + with open(TEST_FILE, "rb") as fh: + container = ContainerIO.ContainerIO(fh, 0, 0) assert container.isatty() is False def test_seekable() -> None: - with hopper() as im: - container = ContainerIO.ContainerIO(im, 0, 0) + with open(TEST_FILE, "rb") as fh: + container = ContainerIO.ContainerIO(fh, 0, 0) assert container.seekable() is True diff --git a/Tests/test_file_dcx.py b/Tests/test_file_dcx.py index 5deacd878..e9d88dd39 100644 --- a/Tests/test_file_dcx.py +++ b/Tests/test_file_dcx.py @@ -26,12 +26,12 @@ def test_sanity() -> None: @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file() -> None: - def open() -> None: + def open_test_image() -> None: im = Image.open(TEST_FILE) im.load() with pytest.warns(ResourceWarning): - open() + open_test_image() def test_closed_file() -> None: @@ -69,12 +69,14 @@ def test_tell() -> None: def test_n_frames() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, DcxImagePlugin.DcxImageFile) assert im.n_frames == 1 assert not im.is_animated def test_eoferror() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, DcxImagePlugin.DcxImageFile) n_frames = im.n_frames # Test seeking past the last frame diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 9a826ebe8..3388fce16 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -9,7 +9,13 @@ import pytest from PIL import DdsImagePlugin, Image -from .helper import assert_image_equal, assert_image_equal_tofile, hopper +from .helper import ( + assert_image_equal, + assert_image_equal_tofile, + assert_image_similar, + assert_image_similar_tofile, + hopper, +) TEST_FILE_DXT1 = "Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds" TEST_FILE_DXT3 = "Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds" @@ -109,6 +115,32 @@ def test_sanity_ati1_bc4u(image_path: str) -> None: assert_image_equal_tofile(im, TEST_FILE_ATI1.replace(".dds", ".png")) +def test_dx10_bc2(tmp_path: Path) -> None: + out = tmp_path / "temp.dds" + with Image.open(TEST_FILE_DXT3) as im: + im.save(out, pixel_format="BC2") + + with Image.open(out) as reloaded: + assert reloaded.format == "DDS" + assert reloaded.mode == "RGBA" + assert reloaded.size == (256, 256) + + assert_image_similar(im, reloaded, 3.81) + + +def test_dx10_bc3(tmp_path: Path) -> None: + out = tmp_path / "temp.dds" + with Image.open(TEST_FILE_DXT5) as im: + im.save(out, pixel_format="BC3") + + with Image.open(out) as reloaded: + assert reloaded.format == "DDS" + assert reloaded.mode == "RGBA" + assert reloaded.size == (256, 256) + + assert_image_similar(im, reloaded, 3.69) + + @pytest.mark.parametrize( "image_path", ( @@ -331,11 +363,13 @@ def test_dxt5_colorblock_alpha_issue_4142() -> None: with Image.open("Tests/images/dxt5-colorblock-alpha-issue-4142.dds") as im: px = im.getpixel((0, 0)) + assert isinstance(px, tuple) assert px[0] != 0 assert px[1] != 0 assert px[2] != 0 px = im.getpixel((1, 0)) + assert isinstance(px, tuple) assert px[0] != 0 assert px[1] != 0 assert px[2] != 0 @@ -366,9 +400,9 @@ def test_not_implemented(test_file: str) -> None: def test_save_unsupported_mode(tmp_path: Path) -> None: - out = str(tmp_path / "temp.dds") + out = tmp_path / "temp.dds" im = hopper("HSV") - with pytest.raises(OSError): + with pytest.raises(OSError, match="cannot write mode HSV as DDS"): im.save(out) @@ -382,10 +416,98 @@ def test_save_unsupported_mode(tmp_path: Path) -> None: ], ) def test_save(mode: str, test_file: str, tmp_path: Path) -> None: - out = str(tmp_path / "temp.dds") + out = tmp_path / "temp.dds" with Image.open(test_file) as im: assert im.mode == mode im.save(out) - with Image.open(out) as reloaded: - assert_image_equal(im, reloaded) + assert_image_equal_tofile(im, out) + + +def test_save_unsupported_pixel_format(tmp_path: Path) -> None: + out = tmp_path / "temp.dds" + im = hopper() + with pytest.raises(OSError, match="cannot write pixel format UNKNOWN"): + im.save(out, pixel_format="UNKNOWN") + + +def test_save_dxt1(tmp_path: Path) -> None: + # RGB + out = tmp_path / "temp.dds" + with Image.open(TEST_FILE_DXT1) as im: + im.convert("RGB").save(out, pixel_format="DXT1") + assert_image_similar_tofile(im, out, 1.84) + + # RGBA + im_alpha = im.copy() + im_alpha.putpixel((0, 0), (0, 0, 0, 0)) + im_alpha.save(out, pixel_format="DXT1") + with Image.open(out) as reloaded: + assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0) + + # L + im_l = im.convert("L") + im_l.save(out, pixel_format="DXT1") + assert_image_similar_tofile(im_l.convert("RGBA"), out, 6.07) + + # LA + im_alpha.convert("LA").save(out, pixel_format="DXT1") + with Image.open(out) as reloaded: + assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0) + + +def test_save_dxt3(tmp_path: Path) -> None: + # RGB + out = tmp_path / "temp.dds" + with Image.open(TEST_FILE_DXT3) as im: + im_rgb = im.convert("RGB") + im_rgb.save(out, pixel_format="DXT3") + assert_image_similar_tofile(im_rgb.convert("RGBA"), out, 1.26) + + # RGBA + im.save(out, pixel_format="DXT3") + assert_image_similar_tofile(im, out, 3.81) + + # L + im_l = im.convert("L") + im_l.save(out, pixel_format="DXT3") + assert_image_similar_tofile(im_l.convert("RGBA"), out, 5.89) + + # LA + im_la = im.convert("LA") + im_la.save(out, pixel_format="DXT3") + assert_image_similar_tofile(im_la.convert("RGBA"), out, 8.44) + + +def test_save_dxt5(tmp_path: Path) -> None: + # RGB + out = tmp_path / "temp.dds" + with Image.open(TEST_FILE_DXT1) as im: + im.convert("RGB").save(out, pixel_format="DXT5") + assert_image_similar_tofile(im, out, 1.84) + + # RGBA + with Image.open(TEST_FILE_DXT5) as im_rgba: + im_rgba.save(out, pixel_format="DXT5") + assert_image_similar_tofile(im_rgba, out, 3.69) + + # L + im_l = im.convert("L") + im_l.save(out, pixel_format="DXT5") + assert_image_similar_tofile(im_l.convert("RGBA"), out, 6.07) + + # LA + im_la = im_rgba.convert("LA") + im_la.save(out, pixel_format="DXT5") + assert_image_similar_tofile(im_la.convert("RGBA"), out, 8.32) + + +def test_save_dx10_bc5(tmp_path: Path) -> None: + out = tmp_path / "temp.dds" + with Image.open(TEST_FILE_DX10_BC5_TYPELESS) as im: + im.save(out, pixel_format="BC5") + assert_image_similar_tofile(im, out, 9.56) + + im = hopper("L") + with pytest.raises(OSError, match="only RGB mode can be written as BC5"): + im.save(out, pixel_format="BC5") diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 672c04a4d..b484a8cfa 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -86,6 +86,8 @@ simple_eps_file_with_long_binary_data = ( def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: expected_size = tuple(s * scale for s in size) with Image.open(filename) as image: + assert isinstance(image, EpsImagePlugin.EpsImageFile) + image.load(scale=scale) assert image.mode == "RGB" assert image.size == expected_size @@ -95,10 +97,14 @@ def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_load() -> None: with Image.open(FILE1) as im: - assert im.load()[0, 0] == (255, 255, 255) + px = im.load() + assert px is not None + assert px[0, 0] == (255, 255, 255) # Test again now that it has already been loaded once - assert im.load()[0, 0] == (255, 255, 255) + px = im.load() + assert px is not None + assert px[0, 0] == (255, 255, 255) def test_binary() -> None: @@ -223,6 +229,8 @@ def test_showpage() -> None: @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_transparency() -> None: with Image.open("Tests/images/eps/reqd_showpage.eps") as plot_image: + assert isinstance(plot_image, EpsImagePlugin.EpsImageFile) + plot_image.load(transparency=True) assert plot_image.mode == "RGBA" @@ -235,7 +243,7 @@ def test_transparency() -> None: def test_file_object(tmp_path: Path) -> None: # issue 479 with Image.open(FILE1) as image1: - with open(str(tmp_path / "temp.eps"), "wb") as fh: + with open(tmp_path / "temp.eps", "wb") as fh: image1.save(fh, "EPS") @@ -270,7 +278,7 @@ def test_1(filename: str) -> None: def test_image_mode_not_supported(tmp_path: Path) -> None: im = hopper("RGBA") - tmpfile = str(tmp_path / "temp.eps") + tmpfile = tmp_path / "temp.eps" with pytest.raises(ValueError): im.save(tmpfile) @@ -304,6 +312,7 @@ def test_render_scale2() -> None: # Zero bounding box with Image.open(FILE1) as image1_scale2: + assert isinstance(image1_scale2, EpsImagePlugin.EpsImageFile) image1_scale2.load(scale=2) with Image.open(FILE1_COMPARE_SCALE2) as image1_scale2_compare: image1_scale2_compare = image1_scale2_compare.convert("RGB") @@ -312,6 +321,7 @@ def test_render_scale2() -> None: # Non-zero bounding box with Image.open(FILE2) as image2_scale2: + assert isinstance(image2_scale2, EpsImagePlugin.EpsImageFile) image2_scale2.load(scale=2) with Image.open(FILE2_COMPARE_SCALE2) as image2_scale2_compare: image2_scale2_compare = image2_scale2_compare.convert("RGB") diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index 0a7740cc8..81df1ab0b 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import warnings import pytest @@ -21,6 +22,8 @@ animated_test_file_with_prefix_chunk = "Tests/images/2422.flc" def test_sanity() -> None: with Image.open(static_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) + im.load() assert im.mode == "P" assert im.size == (128, 128) @@ -28,6 +31,8 @@ def test_sanity() -> None: assert not im.is_animated with Image.open(animated_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) + assert im.mode == "P" assert im.size == (320, 200) assert im.format == "FLI" @@ -35,32 +40,29 @@ def test_sanity() -> None: assert im.is_animated -def test_prefix_chunk() -> None: - ImageFile.LOAD_TRUNCATED_IMAGES = True - try: - with Image.open(animated_test_file_with_prefix_chunk) as im: - assert im.mode == "P" - assert im.size == (320, 200) - assert im.format == "FLI" - assert im.info["duration"] == 171 - assert im.is_animated +def test_prefix_chunk(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) + with Image.open(animated_test_file_with_prefix_chunk) as im: + assert im.mode == "P" + assert im.size == (320, 200) + assert im.format == "FLI" + assert im.info["duration"] == 171 + assert im.is_animated - palette = im.getpalette() - assert palette[3:6] == [255, 255, 255] - assert palette[381:384] == [204, 204, 12] - assert palette[765:] == [252, 0, 0] - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + palette = im.getpalette() + assert palette[3:6] == [255, 255, 255] + assert palette[381:384] == [204, 204, 12] + assert palette[765:] == [252, 0, 0] @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file() -> None: - def open() -> None: + def open_test_image() -> None: im = Image.open(static_test_file) im.load() with pytest.warns(ResourceWarning): - open() + open_test_image() def test_closed_file() -> None: @@ -114,16 +116,19 @@ def test_palette_chunk_second() -> None: def test_n_frames() -> None: with Image.open(static_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) assert im.n_frames == 1 assert not im.is_animated with Image.open(animated_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) assert im.n_frames == 384 assert im.is_animated def test_eoferror() -> None: with Image.open(animated_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) n_frames = im.n_frames # Test seeking past the last frame @@ -135,6 +140,15 @@ def test_eoferror() -> None: im.seek(n_frames - 1) +def test_missing_frame_size() -> None: + with open(animated_test_file, "rb") as fp: + data = fp.read() + data = data[:6188] + with Image.open(io.BytesIO(data)) as im: + with pytest.raises(EOFError, match="missing frame size"): + im.seek(1) + + def test_seek_tell() -> None: with Image.open(animated_test_file) as im: layer_number = im.tell() @@ -159,10 +173,14 @@ def test_seek_tell() -> None: def test_seek() -> None: with Image.open(animated_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) im.seek(50) assert_image_equal_tofile(im, "Tests/images/a_fli.png") + with pytest.raises(ValueError, match="cannot seek to frame 52"): + im._seek(52) + @pytest.mark.parametrize( "test_file", diff --git a/Tests/test_file_fpx.py b/Tests/test_file_fpx.py index e32f30a01..8d8064692 100644 --- a/Tests/test_file_fpx.py +++ b/Tests/test_file_fpx.py @@ -22,10 +22,11 @@ def test_sanity() -> None: def test_close() -> None: with Image.open("Tests/images/input_bw_one_band.fpx") as im: - pass + assert isinstance(im, FpxImagePlugin.FpxImageFile) assert im.ole.fp.closed im = Image.open("Tests/images/input_bw_one_band.fpx") + assert isinstance(im, FpxImagePlugin.FpxImageFile) im.close() assert im.ole.fp.closed diff --git a/Tests/test_file_ftex.py b/Tests/test_file_ftex.py index 0c544245a..fdd7b3757 100644 --- a/Tests/test_file_ftex.py +++ b/Tests/test_file_ftex.py @@ -1,5 +1,8 @@ from __future__ import annotations +import io +import struct + import pytest from PIL import FtexImagePlugin, Image @@ -23,3 +26,15 @@ def test_invalid_file() -> None: with pytest.raises(SyntaxError): FtexImagePlugin.FtexImageFile(invalid_file) + + +def test_invalid_texture() -> None: + with open("Tests/images/ftex_dxt1.ftc", "rb") as fp: + data = fp.read() + + # Change texture compression format + data = data[:24] + struct.pack(" None: def test_load() -> None: with Image.open("Tests/images/gbr.gbr") as im: - assert im.load()[0, 0] == (0, 0, 0, 0) + px = im.load() + assert px is not None + assert px[0, 0] == (0, 0, 0, 0) # Test again now that it has already been loaded once - assert im.load()[0, 0] == (0, 0, 0, 0) + px = im.load() + assert px is not None + assert px[0, 0] == (0, 0, 0, 0) def test_multiple_load_operations() -> None: diff --git a/Tests/test_file_gd.py b/Tests/test_file_gd.py index d512df284..806532c17 100644 --- a/Tests/test_file_gd.py +++ b/Tests/test_file_gd.py @@ -4,6 +4,8 @@ import pytest from PIL import GdImageFile, UnidentifiedImageError +from .helper import assert_image_similar_tofile + TEST_GD_FILE = "Tests/images/hopper.gd" @@ -11,6 +13,7 @@ def test_sanity() -> None: with GdImageFile.open(TEST_GD_FILE) as im: assert im.size == (128, 128) assert im.format == "GD" + assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.jpg", 14) def test_bad_mode() -> None: diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 248347d5b..20d58a9dd 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -4,6 +4,7 @@ import warnings from collections.abc import Generator from io import BytesIO from pathlib import Path +from typing import Any import pytest @@ -21,9 +22,6 @@ from .helper import ( # sample gif stream TEST_GIF = "Tests/images/hopper.gif" -with open(TEST_GIF, "rb") as f: - data = f.read() - def test_sanity() -> None: with Image.open(TEST_GIF) as im: @@ -36,12 +34,12 @@ def test_sanity() -> None: @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file() -> None: - def open() -> None: + def open_test_image() -> None: im = Image.open(TEST_GIF) im.load() with pytest.warns(ResourceWarning): - open() + open_test_image() def test_closed_file() -> None: @@ -85,12 +83,12 @@ def test_invalid_file() -> None: def test_l_mode_transparency() -> None: with Image.open("Tests/images/no_palette_with_transparency.gif") as im: assert im.mode == "L" - assert im.load()[0, 0] == 128 + assert im.getpixel((0, 0)) == 128 assert im.info["transparency"] == 255 im.seek(1) assert im.mode == "L" - assert im.load()[0, 0] == 128 + assert im.getpixel((0, 0)) == 128 def test_l_mode_after_rgb() -> None: @@ -108,7 +106,7 @@ def test_palette_not_needed_for_second_frame() -> None: assert_image_similar(im, hopper("L").convert("RGB"), 8) -def test_strategy() -> None: +def test_strategy(monkeypatch: pytest.MonkeyPatch) -> None: with Image.open("Tests/images/iss634.gif") as im: expected_rgb_always = im.convert("RGB") @@ -118,35 +116,36 @@ def test_strategy() -> None: im.seek(1) expected_different = im.convert("RGB") - try: - GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS - with Image.open("Tests/images/iss634.gif") as im: - assert im.mode == "RGB" - assert_image_equal(im, expected_rgb_always) + monkeypatch.setattr( + GifImagePlugin, "LOADING_STRATEGY", GifImagePlugin.LoadingStrategy.RGB_ALWAYS + ) + with Image.open("Tests/images/iss634.gif") as im: + assert im.mode == "RGB" + assert_image_equal(im, expected_rgb_always) - with Image.open("Tests/images/chi.gif") as im: - assert im.mode == "RGBA" - assert_image_equal(im, expected_rgb_always_rgba) + with Image.open("Tests/images/chi.gif") as im: + assert im.mode == "RGBA" + assert_image_equal(im, expected_rgb_always_rgba) - GifImagePlugin.LOADING_STRATEGY = ( - GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY - ) - # Stay in P mode with only a global palette - with Image.open("Tests/images/chi.gif") as im: - assert im.mode == "P" + monkeypatch.setattr( + GifImagePlugin, + "LOADING_STRATEGY", + GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY, + ) + # Stay in P mode with only a global palette + with Image.open("Tests/images/chi.gif") as im: + assert im.mode == "P" - im.seek(1) - assert im.mode == "P" - assert_image_equal(im.convert("RGB"), expected_different) + im.seek(1) + assert im.mode == "P" + assert_image_equal(im.convert("RGB"), expected_different) - # Change to RGB mode when a frame has an individual palette - with Image.open("Tests/images/iss634.gif") as im: - assert im.mode == "P" + # Change to RGB mode when a frame has an individual palette + with Image.open("Tests/images/iss634.gif") as im: + assert im.mode == "P" - im.seek(1) - assert im.mode == "RGB" - finally: - GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST + im.seek(1) + assert im.mode == "RGB" def test_optimize() -> None: @@ -229,7 +228,7 @@ def test_optimize_if_palette_can_be_reduced_by_half() -> None: def test_full_palette_second_frame(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("P", (1, 256)) full_palette_im = Image.new("P", (1, 256)) @@ -250,7 +249,7 @@ def test_full_palette_second_frame(tmp_path: Path) -> None: def test_roundtrip(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = hopper() im.save(out) with Image.open(out) as reread: @@ -259,7 +258,7 @@ def test_roundtrip(tmp_path: Path) -> None: def test_roundtrip2(tmp_path: Path) -> None: # see https://github.com/python-pillow/Pillow/issues/403 - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" with Image.open(TEST_GIF) as im: im2 = im.copy() im2.save(out) @@ -269,7 +268,7 @@ def test_roundtrip2(tmp_path: Path) -> None: def test_roundtrip_save_all(tmp_path: Path) -> None: # Single frame image - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = hopper() im.save(out, save_all=True) with Image.open(out) as reread: @@ -277,7 +276,7 @@ def test_roundtrip_save_all(tmp_path: Path) -> None: # Multiframe image with Image.open("Tests/images/dispose_bgnd.gif") as im: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im.save(out, save_all=True) with Image.open(out) as reread: @@ -285,7 +284,7 @@ def test_roundtrip_save_all(tmp_path: Path) -> None: def test_roundtrip_save_all_1(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("1", (1, 1)) im2 = Image.new("1", (1, 1), 1) im.save(out, save_all=True, append_images=[im2]) @@ -308,8 +307,9 @@ def test_roundtrip_save_all_1(tmp_path: Path) -> None: def test_loading_multiple_palettes(path: str, mode: str) -> None: with Image.open(path) as im: assert im.mode == "P" + assert im.palette is not None first_frame_colors = im.palette.colors.keys() - original_color = im.convert("RGB").load()[0, 0] + original_color = im.convert("RGB").getpixel((0, 0)) im.seek(1) assert im.mode == mode @@ -317,10 +317,10 @@ def test_loading_multiple_palettes(path: str, mode: str) -> None: im = im.convert("RGB") # Check a color only from the old palette - assert im.load()[0, 0] == original_color + assert im.getpixel((0, 0)) == original_color # Check a color from the new palette - assert im.load()[24, 24] not in first_frame_colors + assert im.getpixel((24, 24)) not in first_frame_colors def test_headers_saving_for_animated_gifs(tmp_path: Path) -> None: @@ -329,7 +329,7 @@ def test_headers_saving_for_animated_gifs(tmp_path: Path) -> None: with Image.open("Tests/images/dispose_bgnd.gif") as im: info = im.info.copy() - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im.save(out, save_all=True) with Image.open(out) as reread: for header in important_headers: @@ -345,7 +345,7 @@ def test_palette_handling(tmp_path: Path) -> None: im = im.resize((100, 100), Image.Resampling.LANCZOS) im2 = im.convert("P", palette=Image.Palette.ADAPTIVE, colors=256) - f = str(tmp_path / "temp.gif") + f = tmp_path / "temp.gif" im2.save(f, optimize=True) with Image.open(f) as reloaded: @@ -356,7 +356,7 @@ def test_palette_434(tmp_path: Path) -> None: # see https://github.com/python-pillow/Pillow/issues/434 def roundtrip(im: Image.Image, **kwargs: bool) -> Image.Image: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im.copy().save(out, "GIF", **kwargs) reloaded = Image.open(out) @@ -402,6 +402,7 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None: def test_seek() -> None: with Image.open("Tests/images/dispose_none.gif") as img: + assert isinstance(img, GifImagePlugin.GifImageFile) frame_count = 0 try: while True: @@ -410,6 +411,10 @@ def test_seek() -> None: except EOFError: assert frame_count == 5 + img.seek(0) + with pytest.raises(ValueError, match="cannot seek to frame 2"): + img._seek(2) + def test_seek_info() -> None: with Image.open("Tests/images/iss634.gif") as im: @@ -442,10 +447,12 @@ def test_seek_rewind() -> None: def test_n_frames(path: str, n_frames: int) -> None: # Test is_animated before n_frames with Image.open(path) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.is_animated == (n_frames != 1) # Test is_animated after n_frames with Image.open(path) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.n_frames == n_frames assert im.is_animated == (n_frames != 1) @@ -455,6 +462,7 @@ def test_no_change() -> None: with Image.open("Tests/images/dispose_bgnd.gif") as im: im.seek(1) expected = im.copy() + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.n_frames == 5 assert_image_equal(im, expected) @@ -462,17 +470,20 @@ def test_no_change() -> None: with Image.open("Tests/images/dispose_bgnd.gif") as im: im.seek(3) expected = im.copy() + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.is_animated assert_image_equal(im, expected) with Image.open("Tests/images/comment_after_only_frame.gif") as im: expected = Image.new("P", (1, 1)) + assert isinstance(im, GifImagePlugin.GifImageFile) assert not im.is_animated assert_image_equal(im, expected) def test_eoferror() -> None: with Image.open(TEST_GIF) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) n_frames = im.n_frames # Test seeking past the last frame @@ -486,12 +497,12 @@ def test_eoferror() -> None: def test_first_frame_transparency() -> None: with Image.open("Tests/images/first_frame_transparency.gif") as im: - px = im.load() - assert px[0, 0] == im.info["transparency"] + assert im.getpixel((0, 0)) == im.info["transparency"] def test_dispose_none() -> None: with Image.open("Tests/images/dispose_none.gif") as img: + assert isinstance(img, GifImagePlugin.GifImageFile) try: while True: img.seek(img.tell() + 1) @@ -515,6 +526,7 @@ def test_dispose_none_load_end() -> None: def test_dispose_background() -> None: with Image.open("Tests/images/dispose_bgnd.gif") as img: + assert isinstance(img, GifImagePlugin.GifImageFile) try: while True: img.seek(img.tell() + 1) @@ -527,6 +539,7 @@ def test_dispose_background_transparency() -> None: with Image.open("Tests/images/dispose_bgnd_transparency.gif") as img: img.seek(2) px = img.load() + assert px is not None assert px[35, 30][3] == 0 @@ -554,21 +567,20 @@ def test_dispose_background_transparency() -> None: def test_transparent_dispose( loading_strategy: GifImagePlugin.LoadingStrategy, expected_colors: tuple[tuple[int | tuple[int, int, int, int], ...]], + monkeypatch: pytest.MonkeyPatch, ) -> None: - GifImagePlugin.LOADING_STRATEGY = loading_strategy - try: - with Image.open("Tests/images/transparent_dispose.gif") as img: - for frame in range(3): - img.seek(frame) - for x in range(3): - color = img.getpixel((x, 0)) - assert color == expected_colors[frame][x] - finally: - GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST + monkeypatch.setattr(GifImagePlugin, "LOADING_STRATEGY", loading_strategy) + with Image.open("Tests/images/transparent_dispose.gif") as img: + for frame in range(3): + img.seek(frame) + for x in range(3): + color = img.getpixel((x, 0)) + assert color == expected_colors[frame][x] def test_dispose_previous() -> None: with Image.open("Tests/images/dispose_prev.gif") as img: + assert isinstance(img, GifImagePlugin.GifImageFile) try: while True: img.seek(img.tell() + 1) @@ -597,15 +609,16 @@ def test_previous_frame_loaded() -> None: def test_save_dispose(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im_list = [ Image.new("L", (100, 100), "#000"), Image.new("L", (100, 100), "#111"), Image.new("L", (100, 100), "#222"), ] - for method in range(0, 4): + for method in range(4): im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=method) with Image.open(out) as img: + assert isinstance(img, GifImagePlugin.GifImageFile) for _ in range(2): img.seek(img.tell() + 1) assert img.disposal_method == method @@ -619,13 +632,14 @@ def test_save_dispose(tmp_path: Path) -> None: ) with Image.open(out) as img: + assert isinstance(img, GifImagePlugin.GifImageFile) for i in range(2): img.seek(img.tell() + 1) assert img.disposal_method == i + 1 def test_dispose2_palette(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" # Four colors: white, gray, black, red circles = [(255, 255, 255), (153, 153, 153), (0, 0, 0), (255, 0, 0)] @@ -659,7 +673,7 @@ def test_dispose2_palette(tmp_path: Path) -> None: def test_dispose2_diff(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" # 4 frames: red/blue, red/red, blue/blue, red/blue circles = [ @@ -701,7 +715,7 @@ def test_dispose2_diff(tmp_path: Path) -> None: def test_dispose2_background(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im_list = [] @@ -727,7 +741,7 @@ def test_dispose2_background(tmp_path: Path) -> None: def test_dispose2_background_frame(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im_list = [Image.new("RGBA", (1, 20))] @@ -741,11 +755,12 @@ def test_dispose2_background_frame(tmp_path: Path) -> None: im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=2) with Image.open(out) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.n_frames == 3 def test_dispose2_previous_frame(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("P", (100, 100)) im.info["transparency"] = 0 @@ -763,8 +778,23 @@ def test_dispose2_previous_frame(tmp_path: Path) -> None: assert im.getpixel((0, 0)) == (0, 0, 0, 255) +def test_dispose2_without_transparency(tmp_path: Path) -> None: + out = tmp_path / "temp.gif" + + im = Image.new("P", (100, 100)) + + im2 = Image.new("P", (100, 100), (0, 0, 0)) + im2.putpixel((50, 50), (255, 0, 0)) + + im.save(out, save_all=True, append_images=[im2], disposal=2) + + with Image.open(out) as reloaded: + reloaded.seek(1) + assert reloaded.tile[0].extents == (0, 0, 100, 100) + + def test_transparency_in_second_frame(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" with Image.open("Tests/images/different_transparency.gif") as im: assert im.info["transparency"] == 0 @@ -794,7 +824,7 @@ def test_no_transparency_in_second_frame() -> None: def test_remapped_transparency(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("P", (1, 2)) im2 = im.copy() @@ -812,7 +842,7 @@ def test_remapped_transparency(tmp_path: Path) -> None: def test_duration(tmp_path: Path) -> None: duration = 1000 - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("L", (100, 100), "#000") # Check that the argument has priority over the info settings @@ -826,7 +856,7 @@ def test_duration(tmp_path: Path) -> None: def test_multiple_duration(tmp_path: Path) -> None: duration_list = [1000, 2000, 3000] - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im_list = [ Image.new("L", (100, 100), "#000"), Image.new("L", (100, 100), "#111"), @@ -861,7 +891,7 @@ def test_multiple_duration(tmp_path: Path) -> None: def test_roundtrip_info_duration(tmp_path: Path) -> None: duration_list = [100, 500, 500] - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" with Image.open("Tests/images/transparent_dispose.gif") as im: assert [ frame.info["duration"] for frame in ImageSequence.Iterator(im) @@ -876,7 +906,7 @@ def test_roundtrip_info_duration(tmp_path: Path) -> None: def test_roundtrip_info_duration_combined(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" with Image.open("Tests/images/duplicate_frame.gif") as im: assert [frame.info["duration"] for frame in ImageSequence.Iterator(im)] == [ 1000, @@ -894,7 +924,7 @@ def test_roundtrip_info_duration_combined(tmp_path: Path) -> None: def test_identical_frames(tmp_path: Path) -> None: duration_list = [1000, 1500, 2000, 4000] - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im_list = [ Image.new("L", (100, 100), "#000"), Image.new("L", (100, 100), "#000"), @@ -907,6 +937,8 @@ def test_identical_frames(tmp_path: Path) -> None: out, save_all=True, append_images=im_list[1:], duration=duration_list ) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) + # Assert that the first three frames were combined assert reread.n_frames == 2 @@ -927,7 +959,7 @@ def test_identical_frames(tmp_path: Path) -> None: def test_identical_frames_to_single_frame( duration: int | list[int], tmp_path: Path ) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im_list = [ Image.new("L", (100, 100), "#000"), Image.new("L", (100, 100), "#000"), @@ -936,6 +968,8 @@ def test_identical_frames_to_single_frame( im_list[0].save(out, save_all=True, append_images=im_list[1:], duration=duration) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) + # Assert that all frames were combined assert reread.n_frames == 1 @@ -944,7 +978,7 @@ def test_identical_frames_to_single_frame( def test_loop_none(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("L", (100, 100), "#000") im.save(out, loop=None) with Image.open(out) as reread: @@ -954,7 +988,7 @@ def test_loop_none(tmp_path: Path) -> None: def test_number_of_loops(tmp_path: Path) -> None: number_of_loops = 2 - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("L", (100, 100), "#000") im.save(out, loop=number_of_loops) with Image.open(out) as reread: @@ -970,7 +1004,7 @@ def test_number_of_loops(tmp_path: Path) -> None: def test_background(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("L", (100, 100), "#000") im.info["background"] = 1 im.save(out) @@ -979,7 +1013,7 @@ def test_background(tmp_path: Path) -> None: def test_webp_background(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" # Test opaque WebP background if features.check("webp"): @@ -997,7 +1031,7 @@ def test_comment(tmp_path: Path) -> None: with Image.open(TEST_GIF) as im: assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0" - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("L", (100, 100), "#000") im.info["comment"] = b"Test comment text" im.save(out) @@ -1014,7 +1048,7 @@ def test_comment(tmp_path: Path) -> None: def test_comment_over_255(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("L", (100, 100), "#000") comment = b"Test comment text" while len(comment) < 256: @@ -1040,7 +1074,7 @@ def test_read_multiple_comment_blocks() -> None: def test_empty_string_comment(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" with Image.open("Tests/images/chi.gif") as im: assert "comment" in im.info @@ -1074,7 +1108,7 @@ def test_retain_comment_in_subsequent_frames(tmp_path: Path) -> None: assert "comment" not in im.info # Test that a saved image keeps the comment - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" with Image.open("Tests/images/dispose_prev.gif") as im: im.save(out, save_all=True, comment="Test") @@ -1084,7 +1118,7 @@ def test_retain_comment_in_subsequent_frames(tmp_path: Path) -> None: def test_version(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" def assert_version_after_save(im: Image.Image, version: bytes) -> None: im.save(out) @@ -1114,7 +1148,7 @@ def test_version(tmp_path: Path) -> None: def test_append_images(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" # Test appending single frame images im = Image.new("RGB", (100, 100), "#f00") @@ -1122,6 +1156,14 @@ def test_append_images(tmp_path: Path) -> None: im.copy().save(out, save_all=True, append_images=ims) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) + assert reread.n_frames == 3 + + # Test append_images without save_all + im.copy().save(out, append_images=ims) + + with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 3 # Tests appending using a generator @@ -1131,6 +1173,7 @@ def test_append_images(tmp_path: Path) -> None: im.save(out, save_all=True, append_images=im_generator(ims)) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 3 # Tests appending single and multiple frame images @@ -1139,11 +1182,12 @@ def test_append_images(tmp_path: Path) -> None: im.save(out, save_all=True, append_images=[im2]) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 10 def test_append_different_size_image(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("RGB", (100, 100)) bigger_im = Image.new("RGB", (200, 200), "#f00") @@ -1170,7 +1214,7 @@ def test_transparent_optimize(tmp_path: Path) -> None: im.frombytes(data) im.putpalette(palette) - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im.save(out, transparency=im.getpixel((252, 0))) with Image.open(out) as reloaded: @@ -1178,7 +1222,7 @@ def test_transparent_optimize(tmp_path: Path) -> None: def test_removed_transparency(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("RGB", (256, 1)) for x in range(256): @@ -1193,7 +1237,7 @@ def test_removed_transparency(tmp_path: Path) -> None: def test_rgb_transparency(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" # Single frame im = Image.new("RGB", (1, 1)) @@ -1215,7 +1259,7 @@ def test_rgb_transparency(tmp_path: Path) -> None: def test_rgba_transparency(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = hopper("P") im.save(out, save_all=True, append_images=[Image.new("RGBA", im.size)]) @@ -1225,25 +1269,26 @@ def test_rgba_transparency(tmp_path: Path) -> None: assert_image_equal(hopper("P").convert("RGB"), reloaded) -def test_background_outside_palettte(tmp_path: Path) -> None: +def test_background_outside_palettte() -> None: with Image.open("Tests/images/background_outside_palette.gif") as im: im.seek(1) assert im.info["background"] == 255 def test_bbox(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("RGB", (100, 100), "#fff") ims = [Image.new("RGB", (100, 100), "#000")] im.save(out, save_all=True, append_images=ims) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 2 def test_bbox_alpha(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im = Image.new("RGBA", (1, 2), (255, 0, 0, 255)) im.putpixel((0, 1), (255, 0, 0, 0)) @@ -1251,6 +1296,7 @@ def test_bbox_alpha(tmp_path: Path) -> None: im.save(out, save_all=True, append_images=[im2]) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 2 @@ -1262,7 +1308,7 @@ def test_palette_save_L(tmp_path: Path) -> None: palette = im.getpalette() assert palette is not None - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im_l.save(out, palette=bytes(palette)) with Image.open(out) as reloaded: @@ -1273,7 +1319,7 @@ def test_palette_save_P(tmp_path: Path) -> None: im = Image.new("P", (1, 2)) im.putpixel((0, 1), 1) - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im.save(out, palette=bytes((1, 2, 3, 4, 5, 6))) with Image.open(out) as reloaded: @@ -1289,7 +1335,7 @@ def test_palette_save_duplicate_entries(tmp_path: Path) -> None: im.putpalette((0, 0, 0, 0, 0, 0)) - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im.save(out, palette=[0, 0, 0, 0, 0, 0, 1, 1, 1]) with Image.open(out) as reloaded: @@ -1304,7 +1350,7 @@ def test_palette_save_all_P(tmp_path: Path) -> None: frame.putpalette(color) frames.append(frame) - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" frames[0].save( out, save_all=True, palette=[255, 0, 0, 0, 255, 0], append_images=frames[1:] ) @@ -1312,6 +1358,7 @@ def test_palette_save_all_P(tmp_path: Path) -> None: with Image.open(out) as im: # Assert that the frames are correct, and each frame has the same palette assert_image_equal(im.convert("RGB"), frames[0].convert("RGB")) + assert im.palette is not None assert im.palette.palette == im.global_palette.palette im.seek(1) @@ -1326,7 +1373,7 @@ def test_palette_save_ImagePalette(tmp_path: Path) -> None: im = hopper("P") palette = ImagePalette.ImagePalette("RGB", list(range(256))[::-1] * 3) - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im.save(out, palette=palette) with Image.open(out) as reloaded: @@ -1339,39 +1386,37 @@ def test_save_I(tmp_path: Path) -> None: im = hopper("I") - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" im.save(out) with Image.open(out) as reloaded: assert_image_equal(reloaded.convert("L"), im.convert("L")) -def test_getdata() -> None: +def test_getdata(monkeypatch: pytest.MonkeyPatch) -> None: # Test getheader/getdata against legacy values. # Create a 'P' image with holes in the palette. - im = Image._wedge().resize((16, 16), Image.Resampling.NEAREST) + im = Image.linear_gradient(mode="L").resize((16, 16), Image.Resampling.NEAREST) im.putpalette(ImagePalette.ImagePalette("RGB")) im.info = {"background": 0} passed_palette = bytes(255 - i // 3 for i in range(768)) - GifImagePlugin._FORCE_OPTIMIZE = True - try: - h = GifImagePlugin.getheader(im, passed_palette) - d = GifImagePlugin.getdata(im) + monkeypatch.setattr(GifImagePlugin, "_FORCE_OPTIMIZE", True) - import pickle + h = GifImagePlugin.getheader(im, passed_palette) + d = GifImagePlugin.getdata(im) - # Enable to get target values on pre-refactor version - # with open('Tests/images/gif_header_data.pkl', 'wb') as f: - # pickle.dump((h, d), f, 1) - with open("Tests/images/gif_header_data.pkl", "rb") as f: - (h_target, d_target) = pickle.load(f) + import pickle - assert h == h_target - assert d == d_target - finally: - GifImagePlugin._FORCE_OPTIMIZE = False + # Enable to get target values on pre-refactor version + # with open('Tests/images/gif_header_data.pkl', 'wb') as f: + # pickle.dump((h, d), f, 1) + with open("Tests/images/gif_header_data.pkl", "rb") as f: + (h_target, d_target) = pickle.load(f) + + assert h == h_target + assert d == d_target def test_lzw_bits() -> None: @@ -1397,24 +1442,24 @@ def test_lzw_bits() -> None: ), ) def test_extents( - test_file: str, loading_strategy: GifImagePlugin.LoadingStrategy + test_file: str, + loading_strategy: GifImagePlugin.LoadingStrategy, + monkeypatch: pytest.MonkeyPatch, ) -> None: - GifImagePlugin.LOADING_STRATEGY = loading_strategy - try: - with Image.open("Tests/images/" + test_file) as im: - assert im.size == (100, 100) + monkeypatch.setattr(GifImagePlugin, "LOADING_STRATEGY", loading_strategy) + with Image.open("Tests/images/" + test_file) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) + assert im.size == (100, 100) - # Check that n_frames does not change the size - assert im.n_frames == 2 - assert im.size == (100, 100) + # Check that n_frames does not change the size + assert im.n_frames == 2 + assert im.size == (100, 100) - im.seek(1) - assert im.size == (150, 150) + im.seek(1) + assert im.size == (150, 150) - im.load() - assert im.im.size == (150, 150) - finally: - GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST + im.load() + assert im.im.size == (150, 150) def test_missing_background() -> None: @@ -1426,7 +1471,7 @@ def test_missing_background() -> None: def test_saving_rgba(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") + out = tmp_path / "temp.gif" with Image.open("Tests/images/transparent.png") as im: im.save(out) @@ -1435,8 +1480,9 @@ def test_saving_rgba(tmp_path: Path) -> None: assert reloaded_rgba.load()[0, 0][3] == 0 -def test_optimizing_p_rgba(tmp_path: Path) -> None: - out = str(tmp_path / "temp.gif") +@pytest.mark.parametrize("params", ({}, {"disposal": 2, "optimize": False})) +def test_p_rgba(tmp_path: Path, params: dict[str, Any]) -> None: + out = tmp_path / "temp.gif" im1 = Image.new("P", (100, 100)) d = ImageDraw.Draw(im1) @@ -1447,7 +1493,8 @@ def test_optimizing_p_rgba(tmp_path: Path) -> None: im2 = Image.new("P", (100, 100)) im2.putpalette(data, "RGBA") - im1.save(out, save_all=True, append_images=[im2]) + im1.save(out, save_all=True, append_images=[im2], **params) with Image.open(out) as reloaded: + assert isinstance(reloaded, GifImagePlugin.GifImageFile) assert reloaded.n_frames == 2 diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index e8d5f1705..08862113b 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -1,5 +1,7 @@ from __future__ import annotations +from io import BytesIO + import pytest from PIL.GimpPaletteFile import GimpPaletteFile @@ -14,17 +16,20 @@ def test_sanity() -> None: GimpPaletteFile(fp) with open("Tests/images/bad_palette_file.gpl", "rb") as fp: - with pytest.raises(SyntaxError): + with pytest.raises(SyntaxError, match="bad palette file"): GimpPaletteFile(fp) with open("Tests/images/bad_palette_entry.gpl", "rb") as fp: - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="bad palette entry"): GimpPaletteFile(fp) -def test_get_palette() -> None: +@pytest.mark.parametrize( + "filename, size", (("custom_gimp_palette.gpl", 8), ("full_gimp_palette.gpl", 256)) +) +def test_get_palette(filename: str, size: int) -> None: # Arrange - with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp: + with open("Tests/images/" + filename, "rb") as fp: palette_file = GimpPaletteFile(fp) # Act @@ -32,3 +37,36 @@ def test_get_palette() -> None: # Assert assert mode == "RGB" + assert len(palette) / 3 == size + + +def test_frombytes() -> None: + # Test that __init__ stops reading after 260 lines + with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp: + custom_data = fp.read() + custom_data += b"#\n" * 300 + b" 0 0 0 Index 12" + b = BytesIO(custom_data) + palette = GimpPaletteFile(b) + assert len(palette.palette) / 3 == 8 + + # Test that __init__ only reads 256 entries + with open("Tests/images/full_gimp_palette.gpl", "rb") as fp: + full_data = fp.read() + data = full_data.replace(b"#\n", b"") + b" 0 0 0 Index 256" + b = BytesIO(data) + palette = GimpPaletteFile(b) + assert len(palette.palette) / 3 == 256 + + # Test that frombytes() can read beyond that + palette = GimpPaletteFile.frombytes(data) + assert len(palette.palette) / 3 == 257 + + # Test that __init__ raises an error if a comment is too long + data = full_data[:-1] + b"a" * 100 + b = BytesIO(data) + with pytest.raises(SyntaxError, match="bad palette file"): + palette = GimpPaletteFile(b) + + # Test that frombytes() can read the data regardless + palette = GimpPaletteFile.frombytes(data) + assert len(palette.palette) / 3 == 256 diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index aba473d24..960e5f4be 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -43,7 +43,7 @@ def test_load() -> None: def test_save(tmp_path: Path) -> None: # Arrange im = hopper() - tmpfile = str(tmp_path / "temp.grib") + tmpfile = tmp_path / "temp.grib" # Act / Assert: stub cannot save without an implemented handler with pytest.raises(OSError): @@ -79,8 +79,8 @@ def test_handler(tmp_path: Path) -> None: im.load() assert handler.is_loaded() - temp_file = str(tmp_path / "temp.grib") + temp_file = tmp_path / "temp.grib" im.save(temp_file) assert handler.saved - GribStubImagePlugin._handler = None + GribStubImagePlugin.register_handler(None) diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index 8275bd0d8..e4f09a09c 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -43,7 +43,7 @@ def test_save() -> None: # Arrange with Image.open(TEST_FILE) as im: dummy_fp = BytesIO() - dummy_filename = "dummy.filename" + dummy_filename = "dummy.h5" # Act / Assert: stub cannot save without an implemented handler with pytest.raises(OSError): @@ -81,8 +81,8 @@ def test_handler(tmp_path: Path) -> None: im.load() assert handler.is_loaded() - temp_file = str(tmp_path / "temp.h5") + temp_file = tmp_path / "temp.h5" im.save(temp_file) assert handler.saved - Hdf5StubImagePlugin._handler = None + Hdf5StubImagePlugin.register_handler(None) diff --git a/Tests/test_file_icns.py b/Tests/test_file_icns.py index 141b88dfa..2dabfd2f3 100644 --- a/Tests/test_file_icns.py +++ b/Tests/test_file_icns.py @@ -32,14 +32,18 @@ def test_sanity() -> None: def test_load() -> None: with Image.open(TEST_FILE) as im: - assert im.load()[0, 0] == (0, 0, 0, 0) + px = im.load() + assert px is not None + assert px[0, 0] == (0, 0, 0, 0) # Test again now that it has already been loaded once - assert im.load()[0, 0] == (0, 0, 0, 0) + px = im.load() + assert px is not None + assert px[0, 0] == (0, 0, 0, 0) def test_save(tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.icns") + temp_file = tmp_path / "temp.icns" with Image.open(TEST_FILE) as im: im.save(temp_file) @@ -56,7 +60,7 @@ def test_save(tmp_path: Path) -> None: def test_save_append_images(tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.icns") + temp_file = tmp_path / "temp.icns" provided_im = Image.new("RGBA", (32, 32), (255, 0, 0, 128)) with Image.open(TEST_FILE) as im: @@ -65,6 +69,7 @@ def test_save_append_images(tmp_path: Path) -> None: assert_image_similar_tofile(im, temp_file, 1) with Image.open(temp_file) as reread: + assert isinstance(reread, IcnsImagePlugin.IcnsImageFile) reread.size = (16, 16) reread.load(2) assert_image_equal(reread, provided_im) @@ -86,6 +91,7 @@ def test_sizes() -> None: # Check that we can load all of the sizes, and that the final pixel # dimensions are as expected with Image.open(TEST_FILE) as im: + assert isinstance(im, IcnsImagePlugin.IcnsImageFile) for w, h, r in im.info["sizes"]: wr = w * r hr = h * r @@ -114,6 +120,7 @@ def test_older_icon() -> None: wr = w * r hr = h * r with Image.open("Tests/images/pillow2.icns") as im2: + assert isinstance(im2, IcnsImagePlugin.IcnsImageFile) im2.size = (w, h) im2.load(r) assert im2.mode == "RGBA" @@ -131,6 +138,7 @@ def test_jp2_icon() -> None: wr = w * r hr = h * r with Image.open("Tests/images/pillow3.icns") as im2: + assert isinstance(im2, IcnsImagePlugin.IcnsImageFile) im2.size = (w, h) im2.load(r) assert im2.mode == "RGBA" diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 37770498a..5d2ace35e 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -24,7 +24,9 @@ def test_sanity() -> None: def test_load() -> None: with Image.open(TEST_ICO_FILE) as im: - assert im.load()[0, 0] == (1, 1, 9, 255) + px = im.load() + assert px is not None + assert px[0, 0] == (1, 1, 9, 255) def test_mask() -> None: @@ -39,7 +41,7 @@ def test_black_and_white() -> None: def test_palette(tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.ico") + temp_file = tmp_path / "temp.ico" im = Image.new("P", (16, 16)) im.save(temp_file) @@ -75,6 +77,7 @@ def test_save_to_bytes() -> None: # The other one output.seek(0) with Image.open(output) as reloaded: + assert isinstance(reloaded, IcoImagePlugin.IcoImageFile) reloaded.size = (32, 32) assert im.mode == reloaded.mode @@ -86,12 +89,13 @@ def test_save_to_bytes() -> None: def test_getpixel(tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.ico") + temp_file = tmp_path / "temp.ico" im = hopper() im.save(temp_file, "ico", sizes=[(32, 32), (64, 64)]) with Image.open(temp_file) as reloaded: + assert isinstance(reloaded, IcoImagePlugin.IcoImageFile) reloaded.load() reloaded.size = (32, 32) @@ -99,8 +103,8 @@ def test_getpixel(tmp_path: Path) -> None: def test_no_duplicates(tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.ico") - temp_file2 = str(tmp_path / "temp2.ico") + temp_file = tmp_path / "temp.ico" + temp_file2 = tmp_path / "temp2.ico" im = hopper() sizes = [(32, 32), (64, 64)] @@ -113,8 +117,8 @@ def test_no_duplicates(tmp_path: Path) -> None: def test_different_bit_depths(tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.ico") - temp_file2 = str(tmp_path / "temp2.ico") + temp_file = tmp_path / "temp.ico" + temp_file2 = tmp_path / "temp2.ico" im = hopper() im.save(temp_file, "ico", bitmap_format="bmp", sizes=[(128, 128)]) @@ -130,8 +134,8 @@ def test_different_bit_depths(tmp_path: Path) -> None: assert os.path.getsize(temp_file) != os.path.getsize(temp_file2) # Test that only matching sizes of different bit depths are saved - temp_file3 = str(tmp_path / "temp3.ico") - temp_file4 = str(tmp_path / "temp4.ico") + temp_file3 = tmp_path / "temp3.ico" + temp_file4 = tmp_path / "temp4.ico" im.save(temp_file3, "ico", bitmap_format="bmp", sizes=[(128, 128)]) im.save( @@ -165,6 +169,7 @@ def test_save_to_bytes_bmp(mode: str) -> None: # The other one output.seek(0) with Image.open(output) as reloaded: + assert isinstance(reloaded, IcoImagePlugin.IcoImageFile) reloaded.size = (32, 32) assert "RGBA" == reloaded.mode @@ -176,6 +181,7 @@ def test_save_to_bytes_bmp(mode: str) -> None: def test_incorrect_size() -> None: with Image.open(TEST_ICO_FILE) as im: + assert isinstance(im, IcoImagePlugin.IcoImageFile) with pytest.raises(ValueError): im.size = (1, 1) @@ -184,7 +190,7 @@ def test_save_256x256(tmp_path: Path) -> None: """Issue #2264 https://github.com/python-pillow/Pillow/issues/2264""" # Arrange with Image.open("Tests/images/hopper_256x256.ico") as im: - outfile = str(tmp_path / "temp_saved_hopper_256x256.ico") + outfile = tmp_path / "temp_saved_hopper_256x256.ico" # Act im.save(outfile) @@ -200,7 +206,7 @@ def test_only_save_relevant_sizes(tmp_path: Path) -> None: """ # Arrange with Image.open("Tests/images/python.ico") as im: # 16x16, 32x32, 48x48 - outfile = str(tmp_path / "temp_saved_python.ico") + outfile = tmp_path / "temp_saved_python.ico" # Act im.save(outfile) @@ -213,10 +219,11 @@ def test_save_append_images(tmp_path: Path) -> None: # append_images should be used for scaled down versions of the image im = hopper("RGBA") provided_im = Image.new("RGBA", (32, 32), (255, 0, 0)) - outfile = str(tmp_path / "temp_saved_multi_icon.ico") + outfile = tmp_path / "temp_saved_multi_icon.ico" im.save(outfile, sizes=[(32, 32), (128, 128)], append_images=[provided_im]) with Image.open(outfile) as reread: + assert isinstance(reread, IcoImagePlugin.IcoImageFile) assert_image_equal(reread, hopper("RGBA")) reread.size = (32, 32) @@ -233,7 +240,7 @@ def test_unexpected_size() -> None: def test_draw_reloaded(tmp_path: Path) -> None: with Image.open(TEST_ICO_FILE) as im: - outfile = str(tmp_path / "temp_saved_hopper_draw.ico") + outfile = tmp_path / "temp_saved_hopper_draw.ico" draw = ImageDraw.Draw(im) draw.line((0, 0) + im.size, "#f00") @@ -243,27 +250,23 @@ def test_draw_reloaded(tmp_path: Path) -> None: assert_image_equal_tofile(im, "Tests/images/hopper_draw.ico") -def test_truncated_mask() -> None: +def test_truncated_mask(monkeypatch: pytest.MonkeyPatch) -> None: # 1 bpp with open("Tests/images/hopper_mask.ico", "rb") as fp: data = fp.read() - ImageFile.LOAD_TRUNCATED_IMAGES = True + monkeypatch.setattr(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" + with Image.open(io.BytesIO(data)) as im: + assert im.mode == "1" - # 32 bpp - output = io.BytesIO() - expected = hopper("RGBA") - expected.save(output, "ico", bitmap_format="bmp") + # 32 bpp + output = io.BytesIO() + expected = hopper("RGBA") + expected.save(output, "ico", bitmap_format="bmp") - data = output.getvalue()[:-1] + data = output.getvalue()[:-1] - with Image.open(io.BytesIO(data)) as im: - assert im.mode == "RGB" - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + with Image.open(io.BytesIO(data)) as im: + assert im.mode == "RGB" diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index 1d3fa485f..55c6b7305 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -23,7 +23,7 @@ def test_sanity() -> None: def test_name_limit(tmp_path: Path) -> None: - out = str(tmp_path / ("name_limit_test" * 7 + ".im")) + out = tmp_path / ("name_limit_test" * 7 + ".im") with Image.open(TEST_IM) as im: im.save(out) assert filecmp.cmp(out, "Tests/images/hopper_long_name.im") @@ -31,12 +31,12 @@ def test_name_limit(tmp_path: Path) -> None: @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file() -> None: - def open() -> None: + def open_test_image() -> None: im = Image.open(TEST_IM) im.load() with pytest.warns(ResourceWarning): - open() + open_test_image() def test_closed_file() -> None: @@ -68,12 +68,14 @@ def test_tell() -> None: def test_n_frames() -> None: with Image.open(TEST_IM) as im: + assert isinstance(im, ImImagePlugin.ImImageFile) assert im.n_frames == 1 assert not im.is_animated def test_eoferror() -> None: with Image.open(TEST_IM) as im: + assert isinstance(im, ImImagePlugin.ImImageFile) n_frames = im.n_frames # Test seeking past the last frame @@ -87,7 +89,7 @@ def test_eoferror() -> None: @pytest.mark.parametrize("mode", ("RGB", "P", "PA")) def test_roundtrip(mode: str, tmp_path: Path) -> None: - out = str(tmp_path / "temp.im") + out = tmp_path / "temp.im" im = hopper(mode) im.save(out) assert_image_equal_tofile(im, out) @@ -98,7 +100,7 @@ def test_small_palette(tmp_path: Path) -> None: colors = [0, 1, 2] im.putpalette(colors) - out = str(tmp_path / "temp.im") + out = tmp_path / "temp.im" im.save(out) with Image.open(out) as reloaded: @@ -106,7 +108,7 @@ def test_small_palette(tmp_path: Path) -> None: def test_save_unsupported_mode(tmp_path: Path) -> None: - out = str(tmp_path / "temp.im") + out = tmp_path / "temp.im" im = hopper("HSV") with pytest.raises(ValueError): im.save(out) diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index 8a7c59fb1..c6c0c1aab 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -58,10 +58,7 @@ def test_getiptcinfo_fotostation() -> None: # Assert assert iptc is not None - for tag in iptc.keys(): - if tag[0] == 240: - return - pytest.fail("FotoStation tag not found") + assert 240 in (tag[0] for tag in iptc.keys()), "FotoStation tag not found" def test_getiptcinfo_zero_padding() -> None: diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 347a162a5..79f0ec1a8 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -83,7 +83,7 @@ class TestFileJpeg: @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) def test_zero(self, size: tuple[int, int], tmp_path: Path) -> None: - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" im = Image.new("RGB", size) with pytest.raises(ValueError): im.save(f) @@ -91,6 +91,7 @@ class TestFileJpeg: def test_app(self) -> None: # Test APP/COM reader (@PIL135) with Image.open(TEST_FILE) as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert im.applist[0] == ("APP0", b"JFIF\x00\x01\x01\x01\x00`\x00`\x00\x00") assert im.applist[1] == ( "COM", @@ -181,6 +182,10 @@ class TestFileJpeg: assert test(100, 200) == (100, 200) assert test(0) is None # square pixels + def test_dpi_jfif_cm(self) -> None: + with Image.open("Tests/images/jfif_unit_cm.jpg") as im: + assert im.info["dpi"] == (2.54, 5.08) + @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) @@ -190,7 +195,7 @@ class TestFileJpeg: icc_profile = im1.info["icc_profile"] assert len(icc_profile) == 3144 # Roundtrip via physical file. - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" im1.save(f, icc_profile=icc_profile) with Image.open(f) as im2: assert im2.info.get("icc_profile") == icc_profile @@ -234,7 +239,7 @@ class TestFileJpeg: # Sometimes the meta data on the icc_profile block is bigger than # Image.MAXBLOCK or the image size. with Image.open("Tests/images/icc_profile_big.jpg") as im: - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" icc_profile = im.info["icc_profile"] # Should not raise OSError for image with icc larger than image size. im.save( @@ -246,11 +251,11 @@ class TestFileJpeg: ) with Image.open("Tests/images/flower2.jpg") as im: - f = str(tmp_path / "temp2.jpg") + f = tmp_path / "temp2.jpg" im.save(f, progressive=True, quality=94, icc_profile=b" " * 53955) with Image.open("Tests/images/flower2.jpg") as im: - f = str(tmp_path / "temp3.jpg") + f = tmp_path / "temp3.jpg" im.save(f, progressive=True, quality=94, exif=b" " * 43668) def test_optimize(self) -> None: @@ -264,7 +269,7 @@ class TestFileJpeg: def test_optimize_large_buffer(self, tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/148 - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" # this requires ~ 1.5x Image.MAXBLOCK im = Image.new("RGB", (4096, 4096), 0xFF3333) im.save(f, format="JPEG", optimize=True) @@ -277,17 +282,20 @@ class TestFileJpeg: assert not im2.info.get("progressive") assert im3.info.get("progressive") - assert_image_equal(im1, im3) + if features.check_feature("mozjpeg"): + assert_image_similar(im1, im3, 9.39) + else: + assert_image_equal(im1, im3) assert im1_bytes >= im3_bytes def test_progressive_large_buffer(self, tmp_path: Path) -> None: - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" # this requires ~ 1.5x Image.MAXBLOCK im = Image.new("RGB", (4096, 4096), 0xFF3333) im.save(f, format="JPEG", progressive=True) def test_progressive_large_buffer_highest_quality(self, tmp_path: Path) -> None: - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" im = self.gen_random_image((255, 255)) # this requires more bytes than pixels in the image im.save(f, format="JPEG", progressive=True, quality=100) @@ -300,7 +308,7 @@ class TestFileJpeg: def test_large_exif(self, tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/148 - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" im = hopper() im.save(f, "JPEG", quality=90, exif=b"1" * 65533) @@ -309,6 +317,8 @@ class TestFileJpeg: def test_exif_typeerror(self) -> None: with Image.open("Tests/images/exif_typeerror.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) + # Should not raise a TypeError im._getexif() @@ -328,7 +338,7 @@ class TestFileJpeg: assert exif[gps_index] == expected_exif_gps # Writing - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" exif = Image.Exif() exif[gps_index] = expected_exif_gps hopper().save(f, exif=exif) @@ -349,7 +359,6 @@ class TestFileJpeg: assert exif.get_ifd(0x8825) == {} transposed = ImageOps.exif_transpose(im) - assert transposed is not None exif = transposed.getexif() assert exif.get_ifd(0x8825) == {} @@ -420,8 +429,12 @@ class TestFileJpeg: im2 = self.roundtrip(hopper(), progressive=1) im3 = self.roundtrip(hopper(), progression=1) # compatibility - assert_image_equal(im1, im2) - assert_image_equal(im1, im3) + if features.check_feature("mozjpeg"): + assert_image_similar(im1, im2, 9.39) + assert_image_similar(im1, im3, 9.39) + else: + assert_image_equal(im1, im2) + assert_image_equal(im1, im3) assert im2.info.get("progressive") assert im2.info.get("progression") assert im3.info.get("progressive") @@ -490,20 +503,21 @@ class TestFileJpeg: def test_mp(self) -> None: with Image.open("Tests/images/pil_sample_rgb.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert im._getmp() is None def test_quality_keep(self, tmp_path: Path) -> None: # RGB with Image.open("Tests/images/hopper.jpg") as im: - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" im.save(f, quality="keep") # Grayscale with Image.open("Tests/images/hopper_gray.jpg") as im: - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" im.save(f, quality="keep") # CMYK with Image.open("Tests/images/pil_sample_cmyk.jpg") as im: - f = str(tmp_path / "temp.jpg") + f = tmp_path / "temp.jpg" im.save(f, quality="keep") def test_junk_jpeg_header(self) -> None: @@ -520,12 +534,13 @@ class TestFileJpeg: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_truncated_jpeg_should_read_all_the_data(self) -> None: + def test_truncated_jpeg_should_read_all_the_data( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: filename = "Tests/images/truncated_jpeg.jpg" - ImageFile.LOAD_TRUNCATED_IMAGES = True + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) with Image.open(filename) as im: im.load() - ImageFile.LOAD_TRUNCATED_IMAGES = False assert im.getbbox() is not None def test_truncated_jpeg_throws_oserror(self) -> None: @@ -547,12 +562,14 @@ class TestFileJpeg: with Image.open(test_file) as im: im.save(b, "JPEG", qtables=[[n] * 64] * n) with Image.open(b) as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert len(im.quantization) == n reloaded = self.roundtrip(im, qtables="keep") assert im.quantization == reloaded.quantization assert max(reloaded.quantization[0]) <= 255 with Image.open("Tests/images/hopper.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) qtables = im.quantization reloaded = self.roundtrip(im, qtables=qtables, subsampling=0) assert im.quantization == reloaded.quantization @@ -652,6 +669,7 @@ class TestFileJpeg: def test_load_16bit_qtables(self) -> None: with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert len(im.quantization) == 2 assert len(im.quantization[0]) == 64 assert max(im.quantization[0]) > 255 @@ -694,6 +712,7 @@ class TestFileJpeg: @pytest.mark.skipif(not djpeg_available(), reason="djpeg not available") def test_load_djpeg(self) -> None: with Image.open(TEST_FILE) as img: + assert isinstance(img, JpegImagePlugin.JpegImageFile) img.load_djpeg() assert_image_similar_tofile(img, TEST_FILE, 5) @@ -715,7 +734,7 @@ class TestFileJpeg: def test_MAXBLOCK_scaling(self, tmp_path: Path) -> None: im = self.gen_random_image((512, 512)) - f = str(tmp_path / "temp.jpeg") + f = tmp_path / "temp.jpeg" im.save(f, quality=100, optimize=True) with Image.open(f) as reloaded: @@ -751,7 +770,7 @@ class TestFileJpeg: def test_save_tiff_with_dpi(self, tmp_path: Path) -> None: # Arrange - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" with Image.open("Tests/images/hopper.tif") as im: # Act im.save(outfile, "JPEG", dpi=im.info["dpi"]) @@ -762,7 +781,7 @@ class TestFileJpeg: assert im.info["dpi"] == reloaded.info["dpi"] def test_save_dpi_rounding(self, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.jpg") + outfile = tmp_path / "temp.jpg" with Image.open("Tests/images/hopper.jpg") as im: im.save(outfile, dpi=(72.2, 72.2)) @@ -848,7 +867,7 @@ class TestFileJpeg: exif = im.getexif() assert exif[282] == 180 - out = str(tmp_path / "out.jpg") + out = tmp_path / "out.jpg" with warnings.catch_warnings(): warnings.simplefilter("error") @@ -898,6 +917,7 @@ class TestFileJpeg: def test_photoshop_malformed_and_multiple(self) -> None: with Image.open("Tests/images/app13-multiple.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert "photoshop" in im.info assert 24 == len(im.info["photoshop"]) apps_13_lengths = [len(v) for k, v in im.applist if k == "APP13"] @@ -923,7 +943,7 @@ class TestFileJpeg: def test_jpeg_magic_number(self, monkeypatch: pytest.MonkeyPatch) -> None: size = 4097 - buffer = BytesIO(b"\xFF" * size) # Many xFF bytes + buffer = BytesIO(b"\xff" * size) # Many xff bytes max_pos = 0 orig_read = buffer.read @@ -994,14 +1014,19 @@ class TestFileJpeg: assert im.getxmp() == {"xmpmeta": None} def test_save_xmp(self, tmp_path: Path) -> None: - f = str(tmp_path / "temp.jpg") + f = 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) + # Check that XMP is not saved from image info + reloaded.save(f) + + with Image.open(f) as reloaded: + assert "xmp" not in reloaded.info + + im.save(f, xmp=b"1" * 65504) with Image.open(f) as reloaded: assert reloaded.info["xmp"] == b"1" * 65504 @@ -1009,7 +1034,7 @@ class TestFileJpeg: im.save(f, xmp=b"1" * 65505) @pytest.mark.timeout(timeout=1) - def test_eof(self) -> None: + def test_eof(self, monkeypatch: pytest.MonkeyPatch) -> None: # Even though this decoder never says that it is finished # the image should still end when there is no new data class InfiniteMockPyDecoder(ImageFile.PyDecoder): @@ -1022,11 +1047,10 @@ class TestFileJpeg: with Image.open(TEST_FILE) as im: im.tile = [ - ("INFINITE", (0, 0, 128, 128), 0, ("RGB", 0, 1)), + ImageFile._Tile("INFINITE", (0, 0, 128, 128), 0, ("RGB", 0, 1)), ] - ImageFile.LOAD_TRUNCATED_IMAGES = True + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) im.load() - ImageFile.LOAD_TRUNCATED_IMAGES = False def test_separate_tables(self) -> None: im = hopper() @@ -1069,6 +1093,7 @@ class TestFileJpeg: def test_deprecation(self) -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) with pytest.warns(DeprecationWarning): assert im.huffman_ac == {} with pytest.warns(DeprecationWarning): @@ -1079,7 +1104,7 @@ class TestFileJpeg: @skip_unless_feature("jpg") class TestFileCloseW32: def test_fd_leak(self, tmp_path: Path) -> None: - tmpfile = str(tmp_path / "temp.jpg") + tmpfile = tmp_path / "temp.jpg" with Image.open("Tests/images/hopper.jpg") as im: im.save(tmpfile) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index fbf72ae05..4095bfaf2 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -63,6 +63,7 @@ def test_sanity() -> None: with Image.open("Tests/images/test-card-lossless.jp2") as im: px = im.load() + assert px is not None assert px[0, 0] == (0, 0, 0) assert im.mode == "RGB" assert im.size == (640, 480) @@ -98,7 +99,7 @@ def test_bytesio(card: ImageFile.ImageFile) -> None: def test_lossless(card: ImageFile.ImageFile, tmp_path: Path) -> None: with Image.open("Tests/images/test-card-lossless.jp2") as im: im.load() - outfile = str(tmp_path / "temp_test-card.png") + outfile = tmp_path / "temp_test-card.png" im.save(outfile) assert_image_similar(im, card, 1.0e-3) @@ -181,14 +182,11 @@ def test_load_dpi() -> None: assert "dpi" not in im.info -def test_restricted_icc_profile() -> None: - ImageFile.LOAD_TRUNCATED_IMAGES = True - try: - # JPEG2000 image with a restricted ICC profile and a known colorspace - with Image.open("Tests/images/balloon_eciRGBv2_aware.jp2") as im: - assert im.mode == "RGB" - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False +def test_restricted_icc_profile(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) + # JPEG2000 image with a restricted ICC profile and a known colorspace + with Image.open("Tests/images/balloon_eciRGBv2_aware.jp2") as im: + assert im.mode == "RGB" @pytest.mark.skipif( @@ -215,7 +213,7 @@ def test_header_errors() -> None: def test_layers_type(card: ImageFile.ImageFile, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp_layers.jp2") + outfile = tmp_path / "temp_layers.jp2" for quality_layers in [[100, 50, 10], (100, 50, 10), None]: card.save(outfile, quality_layers=quality_layers) @@ -230,12 +228,14 @@ def test_layers(card: ImageFile.ImageFile) -> None: out.seek(0) with Image.open(out) as im: + assert isinstance(im, Jpeg2KImagePlugin.Jpeg2KImageFile) im.layers = 1 im.load() assert_image_similar(im, card, 13) out.seek(0) with Image.open(out) as im: + assert isinstance(im, Jpeg2KImagePlugin.Jpeg2KImageFile) im.layers = 3 im.load() assert_image_similar(im, card, 0.4) @@ -291,7 +291,7 @@ def test_mct(card: ImageFile.ImageFile) -> None: def test_sgnd(tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.jp2") + outfile = tmp_path / "temp.jp2" im = Image.new("L", (1, 1)) im.save(outfile) @@ -315,6 +315,18 @@ def test_rgba(ext: str) -> None: assert im.mode == "RGBA" +def test_grayscale_four_channels() -> None: + with open("Tests/images/rgb_trns_ycbc.jp2", "rb") as fp: + data = fp.read() + + # Change color space to OPJ_CLRSPC_GRAY + data = data[:76] + b"\x11" + data[77:] + + with Image.open(BytesIO(data)) as im: + im.load() + assert im.mode == "RGBA" + + @pytest.mark.skipif( not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) @@ -325,6 +337,18 @@ def test_cmyk() -> None: assert im.getpixel((0, 0)) == (185, 134, 0, 0) +@pytest.mark.skipif( + not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" +) +@skip_unless_feature_version("jpg_2000", "2.5.3") +def test_cmyk_save() -> None: + with Image.open(f"{EXTRA_DIR}/issue205.jp2") as jp2: + assert jp2.mode == "CMYK" + + im = roundtrip(jp2) + assert_image_equal(im, jp2) + + @pytest.mark.parametrize("ext", (".j2k", ".jp2")) def test_16bit_monochrome_has_correct_mode(ext: str) -> None: with Image.open("Tests/images/16bit.cropped" + ext) as im: @@ -412,6 +436,7 @@ def test_subsampling_decode(name: str) -> None: def test_pclr() -> None: with Image.open(f"{EXTRA_DIR}/issue104_jpxstream.jp2") as im: assert im.mode == "P" + assert im.palette is not None assert len(im.palette.colors) == 256 assert im.palette.colors[(255, 255, 255)] == 0 @@ -419,13 +444,15 @@ def test_pclr() -> None: f"{EXTRA_DIR}/147af3f1083de4393666b7d99b01b58b_signal_sigsegv_130c531_6155_5136.jp2" ) as im: assert im.mode == "P" + assert im.palette is not None assert len(im.palette.colors) == 139 assert im.palette.colors[(0, 0, 0, 0)] == 0 def test_comment() -> None: - with Image.open("Tests/images/comment.jp2") as im: - assert im.info["comment"] == b"Created by OpenJPEG version 2.5.0" + for path in ("Tests/images/9bit.j2k", "Tests/images/comment.jp2"): + with Image.open(path) as im: + assert im.info["comment"] == b"Created by OpenJPEG version 2.5.0" # Test an image that is truncated partway through a codestream with open("Tests/images/comment.jp2", "rb") as fp: @@ -479,8 +506,7 @@ def test_plt_marker(card: ImageFile.ImageFile) -> None: out.seek(0) while True: marker = out.read(2) - if not marker: - pytest.fail("End of stream without PLT") + assert marker, "End of stream without PLT" jp2_boxid = _binary.i16be(marker) if jp2_boxid == 0xFF4F: diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 62f8719af..9916215fb 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -36,14 +36,11 @@ class LibTiffTestCase: im.load() im.getdata() - try: - assert im._compression == "group4" - except AttributeError: - print("No _compression") - print(dir(im)) + assert isinstance(im, TiffImagePlugin.TiffImageFile) + assert im._compression == "group4" # can we write it back out, in a different form. - out = str(tmp_path / "temp.png") + out = tmp_path / "temp.png" im.save(out) out_bytes = io.BytesIO() @@ -127,7 +124,7 @@ class TestFileLibTiff(LibTiffTestCase): """Checking to see that the saved image is the same as what we wrote""" test_file = "Tests/images/hopper_g4_500.tif" with Image.open(test_file) as orig: - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" rot = orig.transpose(Image.Transpose.ROTATE_90) assert rot.size == (500, 500) rot.save(out) @@ -155,8 +152,9 @@ class TestFileLibTiff(LibTiffTestCase): @pytest.mark.parametrize("legacy_api", (False, True)) def test_write_metadata(self, legacy_api: bool, tmp_path: Path) -> None: """Test metadata writing through libtiff""" - f = str(tmp_path / "temp.tiff") + f = tmp_path / "temp.tiff" with Image.open("Tests/images/hopper_g4.tif") as img: + assert isinstance(img, TiffImagePlugin.TiffImageFile) img.save(f, tiffinfo=img.tag) if legacy_api: @@ -174,6 +172,7 @@ class TestFileLibTiff(LibTiffTestCase): ] with Image.open(f) as loaded: + assert isinstance(loaded, TiffImagePlugin.TiffImageFile) if legacy_api: reloaded = loaded.tag.named() else: @@ -216,6 +215,7 @@ class TestFileLibTiff(LibTiffTestCase): # Exclude ones that have special meaning # that we're already testing them with Image.open("Tests/images/hopper_g4.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) for tag in im.tag_v2: try: del core_items[tag] @@ -251,7 +251,7 @@ class TestFileLibTiff(LibTiffTestCase): # Extra samples really doesn't make sense in this application. del new_ifd[338] - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) im.save(out, tiffinfo=new_ifd) @@ -313,14 +313,15 @@ class TestFileLibTiff(LibTiffTestCase): } def check_tags( - tiffinfo: TiffImagePlugin.ImageFileDirectory_v2 | dict[int, str] + tiffinfo: TiffImagePlugin.ImageFileDirectory_v2 | dict[int, str], ) -> None: im = hopper() - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" im.save(out, tiffinfo=tiffinfo) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) for tag, value in tiffinfo.items(): reloaded_value = reloaded.tag_v2[tag] if ( @@ -351,14 +352,16 @@ class TestFileLibTiff(LibTiffTestCase): ) def test_osubfiletype(self, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" with Image.open("Tests/images/g4_orientation_6.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.tag_v2[OSUBFILETYPE] = 1 im.save(outfile) def test_subifd(self, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" with Image.open("Tests/images/g4_orientation_6.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.tag_v2[SUBIFD] = 10000 # Should not segfault @@ -369,17 +372,18 @@ class TestFileLibTiff(LibTiffTestCase): ) -> None: monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" hopper().save(out, tiffinfo={700: b"xmlpacket tag"}) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) if 700 in reloaded.tag_v2: assert reloaded.tag_v2[700] == b"xmlpacket tag" def test_int_dpi(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # issue #1765 im = hopper("RGB") - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) im.save(out, dpi=(72, 72)) with Image.open(out) as reloaded: @@ -387,7 +391,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_g3_compression(self, tmp_path: Path) -> None: with Image.open("Tests/images/hopper_g4_500.tif") as i: - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" i.save(out, compression="group3") with Image.open(out) as reread: @@ -404,7 +408,7 @@ class TestFileLibTiff(LibTiffTestCase): assert b[0] == ord(b"\xe0") assert b[1] == ord(b"\x01") - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" # out = "temp.le.tif" im.save(out) with Image.open(out) as reread: @@ -424,7 +428,7 @@ class TestFileLibTiff(LibTiffTestCase): assert b[0] == ord(b"\x01") assert b[1] == ord(b"\xe0") - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" im.save(out) with Image.open(out) as reread: assert reread.info["compression"] == im.info["compression"] @@ -434,12 +438,15 @@ class TestFileLibTiff(LibTiffTestCase): """Tests String data in info directory""" test_file = "Tests/images/hopper_g4_500.tif" with Image.open(test_file) as orig: - out = str(tmp_path / "temp.tif") + assert isinstance(orig, TiffImagePlugin.TiffImageFile) + + out = tmp_path / "temp.tif" orig.tag[269] = "temp.tif" orig.save(out) with Image.open(out) as reread: + assert isinstance(reread, TiffImagePlugin.TiffImageFile) assert "temp.tif" == reread.tag_v2[269] assert "temp.tif" == reread.tag[269][0] @@ -461,7 +468,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_blur(self, tmp_path: Path) -> None: # test case from irc, how to do blur on b/w image # and save to compressed tif. - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" with Image.open("Tests/images/pport_g4.tif") as im: im = im.convert("L") @@ -474,7 +481,7 @@ class TestFileLibTiff(LibTiffTestCase): # Test various tiff compressions and assert similar image content but reduced # file sizes. im = hopper("RGB") - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" im.save(out) size_raw = os.path.getsize(out) @@ -498,7 +505,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_tiff_jpeg_compression(self, tmp_path: Path) -> None: im = hopper("RGB") - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" im.save(out, compression="tiff_jpeg") with Image.open(out) as reloaded: @@ -506,7 +513,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_tiff_deflate_compression(self, tmp_path: Path) -> None: im = hopper("RGB") - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" im.save(out, compression="tiff_deflate") with Image.open(out) as reloaded: @@ -514,7 +521,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_quality(self, tmp_path: Path) -> None: im = hopper("RGB") - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" with pytest.raises(ValueError): im.save(out, compression="tiff_lzw", quality=50) @@ -529,7 +536,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_cmyk_save(self, tmp_path: Path) -> None: im = hopper("CMYK") - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" im.save(out, compression="tiff_adobe_deflate") assert_image_equal_tofile(im, out) @@ -538,19 +545,20 @@ class TestFileLibTiff(LibTiffTestCase): def test_palette_save( self, im: Image.Image, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) im.save(out) with Image.open(out) as reloaded: # colormap/palette tag + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert len(reloaded.tag_v2[320]) == 768 @pytest.mark.parametrize("compression", ("tiff_ccitt", "group3", "group4")) def test_bw_compression_w_rgb(self, compression: str, tmp_path: Path) -> None: im = hopper("RGB") - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" with pytest.raises(OSError): im.save(out, compression=compression) @@ -576,6 +584,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/multipage.tiff") as im: # file is a multipage tiff, 10x10 green, 10x10 red, 20x20 blue + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.seek(0) assert im.size == (10, 10) assert im.convert("RGB").getpixel((0, 0)) == (0, 128, 0) @@ -595,6 +604,7 @@ class TestFileLibTiff(LibTiffTestCase): # issue #862 monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True) with Image.open("Tests/images/multipage.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) frames = im.n_frames assert frames == 3 for _ in range(frames): @@ -614,6 +624,7 @@ class TestFileLibTiff(LibTiffTestCase): def test__next(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True) with Image.open("Tests/images/hopper.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert not im.tag.next im.load() assert not im.tag.next @@ -690,25 +701,29 @@ class TestFileLibTiff(LibTiffTestCase): def test_save_ycbcr(self, tmp_path: Path) -> None: im = hopper("YCbCr") - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" im.save(outfile, compression="jpeg") with Image.open(outfile) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[530] == (1, 1) assert reloaded.tag_v2[532] == (0, 255, 128, 255, 128, 255) def test_exif_ifd(self) -> None: out = io.BytesIO() with Image.open("Tests/images/tiff_adobe_deflate.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2[34665] == 125456 im.save(out, "TIFF") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert 34665 not in reloaded.tag_v2 im.save(out, "TIFF", tiffinfo={34665: 125456}) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) if Image.core.libtiff_support_custom_tags: assert reloaded.tag_v2[34665] == 125456 @@ -717,7 +732,7 @@ class TestFileLibTiff(LibTiffTestCase): ) -> None: # issue 1597 with Image.open("Tests/images/rdf.tif") as im: - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) # this shouldn't crash @@ -728,7 +743,7 @@ class TestFileLibTiff(LibTiffTestCase): # Test TIFF with tag 297 (Page Number) having value of 0 0. # The first number is the current page number. # The second is the total number of pages, zero means not available. - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" # Created by printing a page in Chrome to PDF, then: # /usr/bin/gs -q -sDEVICE=tiffg3 -sOutputFile=total-pages-zero.tif # -dNOPAUSE /tmp/test.pdf -c quit @@ -740,7 +755,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_fd_duplication(self, tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/1651 - tmpfile = str(tmp_path / "temp.tif") + tmpfile = tmp_path / "temp.tif" with open(tmpfile, "wb") as f: with open("Tests/images/g4-multi.tiff", "rb") as src: f.write(src.read()) @@ -783,13 +798,14 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/hopper.iccprofile.tif") as img: icc_profile = img.info["icc_profile"] - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" img.save(out, icc_profile=icc_profile) with Image.open(out) as reloaded: assert icc_profile == reloaded.info["icc_profile"] def test_multipage_compression(self) -> None: with Image.open("Tests/images/compression.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.seek(0) assert im._compression == "tiff_ccitt" assert im.size == (10, 10) @@ -806,7 +822,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_save_tiff_with_jpegtables(self, tmp_path: Path) -> None: # Arrange - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" # Created with ImageMagick: convert hopper.jpg hopper_jpg.tif # Contains JPEGTables (347) tag @@ -868,7 +884,7 @@ class TestFileLibTiff(LibTiffTestCase): self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: im = Image.new("F", (1, 1)) - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) im.save(out) @@ -1012,7 +1028,7 @@ class TestFileLibTiff(LibTiffTestCase): @pytest.mark.parametrize("compression", (None, "jpeg")) def test_block_tile_tags(self, compression: str | None, tmp_path: Path) -> None: im = hopper() - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" tags = { TiffImagePlugin.TILEWIDTH: 256, @@ -1030,6 +1046,17 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/old-style-jpeg-compression.tif") as im: assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + def test_old_style_jpeg_orientation(self) -> None: + with open("Tests/images/old-style-jpeg-compression.tif", "rb") as fp: + data = fp.read() + + # Set EXIF Orientation to 2 + data = data[:102] + b"\x02" + data[103:] + + with Image.open(io.BytesIO(data)) as im: + im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + def test_open_missing_samplesperpixel(self) -> None: with Image.open( "Tests/images/old-style-jpeg-compression-no-samplesperpixel.tif" @@ -1083,6 +1110,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/g4_orientation_1.tif") as base_im: for i in range(2, 9): with Image.open("Tests/images/g4_orientation_" + str(i) + ".tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert 274 in im.tag_v2 im.load() @@ -1098,6 +1126,27 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_similar(base_im, im, 0.7) + @pytest.mark.parametrize( + "test_file", + [ + "Tests/images/old-style-jpeg-compression-no-samplesperpixel.tif", + "Tests/images/old-style-jpeg-compression.tif", + ], + ) + def test_buffering(self, test_file: str) -> None: + # load exif first + with open(test_file, "rb", buffering=1048576) as f: + with Image.open(f) as im: + exif = dict(im.getexif()) + + # load image before exif + with open(test_file, "rb", buffering=1048576) as f: + with Image.open(f) as im2: + im2.load() + exif_after_load = dict(im2.getexif()) + + assert exif == exif_after_load + @pytest.mark.valgrind_known_error(reason="Backtrace in Python Core") def test_sampleformat_not_corrupted(self) -> None: # Assert that a TIFF image with SampleFormat=UINT tag is not corrupted @@ -1123,16 +1172,14 @@ class TestFileLibTiff(LibTiffTestCase): def test_realloc_overflow(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True) with Image.open("Tests/images/tiff_overflow_rows_per_strip.tif") as im: - with pytest.raises(OSError) as e: - im.load() - # Assert that the error code is IMAGING_CODEC_MEMORY - assert str(e.value) == "-9" + with pytest.raises(OSError, match="decoder error -9"): + im.load() @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: im = hopper("RGB").resize((256, 256)) - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" im.save(out, compression=compression) with Image.open(out) as im: @@ -1141,34 +1188,33 @@ class TestFileLibTiff(LibTiffTestCase): assert len(im.tag_v2[STRIPOFFSETS]) > 1 @pytest.mark.parametrize("argument", (True, False)) - def test_save_single_strip(self, argument: bool, tmp_path: Path) -> None: + def test_save_single_strip( + self, argument: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: im = hopper("RGB").resize((256, 256)) - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" if not argument: - TiffImagePlugin.STRIP_SIZE = 2**18 - try: - arguments: dict[str, str | int] = {"compression": "tiff_adobe_deflate"} - if argument: - arguments["strip_size"] = 2**18 - im.save(out, "TIFF", **arguments) + monkeypatch.setattr(TiffImagePlugin, "STRIP_SIZE", 2**18) + arguments: dict[str, str | int] = {"compression": "tiff_adobe_deflate"} + if argument: + arguments["strip_size"] = 2**18 + im.save(out, "TIFF", **arguments) - with Image.open(out) as im: - assert isinstance(im, TiffImagePlugin.TiffImageFile) - assert len(im.tag_v2[STRIPOFFSETS]) == 1 - finally: - TiffImagePlugin.STRIP_SIZE = 65536 + with Image.open(out) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + assert len(im.tag_v2[STRIPOFFSETS]) == 1 @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", None)) def test_save_zero(self, compression: str | None, tmp_path: Path) -> None: im = Image.new("RGB", (0, 0)) - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" with pytest.raises(SystemError): im.save(out, compression=compression) def test_save_many_compressed(self, tmp_path: Path) -> None: im = hopper() - out = str(tmp_path / "temp.tif") + out = tmp_path / "temp.tif" for _ in range(10000): im.save(out, compression="jpeg") diff --git a/Tests/test_file_mic.py b/Tests/test_file_mic.py index 9a6f13ea3..9aeb306e4 100644 --- a/Tests/test_file_mic.py +++ b/Tests/test_file_mic.py @@ -30,11 +30,13 @@ def test_sanity() -> None: def test_n_frames() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, MicImagePlugin.MicImageFile) assert im.n_frames == 1 def test_is_animated() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, MicImagePlugin.MicImageFile) assert not im.is_animated @@ -55,10 +57,11 @@ def test_seek() -> None: def test_close() -> None: with Image.open(TEST_FILE) as im: - pass + assert isinstance(im, MicImagePlugin.MicImageFile) assert im.ole.fp.closed im = Image.open(TEST_FILE) + assert isinstance(im, MicImagePlugin.MicImageFile) im.close() assert im.ole.fp.closed diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 949583185..73838ef44 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -6,7 +6,7 @@ from typing import Any import pytest -from PIL import Image, ImageFile, MpoImagePlugin +from PIL import Image, ImageFile, JpegImagePlugin, MpoImagePlugin from .helper import ( assert_image_equal, @@ -29,21 +29,26 @@ def roundtrip(im: Image.Image, **options: Any) -> ImageFile.ImageFile: @pytest.mark.parametrize("test_file", test_files) def test_sanity(test_file: str) -> None: - with Image.open(test_file) as im: + def check(im: ImageFile.ImageFile) -> None: im.load() assert im.mode == "RGB" assert im.size == (640, 480) assert im.format == "MPO" + with Image.open(test_file) as im: + check(im) + with MpoImagePlugin.MpoImageFile(test_file) as im: + check(im) + @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file() -> None: - def open() -> None: + def open_test_image() -> None: im = Image.open(test_files[0]) im.load() with pytest.warns(ResourceWarning): - open() + open_test_image() def test_closed_file() -> None: @@ -75,10 +80,11 @@ def test_context_manager() -> None: def test_app(test_file: str) -> None: # Test APP/COM reader (@PIL135) with Image.open(test_file) as im: + assert isinstance(im, MpoImagePlugin.MpoImageFile) assert im.applist[0][0] == "APP1" assert im.applist[1][0] == "APP2" - assert ( - im.applist[1][1][:16] == b"MPF\x00MM\x00*\x00\x00\x00\x08\x00\x03\xb0\x00" + assert im.applist[1][1].startswith( + b"MPF\x00MM\x00*\x00\x00\x00\x08\x00\x03\xb0\x00" ) assert len(im.applist) == 2 @@ -215,12 +221,14 @@ def test_seek(test_file: str) -> None: def test_n_frames() -> None: with Image.open("Tests/images/sugarshack.mpo") as im: + assert isinstance(im, MpoImagePlugin.MpoImageFile) assert im.n_frames == 2 assert im.is_animated def test_eoferror() -> None: with Image.open("Tests/images/sugarshack.mpo") as im: + assert isinstance(im, MpoImagePlugin.MpoImageFile) n_frames = im.n_frames # Test seeking past the last frame @@ -234,6 +242,8 @@ def test_eoferror() -> None: def test_adopt_jpeg() -> None: with Image.open("Tests/images/hopper.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) + with pytest.raises(ValueError): MpoImagePlugin.MpoImageFile.adopt(im) @@ -297,3 +307,15 @@ def test_save_all() -> None: # Test that a single frame image will not be saved as an MPO jpg = roundtrip(im, save_all=True) assert "mp" not in jpg.info + + +def test_save_xmp() -> None: + im = Image.new("RGB", (1, 1)) + im2 = Image.new("RGB", (1, 1), "#f00") + im2.encoderinfo = {"xmp": b"Second frame"} + im_reloaded = roundtrip(im, xmp=b"First frame", save_all=True, append_images=[im2]) + + assert im_reloaded.info["xmp"] == b"First frame" + + im_reloaded.seek(1) + assert im_reloaded.info["xmp"] == b"Second frame" diff --git a/Tests/test_file_msp.py b/Tests/test_file_msp.py index b0964aabe..8c91922bd 100644 --- a/Tests/test_file_msp.py +++ b/Tests/test_file_msp.py @@ -15,7 +15,7 @@ YA_EXTRA_DIR = "Tests/images/msp" def test_sanity(tmp_path: Path) -> None: - test_file = str(tmp_path / "temp.msp") + test_file = tmp_path / "temp.msp" hopper("1").save(test_file) @@ -84,7 +84,7 @@ def test_msp_v2() -> None: def test_cannot_save_wrong_mode(tmp_path: Path) -> None: # Arrange im = hopper() - filename = str(tmp_path / "temp.msp") + filename = tmp_path / "temp.msp" # Act/Assert with pytest.raises(OSError): diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index 194f39b30..58208ba99 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -14,7 +14,7 @@ from .helper import assert_image_equal, hopper, magick_command def helper_save_as_palm(tmp_path: Path, mode: str) -> None: # Arrange im = hopper(mode) - outfile = str(tmp_path / ("temp_" + mode + ".palm")) + outfile = tmp_path / ("temp_" + mode + ".palm") # Act im.save(outfile) @@ -25,7 +25,7 @@ def helper_save_as_palm(tmp_path: Path, mode: str) -> None: def open_with_magick(magick: list[str], tmp_path: Path, f: str) -> Image.Image: - outfile = str(tmp_path / "temp.png") + outfile = tmp_path / "temp.png" rc = subprocess.call( magick + [f, outfile], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT ) @@ -43,6 +43,11 @@ def roundtrip(tmp_path: Path, mode: str) -> None: im.save(outfile) converted = open_with_magick(magick, tmp_path, outfile) + if mode == "P": + assert converted.mode == "P" + + im = im.convert("RGB") + converted = converted.convert("RGB") assert_image_equal(converted, im) @@ -55,7 +60,6 @@ def test_monochrome(tmp_path: Path) -> None: roundtrip(tmp_path, mode) -@pytest.mark.xfail(reason="Palm P image is wrong") def test_p_mode(tmp_path: Path) -> None: # Arrange mode = "P" diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index b3f38c3e5..5d7fd1c1b 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io from pathlib import Path import pytest @@ -10,7 +11,7 @@ from .helper import assert_image_equal, hopper def _roundtrip(tmp_path: Path, im: Image.Image) -> None: - f = str(tmp_path / "temp.pcx") + f = tmp_path / "temp.pcx" im.save(f) with Image.open(f) as im2: assert im2.mode == im.mode @@ -30,12 +31,34 @@ def test_sanity(tmp_path: Path) -> None: _roundtrip(tmp_path, im) # Test an unsupported mode - f = str(tmp_path / "temp.pcx") + f = tmp_path / "temp.pcx" im = hopper("RGBA") with pytest.raises(ValueError): im.save(f) +def test_bad_image_size() -> None: + with open("Tests/images/pil184.pcx", "rb") as fp: + data = fp.read() + data = data[:4] + b"\xff\xff" + data[6:] + + b = io.BytesIO(data) + with pytest.raises(SyntaxError, match="bad PCX image size"): + with PcxImagePlugin.PcxImageFile(b): + pass + + +def test_unknown_mode() -> None: + with open("Tests/images/pil184.pcx", "rb") as fp: + data = fp.read() + data = data[:3] + b"\xff" + data[4:] + + b = io.BytesIO(data) + with pytest.raises(OSError, match="unknown PCX mode"): + with Image.open(b): + pass + + def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 1d5001b1a..bde1e3ab8 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -55,7 +55,7 @@ def test_save_alpha(tmp_path: Path, mode: str) -> None: def test_p_alpha(tmp_path: Path) -> None: # Arrange - outfile = str(tmp_path / "temp.pdf") + outfile = tmp_path / "temp.pdf" with Image.open("Tests/images/pil123p.png") as im: assert im.mode == "P" assert isinstance(im.info["transparency"], bytes) @@ -80,7 +80,7 @@ def test_monochrome(tmp_path: Path) -> None: def test_unsupported_mode(tmp_path: Path) -> None: im = hopper("PA") - outfile = str(tmp_path / "temp_PA.pdf") + outfile = tmp_path / "temp_PA.pdf" with pytest.raises(ValueError): im.save(outfile) @@ -89,7 +89,7 @@ def test_unsupported_mode(tmp_path: Path) -> None: def test_resolution(tmp_path: Path) -> None: im = hopper() - outfile = str(tmp_path / "temp.pdf") + outfile = tmp_path / "temp.pdf" im.save(outfile, resolution=150) with open(outfile, "rb") as fp: @@ -117,7 +117,7 @@ def test_resolution(tmp_path: Path) -> None: def test_dpi(params: dict[str, int | tuple[int, int]], tmp_path: Path) -> None: im = hopper() - outfile = str(tmp_path / "temp.pdf") + outfile = tmp_path / "temp.pdf" im.save(outfile, "PDF", **params) with open(outfile, "rb") as fp: @@ -144,7 +144,7 @@ def test_save_all(tmp_path: Path) -> None: # Multiframe image with Image.open("Tests/images/dispose_bgnd.gif") as im: - outfile = str(tmp_path / "temp.pdf") + outfile = tmp_path / "temp.pdf" im.save(outfile, save_all=True) assert os.path.isfile(outfile) @@ -177,7 +177,7 @@ def test_save_all(tmp_path: Path) -> None: def test_multiframe_normal_save(tmp_path: Path) -> None: # Test saving a multiframe image without save_all with Image.open("Tests/images/dispose_bgnd.gif") as im: - outfile = str(tmp_path / "temp.pdf") + outfile = tmp_path / "temp.pdf" im.save(outfile) assert os.path.isfile(outfile) @@ -264,7 +264,7 @@ def test_pdf_append(tmp_path: Path) -> None: # append some info pdf.info.Title = "abc" pdf.info.Author = "def" - pdf.info.Subject = "ghi\uABCD" + pdf.info.Subject = "ghi\uabcd" pdf.info.Keywords = "qw)e\\r(ty" pdf.info.Creator = "hopper()" pdf.start_writing() @@ -292,7 +292,7 @@ def test_pdf_append(tmp_path: Path) -> None: assert pdf.info.Title == "abc" assert pdf.info.Producer == "PdfParser" assert pdf.info.Keywords == "qw)e\\r(ty" - assert pdf.info.Subject == "ghi\uABCD" + assert pdf.info.Subject == "ghi\uabcd" assert b"CreationDate" in pdf.info assert b"ModDate" in pdf.info check_pdf_pages_consistency(pdf) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index ffafc3c58..0f0886ab8 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -68,7 +68,7 @@ def roundtrip(im: Image.Image, **options: Any) -> PngImagePlugin.PngImageFile: @skip_unless_feature("zlib") class TestFilePng: - def get_chunks(self, filename: str) -> list[bytes]: + def get_chunks(self, filename: Path) -> list[bytes]: chunks = [] with open(filename, "rb") as fp: fp.read(8) @@ -89,7 +89,7 @@ class TestFilePng: assert version is not None assert re.search(r"\d+(\.\d+){1,3}(\.zlib\-ng)?$", version) - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" hopper("RGB").save(test_file) @@ -250,7 +250,7 @@ class TestFilePng: # each palette entry assert len(im.info["transparency"]) == 256 - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im.save(test_file) # check if saved image contains same transparency @@ -271,7 +271,7 @@ class TestFilePng: assert im.info["transparency"] == 164 assert im.getpixel((31, 31)) == 164 - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im.save(test_file) # check if saved image contains same transparency @@ -294,7 +294,7 @@ class TestFilePng: assert im.getcolors() == [(100, (0, 0, 0, 0))] im = im.convert("P") - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im.save(test_file) # check if saved image contains same transparency @@ -315,7 +315,7 @@ class TestFilePng: im_rgba = im.convert("RGBA") assert im_rgba.getchannel("A").getcolors()[0][0] == num_transparent - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im.save(test_file) with Image.open(test_file) as test_im: @@ -329,7 +329,7 @@ class TestFilePng: def test_save_rgb_single_transparency(self, tmp_path: Path) -> None: in_file = "Tests/images/caption_6_33_22.png" with Image.open(in_file) as im: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im.save(test_file) def test_load_verify(self) -> None: @@ -363,7 +363,7 @@ class TestFilePng: with pytest.raises((OSError, SyntaxError)): im.verify() - def test_verify_ignores_crc_error(self) -> None: + def test_verify_ignores_crc_error(self, monkeypatch: pytest.MonkeyPatch) -> None: # check ignores crc errors in ancillary chunks chunk_data = chunk(b"tEXt", b"spam") @@ -373,24 +373,20 @@ class TestFilePng: with pytest.raises(SyntaxError): PngImagePlugin.PngImageFile(BytesIO(image_data)) - ImageFile.LOAD_TRUNCATED_IMAGES = True - try: - im = load(image_data) - assert im is not None - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) + im = load(image_data) + assert im is not None - def test_verify_not_ignores_crc_error_in_required_chunk(self) -> None: + def test_verify_not_ignores_crc_error_in_required_chunk( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: # check does not ignore crc errors in required chunks image_data = MAGIC + IHDR[:-1] + b"q" + TAIL - ImageFile.LOAD_TRUNCATED_IMAGES = True - try: - with pytest.raises(SyntaxError): - PngImagePlugin.PngImageFile(BytesIO(image_data)) - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) + with pytest.raises(SyntaxError): + PngImagePlugin.PngImageFile(BytesIO(image_data)) def test_roundtrip_dpi(self) -> None: # Check dpi roundtripping @@ -492,7 +488,7 @@ class TestFilePng: im = hopper("P") im.info["transparency"] = 0 - f = str(tmp_path / "temp.png") + f = tmp_path / "temp.png" im.save(f) with Image.open(f) as im2: @@ -553,7 +549,7 @@ class TestFilePng: def test_chunk_order(self, tmp_path: Path) -> None: with Image.open("Tests/images/icc_profile.png") as im: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im.convert("P").save(test_file, dpi=(100, 100)) chunks = self.get_chunks(test_file) @@ -580,6 +576,7 @@ class TestFilePng: def test_read_private_chunks(self) -> None: with Image.open("Tests/images/exif.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.private_chunks == [(b"orNT", b"\x01")] def test_roundtrip_private_chunk(self) -> None: @@ -600,8 +597,9 @@ class TestFilePng: (b"prIV", b"VALUE3", True), ] - def test_textual_chunks_after_idat(self) -> None: + def test_textual_chunks_after_idat(self, monkeypatch: pytest.MonkeyPatch) -> None: with Image.open("Tests/images/hopper.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert "comment" in im.text for k, v in { "date:create": "2014-09-04T09:37:08+03:00", @@ -611,22 +609,25 @@ class TestFilePng: # Raises a SyntaxError in load_end with Image.open("Tests/images/broken_data_stream.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) with pytest.raises(OSError): assert isinstance(im.text, dict) - # Raises a UnicodeDecodeError in load_end - with Image.open("Tests/images/truncated_image.png") as im: - # The file is truncated - with pytest.raises(OSError): - im.text() - ImageFile.LOAD_TRUNCATED_IMAGES = True - assert isinstance(im.text, dict) - ImageFile.LOAD_TRUNCATED_IMAGES = False - # Raises an EOFError in load_end with Image.open("Tests/images/hopper_idat_after_image_end.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.text == {"TXT": "VALUE", "ZIP": "VALUE"} + # Raises a UnicodeDecodeError in load_end + with Image.open("Tests/images/truncated_image.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) + + # The file is truncated + with pytest.raises(OSError): + im.text + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) + assert isinstance(im.text, dict) + def test_unknown_compression_method(self) -> None: with pytest.raises(SyntaxError, match="Unknown compression method"): PngImagePlugin.PngImageFile("Tests/images/unknown_compression_method.png") @@ -651,21 +652,22 @@ class TestFilePng: @pytest.mark.parametrize( "cid", (b"IHDR", b"sRGB", b"pHYs", b"acTL", b"fcTL", b"fdAT") ) - def test_truncated_chunks(self, cid: bytes) -> None: + def test_truncated_chunks( + self, cid: bytes, monkeypatch: pytest.MonkeyPatch + ) -> None: fp = BytesIO() with PngImagePlugin.PngStream(fp) as png: with pytest.raises(ValueError): png.call(cid, 0, 0) - ImageFile.LOAD_TRUNCATED_IMAGES = True + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) png.call(cid, 0, 0) - ImageFile.LOAD_TRUNCATED_IMAGES = False @pytest.mark.parametrize("save_all", (True, False)) def test_specify_bits(self, save_all: bool, tmp_path: Path) -> None: im = hopper("P") - out = str(tmp_path / "temp.png") + out = tmp_path / "temp.png" im.save(out, bits=4, save_all=save_all) with Image.open(out) as reloaded: @@ -675,8 +677,8 @@ class TestFilePng: im = Image.new("P", (1, 1)) im.putpalette((1, 1, 1)) - out = str(tmp_path / "temp.png") - im.save(str(tmp_path / "temp.png")) + out = tmp_path / "temp.png" + im.save(out) with Image.open(out) as reloaded: assert len(reloaded.png.im_palette[1]) == 3 @@ -725,11 +727,12 @@ class TestFilePng: def test_exif_save(self, tmp_path: Path) -> None: # Test exif is not saved from info - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" with Image.open("Tests/images/exif.png") as im: im.save(test_file) with Image.open(test_file) as reloaded: + assert isinstance(reloaded, PngImagePlugin.PngImageFile) assert reloaded._getexif() is None # Test passing in exif @@ -745,7 +748,7 @@ class TestFilePng: ) def test_exif_from_jpg(self, tmp_path: Path) -> None: with Image.open("Tests/images/pil_sample_rgb.jpg") as im: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im.save(test_file, exif=im.getexif()) with Image.open(test_file) as reloaded: @@ -754,7 +757,7 @@ class TestFilePng: def test_exif_argument(self, tmp_path: Path) -> None: with Image.open(TEST_PNG_FILE) as im: - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" im.save(test_file, exif=b"exifstring") with Image.open(test_file) as reloaded: @@ -772,38 +775,31 @@ class TestFilePng: im.seek(1) @pytest.mark.parametrize("buffer", (True, False)) - def test_save_stdout(self, buffer: bool) -> None: - old_stdout = sys.stdout + def test_save_stdout(self, buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None: class MyStdOut: buffer = BytesIO() mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() - sys.stdout = mystdout + monkeypatch.setattr(sys, "stdout", mystdout) with Image.open(TEST_PNG_FILE) as im: im.save(sys.stdout, "PNG") - # Reset stdout - sys.stdout = old_stdout - if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) - def test_truncated_end_chunk(self) -> None: + def test_truncated_end_chunk(self, monkeypatch: pytest.MonkeyPatch) -> None: with Image.open("Tests/images/truncated_end_chunk.png") as im: with pytest.raises(OSError): im.load() - ImageFile.LOAD_TRUNCATED_IMAGES = True - try: - with Image.open("Tests/images/truncated_end_chunk.png") as im: - assert_image_equal_tofile(im, "Tests/images/hopper.png") - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) + with Image.open("Tests/images/truncated_end_chunk.png") as im: + assert_image_equal_tofile(im, "Tests/images/hopper.png") @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS") @@ -812,11 +808,11 @@ class TestTruncatedPngPLeaks(PillowLeakTestCase): mem_limit = 2 * 1024 # max increase in K iterations = 100 # Leak is 56k/iteration, this will leak 5.6megs - def test_leak_load(self) -> None: + def test_leak_load(self, monkeypatch: pytest.MonkeyPatch) -> None: with open("Tests/images/hopper.png", "rb") as f: DATA = BytesIO(f.read(16 * 1024)) - ImageFile.LOAD_TRUNCATED_IMAGES = True + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) with Image.open(DATA) as im: im.load() @@ -824,7 +820,4 @@ class TestTruncatedPngPLeaks(PillowLeakTestCase): with Image.open(DATA) as im: im.load() - try: - self._test_leak(core) - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + self._test_leak(core) diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index fb08d613a..41e2b5416 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -49,7 +49,7 @@ def test_sanity() -> None: (b"P5 3 1 257 \x00\x00\x00\x80\x01\x01", "I", (0, 32640, 65535)), # P6 with maxval < 255 ( - b"P6 3 1 17 \x00\x01\x02\x08\x09\x0A\x0F\x10\x11", + b"P6 3 1 17 \x00\x01\x02\x08\x09\x0a\x0f\x10\x11", "RGB", ( (0, 15, 30), @@ -60,7 +60,7 @@ def test_sanity() -> None: # P6 with maxval > 255 ( b"P6 3 1 257 \x00\x00\x00\x01\x00\x02" - b"\x00\x80\x00\x81\x00\x82\x01\x00\x01\x01\xFF\xFF", + b"\x00\x80\x00\x81\x00\x82\x01\x00\x01\x01\xff\xff", "RGB", ( (0, 1, 2), @@ -79,6 +79,7 @@ def test_arbitrary_maxval( assert im.mode == mode px = im.load() + assert px is not None assert tuple(px[x, 0] for x in range(3)) == pixels @@ -93,7 +94,7 @@ def test_16bit_pgm() -> None: def test_16bit_pgm_write(tmp_path: Path) -> None: with Image.open("Tests/images/16_bit_binary.pgm") as im: - filename = str(tmp_path / "temp.pgm") + filename = tmp_path / "temp.pgm" im.save(filename, "PPM") assert_image_equal_tofile(im, filename) @@ -105,7 +106,7 @@ def test_pnm(tmp_path: Path) -> None: with Image.open("Tests/images/hopper.pnm") as im: assert_image_similar(im, hopper(), 0.0001) - filename = str(tmp_path / "temp.pnm") + filename = tmp_path / "temp.pnm" im.save(filename) assert_image_equal_tofile(im, filename) @@ -116,7 +117,7 @@ def test_pfm(tmp_path: Path) -> None: assert im.info["scale"] == 1.0 assert_image_equal(im, hopper("F")) - filename = str(tmp_path / "tmp.pfm") + filename = tmp_path / "tmp.pfm" im.save(filename) assert_image_equal_tofile(im, filename) @@ -127,7 +128,7 @@ def test_pfm_big_endian(tmp_path: Path) -> None: assert im.info["scale"] == 2.5 assert_image_equal(im, hopper("F")) - filename = str(tmp_path / "tmp.pfm") + filename = tmp_path / "tmp.pfm" im.save(filename) assert_image_equal_tofile(im, filename) @@ -193,8 +194,8 @@ def test_16bit_plain_pgm() -> None: def test_plain_data_with_comment( tmp_path: Path, header: bytes, data: bytes, comment_count: int ) -> None: - path1 = str(tmp_path / "temp1.ppm") - path2 = str(tmp_path / "temp2.ppm") + path1 = tmp_path / "temp1.ppm" + path2 = tmp_path / "temp2.ppm" comment = b"# comment" * comment_count with open(path1, "wb") as f1, open(path2, "wb") as f2: f1.write(header + b"\n\n" + data) @@ -206,7 +207,7 @@ def test_plain_data_with_comment( @pytest.mark.parametrize("data", (b"P1\n128 128\n", b"P3\n128 128\n255\n")) def test_plain_truncated_data(tmp_path: Path, data: bytes) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(data) @@ -217,7 +218,7 @@ def test_plain_truncated_data(tmp_path: Path, data: bytes) -> None: @pytest.mark.parametrize("data", (b"P1\n128 128\n1009", b"P3\n128 128\n255\n100A")) def test_plain_invalid_data(tmp_path: Path, data: bytes) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(data) @@ -234,7 +235,7 @@ def test_plain_invalid_data(tmp_path: Path, data: bytes) -> None: ), ) def test_plain_ppm_token_too_long(tmp_path: Path, data: bytes) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(data) @@ -244,7 +245,7 @@ def test_plain_ppm_token_too_long(tmp_path: Path, data: bytes) -> None: def test_plain_ppm_value_negative(tmp_path: Path) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(b"P3\n128 128\n255\n-1") @@ -254,7 +255,7 @@ def test_plain_ppm_value_negative(tmp_path: Path) -> None: def test_plain_ppm_value_too_large(tmp_path: Path) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(b"P3\n128 128\n255\n256") @@ -269,7 +270,7 @@ def test_magic() -> None: def test_header_with_comments(tmp_path: Path) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(b"P6 #comment\n#comment\r12#comment\r8\n128 #comment\n255\n") @@ -278,7 +279,7 @@ def test_header_with_comments(tmp_path: Path) -> None: def test_non_integer_token(tmp_path: Path) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(b"P6\nTEST") @@ -288,29 +289,25 @@ def test_non_integer_token(tmp_path: Path) -> None: def test_header_token_too_long(tmp_path: Path) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(b"P6\n 01234567890") - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Token too long in file header: 01234567890"): with Image.open(path): pass - assert str(e.value) == "Token too long in file header: 01234567890" - def test_truncated_file(tmp_path: Path) -> None: # Test EOF in header - path = str(tmp_path / "temp.pgm") + path = tmp_path / "temp.pgm" with open(path, "wb") as f: f.write(b"P6") - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Reached EOF while reading header"): with Image.open(path): pass - assert str(e.value) == "Reached EOF while reading header" - # Test EOF for PyDecoder fp = BytesIO(b"P5 3 1 4") with Image.open(fp) as im: @@ -319,7 +316,7 @@ def test_truncated_file(tmp_path: Path) -> None: def test_not_enough_image_data(tmp_path: Path) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(b"P2 1 2 255 255") @@ -330,16 +327,16 @@ def test_not_enough_image_data(tmp_path: Path) -> None: @pytest.mark.parametrize("maxval", (b"0", b"65536")) def test_invalid_maxval(maxval: bytes, tmp_path: Path) -> None: - path = str(tmp_path / "temp.ppm") + path = tmp_path / "temp.ppm" with open(path, "wb") as f: f.write(b"P6\n3 1 " + maxval) - with pytest.raises(ValueError) as e: + with pytest.raises( + ValueError, match="maxval must be greater than 0 and less than 65536" + ): with Image.open(path): pass - assert str(e.value) == "maxval must be greater than 0 and less than 65536" - def test_neg_ppm() -> None: # Storage.c accepted negative values for xsize, ysize. the @@ -353,7 +350,7 @@ def test_neg_ppm() -> None: def test_mimetypes(tmp_path: Path) -> None: - path = str(tmp_path / "temp.pgm") + path = tmp_path / "temp.pgm" with open(path, "wb") as f: f.write(b"P4\n128 128\n255") @@ -367,22 +364,18 @@ def test_mimetypes(tmp_path: Path) -> None: @pytest.mark.parametrize("buffer", (True, False)) -def test_save_stdout(buffer: bool) -> None: - old_stdout = sys.stdout +def test_save_stdout(buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None: class MyStdOut: buffer = BytesIO() mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() - sys.stdout = mystdout + monkeypatch.setattr(sys, "stdout", mystdout) with Image.open(TEST_FILE) as im: im.save(sys.stdout, "PPM") - # Reset stdout - sys.stdout = old_stdout - if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 5f22001f3..38a88cd17 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -25,12 +25,12 @@ def test_sanity() -> None: @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file() -> None: - def open() -> None: + def open_test_image() -> None: im = Image.open(test_file) im.load() with pytest.warns(ResourceWarning): - open() + open_test_image() def test_closed_file() -> None: @@ -59,17 +59,21 @@ def test_invalid_file() -> None: def test_n_frames() -> None: with Image.open("Tests/images/hopper_merged.psd") as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) assert im.n_frames == 1 assert not im.is_animated for path in [test_file, "Tests/images/negative_layer_count.psd"]: with Image.open(path) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) assert im.n_frames == 2 assert im.is_animated def test_eoferror() -> None: with Image.open(test_file) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) + # PSD seek index starts at 1 rather than 0 n_frames = im.n_frames + 1 @@ -119,11 +123,13 @@ def test_rgba() -> None: def test_negative_top_left_layer() -> None: with Image.open("Tests/images/negative_top_left_layer.psd") as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) assert im.layers[0][2] == (-50, -50, 50, 50) def test_layer_skip() -> None: with Image.open("Tests/images/five_channels.psd") as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) assert im.n_frames == 1 @@ -175,5 +181,6 @@ def test_crashes(test_file: str, raises: type[Exception]) -> None: def test_layer_crashes(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) with pytest.raises(SyntaxError): im.layers diff --git a/Tests/test_file_sgi.py b/Tests/test_file_sgi.py index e13a8019e..da0965fa1 100644 --- a/Tests/test_file_sgi.py +++ b/Tests/test_file_sgi.py @@ -71,31 +71,33 @@ def test_invalid_file() -> None: SgiImagePlugin.SgiImageFile(invalid_file) -def test_write(tmp_path: Path) -> None: - def roundtrip(img: Image.Image) -> None: - out = str(tmp_path / "temp.sgi") - img.save(out, format="sgi") +def roundtrip(img: Image.Image, tmp_path: Path) -> None: + out = tmp_path / "temp.sgi" + img.save(out, format="sgi") + assert_image_equal_tofile(img, out) + + out = tmp_path / "fp.sgi" + with open(out, "wb") as fp: + img.save(fp) assert_image_equal_tofile(img, out) - out = str(tmp_path / "fp.sgi") - with open(out, "wb") as fp: - img.save(fp) - assert_image_equal_tofile(img, out) + assert not fp.closed - assert not fp.closed - for mode in ("L", "RGB", "RGBA"): - roundtrip(hopper(mode)) +@pytest.mark.parametrize("mode", ("L", "RGB", "RGBA")) +def test_write(mode: str, tmp_path: Path) -> None: + roundtrip(hopper(mode), tmp_path) - # Test 1 dimension for an L mode image - roundtrip(Image.new("L", (10, 1))) + +def test_write_L_mode_1_dimension(tmp_path: Path) -> None: + roundtrip(Image.new("L", (10, 1)), tmp_path) def test_write16(tmp_path: Path) -> None: test_file = "Tests/images/hopper16.rgb" with Image.open(test_file) as im: - out = str(tmp_path / "temp.sgi") + out = tmp_path / "temp.sgi" im.save(out, format="sgi", bpc=2) assert_image_equal_tofile(im, out) @@ -103,7 +105,7 @@ def test_write16(tmp_path: Path) -> None: def test_unsupported_mode(tmp_path: Path) -> None: im = hopper("LA") - out = str(tmp_path / "temp.sgi") + out = tmp_path / "temp.sgi" with pytest.raises(ValueError): im.save(out, format="sgi") diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 4cafda865..3b3c3b4a5 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest -from PIL import Image, ImageSequence, SpiderImagePlugin +from PIL import Image, SpiderImagePlugin from .helper import assert_image_equal, hopper, is_pypy @@ -24,12 +24,12 @@ def test_sanity() -> None: @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file() -> None: - def open() -> None: + def open_test_image() -> None: im = Image.open(TEST_FILE) im.load() with pytest.warns(ResourceWarning): - open() + open_test_image() def test_closed_file() -> None: @@ -51,7 +51,7 @@ def test_context_manager() -> None: def test_save(tmp_path: Path) -> None: # Arrange - temp = str(tmp_path / "temp.spider") + temp = tmp_path / "temp.spider" im = hopper() # Act @@ -96,6 +96,7 @@ def test_tell() -> None: def test_n_frames() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, SpiderImagePlugin.SpiderImageFile) assert im.n_frames == 1 assert not im.is_animated @@ -153,8 +154,8 @@ def test_nonstack_file() -> None: def test_nonstack_dos() -> None: with Image.open(TEST_FILE) as im: - for i, frame in enumerate(ImageSequence.Iterator(im)): - assert i <= 1, "Non-stack DOS file test failed" + with pytest.raises(EOFError): + im.seek(0) # for issue #4093 diff --git a/Tests/test_file_sun.py b/Tests/test_file_sun.py index 6cfff8730..c2f162cf9 100644 --- a/Tests/test_file_sun.py +++ b/Tests/test_file_sun.py @@ -1,10 +1,11 @@ from __future__ import annotations +import io import os import pytest -from PIL import Image, SunImagePlugin +from PIL import Image, SunImagePlugin, _binary from .helper import assert_image_equal_tofile, assert_image_similar, hopper @@ -33,6 +34,60 @@ def test_im1() -> None: assert_image_equal_tofile(im, "Tests/images/sunraster.im1.png") +def _sun_header( + depth: int = 0, file_type: int = 0, palette_length: int = 0 +) -> io.BytesIO: + return io.BytesIO( + _binary.o32be(0x59A66A95) + + b"\x00" * 8 + + _binary.o32be(depth) + + b"\x00" * 4 + + _binary.o32be(file_type) + + b"\x00" * 4 + + _binary.o32be(palette_length) + ) + + +def test_unsupported_mode_bit_depth() -> None: + with pytest.raises(SyntaxError, match="Unsupported Mode/Bit Depth"): + with SunImagePlugin.SunImageFile(_sun_header()): + pass + + +def test_unsupported_color_palette_length() -> None: + with pytest.raises(SyntaxError, match="Unsupported Color Palette Length"): + with SunImagePlugin.SunImageFile(_sun_header(depth=1, palette_length=1025)): + pass + + +def test_unsupported_palette_type() -> None: + with pytest.raises(SyntaxError, match="Unsupported Palette Type"): + with SunImagePlugin.SunImageFile(_sun_header(depth=1, palette_length=1)): + pass + + +def test_unsupported_file_type() -> None: + with pytest.raises(SyntaxError, match="Unsupported Sun Raster file type"): + with SunImagePlugin.SunImageFile(_sun_header(depth=1, file_type=6)): + pass + + +@pytest.mark.skipif( + not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" +) +def test_rgbx() -> None: + with open(os.path.join(EXTRA_DIR, "32bpp.ras"), "rb") as fp: + data = fp.read() + + # Set file type to 3 + data = data[:20] + _binary.o32be(3) + data[24:] + + with Image.open(io.BytesIO(data)) as im: + r, g, b = im.split() + im = Image.merge("RGB", (b, g, r)) + assert_image_equal_tofile(im, os.path.join(EXTRA_DIR, "32bpp.png")) + + @pytest.mark.skipif( not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) diff --git a/Tests/test_file_tar.py b/Tests/test_file_tar.py index 49220a8b6..084d0f288 100644 --- a/Tests/test_file_tar.py +++ b/Tests/test_file_tar.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from pathlib import Path import pytest @@ -29,6 +30,22 @@ def test_sanity(codec: str, test_path: str, format: str) -> None: assert im.format == format +def test_unexpected_end(tmp_path: Path) -> None: + tmpfile = str(tmp_path / "temp.tar") + with open(tmpfile, "w"): + pass + + with pytest.raises(OSError, match="unexpected end of tar file"): + with TarIO.TarIO(tmpfile, "test"): + pass + + +def test_cannot_find_subfile() -> None: + with pytest.raises(OSError, match="cannot find subfile"): + with TarIO.TarIO(TEST_TAR_FILE, "test"): + pass + + @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file() -> None: with pytest.warns(ResourceWarning): diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index a03a6a6e1..8b6ed3ed2 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -1,8 +1,6 @@ from __future__ import annotations import os -from glob import glob -from itertools import product from pathlib import Path import pytest @@ -15,16 +13,29 @@ _TGA_DIR = os.path.join("Tests", "images", "tga") _TGA_DIR_COMMON = os.path.join(_TGA_DIR, "common") -_MODES = ("L", "LA", "P", "RGB", "RGBA") _ORIGINS = ("tl", "bl") _ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1} -@pytest.mark.parametrize("mode", _MODES) -def test_sanity(mode: str, tmp_path: Path) -> None: +@pytest.mark.parametrize( + "size_mode", + ( + ("1x1", "L"), + ("200x32", "L"), + ("200x32", "LA"), + ("200x32", "P"), + ("200x32", "RGB"), + ("200x32", "RGBA"), + ), +) +@pytest.mark.parametrize("origin", _ORIGINS) +@pytest.mark.parametrize("rle", (True, False)) +def test_sanity( + size_mode: tuple[str, str], origin: str, rle: str, tmp_path: Path +) -> None: def roundtrip(original_im: Image.Image) -> None: - out = str(tmp_path / "temp.tga") + out = tmp_path / "temp.tga" original_im.save(out, rle=rle) with Image.open(out) as saved_im: @@ -36,46 +47,40 @@ def test_sanity(mode: str, tmp_path: Path) -> None: assert_image_equal(saved_im, original_im) - png_paths = glob(os.path.join(_TGA_DIR_COMMON, f"*x*_{mode.lower()}.png")) + size, mode = size_mode + png_path = os.path.join(_TGA_DIR_COMMON, size + "_" + mode.lower() + ".png") + with Image.open(png_path) as reference_im: + assert reference_im.mode == mode - for png_path in png_paths: - with Image.open(png_path) as reference_im: - assert reference_im.mode == mode + path_no_ext = os.path.splitext(png_path)[0] + tga_path = "{}_{}_{}.tga".format(path_no_ext, origin, "rle" if rle else "raw") - path_no_ext = os.path.splitext(png_path)[0] - for origin, rle in product(_ORIGINS, (True, False)): - tga_path = "{}_{}_{}.tga".format( - path_no_ext, origin, "rle" if rle else "raw" - ) + with Image.open(tga_path) as original_im: + assert original_im.format == "TGA" + assert original_im.get_format_mimetype() == "image/x-tga" + if rle: + assert original_im.info["compression"] == "tga_rle" + assert original_im.info["orientation"] == _ORIGIN_TO_ORIENTATION[origin] + if mode == "P": + assert original_im.getpalette() == reference_im.getpalette() - with Image.open(tga_path) as original_im: - assert original_im.format == "TGA" - assert original_im.get_format_mimetype() == "image/x-tga" - if rle: - assert original_im.info["compression"] == "tga_rle" - assert ( - original_im.info["orientation"] - == _ORIGIN_TO_ORIENTATION[origin] - ) - if mode == "P": - assert original_im.getpalette() == reference_im.getpalette() + assert_image_equal(original_im, reference_im) - assert_image_equal(original_im, reference_im) - - roundtrip(original_im) + roundtrip(original_im) -def test_palette_depth_8(tmp_path: Path) -> None: +def test_palette_depth_8() -> None: with pytest.raises(UnidentifiedImageError): Image.open("Tests/images/p_8.tga") def test_palette_depth_16(tmp_path: Path) -> None: with Image.open("Tests/images/p_16.tga") as im: + assert im.palette is not None assert im.palette.mode == "RGBA" assert_image_equal_tofile(im.convert("RGBA"), "Tests/images/p_16.png") - out = str(tmp_path / "temp.png") + out = tmp_path / "temp.png" im.save(out) with Image.open(out) as reloaded: assert_image_equal_tofile(reloaded.convert("RGBA"), "Tests/images/p_16.png") @@ -121,7 +126,7 @@ def test_cross_scan_line() -> None: def test_save(tmp_path: Path) -> None: test_file = "Tests/images/tga_id_field.tga" with Image.open(test_file) as im: - out = str(tmp_path / "temp.tga") + out = tmp_path / "temp.tga" # Save im.save(out) @@ -140,7 +145,7 @@ def test_small_palette(tmp_path: Path) -> None: colors = [0, 0, 0] im.putpalette(colors) - out = str(tmp_path / "temp.tga") + out = tmp_path / "temp.tga" im.save(out) with Image.open(out) as reloaded: @@ -154,7 +159,7 @@ def test_missing_palette() -> None: def test_save_wrong_mode(tmp_path: Path) -> None: im = hopper("PA") - out = str(tmp_path / "temp.tga") + out = tmp_path / "temp.tga" with pytest.raises(OSError): im.save(out) @@ -171,7 +176,7 @@ def test_save_mapdepth() -> None: def test_save_id_section(tmp_path: Path) -> None: test_file = "Tests/images/rgb32rle.tga" with Image.open(test_file) as im: - out = str(tmp_path / "temp.tga") + out = tmp_path / "temp.tga" # Check there is no id section im.save(out) @@ -201,7 +206,7 @@ def test_save_id_section(tmp_path: Path) -> None: def test_save_orientation(tmp_path: Path) -> None: test_file = "Tests/images/rgb32rle.tga" - out = str(tmp_path / "temp.tga") + out = tmp_path / "temp.tga" with Image.open(test_file) as im: assert im.info["orientation"] == -1 @@ -213,10 +218,14 @@ def test_save_orientation(tmp_path: Path) -> None: def test_horizontal_orientations() -> None: # These images have been manually hexedited to have the relevant orientations with Image.open("Tests/images/rgb32rle_top_right.tga") as im: - assert im.load()[90, 90][:3] == (0, 0, 0) + px = im.load() + assert px is not None + assert px[90, 90][:3] == (0, 0, 0) with Image.open("Tests/images/rgb32rle_bottom_right.tga") as im: - assert im.load()[90, 90][:3] == (0, 255, 0) + px = im.load() + assert px is not None + assert px[90, 90][:3] == (0, 255, 0) def test_save_rle(tmp_path: Path) -> None: @@ -224,7 +233,7 @@ def test_save_rle(tmp_path: Path) -> None: with Image.open(test_file) as im: assert im.info["compression"] == "tga_rle" - out = str(tmp_path / "temp.tga") + out = tmp_path / "temp.tga" # Save im.save(out) @@ -261,7 +270,7 @@ def test_save_l_transparency(tmp_path: Path) -> None: assert im.mode == "LA" assert im.getchannel("A").getcolors()[0][0] == num_transparent - out = str(tmp_path / "temp.tga") + out = tmp_path / "temp.tga" im.save(out) with Image.open(out) as test_im: diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 6f51d4651..502d9df9a 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -9,7 +9,13 @@ from types import ModuleType import pytest -from PIL import Image, ImageFile, TiffImagePlugin, UnidentifiedImageError +from PIL import ( + Image, + ImageFile, + JpegImagePlugin, + TiffImagePlugin, + UnidentifiedImageError, +) from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION from .helper import ( @@ -31,7 +37,7 @@ except ImportError: class TestFileTiff: def test_sanity(self, tmp_path: Path) -> None: - filename = str(tmp_path / "temp.tif") + filename = tmp_path / "temp.tif" hopper("RGB").save(filename) @@ -63,12 +69,12 @@ class TestFileTiff: @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file(self) -> None: - def open() -> None: + def open_test_image() -> None: im = Image.open("Tests/images/multipage.tiff") im.load() with pytest.warns(ResourceWarning): - open() + open_test_image() def test_closed_file(self) -> None: with warnings.catch_warnings(): @@ -112,22 +118,39 @@ class TestFileTiff: assert_image_equal_tofile(im, "Tests/images/hopper.tif") with Image.open("Tests/images/hopper_bigtiff.tif") as im: - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2) + def test_bigtiff_save(self, tmp_path: Path) -> None: + outfile = tmp_path / "temp.tif" + im = hopper() + im.save(outfile, big_tiff=True) + + with Image.open(outfile) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) + assert reloaded.tag_v2._bigtiff is True + + im.save(outfile, save_all=True, append_images=[im], big_tiff=True) + + with Image.open(outfile) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) + assert reloaded.tag_v2._bigtiff is True + def test_seek_too_large(self) -> None: with pytest.raises(ValueError, match="Unable to seek to frame"): Image.open("Tests/images/seek_too_large.tif") def test_set_legacy_api(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="Not allowing setting of legacy api"): ifd.legacy_api = False - assert str(e.value) == "Not allowing setting of legacy api" def test_xyres_tiff(self) -> None: filename = "Tests/images/pil168.tif" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # legacy api assert isinstance(im.tag[X_RESOLUTION][0], tuple) assert isinstance(im.tag[Y_RESOLUTION][0], tuple) @@ -141,6 +164,8 @@ class TestFileTiff: def test_xyres_fallback_tiff(self) -> None: filename = "Tests/images/compression.tif" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # v2 api assert isinstance(im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational) assert isinstance(im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational) @@ -155,6 +180,8 @@ class TestFileTiff: def test_int_resolution(self) -> None: filename = "Tests/images/pil168.tif" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # Try to read a file where X,Y_RESOLUTION are ints im.tag_v2[X_RESOLUTION] = 71 im.tag_v2[Y_RESOLUTION] = 71 @@ -169,11 +196,12 @@ class TestFileTiff: with Image.open( "Tests/images/hopper_float_dpi_" + str(resolution_unit) + ".tif" ) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2.get(RESOLUTION_UNIT) == resolution_unit assert im.info["dpi"] == (dpi, dpi) def test_save_float_dpi(self, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" with Image.open("Tests/images/hopper.tif") as im: dpi = (72.2, 72.2) im.save(outfile, dpi=dpi) @@ -186,6 +214,7 @@ class TestFileTiff: with Image.open("Tests/images/10ct_32bit_128.tiff") as im: im.save(b, format="tiff", resolution=123.45) with Image.open(b) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2[X_RESOLUTION] == 123.45 assert im.tag_v2[Y_RESOLUTION] == 123.45 @@ -201,19 +230,21 @@ class TestFileTiff: TiffImagePlugin.PREFIXES.pop() def test_bad_exif(self) -> None: - with Image.open("Tests/images/hopper_bad_exif.jpg") as i: + with Image.open("Tests/images/hopper_bad_exif.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) + # Should not raise struct.error. with pytest.warns(UserWarning): - i._getexif() + im._getexif() def test_save_rgba(self, tmp_path: Path) -> None: im = hopper("RGBA") - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" im.save(outfile) def test_save_unsupported_mode(self, tmp_path: Path) -> None: im = hopper("HSV") - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" with pytest.raises(OSError): im.save(outfile) @@ -295,11 +326,13 @@ class TestFileTiff: ) def test_n_frames(self, path: str, n_frames: int) -> None: with Image.open(path) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == n_frames assert im.is_animated == (n_frames != 1) def test_eoferror(self) -> None: with Image.open("Tests/images/multipage-lastframe.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) n_frames = im.n_frames # Test seeking past the last frame @@ -343,19 +376,24 @@ class TestFileTiff: def test_frame_order(self) -> None: # A frame can't progress to itself after reading with Image.open("Tests/images/multipage_single_frame_loop.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == 1 # A frame can't progress to a frame that has already been read with Image.open("Tests/images/multipage_multiple_frame_loop.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == 2 # Frames don't have to be in sequence with Image.open("Tests/images/multipage_out_of_order.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == 3 def test___str__(self) -> None: filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # Act ret = str(im.ifd) @@ -366,6 +404,8 @@ class TestFileTiff: # Arrange filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # v2 interface v2_tags = { 256: 55, @@ -405,6 +445,7 @@ class TestFileTiff: def test__delitem__(self) -> None: filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) len_before = len(dict(im.ifd)) del im.ifd[256] len_after = len(dict(im.ifd)) @@ -437,6 +478,7 @@ class TestFileTiff: def test_ifd_tag_type(self) -> None: with Image.open("Tests/images/ifd_tag_type.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert 0x8825 in im.tag_v2 def test_exif(self, tmp_path: Path) -> None: @@ -473,14 +515,14 @@ class TestFileTiff: assert gps[0] == b"\x03\x02\x00\x00" assert gps[18] == "WGS-84" - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" with Image.open("Tests/images/ifd_tag_type.tiff") as im: exif = im.getexif() check_exif(exif) im.save(outfile, exif=exif) - outfile2 = str(tmp_path / "temp2.tif") + outfile2 = tmp_path / "temp2.tif" with Image.open(outfile) as im: exif = im.getexif() check_exif(exif) @@ -492,7 +534,7 @@ class TestFileTiff: check_exif(exif) def test_modify_exif(self, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" with Image.open("Tests/images/ifd_tag_type.tiff") as im: exif = im.getexif() exif[264] = 100 @@ -521,10 +563,11 @@ class TestFileTiff: @pytest.mark.parametrize("mode", ("1", "L")) def test_photometric(self, mode: str, tmp_path: Path) -> None: - filename = str(tmp_path / "temp.tif") + filename = tmp_path / "temp.tif" im = hopper(mode) im.save(filename, tiffinfo={262: 0}) with Image.open(filename) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[262] == 0 assert_image_equal(im, reloaded) @@ -600,9 +643,11 @@ class TestFileTiff: def test_with_underscores(self, tmp_path: Path) -> None: kwargs = {"resolution_unit": "inch", "x_resolution": 72, "y_resolution": 36} - filename = str(tmp_path / "temp.tif") + filename = tmp_path / "temp.tif" hopper("RGB").save(filename, "TIFF", **kwargs) with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # legacy interface assert im.tag[X_RESOLUTION][0][0] == 72 assert im.tag[Y_RESOLUTION][0][0] == 36 @@ -618,14 +663,14 @@ class TestFileTiff: with Image.open(infile) as im: assert im.getpixel((0, 0)) == pixel_value - tmpfile = str(tmp_path / "temp.tif") + tmpfile = tmp_path / "temp.tif" im.save(tmpfile) assert_image_equal_tofile(im, tmpfile) def test_iptc(self, tmp_path: Path) -> None: # Do not preserve IPTC_NAA_CHUNK by default if type is LONG - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" with Image.open("Tests/images/hopper.tif") as im: im.load() assert isinstance(im, TiffImagePlugin.TiffImageFile) @@ -640,7 +685,7 @@ class TestFileTiff: assert 33723 not in im.tag_v2 def test_rowsperstrip(self, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" im = hopper() im.save(outfile, tiffinfo={278: 256}) @@ -648,6 +693,18 @@ class TestFileTiff: assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2[278] == 256 + im = hopper() + im2 = Image.new("L", (128, 128)) + im2.encoderinfo = {"tiffinfo": {278: 256}} + im.save(outfile, save_all=True, append_images=[im2]) + + with Image.open(outfile) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + assert im.tag_v2[278] == 128 + + im.seek(1) + assert im.tag_v2[278] == 256 + def test_strip_raw(self) -> None: infile = "Tests/images/tiff_strip_raw.tif" with Image.open(infile) as im: @@ -677,9 +734,10 @@ class TestFileTiff: def test_planar_configuration_save(self, tmp_path: Path) -> None: infile = "Tests/images/tiff_tiled_planar_raw.tif" with Image.open(infile) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im._planar_configuration == 2 - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" im.save(outfile) with Image.open(outfile) as reloaded: @@ -694,7 +752,7 @@ class TestFileTiff: @pytest.mark.parametrize("mode", ("P", "PA")) def test_palette(self, mode: str, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" im = hopper(mode) im.save(outfile) @@ -709,6 +767,7 @@ class TestFileTiff: mp.seek(0, os.SEEK_SET) with Image.open(mp) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == 3 # Test appending images @@ -719,6 +778,7 @@ class TestFileTiff: mp.seek(0, os.SEEK_SET) with Image.open(mp) as reread: + assert isinstance(reread, TiffImagePlugin.TiffImageFile) assert reread.n_frames == 3 # Test appending using a generator @@ -730,6 +790,7 @@ class TestFileTiff: mp.seek(0, os.SEEK_SET) with Image.open(mp) as reread: + assert isinstance(reread, TiffImagePlugin.TiffImageFile) assert reread.n_frames == 3 def test_fixoffsets(self) -> None: @@ -746,6 +807,39 @@ class TestFileTiff: with pytest.raises(RuntimeError): a.fixOffsets(1) + b = BytesIO(b"II\x2a\x00\x00\x00\x00\x00") + with TiffImagePlugin.AppendingTiffWriter(b) as a: + a.offsetOfNewPage = 2**16 + + b.seek(0) + a.fixOffsets(1, isShort=True) + + b = BytesIO(b"II\x2b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00") + with TiffImagePlugin.AppendingTiffWriter(b) as a: + a.offsetOfNewPage = 2**32 + + b.seek(0) + a.fixOffsets(1, isShort=True) + + b.seek(0) + a.fixOffsets(1, isLong=True) + + def test_appending_tiff_writer_writelong(self) -> None: + data = b"II\x2a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b = BytesIO(data) + with TiffImagePlugin.AppendingTiffWriter(b) as a: + a.seek(-4, os.SEEK_CUR) + a.writeLong(2**32 - 1) + assert b.getvalue() == data[:-4] + b"\xff\xff\xff\xff" + + def test_appending_tiff_writer_rewritelastshorttolong(self) -> None: + data = b"II\x2a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b = BytesIO(data) + with TiffImagePlugin.AppendingTiffWriter(b) as a: + a.seek(-2, os.SEEK_CUR) + a.rewriteLastShortToLong(2**32 - 1) + assert b.getvalue() == data[:-4] + b"\xff\xff\xff\xff" + def test_saving_icc_profile(self, tmp_path: Path) -> None: # Tests saving TIFF with icc_profile set. # At the time of writing this will only work for non-compressed tiffs @@ -755,7 +849,7 @@ class TestFileTiff: im.info["icc_profile"] = "Dummy value" # Try save-load round trip to make sure both handle icc_profile. - tmpfile = str(tmp_path / "temp.tif") + tmpfile = tmp_path / "temp.tif" im.save(tmpfile, "TIFF", compression="raw") with Image.open(tmpfile) as reloaded: assert b"Dummy value" == reloaded.info["icc_profile"] @@ -764,7 +858,7 @@ class TestFileTiff: im = hopper() assert "icc_profile" not in im.info - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" icc_profile = b"Dummy value" im.save(outfile, icc_profile=icc_profile) @@ -775,11 +869,11 @@ class TestFileTiff: with Image.open("Tests/images/hopper.bmp") as im: assert im.info["compression"] == 0 - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" im.save(outfile) def test_discard_icc_profile(self, tmp_path: Path) -> None: - outfile = str(tmp_path / "temp.tif") + outfile = tmp_path / "temp.tif" with Image.open("Tests/images/icc_profile.png") as im: assert "icc_profile" in im.info @@ -807,6 +901,7 @@ class TestFileTiff: def test_get_photoshop_blocks(self) -> None: with Image.open("Tests/images/lab.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert list(im.get_photoshop_blocks().keys()) == [ 1061, 1002, @@ -832,7 +927,7 @@ class TestFileTiff: ] def test_tiff_chunks(self, tmp_path: Path) -> None: - tmpfile = str(tmp_path / "temp.tif") + tmpfile = tmp_path / "temp.tif" im = hopper() with open(tmpfile, "wb") as fp: @@ -854,7 +949,7 @@ class TestFileTiff: def test_close_on_load_exclusive(self, tmp_path: Path) -> None: # similar to test_fd_leak, but runs on unixlike os - tmpfile = str(tmp_path / "temp.tif") + tmpfile = tmp_path / "temp.tif" with Image.open("Tests/images/uint16_1_4660.tif") as im: im.save(tmpfile) @@ -866,7 +961,7 @@ class TestFileTiff: assert fp.closed def test_close_on_load_nonexclusive(self, tmp_path: Path) -> None: - tmpfile = str(tmp_path / "temp.tif") + tmpfile = tmp_path / "temp.tif" with Image.open("Tests/images/uint16_1_4660.tif") as im: im.save(tmpfile) @@ -895,11 +990,10 @@ class TestFileTiff: @pytest.mark.timeout(6) @pytest.mark.filterwarnings("ignore:Truncated File Read") - def test_timeout(self) -> None: + def test_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None: with Image.open("Tests/images/timeout-6646305047838720") as im: - ImageFile.LOAD_TRUNCATED_IMAGES = True + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) im.load() - ImageFile.LOAD_TRUNCATED_IMAGES = False @pytest.mark.parametrize( "test_file", @@ -918,7 +1012,7 @@ class TestFileTiff: @pytest.mark.skipif(not is_win32(), reason="Windows only") class TestFileTiffW32: def test_fd_leak(self, tmp_path: Path) -> None: - tmpfile = str(tmp_path / "temp.tif") + tmpfile = tmp_path / "temp.tif" # this is an mmaped file. with Image.open("Tests/images/uint16_1_4660.tif") as im: diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index 36aabf4f8..884868345 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -56,11 +56,12 @@ def test_rt_metadata(tmp_path: Path) -> None: info[ImageDescription] = text_data - f = str(tmp_path / "temp.tif") + f = tmp_path / "temp.tif" img.save(f, tiffinfo=info) with Image.open(f) as loaded: + assert isinstance(loaded, TiffImagePlugin.TiffImageFile) assert loaded.tag[ImageJMetaDataByteCounts] == (len(bin_data),) assert loaded.tag_v2[ImageJMetaDataByteCounts] == (len(bin_data),) @@ -80,12 +81,14 @@ def test_rt_metadata(tmp_path: Path) -> None: info[ImageJMetaDataByteCounts] = (8, len(bin_data) - 8) img.save(f, tiffinfo=info) with Image.open(f) as loaded: + assert isinstance(loaded, TiffImagePlugin.TiffImageFile) assert loaded.tag[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8) assert loaded.tag_v2[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8) def test_read_metadata() -> None: with Image.open("Tests/images/hopper_g4.tif") as img: + assert isinstance(img, TiffImagePlugin.TiffImageFile) assert { "YResolution": IFDRational(4294967295, 113653537), "PlanarConfiguration": 1, @@ -128,13 +131,15 @@ def test_read_metadata() -> None: def test_write_metadata(tmp_path: Path) -> None: """Test metadata writing through the python code""" with Image.open("Tests/images/hopper.tif") as img: - f = str(tmp_path / "temp.tiff") + assert isinstance(img, TiffImagePlugin.TiffImageFile) + f = tmp_path / "temp.tiff" del img.tag[278] img.save(f, tiffinfo=img.tag) original = img.tag_v2.named() with Image.open(f) as loaded: + assert isinstance(loaded, TiffImagePlugin.TiffImageFile) reloaded = loaded.tag_v2.named() ignored = ["StripByteCounts", "RowsPerStrip", "PageNumber", "StripOffsets"] @@ -163,8 +168,9 @@ def test_write_metadata(tmp_path: Path) -> None: def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None: - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" with Image.open("Tests/images/hopper.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) info = im.tag_v2 del info[278] @@ -178,6 +184,7 @@ def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None: im.save(out, tiffinfo=info) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] == TiffTags.LONG @@ -210,7 +217,7 @@ def test_no_duplicate_50741_tag() -> None: def test_iptc(tmp_path: Path) -> None: - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" with Image.open("Tests/images/hopper.Lab.tif") as im: im.save(out) @@ -227,10 +234,11 @@ def test_writing_other_types_to_ascii( info[271] = value im = hopper() - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[271] == expected @@ -244,10 +252,11 @@ def test_writing_other_types_to_bytes(value: int | IFDRational, tmp_path: Path) info[700] = value - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[700] == b"\x01" @@ -263,10 +272,11 @@ def test_writing_other_types_to_undefined( info[33723] = value - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[33723] == b"1" @@ -296,7 +306,7 @@ def test_empty_metadata() -> None: def test_iccprofile(tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/1462 - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" with Image.open("Tests/images/hopper.iccprofile.tif") as im: im.save(out) @@ -311,19 +321,20 @@ def test_iccprofile_binary() -> None: # but probably won't be able to save it. with Image.open("Tests/images/hopper.iccprofile_binary.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2.tagtype[34675] == 1 assert im.info["icc_profile"] def test_iccprofile_save_png(tmp_path: Path) -> None: with Image.open("Tests/images/hopper.iccprofile.tif") as im: - outfile = str(tmp_path / "temp.png") + outfile = tmp_path / "temp.png" im.save(outfile) def test_iccprofile_binary_save_png(tmp_path: Path) -> None: with Image.open("Tests/images/hopper.iccprofile_binary.tif") as im: - outfile = str(tmp_path / "temp.png") + outfile = tmp_path / "temp.png" im.save(outfile) @@ -332,10 +343,11 @@ def test_exif_div_zero(tmp_path: Path) -> None: info = TiffImagePlugin.ImageFileDirectory_v2() info[41988] = TiffImagePlugin.IFDRational(0, 0) - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert 0 == reloaded.tag_v2[41988].numerator assert 0 == reloaded.tag_v2[41988].denominator @@ -351,10 +363,11 @@ def test_ifd_unsigned_rational(tmp_path: Path) -> None: info[41493] = TiffImagePlugin.IFDRational(numerator, 1) - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert max_long == reloaded.tag_v2[41493].numerator assert 1 == reloaded.tag_v2[41493].denominator @@ -363,10 +376,11 @@ def test_ifd_unsigned_rational(tmp_path: Path) -> None: info[41493] = TiffImagePlugin.IFDRational(numerator, 1) - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert max_long == reloaded.tag_v2[41493].numerator assert 1 == reloaded.tag_v2[41493].denominator @@ -381,10 +395,11 @@ def test_ifd_signed_rational(tmp_path: Path) -> None: info[37380] = TiffImagePlugin.IFDRational(numerator, denominator) - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert numerator == reloaded.tag_v2[37380].numerator assert denominator == reloaded.tag_v2[37380].denominator @@ -393,10 +408,11 @@ def test_ifd_signed_rational(tmp_path: Path) -> None: info[37380] = TiffImagePlugin.IFDRational(numerator, denominator) - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert numerator == reloaded.tag_v2[37380].numerator assert denominator == reloaded.tag_v2[37380].denominator @@ -406,10 +422,11 @@ def test_ifd_signed_rational(tmp_path: Path) -> None: info[37380] = TiffImagePlugin.IFDRational(numerator, denominator) - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert 2**31 - 1 == reloaded.tag_v2[37380].numerator assert -1 == reloaded.tag_v2[37380].denominator @@ -420,10 +437,11 @@ def test_ifd_signed_long(tmp_path: Path) -> None: info[37000] = -60000 - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[37000] == -60000 @@ -444,11 +462,13 @@ def test_empty_values() -> None: def test_photoshop_info(tmp_path: Path) -> None: with Image.open("Tests/images/issue_2278.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert len(im.tag_v2[34377]) == 70 assert isinstance(im.tag_v2[34377], bytes) - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" im.save(out) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert len(reloaded.tag_v2[34377]) == 70 assert isinstance(reloaded.tag_v2[34377], bytes) @@ -480,7 +500,7 @@ def test_tag_group_data() -> None: def test_empty_subifd(tmp_path: Path) -> None: - out = str(tmp_path / "temp.jpg") + out = tmp_path / "temp.jpg" im = hopper() exif = im.getexif() diff --git a/Tests/test_file_wal.py b/Tests/test_file_wal.py index b34975e83..b15d79d61 100644 --- a/Tests/test_file_wal.py +++ b/Tests/test_file_wal.py @@ -21,7 +21,11 @@ def test_open() -> None: def test_load() -> None: with WalImageFile.open(TEST_FILE) as im: - assert im.load()[0, 0] == 122 + px = im.load() + assert px is not None + assert px[0, 0] == 122 # Test again now that it has already been loaded once - assert im.load()[0, 0] == 122 + px = im.load() + assert px is not None + assert px[0, 0] == 122 diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index ad5aa9ed6..f61e2c82e 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -28,9 +28,9 @@ except ImportError: class TestUnsupportedWebp: - def test_unsupported(self) -> None: + def test_unsupported(self, monkeypatch: pytest.MonkeyPatch) -> None: if HAVE_WEBP: - WebPImagePlugin.SUPPORTED = False + monkeypatch.setattr(WebPImagePlugin, "SUPPORTED", False) file_path = "Tests/images/hopper.webp" with pytest.warns(UserWarning): @@ -38,9 +38,6 @@ class TestUnsupportedWebp: with Image.open(file_path): pass - if HAVE_WEBP: - WebPImagePlugin.SUPPORTED = True - @skip_unless_feature("webp") class TestFileWebp: @@ -157,9 +154,8 @@ class TestFileWebp: @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_write_encoding_error_message(self, tmp_path: Path) -> None: im = Image.new("RGB", (15000, 15000)) - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="encoding error 6"): im.save(tmp_path / "temp.webp", method=0) - assert str(e.value) == "encoding error 6" @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None: @@ -234,7 +230,7 @@ class TestFileWebp: with Image.open(out_gif) as reread: reread_value = reread.convert("RGB").getpixel((1, 1)) - difference = sum(abs(original_value[i] - reread_value[i]) for i in range(0, 3)) + difference = sum(abs(original_value[i] - reread_value[i]) for i in range(3)) assert difference < 5 def test_duration(self, tmp_path: Path) -> None: diff --git a/Tests/test_file_webp_alpha.py b/Tests/test_file_webp_alpha.py index c88fe3589..c573390c4 100644 --- a/Tests/test_file_webp_alpha.py +++ b/Tests/test_file_webp_alpha.py @@ -42,7 +42,7 @@ def test_write_lossless_rgb(tmp_path: Path) -> None: Does it have the bits we expect? """ - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" # temp_file = "temp.webp" pil_image = hopper("RGBA") @@ -71,7 +71,7 @@ def test_write_rgba(tmp_path: Path) -> None: Does it have the bits we expect? """ - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" pil_image = Image.new("RGBA", (10, 10), (255, 0, 0, 20)) pil_image.save(temp_file) @@ -104,7 +104,7 @@ def test_keep_rgb_values_when_transparent(tmp_path: Path) -> None: half_transparent_image.putalpha(new_alpha) # save with transparent area preserved - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" half_transparent_image.save(temp_file, exact=True, lossless=True) with Image.open(temp_file) as reloaded: @@ -123,7 +123,7 @@ def test_write_unsupported_mode_PA(tmp_path: Path) -> None: should work, and be similar to the original file. """ - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" file_path = "Tests/images/transparent.gif" with Image.open(file_path) as im: im.save(temp_file) @@ -142,10 +142,10 @@ def test_write_unsupported_mode_PA(tmp_path: Path) -> None: def test_alpha_quality(tmp_path: Path) -> None: with Image.open("Tests/images/transparent.png") as im: - out = str(tmp_path / "temp.webp") + out = tmp_path / "temp.webp" im.save(out) - out_quality = str(tmp_path / "quality.webp") + out_quality = tmp_path / "quality.webp" im.save(out_quality, alpha_quality=50) with Image.open(out) as reloaded: with Image.open(out_quality) as reloaded_quality: diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index 967a0aae8..503761374 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest from packaging.version import parse as parse_version -from PIL import Image, features +from PIL import GifImagePlugin, Image, WebPImagePlugin, features from .helper import ( assert_image_equal, @@ -22,10 +22,12 @@ def test_n_frames() -> None: """Ensure that WebP format sets n_frames and is_animated attributes correctly.""" with Image.open("Tests/images/hopper.webp") as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 1 assert not im.is_animated with Image.open("Tests/images/iss634.webp") as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 42 assert im.is_animated @@ -37,11 +39,13 @@ def test_write_animation_L(tmp_path: Path) -> None: """ with Image.open("Tests/images/iss634.gif") as orig: + assert isinstance(orig, GifImagePlugin.GifImageFile) assert orig.n_frames > 1 - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" orig.save(temp_file, save_all=True) with Image.open(temp_file) as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == orig.n_frames # Compare first and last frames to the original animated GIF @@ -67,8 +71,9 @@ def test_write_animation_RGB(tmp_path: Path) -> None: are visually similar to the originals. """ - def check(temp_file: str) -> None: + def check(temp_file: Path) -> None: with Image.open(temp_file) as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 2 # Compare first frame to original @@ -87,7 +92,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None: with Image.open("Tests/images/anim_frame1.webp") as frame1: with Image.open("Tests/images/anim_frame2.webp") as frame2: - temp_file1 = str(tmp_path / "temp.webp") + temp_file1 = tmp_path / "temp.webp" frame1.copy().save( temp_file1, save_all=True, append_images=[frame2], lossless=True ) @@ -99,7 +104,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None: ) -> Generator[Image.Image, None, None]: yield from ims - temp_file2 = str(tmp_path / "temp_generator.webp") + temp_file2 = tmp_path / "temp_generator.webp" frame1.copy().save( temp_file2, save_all=True, @@ -116,7 +121,7 @@ def test_timestamp_and_duration(tmp_path: Path) -> None: """ durations = [0, 10, 20, 30, 40] - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" with Image.open("Tests/images/anim_frame1.webp") as frame1: with Image.open("Tests/images/anim_frame2.webp") as frame2: frame1.save( @@ -127,6 +132,7 @@ def test_timestamp_and_duration(tmp_path: Path) -> None: ) with Image.open(temp_file) as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 5 assert im.is_animated @@ -141,7 +147,7 @@ def test_timestamp_and_duration(tmp_path: Path) -> None: def test_float_duration(tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" with Image.open("Tests/images/iss634.apng") as im: assert im.info["duration"] == 70.0 @@ -159,7 +165,7 @@ def test_seeking(tmp_path: Path) -> None: """ dur = 33 - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" with Image.open("Tests/images/anim_frame1.webp") as frame1: with Image.open("Tests/images/anim_frame2.webp") as frame2: frame1.save( @@ -170,6 +176,7 @@ def test_seeking(tmp_path: Path) -> None: ) with Image.open(temp_file) as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 5 assert im.is_animated @@ -196,10 +203,10 @@ def test_alpha_quality(tmp_path: Path) -> None: with Image.open("Tests/images/transparent.png") as im: first_frame = Image.new("L", im.size) - out = str(tmp_path / "temp.webp") + out = tmp_path / "temp.webp" first_frame.save(out, save_all=True, append_images=[im]) - out_quality = str(tmp_path / "quality.webp") + out_quality = tmp_path / "quality.webp" first_frame.save( out_quality, save_all=True, append_images=[im], alpha_quality=50 ) diff --git a/Tests/test_file_webp_lossless.py b/Tests/test_file_webp_lossless.py index 80429715e..5eaa4f599 100644 --- a/Tests/test_file_webp_lossless.py +++ b/Tests/test_file_webp_lossless.py @@ -13,7 +13,7 @@ RGB_MODE = "RGB" def test_write_lossless_rgb(tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" hopper(RGB_MODE).save(temp_file, lossless=True) diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index d9a834c75..7543d22da 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -6,7 +6,7 @@ from types import ModuleType import pytest -from PIL import Image +from PIL import Image, WebPImagePlugin from .helper import mark_if_feature_version, skip_unless_feature @@ -40,7 +40,7 @@ def test_read_exif_metadata() -> None: def test_read_exif_metadata_without_prefix() -> None: with Image.open("Tests/images/flower2.webp") as im: # Assert prefix is not present - assert im.info["exif"][:6] != b"Exif\x00\x00" + assert not im.info["exif"].startswith(b"Exif\x00\x00") exif = im.getexif() assert exif[305] == "Adobe Photoshop CS6 (Macintosh)" @@ -110,6 +110,7 @@ def test_read_no_exif() -> None: test_buffer.seek(0) with Image.open(test_buffer) as webp_image: + assert isinstance(webp_image, WebPImagePlugin.WebPImageFile) assert not webp_image._getexif() @@ -146,7 +147,7 @@ def test_write_animated_metadata(tmp_path: Path) -> None: exif_data = b"" xmp_data = b"" - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" with Image.open("Tests/images/anim_frame1.webp") as frame1: with Image.open("Tests/images/anim_frame2.webp") as frame2: frame1.save( diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 424640d7b..dcf5f000f 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -8,7 +8,7 @@ import pytest from PIL import Image, ImageFile, WmfImagePlugin -from .helper import assert_image_similar_tofile, hopper +from .helper import assert_image_equal_tofile, assert_image_similar_tofile, hopper def test_load_raw() -> None: @@ -32,7 +32,25 @@ def test_load_raw() -> None: def test_load() -> None: with Image.open("Tests/images/drawing.emf") as im: if hasattr(Image.core, "drawwmf"): - assert im.load()[0, 0] == (255, 255, 255) + px = im.load() + assert px is not None + assert px[0, 0] == (255, 255, 255) + + +def test_load_zero_inch() -> None: + b = BytesIO(b"\xd7\xcd\xc6\x9a\x00\x00" + b"\x00" * 10) + with pytest.raises(ValueError): + with Image.open(b): + pass + + +def test_render() -> None: + with open("Tests/images/drawing.emf", "rb") as fp: + data = fp.read() + b = BytesIO(data[:808] + b"\x00" + data[809:]) + with Image.open(b) as im: + if hasattr(Image.core, "drawwmf"): + assert_image_equal_tofile(im, "Tests/images/drawing.emf") def test_register_handler(tmp_path: Path) -> None: @@ -50,7 +68,7 @@ def test_register_handler(tmp_path: Path) -> None: WmfImagePlugin.register_handler(handler) im = hopper() - tmpfile = str(tmp_path / "temp.wmf") + tmpfile = tmp_path / "temp.wmf" im.save(tmpfile) assert handler.methodCalled @@ -64,13 +82,14 @@ def test_load_float_dpi() -> None: with open("Tests/images/drawing.emf", "rb") as fp: data = fp.read() - b = BytesIO(data[:8] + b"\x06\xFA" + data[10:]) + b = BytesIO(data[:8] + b"\x06\xfa" + data[10:]) with Image.open(b) as im: assert im.info["dpi"][0] == 2540 def test_load_set_dpi() -> None: with Image.open("Tests/images/drawing.wmf") as im: + assert isinstance(im, WmfImagePlugin.WmfStubImageFile) assert im.size == (82, 82) if hasattr(Image.core, "drawwmf"): @@ -79,11 +98,27 @@ def test_load_set_dpi() -> None: assert_image_similar_tofile(im, "Tests/images/drawing_wmf_ref_144.png", 2.1) + with Image.open("Tests/images/drawing.emf") as im: + assert im.size == (1625, 1625) + + if not hasattr(Image.core, "drawwmf"): + return + assert isinstance(im, WmfImagePlugin.WmfStubImageFile) + im.load(im.info["dpi"]) + assert im.size == (1625, 1625) + + with Image.open("Tests/images/drawing.emf") as im: + assert isinstance(im, WmfImagePlugin.WmfStubImageFile) + im.load((72, 144)) + assert im.size == (82, 164) + + assert_image_equal_tofile(im, "Tests/images/drawing_emf_ref_72_144.png") + @pytest.mark.parametrize("ext", (".wmf", ".emf")) def test_save(ext: str, tmp_path: Path) -> None: im = hopper() - tmpfile = str(tmp_path / ("temp" + ext)) + tmpfile = tmp_path / ("temp" + ext) with pytest.raises(OSError): im.save(tmpfile) diff --git a/Tests/test_file_xbm.py b/Tests/test_file_xbm.py index 44dd2541f..154f3dcc0 100644 --- a/Tests/test_file_xbm.py +++ b/Tests/test_file_xbm.py @@ -73,7 +73,7 @@ def test_invalid_file() -> None: def test_save_wrong_mode(tmp_path: Path) -> None: im = hopper() - out = str(tmp_path / "temp.xbm") + out = tmp_path / "temp.xbm" with pytest.raises(OSError): im.save(out) @@ -81,7 +81,7 @@ def test_save_wrong_mode(tmp_path: Path) -> None: def test_hotspot(tmp_path: Path) -> None: im = hopper("1") - out = str(tmp_path / "temp.xbm") + out = tmp_path / "temp.xbm" hotspot = (0, 7) im.save(out, hotspot=hotspot) diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index 26afe93f4..73c62a44d 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -30,6 +30,7 @@ def test_invalid_file() -> None: def test_load_read() -> None: # Arrange with Image.open(TEST_FILE) as im: + assert isinstance(im, XpmImagePlugin.XpmImageFile) dummy_bytes = 1 # Act diff --git a/Tests/test_font_bdf.py b/Tests/test_font_bdf.py index 136070f9e..b4155c879 100644 --- a/Tests/test_font_bdf.py +++ b/Tests/test_font_bdf.py @@ -1,5 +1,7 @@ from __future__ import annotations +import io + import pytest from PIL import BdfFontFile, FontFile @@ -8,13 +10,20 @@ filename = "Tests/images/courB08.bdf" def test_sanity() -> None: - with open(filename, "rb") as test_file: - font = BdfFontFile.BdfFontFile(test_file) + with open(filename, "rb") as fp: + font = BdfFontFile.BdfFontFile(fp) assert isinstance(font, FontFile.FontFile) assert len([_f for _f in font.glyph if _f]) == 190 +def test_zero_width_chars() -> None: + with open(filename, "rb") as fp: + data = fp.read() + data = data[:2650] + b"\x00\x00" + data[2652:] + BdfFontFile.BdfFontFile(io.BytesIO(data)) + + def test_invalid_file() -> None: with open("Tests/images/flower.jpg", "rb") as fp: with pytest.raises(SyntaxError): diff --git a/Tests/test_fontfile.py b/Tests/test_fontfile.py index 206499a04..575dada86 100644 --- a/Tests/test_fontfile.py +++ b/Tests/test_fontfile.py @@ -4,7 +4,20 @@ from pathlib import Path import pytest -from PIL import FontFile +from PIL import FontFile, Image + + +def test_compile() -> None: + font = FontFile.FontFile() + font.glyph[0] = ((0, 0), (0, 0, 0, 0), (0, 0, 0, 1), Image.new("L", (0, 0))) + font.compile() + assert font.ysize == 1 + + font.ysize = 2 + font.compile() + + # Assert that compiling again did not change anything + assert font.ysize == 2 def test_save(tmp_path: Path) -> None: diff --git a/Tests/test_format_hsv.py b/Tests/test_format_hsv.py index c07024a2c..9cbf18566 100644 --- a/Tests/test_format_hsv.py +++ b/Tests/test_format_hsv.py @@ -22,28 +22,26 @@ def test_sanity() -> None: Image.new("HSV", (100, 100)) -def wedge() -> Image.Image: - w = Image._wedge() - w90 = w.rotate(90) +def linear_gradient() -> Image.Image: + im = Image.linear_gradient(mode="L") + im90 = im.rotate(90) - (px, h) = w.size + (px, h) = im.size r = Image.new("L", (px * 3, h)) g = r.copy() b = r.copy() - r.paste(w, (0, 0)) - r.paste(w90, (px, 0)) + r.paste(im, (0, 0)) + r.paste(im90, (px, 0)) - g.paste(w90, (0, 0)) - g.paste(w, (2 * px, 0)) + g.paste(im90, (0, 0)) + g.paste(im, (2 * px, 0)) - b.paste(w, (px, 0)) - b.paste(w90, (2 * px, 0)) + b.paste(im, (px, 0)) + b.paste(im90, (2 * px, 0)) - img = Image.merge("RGB", (r, g, b)) - - return img + return Image.merge("RGB", (r, g, b)) def to_xxx_colorsys( @@ -79,8 +77,8 @@ def to_rgb_colorsys(im: Image.Image) -> Image.Image: return to_xxx_colorsys(im, colorsys.hsv_to_rgb, "RGB") -def test_wedge() -> None: - src = wedge().resize((3 * 32, 32), Image.Resampling.BILINEAR) +def test_linear_gradient() -> None: + src = linear_gradient().resize((3 * 32, 32), Image.Resampling.BILINEAR) im = src.convert("HSV") comparable = to_hsv_colorsys(src) diff --git a/Tests/test_image.py b/Tests/test_image.py index c8df474f4..f18d8489c 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -65,21 +65,20 @@ class TestImage: @pytest.mark.parametrize("mode", ("", "bad", "very very long")) def test_image_modes_fail(self, mode: str) -> None: - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="unrecognized image mode"): Image.new(mode, (1, 1)) - assert str(e.value) == "unrecognized image mode" def test_exception_inheritance(self) -> None: assert issubclass(UnidentifiedImageError, OSError) def test_sanity(self) -> None: im = Image.new("L", (100, 100)) - assert repr(im)[:45] == " None: + out = str(tmp_path / "temp.png") + im = hopper() + im.save(out) + with Image.open(out) as reloaded: + assert_image_equal(im, reloaded) + def test_pathlib(self, tmp_path: Path) -> None: with Image.open(Path("Tests/images/multipage-mmap.tiff")) as im: assert im.mode == "P" @@ -188,16 +194,13 @@ class TestImage: for ext in (".jpg", ".jp2"): if ext == ".jp2" and not features.check_codec("jpg_2000"): pytest.skip("jpg_2000 not available") - temp_file = str(tmp_path / ("temp." + ext)) - if os.path.exists(temp_file): - os.remove(temp_file) - im.save(Path(temp_file)) + im.save(tmp_path / ("temp." + ext)) def test_fp_name(self, tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.jpg") + temp_file = tmp_path / "temp.jpg" class FP(io.BytesIO): - name: str + name: Path if sys.version_info >= (3, 12): from collections.abc import Buffer @@ -228,7 +231,7 @@ class TestImage: def test_unknown_extension(self, tmp_path: Path) -> None: im = hopper() - temp_file = str(tmp_path / "temp.unknown") + temp_file = tmp_path / "temp.unknown" with pytest.raises(ValueError): im.save(temp_file) @@ -248,7 +251,7 @@ class TestImage: reason="Test requires opening an mmaped file for writing", ) def test_readonly_save(self, tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.bmp") + temp_file = tmp_path / "temp.bmp" shutil.copy("Tests/images/rgb32bf-rgba.bmp", temp_file) with Image.open(temp_file) as im: @@ -580,9 +583,7 @@ class TestImage: def test_one_item_tuple(self) -> None: for mode in ("I", "F", "L"): im = Image.new(mode, (100, 100), (5,)) - px = im.load() - assert px is not None - assert px[0, 0] == 5 + assert im.getpixel((0, 0)) == 5 def test_linear_gradient_wrong_mode(self) -> None: # Arrange @@ -662,12 +663,13 @@ class TestImage: im.putpalette(list(range(256)) * 4, "RGBA") im_remapped = im.remap_palette(list(range(256))) assert_image_equal(im, im_remapped) + assert im.palette is not None assert im.palette.palette == im_remapped.palette.palette # Test illegal image mode with hopper() as im: with pytest.raises(ValueError): - im.remap_palette(None) + im.remap_palette([]) def test_remap_palette_transparency(self) -> None: im = Image.new("P", (1, 2), (0, 0, 0)) @@ -732,7 +734,7 @@ class TestImage: # https://github.com/python-pillow/Pillow/issues/835 # Arrange test_file = "Tests/images/hopper.png" - temp_file = str(tmp_path / "temp.jpg") + temp_file = tmp_path / "temp.jpg" # Act/Assert with Image.open(test_file) as im: @@ -742,7 +744,7 @@ class TestImage: im.save(temp_file) def test_no_new_file_on_error(self, tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.jpg") + temp_file = tmp_path / "temp.jpg" im = Image.new("RGB", (0, 0)) with pytest.raises(ValueError): @@ -770,7 +772,7 @@ class TestImage: assert dict(exif) # Test that exif data is cleared after another load - exif.load(None) + exif.load(b"") assert not dict(exif) # Test loading just the EXIF header @@ -793,6 +795,10 @@ class TestImage: ifd[36864] = b"0220" assert exif.get_ifd(0x8769) == {36864: b"0220"} + reloaded_exif = Image.Exif() + reloaded_exif.load(exif.tobytes()) + assert reloaded_exif.get_ifd(0x8769) == {36864: b"0220"} + @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) @@ -805,7 +811,7 @@ class TestImage: assert exif[296] == 2 assert exif[11] == "gThumb 3.0.1" - out = str(tmp_path / "temp.jpg") + out = tmp_path / "temp.jpg" exif[258] = 8 del exif[274] del exif[282] @@ -827,7 +833,7 @@ class TestImage: assert exif[274] == 1 assert exif[305] == "Adobe Photoshop CC 2017 (Macintosh)" - out = str(tmp_path / "temp.jpg") + out = tmp_path / "temp.jpg" exif[258] = 8 del exif[306] exif[274] = 455 @@ -846,7 +852,7 @@ class TestImage: exif = im.getexif() assert exif == {} - out = str(tmp_path / "temp.webp") + out = tmp_path / "temp.webp" exif[258] = 8 exif[40963] = 455 exif[305] = "Pillow test" @@ -868,7 +874,7 @@ class TestImage: exif = im.getexif() assert exif == {274: 1} - out = str(tmp_path / "temp.png") + out = tmp_path / "temp.png" exif[258] = 8 del exif[274] exif[40963] = 455 @@ -987,6 +993,11 @@ class TestImage: else: assert im.getxmp() == {"xmpmeta": None} + def test_get_child_images(self) -> None: + im = Image.new("RGB", (1, 1)) + with pytest.warns(DeprecationWarning): + assert im.get_child_images() == [] + @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) def test_zero_tobytes(self, size: tuple[int, int]) -> None: im = Image.new("RGB", size) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index bb30b462d..14a5e2e7b 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -271,13 +271,25 @@ class TestImagePutPixelError: class TestEmbeddable: - @pytest.mark.xfail(reason="failing test") + @pytest.mark.xfail(not (sys.version_info >= (3, 13)), reason="failing test") @pytest.mark.skipif(not is_win32(), reason="requires Windows") def test_embeddable(self) -> None: import ctypes from setuptools.command import build_ext + compiler = getattr(build_ext, "new_compiler")() + compiler.add_include_dir(sysconfig.get_config_var("INCLUDEPY")) + + libdir = sysconfig.get_config_var("LIBDIR") or sysconfig.get_config_var( + "INCLUDEPY" + ).replace("include", "libs") + compiler.add_library_dir(libdir) + try: + compiler.initialize() + except Exception: + pytest.skip("Compiler could not be initialized") + with open("embed_pil.c", "w", encoding="utf-8") as fh: home = sys.prefix.replace("\\", "\\\\") fh.write( @@ -305,13 +317,6 @@ int main(int argc, char* argv[]) """ ) - compiler = getattr(build_ext, "new_compiler")() - compiler.add_include_dir(sysconfig.get_config_var("INCLUDEPY")) - - libdir = sysconfig.get_config_var("LIBDIR") or sysconfig.get_config_var( - "INCLUDEPY" - ).replace("include", "libs") - compiler.add_library_dir(libdir) objects = compiler.compile(["embed_pil.c"]) compiler.link_executable(objects, "embed_pil") diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 6a925975e..7d4f78c23 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -118,7 +118,7 @@ def test_trns_p(tmp_path: Path) -> None: im = hopper("P") im.info["transparency"] = 0 - f = str(tmp_path / "temp.png") + f = tmp_path / "temp.png" im_l = im.convert("L") assert im_l.info["transparency"] == 0 @@ -154,7 +154,7 @@ def test_trns_l(tmp_path: Path) -> None: im = hopper("L") im.info["transparency"] = 128 - f = str(tmp_path / "temp.png") + f = tmp_path / "temp.png" im_la = im.convert("LA") assert "transparency" not in im_la.info @@ -177,7 +177,7 @@ def test_trns_RGB(tmp_path: Path) -> None: im = hopper("RGB") im.info["transparency"] = im.getpixel((0, 0)) - f = str(tmp_path / "temp.png") + f = tmp_path / "temp.png" im_l = im.convert("L") assert im_l.info["transparency"] == im_l.getpixel((0, 0)) # undone @@ -222,9 +222,7 @@ def test_l_macro_rounding(convert_mode: str) -> None: im.palette.getcolor((0, 1, 2)) converted_im = im.convert(convert_mode) - px = converted_im.load() - assert px is not None - converted_color = px[0, 0] + converted_color = converted_im.getpixel((0, 0)) if convert_mode == "LA": assert isinstance(converted_color, tuple) converted_color = converted_color[0] @@ -236,6 +234,7 @@ def test_gif_with_rgba_palette_to_p() -> None: with Image.open("Tests/images/hopper.gif") as im: im.info["transparency"] = 255 im.load() + assert im.palette is not None assert im.palette.mode == "RGB" im_p = im.convert("P") diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 7c564d967..0ca7ad86e 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -148,10 +148,8 @@ def test_palette(method: Image.Quantize, color: tuple[int, ...]) -> None: im = Image.new("RGBA" if len(color) == 4 else "RGB", (1, 1), color) converted = im.quantize(method=method) - converted_px = converted.load() - assert converted_px is not None assert converted.palette is not None - assert converted_px[0, 0] == converted.palette.colors[color] + assert converted.getpixel((0, 0)) == converted.palette.colors[color] def test_small_palette() -> None: diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 57fcf9a34..f700d20c0 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -171,7 +171,7 @@ class TestImagingCoreResize: # platforms. So if a future Pillow change requires that the test file # be updated, that is okay. im = hopper().resize((64, 64)) - temp_file = str(tmp_path / "temp.gif") + temp_file = tmp_path / "temp.gif" im.save(temp_file) with Image.open(temp_file) as reloaded: @@ -309,7 +309,7 @@ class TestImageResize: # Test unknown resampling filter with hopper() as im: with pytest.raises(ValueError): - im.resize((10, 10), "unknown") + im.resize((10, 10), -1) @skip_unless_feature("libtiff") def test_transposed(self) -> None: diff --git a/Tests/test_image_split.py b/Tests/test_image_split.py index 3385f81f5..43068535e 100644 --- a/Tests/test_image_split.py +++ b/Tests/test_image_split.py @@ -45,9 +45,9 @@ def test_split_merge(mode: str) -> None: def test_split_open(tmp_path: Path) -> None: if features.check("zlib"): - test_file = str(tmp_path / "temp.png") + test_file = tmp_path / "temp.png" else: - test_file = str(tmp_path / "temp.pcx") + test_file = tmp_path / "temp.pcx" def split_open(mode: str) -> int: hopper(mode).save(test_file) diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 01bd4b1d7..1181f6fca 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -104,20 +104,20 @@ def test_transposed() -> None: assert im.size == (590, 88) -def test_load_first_unless_jpeg() -> None: +def test_load_first_unless_jpeg(monkeypatch: pytest.MonkeyPatch) -> None: # Test that thumbnail() still uses draft() for JPEG with Image.open("Tests/images/hopper.jpg") as im: - draft = im.draft + original_draft = im.draft def im_draft( - mode: str, size: tuple[int, int] + mode: str | None, size: tuple[int, int] | None ) -> tuple[str, tuple[int, int, float, float]] | None: - result = draft(mode, size) + result = original_draft(mode, size) assert result is not None return result - im.draft = im_draft + monkeypatch.setattr(im, "draft", im_draft) im.thumbnail((64, 64)) diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 7e83396de..77916929b 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -47,6 +47,7 @@ class TestImageTransform: transformed = im.transform( im.size, Image.Transform.AFFINE, [1, 0, 0, 0, 1, 0] ) + assert im.palette is not None assert im.palette.palette == transformed.palette.palette def test_extent(self) -> None: diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index ed50dfbed..ffe9c0979 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -39,6 +39,8 @@ BBOX = (((X0, Y0), (X1, Y1)), [(X0, Y0), (X1, Y1)], (X0, Y0, X1, Y1), [X0, Y0, X POINTS = ( ((10, 10), (20, 40), (30, 30)), [(10, 10), (20, 40), (30, 30)], + ([10, 10], [20, 40], [30, 30]), + [[10, 10], [20, 40], [30, 30]], (10, 10, 20, 40, 30, 30), [10, 10, 20, 40, 30, 30], ) @@ -46,6 +48,8 @@ POINTS = ( KITE_POINTS = ( ((10, 50), (70, 10), (90, 50), (70, 90), (10, 50)), [(10, 50), (70, 10), (90, 50), (70, 90), (10, 50)], + ([10, 50], [70, 10], [90, 50], [70, 90], [10, 50]), + [[10, 50], [70, 10], [90, 50], [70, 90], [10, 50]], ) @@ -448,7 +452,6 @@ def test_shape1() -> None: x3, y3 = 95, 5 # Act - assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -470,7 +473,6 @@ def test_shape2() -> None: x3, y3 = 5, 95 # Act - assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -489,7 +491,6 @@ def test_transform() -> None: draw = ImageDraw.Draw(im) # Act - assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.line(0, 0) s.transform((0, 0, 0, 0, 0, 0)) @@ -812,7 +813,7 @@ def test_rounded_rectangle( tuple[int, int, int, int] | tuple[list[int]] | tuple[tuple[int, int], tuple[int, int]] - ) + ), ) -> None: # Arrange im = Image.new("RGB", (200, 200)) @@ -1047,8 +1048,8 @@ def create_base_image_draw( background2: tuple[int, int, int] = GRAY, ) -> tuple[Image.Image, ImageDraw.ImageDraw]: img = Image.new(mode, size, background1) - for x in range(0, size[0]): - for y in range(0, size[1]): + for x in range(size[0]): + for y in range(size[1]): if (x + y) % 2 == 0: img.putpixel((x, y), background2) return img, ImageDraw.Draw(img) @@ -1396,6 +1397,28 @@ def test_stroke_descender() -> None: assert_image_similar_tofile(im, "Tests/images/imagedraw_stroke_descender.png", 6.76) +@skip_unless_feature("freetype2") +def test_stroke_inside_gap() -> 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), "i", "#f00", font, stroke_width=20) + + # Assert + for y in range(im.height): + glyph = "" + for x in range(im.width): + if im.getpixel((x, y)) == (0, 0, 0): + if glyph == "started": + glyph = "ended" + else: + assert glyph != "ended", "Gap inside stroked glyph" + glyph = "started" + + @skip_unless_feature("freetype2") def test_split_word() -> None: # Arrange @@ -1504,7 +1527,6 @@ def test_same_color_outline(bbox: Coords) -> None: x2, y2 = 95, 50 x3, y3 = 95, 5 - assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -1608,7 +1630,7 @@ def test_compute_regular_polygon_vertices( 0, ValueError, "bounding_circle should contain 2D coordinates " - "and a radius (e.g. (x, y, r) or ((x, y), r) )", + r"and a radius \(e.g. \(x, y, r\) or \(\(x, y\), r\) \)", ), ( 3, @@ -1622,7 +1644,7 @@ def test_compute_regular_polygon_vertices( ((50, 50, 50), 25), 0, ValueError, - "bounding_circle centre should contain 2D coordinates (e.g. (x, y))", + r"bounding_circle centre should contain 2D coordinates \(e.g. \(x, y\)\)", ), ( 3, @@ -1647,9 +1669,8 @@ def test_compute_regular_polygon_vertices_input_error_handling( expected_error: type[Exception], error_message: str, ) -> None: - with pytest.raises(expected_error) as e: + with pytest.raises(expected_error, match=error_message): ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) # type: ignore[arg-type] - assert str(e.value) == error_message def test_continuous_horizontal_edges_polygon() -> None: @@ -1674,13 +1695,16 @@ def test_continuous_horizontal_edges_polygon() -> None: def test_discontiguous_corners_polygon() -> None: img, draw = create_base_image_draw((84, 68)) draw.polygon(((1, 21), (34, 4), (71, 1), (38, 18)), BLACK) + draw.polygon( + ((82, 29), (82, 26), (82, 24), (67, 22), (52, 29), (52, 15), (67, 22)), BLACK + ) draw.polygon(((71, 44), (38, 27), (1, 24)), BLACK) draw.polygon( ((38, 66), (5, 49), (77, 49), (47, 66), (82, 63), (82, 47), (1, 47), (1, 63)), BLACK, ) expected = os.path.join(IMAGES_PATH, "discontiguous_corners_polygon.png") - assert_image_similar_tofile(img, expected, 1) + assert_image_equal_tofile(img, expected) def test_polygon2() -> None: diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 1ee684926..7622eea99 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -93,6 +93,19 @@ class TestImageFile: assert p.image is not None assert (48, 48) == p.image.size + @pytest.mark.filterwarnings("ignore:Corrupt EXIF data") + def test_incremental_tiff(self) -> None: + with ImageFile.Parser() as p: + with open("Tests/images/hopper.tif", "rb") as f: + p.feed(f.read(1024)) + + # Check that insufficient data was given in the first feed + assert not p.image + + p.feed(f.read()) + assert p.image is not None + assert (128, 128) == p.image.size + @skip_unless_feature("webp") def test_incremental_webp(self) -> None: with ImageFile.Parser() as p: @@ -118,6 +131,26 @@ class TestImageFile: assert_image_equal(im1, im2) + def test_tile_size(self) -> None: + with open("Tests/images/hopper.tif", "rb") as im_fp: + data = im_fp.read() + + reads = [] + + class FP(BytesIO): + def read(self, size: int | None = None) -> bytes: + reads.append(size) + return super().read(size) + + fp = FP(data) + with Image.open(fp) as im: + assert len(im.tile) == 7 + + im.load() + + # Despite multiple tiles, assert only one tile caused a read of maxblock size + assert reads.count(im.decodermaxblock) == 1 + def test_raise_oserror(self) -> None: with pytest.warns(DeprecationWarning): with pytest.raises(OSError): @@ -163,9 +196,8 @@ class TestImageFile: b"0" * ImageFile.SAFEBLOCK ) # only SAFEBLOCK bytes, so that the header is truncated ) - with pytest.raises(OSError) as e: + with pytest.raises(OSError, match="Truncated File Read"): BmpImagePlugin.BmpImageFile(b) - assert str(e.value) == "Truncated File Read" @skip_unless_feature("zlib") def test_truncated_with_errors(self) -> None: @@ -178,13 +210,10 @@ class TestImageFile: im.load() @skip_unless_feature("zlib") - def test_truncated_without_errors(self) -> None: + def test_truncated_without_errors(self, monkeypatch: pytest.MonkeyPatch) -> None: with Image.open("Tests/images/truncated_image.png") as im: - ImageFile.LOAD_TRUNCATED_IMAGES = True - try: - im.load() - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) + im.load() @skip_unless_feature("zlib") def test_broken_datastream_with_errors(self) -> None: @@ -193,13 +222,12 @@ class TestImageFile: im.load() @skip_unless_feature("zlib") - def test_broken_datastream_without_errors(self) -> None: + def test_broken_datastream_without_errors( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: with Image.open("Tests/images/broken_data_stream.png") as im: - ImageFile.LOAD_TRUNCATED_IMAGES = True - try: - im.load() - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) + im.load() class MockPyDecoder(ImageFile.PyDecoder): diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 6a0a940b9..69533c2f8 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -124,7 +124,7 @@ def test_render_equal(layout_engine: ImageFont.Layout) -> None: def test_non_ascii_path(tmp_path: Path, layout_engine: ImageFont.Layout) -> None: - tempfile = str(tmp_path / ("temp_" + chr(128) + ".ttf")) + tempfile = tmp_path / ("temp_" + chr(128) + ".ttf") try: shutil.copy(FONT_PATH, tempfile) except UnicodeEncodeError: @@ -254,7 +254,8 @@ def test_render_multiline_text(font: ImageFont.FreeTypeFont) -> None: @pytest.mark.parametrize( - "align, ext", (("left", ""), ("center", "_center"), ("right", "_right")) + "align, ext", + (("left", ""), ("center", "_center"), ("right", "_right"), ("justify", "_justify")), ) def test_render_multiline_text_align( font: ImageFont.FreeTypeFont, align: str, ext: str @@ -461,6 +462,20 @@ def test_free_type_font_get_mask(font: ImageFont.FreeTypeFont) -> None: assert mask.size == (108, 13) +def test_stroke_mask() -> None: + # Arrange + text = "i" + + # Act + font = ImageFont.truetype(FONT_PATH, 128) + mask = font.getmask(text, stroke_width=2) + + # Assert + assert mask.getpixel((34, 5)) == 255 + assert mask.getpixel((38, 5)) == 0 + assert mask.getpixel((42, 5)) == 255 + + def test_load_when_image_not_found() -> None: with tempfile.NamedTemporaryFile(delete=False) as tmp: pass @@ -543,7 +558,7 @@ def test_render_empty(font: ImageFont.FreeTypeFont) -> None: def test_unicode_extended(layout_engine: ImageFont.Layout) -> None: # issue #3777 - text = "A\u278A\U0001F12B" + text = "A\u278a\U0001f12b" target = "Tests/images/unicode_extended.png" ttf = ImageFont.truetype( @@ -1012,7 +1027,7 @@ def test_sbix(layout_engine: ImageFont.Layout) -> None: im = Image.new("RGB", (400, 400), "white") d = ImageDraw.Draw(im) - d.text((50, 50), "\uE901", font=font, embedded_color=True) + d.text((50, 50), "\ue901", font=font, embedded_color=True) assert_image_similar_tofile(im, "Tests/images/chromacheck-sbix.png", 1) except OSError as e: # pragma: no cover @@ -1029,7 +1044,7 @@ def test_sbix_mask(layout_engine: ImageFont.Layout) -> None: im = Image.new("RGB", (400, 400), "white") d = ImageDraw.Draw(im) - d.text((50, 50), "\uE901", (100, 0, 0), font=font) + d.text((50, 50), "\ue901", (100, 0, 0), font=font) assert_image_similar_tofile(im, "Tests/images/chromacheck-sbix_mask.png", 1) except OSError as e: # pragma: no cover diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index 24c7b871a..c85eb499c 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -229,7 +229,7 @@ def test_getlength( @pytest.mark.parametrize("direction", ("ltr", "ttb")) @pytest.mark.parametrize( "text", - ("i" + ("\u030C" * 15) + "i", "i" + "\u032C" * 15 + "i", "\u035Cii", "i\u0305i"), + ("i" + ("\u030c" * 15) + "i", "i" + "\u032c" * 15 + "i", "\u035cii", "i\u0305i"), ids=("caron-above", "caron-below", "double-breve", "overline"), ) def test_getlength_combine(mode: str, direction: str, text: str) -> None: @@ -272,27 +272,27 @@ def test_anchor_ttb(anchor: str) -> None: combine_tests = ( # extends above (e.g. issue #4553) - ("caron", "a\u030C\u030C\u030C\u030C\u030Cb", None, None, 0.08), - ("caron_la", "a\u030C\u030C\u030C\u030C\u030Cb", "la", None, 0.08), - ("caron_lt", "a\u030C\u030C\u030C\u030C\u030Cb", "lt", None, 0.08), - ("caron_ls", "a\u030C\u030C\u030C\u030C\u030Cb", "ls", None, 0.08), - ("caron_ttb", "ca" + ("\u030C" * 15) + "b", None, "ttb", 0.3), - ("caron_ttb_lt", "ca" + ("\u030C" * 15) + "b", "lt", "ttb", 0.3), + ("caron", "a\u030c\u030c\u030c\u030c\u030cb", None, None, 0.08), + ("caron_la", "a\u030c\u030c\u030c\u030c\u030cb", "la", None, 0.08), + ("caron_lt", "a\u030c\u030c\u030c\u030c\u030cb", "lt", None, 0.08), + ("caron_ls", "a\u030c\u030c\u030c\u030c\u030cb", "ls", None, 0.08), + ("caron_ttb", "ca" + ("\u030c" * 15) + "b", None, "ttb", 0.3), + ("caron_ttb_lt", "ca" + ("\u030c" * 15) + "b", "lt", "ttb", 0.3), # extends below - ("caron_below", "a\u032C\u032C\u032C\u032C\u032Cb", None, None, 0.02), - ("caron_below_ld", "a\u032C\u032C\u032C\u032C\u032Cb", "ld", None, 0.02), - ("caron_below_lb", "a\u032C\u032C\u032C\u032C\u032Cb", "lb", None, 0.02), - ("caron_below_ls", "a\u032C\u032C\u032C\u032C\u032Cb", "ls", None, 0.02), - ("caron_below_ttb", "a" + ("\u032C" * 15) + "b", None, "ttb", 0.03), - ("caron_below_ttb_lb", "a" + ("\u032C" * 15) + "b", "lb", "ttb", 0.03), + ("caron_below", "a\u032c\u032c\u032c\u032c\u032cb", None, None, 0.02), + ("caron_below_ld", "a\u032c\u032c\u032c\u032c\u032cb", "ld", None, 0.02), + ("caron_below_lb", "a\u032c\u032c\u032c\u032c\u032cb", "lb", None, 0.02), + ("caron_below_ls", "a\u032c\u032c\u032c\u032c\u032cb", "ls", None, 0.02), + ("caron_below_ttb", "a" + ("\u032c" * 15) + "b", None, "ttb", 0.03), + ("caron_below_ttb_lb", "a" + ("\u032c" * 15) + "b", "lb", "ttb", 0.03), # extends to the right (e.g. issue #3745) - ("double_breve_below", "a\u035Ci", None, None, 0.02), - ("double_breve_below_ma", "a\u035Ci", "ma", None, 0.02), - ("double_breve_below_ra", "a\u035Ci", "ra", None, 0.02), - ("double_breve_below_ttb", "a\u035Cb", None, "ttb", 0.02), - ("double_breve_below_ttb_rt", "a\u035Cb", "rt", "ttb", 0.02), - ("double_breve_below_ttb_mt", "a\u035Cb", "mt", "ttb", 0.02), - ("double_breve_below_ttb_st", "a\u035Cb", "st", "ttb", 0.02), + ("double_breve_below", "a\u035ci", None, None, 0.02), + ("double_breve_below_ma", "a\u035ci", "ma", None, 0.02), + ("double_breve_below_ra", "a\u035ci", "ra", None, 0.02), + ("double_breve_below_ttb", "a\u035cb", None, "ttb", 0.02), + ("double_breve_below_ttb_rt", "a\u035cb", "rt", "ttb", 0.02), + ("double_breve_below_ttb_mt", "a\u035cb", "mt", "ttb", 0.02), + ("double_breve_below_ttb_st", "a\u035cb", "st", "ttb", 0.02), # extends to the left (fail=0.064) ("overline", "i\u0305", None, None, 0.02), ("overline_la", "i\u0305", "la", None, 0.02), @@ -346,7 +346,7 @@ def test_combine_multiline(anchor: str, align: str) -> None: path = f"Tests/images/test_combine_multiline_{anchor}_{align}.png" f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) - text = "i\u0305\u035C\ntext" # i with overline and double breve, and a word + text = "i\u0305\u035c\ntext" # i with overline and double breve, and a word im = Image.new("RGB", (400, 400), "white") d = ImageDraw.Draw(im) diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index 6180a7b5d..515e29cea 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -80,15 +80,12 @@ def test_lut(op: str) -> None: def test_no_operator_loaded() -> None: im = Image.new("L", (1, 1)) mop = ImageMorph.MorphOp() - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="No operator loaded"): mop.apply(im) - assert str(e.value) == "No operator loaded" - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="No operator loaded"): mop.match(im) - assert str(e.value) == "No operator loaded" - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="No operator loaded"): mop.save_lut("") - assert str(e.value) == "No operator loaded" # Test the named patterns @@ -238,15 +235,12 @@ def test_incorrect_mode() -> None: im = hopper("RGB") mop = ImageMorph.MorphOp(op_name="erosion8") - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Image mode must be L"): mop.apply(im) - assert str(e.value) == "Image mode must be L" - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Image mode must be L"): mop.match(im) - assert str(e.value) == "Image mode must be L" - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Image mode must be L"): mop.get_on_pixels(im) - assert str(e.value) == "Image mode must be L" def test_add_patterns() -> None: @@ -279,9 +273,10 @@ def test_pattern_syntax_error() -> None: lb.add_patterns(new_patterns) # Act / Assert - with pytest.raises(Exception) as e: + with pytest.raises( + Exception, match='Syntax error in pattern "a pattern with a syntax error"' + ): lb.build_lut() - assert str(e.value) == 'Syntax error in pattern "a pattern with a syntax error"' def test_load_invalid_mrl() -> None: @@ -290,9 +285,8 @@ def test_load_invalid_mrl() -> None: mop = ImageMorph.MorphOp() # Act / Assert - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="Wrong size operator file!"): mop.load_lut(invalid_mrl) - assert str(e.value) == "Wrong size operator file!" def test_roundtrip_mrl(tmp_path: Path) -> None: diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 2fb2a60b6..9f2fd5ba2 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -165,14 +165,10 @@ def test_pad() -> None: def test_pad_round() -> None: im = Image.new("1", (1, 1), 1) new_im = ImageOps.pad(im, (4, 1)) - px = new_im.load() - assert px is not None - assert px[2, 0] == 1 + assert new_im.getpixel((2, 0)) == 1 new_im = ImageOps.pad(im, (1, 4)) - px = new_im.load() - assert px is not None - assert px[0, 2] == 1 + assert new_im.getpixel((0, 2)) == 1 @pytest.mark.parametrize("mode", ("P", "PA")) @@ -405,7 +401,6 @@ def test_exif_transpose() -> None: else: original_exif = im.info["exif"] transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert_image_similar(base_im, transposed_im, 17) if orientation_im is base_im: assert "exif" not in im.info @@ -417,7 +412,6 @@ def test_exif_transpose() -> None: # Repeat the operation to test that it does not keep transposing transposed_im2 = ImageOps.exif_transpose(transposed_im) - assert transposed_im2 is not None assert_image_equal(transposed_im2, transposed_im) check(base_im) @@ -433,7 +427,6 @@ def test_exif_transpose() -> None: assert im.getexif()[0x0112] == 3 transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() transposed_im._reload_exif() @@ -446,17 +439,24 @@ def test_exif_transpose() -> None: assert im.getexif()[0x0112] == 3 transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() # Orientation set directly on Image.Exif im = hopper() im.getexif()[0x0112] = 3 transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() +def test_exif_transpose_with_xmp_tuple() -> None: + with Image.open("Tests/images/xmp_tags_orientation.png") as im: + assert im.getexif()[0x0112] == 3 + + im.info["xmp"] = (b"test",) + transposed_im = ImageOps.exif_transpose(im) + assert 0x0112 not in transposed_im.getexif() + + def test_exif_transpose_xml_without_xmp() -> None: with Image.open("Tests/images/xmp_tags_orientation.png") as im: assert im.getexif()[0x0112] == 3 @@ -464,7 +464,6 @@ def test_exif_transpose_xml_without_xmp() -> None: del im.info["xmp"] transposed_im = ImageOps.exif_transpose(im) - assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 6cf0079dd..782022f51 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -17,6 +17,7 @@ def test_sanity() -> None: def test_reload() -> None: with Image.open("Tests/images/hopper.gif") as im: original = im.copy() + assert im.palette is not None im.palette.dirty = 1 assert_image_equal(im.convert("RGB"), original.convert("RGB")) @@ -111,7 +112,7 @@ def test_make_linear_lut() -> None: assert isinstance(lut, list) assert len(lut) == 256 # Check values - for i in range(0, len(lut)): + for i in range(len(lut)): assert lut[i] == i @@ -189,7 +190,7 @@ def test_2bit_palette(tmp_path: Path) -> None: rgb = b"\x00" * 2 + b"\x01" * 2 + b"\x02" * 2 img = Image.frombytes("P", (6, 1), rgb) - img.putpalette(b"\xFF\x00\x00\x00\xFF\x00\x00\x00\xFF") # RGB + img.putpalette(b"\xff\x00\x00\x00\xff\x00\x00\x00\xff") # RGB img.save(outfile, format="PNG") assert_image_equal_tofile(img, outfile) diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index 76bdf1e5f..ad8acde49 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -68,25 +68,10 @@ def test_path_constructors( assert list(p) == [(0.0, 1.0)] -@pytest.mark.parametrize( - "coords", - ( - ("a", "b"), - ([0, 1],), - [[0, 1]], - ([0.0, 1.0],), - [[0.0, 1.0]], - ), -) -def test_invalid_path_constructors( - coords: tuple[str, str] | Sequence[Sequence[int]] -) -> None: - # Act - with pytest.raises(ValueError) as e: - ImagePath.Path(coords) - - # Assert - assert str(e.value) == "incorrect coordinate type" +def test_invalid_path_constructors() -> None: + # Arrange / Act + with pytest.raises(ValueError, match="incorrect coordinate type"): + ImagePath.Path(("a", "b")) @pytest.mark.parametrize( @@ -99,13 +84,9 @@ def test_invalid_path_constructors( ), ) def test_path_odd_number_of_coordinates(coords: Sequence[int]) -> None: - # Act - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="wrong number of coordinates"): ImagePath.Path(coords) - # Assert - assert str(e.value) == "wrong number of coordinates" - @pytest.mark.parametrize( "coords, expected", diff --git a/Tests/test_imagesequence.py b/Tests/test_imagesequence.py index 9b37435eb..7b9ac80bc 100644 --- a/Tests/test_imagesequence.py +++ b/Tests/test_imagesequence.py @@ -4,13 +4,13 @@ from pathlib import Path import pytest -from PIL import Image, ImageSequence, TiffImagePlugin +from PIL import Image, ImageSequence, PsdImagePlugin, TiffImagePlugin from .helper import assert_image_equal, hopper, skip_unless_feature def test_sanity(tmp_path: Path) -> None: - test_file = str(tmp_path / "temp.im") + test_file = tmp_path / "temp.im" im = hopper("RGB") im.save(test_file) @@ -31,8 +31,9 @@ def test_sanity(tmp_path: Path) -> None: def test_iterator() -> None: with Image.open("Tests/images/multipage.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) i = ImageSequence.Iterator(im) - for index in range(0, im.n_frames): + for index in range(im.n_frames): assert i[index] == next(i) with pytest.raises(IndexError): i[index + 1] @@ -42,6 +43,7 @@ def test_iterator() -> None: def test_iterator_min_frame() -> None: with Image.open("Tests/images/hopper.psd") as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) i = ImageSequence.Iterator(im) for index in range(1, im.n_frames): assert i[index] == next(i) diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index c23a5c690..e8468e59f 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -88,7 +88,7 @@ if is_win32(): def test_pointer(tmp_path: Path) -> None: im = hopper() (width, height) = im.size - opath = str(tmp_path / "temp.png") + opath = tmp_path / "temp.png" imdib = ImageWin.Dib(im) hdr = BITMAPINFOHEADER() diff --git a/Tests/test_map.py b/Tests/test_map.py index 93140f6e5..1278ba3a6 100644 --- a/Tests/test_map.py +++ b/Tests/test_map.py @@ -7,36 +7,30 @@ import pytest from PIL import Image -def test_overflow() -> None: +def test_overflow(monkeypatch: pytest.MonkeyPatch) -> None: # There is the potential to overflow comparisons in map.c # if there are > SIZE_MAX bytes in the image or if # the file encodes an offset that makes # (offset + size(bytes)) > SIZE_MAX # Note that this image triggers the decompression bomb warning: - max_pixels = Image.MAX_IMAGE_PIXELS - Image.MAX_IMAGE_PIXELS = None + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", None) # This image hits the offset test. with Image.open("Tests/images/l2rgb_read.bmp") as im: with pytest.raises((ValueError, MemoryError, OSError)): im.load() - Image.MAX_IMAGE_PIXELS = max_pixels - -def test_tobytes() -> None: +def test_tobytes(monkeypatch: pytest.MonkeyPatch) -> None: # Note that this image triggers the decompression bomb warning: - max_pixels = Image.MAX_IMAGE_PIXELS - Image.MAX_IMAGE_PIXELS = None + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", None) # Previously raised an access violation on Windows with Image.open("Tests/images/l2rgb_read.bmp") as im: with pytest.raises((ValueError, MemoryError, OSError)): im.tobytes() - Image.MAX_IMAGE_PIXELS = max_pixels - @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_ysize() -> None: diff --git a/Tests/test_mode_i16.py b/Tests/test_mode_i16.py index e26f5d283..b78b7984f 100644 --- a/Tests/test_mode_i16.py +++ b/Tests/test_mode_i16.py @@ -44,7 +44,7 @@ def test_basic(tmp_path: Path, mode: str) -> None: im_out = im_in.transform((w, h), Image.Transform.EXTENT, (0, 0, w, h)) verify(im_out) # transform - filename = str(tmp_path / "temp.im") + filename = tmp_path / "temp.im" im_in.save(filename) with Image.open(filename) as im_out: diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 79cd14b66..c4ad19d23 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -141,9 +141,7 @@ def test_save_tiff_uint16() -> None: a.shape = TEST_IMAGE_SIZE img = Image.fromarray(a) - img_px = img.load() - assert img_px is not None - assert img_px[0, 0] == pixel_value + assert img.getpixel((0, 0)) == pixel_value @pytest.mark.parametrize( diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index f6b12cb20..d85fb1212 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -20,10 +20,10 @@ from PIL.PdfParser import ( def test_text_encode_decode() -> None: - assert encode_text("abc") == b"\xFE\xFF\x00a\x00b\x00c" - assert decode_text(b"\xFE\xFF\x00a\x00b\x00c") == "abc" + assert encode_text("abc") == b"\xfe\xff\x00a\x00b\x00c" + assert decode_text(b"\xfe\xff\x00a\x00b\x00c") == "abc" assert decode_text(b"abc") == "abc" - assert decode_text(b"\x1B a \x1C") == "\u02D9 a \u02DD" + assert decode_text(b"\x1b a \x1c") == "\u02d9 a \u02dd" def test_indirect_refs() -> None: @@ -45,8 +45,8 @@ def test_parsing() -> None: assert PdfParser.get_value(b"false%", 0) == (False, 5) assert PdfParser.get_value(b"null<", 0) == (None, 4) assert PdfParser.get_value(b"%cmt\n %cmt\n 123\n", 0) == (123, 15) - assert PdfParser.get_value(b"<901FA3>", 0) == (b"\x90\x1F\xA3", 8) - assert PdfParser.get_value(b"asd < 9 0 1 f A > qwe", 3) == (b"\x90\x1F\xA0", 17) + assert PdfParser.get_value(b"<901FA3>", 0) == (b"\x90\x1f\xa3", 8) + assert PdfParser.get_value(b"asd < 9 0 1 f A > qwe", 3) == (b"\x90\x1f\xa0", 17) assert PdfParser.get_value(b"(asd)", 0) == (b"asd", 5) assert PdfParser.get_value(b"(asd(qwe)zxc)zzz(aaa)", 0) == (b"asd(qwe)zxc", 13) assert PdfParser.get_value(b"(Two \\\nwords.)", 0) == (b"Two words.", 14) @@ -56,9 +56,9 @@ def test_parsing() -> None: assert PdfParser.get_value(b"(One\\(paren).", 0) == (b"One(paren", 12) assert PdfParser.get_value(b"(One\\)paren).", 0) == (b"One)paren", 12) assert PdfParser.get_value(b"(\\0053)", 0) == (b"\x053", 7) - assert PdfParser.get_value(b"(\\053)", 0) == (b"\x2B", 6) - assert PdfParser.get_value(b"(\\53)", 0) == (b"\x2B", 5) - assert PdfParser.get_value(b"(\\53a)", 0) == (b"\x2Ba", 6) + assert PdfParser.get_value(b"(\\053)", 0) == (b"\x2b", 6) + assert PdfParser.get_value(b"(\\53)", 0) == (b"\x2b", 5) + assert PdfParser.get_value(b"(\\53a)", 0) == (b"\x2ba", 6) assert PdfParser.get_value(b"(\\1111)", 0) == (b"\x491", 7) assert PdfParser.get_value(b" 123 (", 0) == (123, 4) assert round(abs(PdfParser.get_value(b" 123.4 %", 0)[0] - 123.4), 7) == 0 @@ -118,7 +118,7 @@ def test_pdf_repr() -> None: assert pdf_repr(None) == b"null" assert pdf_repr(b"a)/b\\(c") == rb"(a\)/b\\\(c)" assert pdf_repr([123, True, {"a": PdfName(b"b")}]) == b"[ 123 true <<\n/a /b\n>> ]" - assert pdf_repr(PdfBinary(b"\x90\x1F\xA0")) == b"<901FA0>" + assert pdf_repr(PdfBinary(b"\x90\x1f\xa0")) == b"<901FA0>" def test_duplicate_xref_entry() -> None: diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index d250ba369..70661ecc1 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -18,7 +18,7 @@ def helper_pickle_file( ) -> None: # Arrange with Image.open(test_file) as im: - filename = str(tmp_path / "temp.pkl") + filename = tmp_path / "temp.pkl" if mode: im = im.convert(mode) @@ -65,7 +65,7 @@ def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> Non ("Tests/images/itxt_chunks.png", None), ], ) -@pytest.mark.parametrize("protocol", range(0, pickle.HIGHEST_PROTOCOL + 1)) +@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) def test_pickle_image( tmp_path: Path, test_file: str, test_mode: str | None, protocol: int ) -> None: @@ -74,14 +74,25 @@ def test_pickle_image( helper_pickle_file(tmp_path, protocol, test_file, test_mode) +def test_pickle_jpeg() -> None: + # Arrange + with Image.open("Tests/images/hopper.jpg") as image: + # Act: roundtrip + unpickled_image = pickle.loads(pickle.dumps(image)) + + # Assert + assert len(unpickled_image.layer) == 3 + assert unpickled_image.layers == 3 + + def test_pickle_la_mode_with_palette(tmp_path: Path) -> None: # Arrange - filename = str(tmp_path / "temp.pkl") + filename = tmp_path / "temp.pkl" with Image.open("Tests/images/hopper.jpg") as im: im = im.convert("PA") # Act / Assert - for protocol in range(0, pickle.HIGHEST_PROTOCOL + 1): + for protocol in range(pickle.HIGHEST_PROTOCOL + 1): im._mode = "LA" with open(filename, "wb") as f: pickle.dump(im, f, protocol) @@ -122,7 +133,7 @@ def helper_assert_pickled_font_images( @skip_unless_feature("freetype2") -@pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) +@pytest.mark.parametrize("protocol", list(range(pickle.HIGHEST_PROTOCOL + 1))) def test_pickle_font_string(protocol: int) -> None: # Arrange font = ImageFont.truetype(FONT_PATH, FONT_SIZE) @@ -136,11 +147,11 @@ def test_pickle_font_string(protocol: int) -> None: @skip_unless_feature("freetype2") -@pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) +@pytest.mark.parametrize("protocol", list(range(pickle.HIGHEST_PROTOCOL + 1))) def test_pickle_font_file(tmp_path: Path, protocol: int) -> None: # Arrange font = ImageFont.truetype(FONT_PATH, FONT_SIZE) - filename = str(tmp_path / "temp.pkl") + filename = tmp_path / "temp.pkl" # Act: roundtrip with open(filename, "wb") as f: diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index a743d831f..78f5632c5 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -35,7 +35,7 @@ def test_draw_postscript(tmp_path: Path) -> None: # https://pillow.readthedocs.io/en/latest/handbook/tutorial.html#drawing-postscript # Arrange - tempfile = str(tmp_path / "temp.ps") + tempfile = tmp_path / "temp.ps" with open(tempfile, "wb") as fp: # Act ps = PSDraw.PSDraw(fp) diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index dd4fc46c3..03e92b5b9 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -35,10 +35,11 @@ class TestShellInjection: @pytest.mark.skipif(not djpeg_available(), reason="djpeg not available") def test_load_djpeg_filename(self, tmp_path: Path) -> None: for filename in test_filenames: - src_file = str(tmp_path / filename) + src_file = tmp_path / filename shutil.copy(TEST_JPG, src_file) with Image.open(src_file) as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) im.load_djpeg() @pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available") diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index 13f1f9c80..42d06b896 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -65,11 +65,12 @@ def test_ifd_rational_save( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, libtiff: bool ) -> None: im = hopper() - out = str(tmp_path / "temp.tiff") + out = tmp_path / "temp.tiff" res = IFDRational(301, 1) monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", libtiff) im.save(out, dpi=(res, res), compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert float(IFDRational(301, 1)) == float(reloaded.tag_v2[282]) diff --git a/codecov.yml b/codecov.yml index 8646576bb..84920238f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,7 +1,7 @@ # Documentation: https://docs.codecov.com/docs/codecov-yaml codecov: - # Avoid "Missing base report" due to committing CHANGES.rst with "[CI skip]" + # Avoid "Missing base report" due to committing with "[CI skip]" # https://github.com/codecov/support/issues/363 # https://docs.codecov.com/docs/comparing-commits allow_coverage_offsets: true diff --git a/depends/install_imagequant.sh b/depends/install_imagequant.sh index 8d62d5ac7..88756f8f9 100755 --- a/depends/install_imagequant.sh +++ b/depends/install_imagequant.sh @@ -2,7 +2,7 @@ # install libimagequant archive_name=libimagequant -archive_version=4.3.3 +archive_version=4.3.4 archive=$archive_name-$archive_version diff --git a/depends/install_openjpeg.sh b/depends/install_openjpeg.sh index 8c2967bc2..1f8d78193 100755 --- a/depends/install_openjpeg.sh +++ b/depends/install_openjpeg.sh @@ -1,7 +1,7 @@ #!/bin/bash # install openjpeg -archive=openjpeg-2.5.2 +archive=openjpeg-2.5.3 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz diff --git a/depends/install_webp.sh b/depends/install_webp.sh index c47fb35f1..9d2977715 100755 --- a/depends/install_webp.sh +++ b/depends/install_webp.sh @@ -1,7 +1,7 @@ #!/bin/bash # install webp -archive=libwebp-1.4.0 +archive=libwebp-1.5.0 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz diff --git a/docs/COPYING b/docs/COPYING index d5ee19f81..17fba5b87 100644 --- a/docs/COPYING +++ b/docs/COPYING @@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is Pillow is the friendly PIL fork. It is - Copyright © 2010-2024 by Jeffrey A. Clark and contributors + Copyright © 2010 by Jeffrey A. Clark and contributors Like PIL, Pillow is licensed under the open source PIL Software License: diff --git a/docs/about.rst b/docs/about.rst index c51ddebd0..7df895b8f 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -6,12 +6,11 @@ Goals The fork author's goal is to foster and support active development of PIL through: -- Continuous integration testing via `GitHub Actions`_ and `AppVeyor`_ +- Continuous integration testing via `GitHub Actions`_ - Publicized development activity on `GitHub`_ - Regular releases to the `Python Package Index`_ .. _GitHub Actions: https://github.com/python-pillow/Pillow/actions -.. _AppVeyor: https://ci.appveyor.com/project/Python-pillow/pillow .. _GitHub: https://github.com/python-pillow/Pillow .. _Python Package Index: https://pypi.org/project/pillow/ diff --git a/docs/conf.py b/docs/conf.py index b81d86c6c..bfbcf9151 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ import PIL # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -needs_sphinx = "8.1" +needs_sphinx = "8.2" # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -55,7 +55,7 @@ master_doc = "index" project = "Pillow (PIL Fork)" copyright = ( "1995-2011 Fredrik Lundh and contributors, " - "2010-2024 Jeffrey A. Clark and contributors." + "2010 Jeffrey A. Clark and contributors." ) author = "Fredrik Lundh (PIL), Jeffrey A. Clark (Pillow)" @@ -121,7 +121,7 @@ nitpicky = True # generating warnings in “nitpicky mode”. Note that type should include the domain name # if present. Example entries would be ('py:func', 'int') or # ('envvar', 'LD_LIBRARY_PATH'). -nitpick_ignore = [("py:class", "_io.BytesIO"), ("py:class", "_CmsProfileCompatible")] +nitpick_ignore = [("py:class", "_CmsProfileCompatible")] # -- Options for HTML output ---------------------------------------------- diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 25607e27c..634cee689 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -175,6 +175,24 @@ deprecated and will be removed in Pillow 12 (2025-10-15). They were used for obt raw pointers to ``ImagingCore`` internals. To interact with C code, you can use ``Image.Image.getim()``, which returns a ``Capsule`` object. +ExifTags.IFD.Makernote +^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.1.0 + +``ExifTags.IFD.Makernote`` has been deprecated. Instead, use +``ExifTags.IFD.MakerNote``. + +Image.Image.get_child_images() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.2.0 + +``Image.Image.get_child_images()`` has been deprecated. and will be removed in Pillow +13 (2026-10-15). It will be moved to ``ImageFile.ImageFile.get_child_images()``. The +method uses an image's file pointer, and so child images could only be retrieved from +an :py:class:`PIL.ImageFile.ImageFile` instance. + Removed features ---------------- diff --git a/docs/example/DdsImagePlugin.py b/docs/example/DdsImagePlugin.py index 10828bff0..e4b6b9c01 100644 --- a/docs/example/DdsImagePlugin.py +++ b/docs/example/DdsImagePlugin.py @@ -285,7 +285,7 @@ Image.register_decoder("DXT5", DXT5Decoder) def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"DDS " + return prefix.startswith(b"DDS ") Image.register_open(DdsImageFile.format, DdsImageFile, _accept) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index bf3087f6f..c0b1a9d4e 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -68,7 +68,7 @@ by DirectX. DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode. .. versionadded:: 3.4.0 - DXT3 images can be read in ``RGB`` mode and DX10 images can be read in + DXT3 images can be read in ``RGBA`` mode and DX10 images can be read in ``RGB`` and ``RGBA`` mode. .. versionadded:: 6.0.0 @@ -93,6 +93,12 @@ DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode. in ``P`` mode. +.. versionadded:: 11.2.0 + DXT1, DXT3, DXT5, BC2, BC3 and BC5 pixel formats can be saved:: + + im.save(out, pixel_format="DXT1") + + DIB ^^^ @@ -229,8 +235,9 @@ following options are available:: im.save(out, save_all=True, append_images=[im1, im2, ...]) **save_all** - If present and true, all frames of the image will be saved. If - not, then only the first frame of a multiframe image will be saved. + If present and true, or if ``append_images`` is not empty, all frames of + the image will be saved. Otherwise, only the first frame of a multiframe + image will be saved. **append_images** A list of images to append as additional frames. Each of the @@ -454,7 +461,8 @@ The :py:meth:`~PIL.Image.open` method may set the following Raw EXIF data from the image. **comment** - A comment about the image. + A comment about the image, from the COM marker. This is separate from the + UserComment tag that may be stored in the EXIF data. .. versionadded:: 7.1.0 @@ -572,10 +580,19 @@ JPEG 2000 Pillow reads and writes JPEG 2000 files containing ``L``, ``LA``, ``RGB``, ``RGBA``, or ``YCbCr`` data. When reading, ``YCbCr`` data is converted to ``RGB`` or ``RGBA`` depending on whether or not there is an alpha channel. -Beginning with version 8.3.0, Pillow can read (but not write) ``RGB``, -``RGBA``, and ``YCbCr`` images with subsampled components. Pillow supports -JPEG 2000 raw codestreams (``.j2k`` files), as well as boxed JPEG 2000 files -(``.jp2`` or ``.jpx`` files). + +.. versionadded:: 8.3.0 + Pillow can read (but not write) ``RGB``, ``RGBA``, and ``YCbCr`` images with + subsampled components. + +.. versionadded:: 10.4.0 + Pillow can read ``CMYK`` images with OpenJPEG 2.5.1 and later. + +.. versionadded:: 11.1.0 + Pillow can write ``CMYK`` images with OpenJPEG 2.5.3 and later. + +Pillow supports JPEG 2000 raw codestreams (``.j2k`` files), as well as boxed +JPEG 2000 files (``.jp2`` or ``.jpx`` files). When loading, if you set the ``mode`` on the image prior to the :py:meth:`~PIL.Image.Image.load` method being invoked, you can ask Pillow to @@ -707,8 +724,8 @@ Saving When calling :py:meth:`~PIL.Image.Image.save` to write an MPO file, by default only the first frame of a multiframe image will be saved. If the ``save_all`` -argument is present and true, then all frames will be saved, and the following -option will also be available. +argument is present and true, or if ``append_images`` is not empty, all frames +will be saved. **append_images** A list of images to append as additional pictures. Each of the @@ -918,7 +935,8 @@ Saving When calling :py:meth:`~PIL.Image.Image.save`, by default only a single frame PNG file will be saved. To save an APNG file (including a single frame APNG), the ``save_all`` -parameter must be set to ``True``. The following parameters can also be set: +parameter should be set to ``True`` or ``append_images`` should not be empty. The +following parameters can also be set: **default_image** Boolean value, specifying whether or not the base image is a default image. @@ -1147,15 +1165,14 @@ Saving The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: **save_all** - If true, Pillow will save all frames of the image to a multiframe tiff document. + If true, or if ``append_images`` is not empty, Pillow will save all frames of the + image to a multiframe tiff document. .. versionadded:: 3.4.0 **append_images** A list of images to append as additional frames. Each of the - images in the list can be single or multiframe images. Note however, that for - correct results, all the appended images should have the same - ``encoderinfo`` and ``encoderconfig`` properties. + images in the list can be single or multiframe images. .. versionadded:: 4.2.0 @@ -1199,6 +1216,11 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum .. versionadded:: 8.4.0 +**big_tiff** + If true, the image will be saved as a BigTIFF. + + .. versionadded:: 11.1.0 + **compression** A string containing the desired compression method for the file. (valid only with libtiff installed) Valid compression @@ -1294,8 +1316,8 @@ Saving sequences When calling :py:meth:`~PIL.Image.Image.save` to write a WebP file, by default only the first frame of a multiframe image will be saved. If the ``save_all`` -argument is present and true, then all frames will be saved, and the following -options will also be available. +argument is present and true, or if ``append_images`` is not empty, all frames +will be saved, and the following options will also be available. **append_images** A list of images to append as additional frames. Each of the @@ -1597,15 +1619,14 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum **save_all** If a multiframe image is used, by default, only the first image will be saved. To save all frames, each frame to a separate page of the PDF, the ``save_all`` - parameter must be present and set to ``True``. + parameter should be present and set to ``True`` or ``append_images`` should not be + empty. .. versionadded:: 3.0.0 **append_images** A list of :py:class:`PIL.Image.Image` objects to append as additional pages. Each - of the images in the list can be single or multiframe images. The ``save_all`` - parameter must be present and set to ``True`` in conjunction with - ``append_images``. + of the images in the list can be single or multiframe images. .. versionadded:: 4.2.0 diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst index 3df8e0d20..f1a2849b8 100644 --- a/docs/handbook/tutorial.rst +++ b/docs/handbook/tutorial.rst @@ -534,7 +534,6 @@ You can create animated GIFs with Pillow, e.g. # Save the images as an animated GIF images[0].save( "animated_hopper.gif", - save_all=True, append_images=images[1:], duration=500, # duration of each frame in milliseconds loop=0, # loop forever @@ -678,7 +677,7 @@ Reading from URL from PIL import Image from urllib.request import urlopen - url = "https://python-pillow.org/assets/images/pillow-logo.png" + url = "https://python-pillow.github.io/assets/images/pillow-logo.png" img = Image.open(urlopen(url)) diff --git a/docs/handbook/writing-your-own-image-plugin.rst b/docs/handbook/writing-your-own-image-plugin.rst index 2e853224d..9e7d14c57 100644 --- a/docs/handbook/writing-your-own-image-plugin.rst +++ b/docs/handbook/writing-your-own-image-plugin.rst @@ -54,7 +54,7 @@ true color. def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"SPAM" + return prefix.startswith(b"SPAM") class SpamImageFile(ImageFile.ImageFile): diff --git a/docs/index.rst b/docs/index.rst index 18f5c3d13..689088d48 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,10 +33,6 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more `_. After you install Homebrew, run:: - brew install libjpeg libtiff little-cms2 openjpeg webp - - To install libraqm on macOS use Homebrew to install its dependencies:: - - brew install freetype harfbuzz fribidi - - Then see ``depends/install_raqm_cmake.sh`` to install libraqm. + brew install libjpeg libraqm libtiff little-cms2 openjpeg webp .. tab:: Windows @@ -195,11 +189,6 @@ Many of Pillow's features require external libraries: mingw-w64-x86_64-libimagequant \ mingw-w64-x86_64-libraqm - https://www.msys2.org/docs/python/ states that setuptools >= 60 does not work with - MSYS2. To workaround this, before installing Pillow you must run:: - - export SETUPTOOLS_USE_DISTUTILS=stdlib - .. tab:: FreeBSD .. Note:: Only FreeBSD 10 and 11 tested diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index a0bada7b4..9eafad3c4 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -27,6 +27,8 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | CentOS Stream 9 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ +| CentOS Stream 10 | 3.12 | x86-64 | ++----------------------------------+----------------------------+---------------------+ | Debian 12 Bookworm | 3.11 | x86, x86-64 | +----------------------------------+----------------------------+---------------------+ | Fedora 40 | 3.12 | x86-64 | @@ -42,20 +44,16 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Ubuntu Linux 22.04 LTS (Jammy) | 3.9, 3.10, 3.11, | x86-64 | | | 3.12, 3.13, PyPy3 | | -| +----------------------------+---------------------+ -| | 3.10 | arm64v8 | +----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 24.04 LTS (Noble) | 3.12 | x86-64, ppc64le, | -| | | s390x | +| Ubuntu Linux 24.04 LTS (Noble) | 3.12 | x86-64, arm64v8, | +| | | ppc64le, s390x | +----------------------------------+----------------------------+---------------------+ -| Windows Server 2019 | 3.9 | x86-64 | +| Windows Server 2019 | 3.9 | x86 | +----------------------------------+----------------------------+---------------------+ -| Windows Server 2022 | 3.9, 3.10, 3.11, | x86-64 | -| | 3.12, 3.13, PyPy3 | | +| Windows Server 2022 | 3.10, 3.11, 3.12, 3.13, | x86-64 | +| | PyPy3 | | | +----------------------------+---------------------+ -| | 3.13 | x86 | -| +----------------------------+---------------------+ -| | 3.9 (MinGW) | x86-64 | +| | 3.12 (MinGW) | x86-64 | | +----------------------------+---------------------+ | | 3.9 (Cygwin) | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -75,7 +73,9 @@ These platforms have been reported to work at the versions mentioned. | Operating system | | Tested Python | | Latest tested | | Tested | | | | versions | | Pillow version | | processors | +==================================+============================+==================+==============+ -| macOS 15 Sequoia | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.4.0 |arm | +| macOS 15 Sequoia | 3.9, 3.10, 3.11, 3.12, 3.13| 11.1.0 |arm | +| +----------------------------+------------------+ | +| | 3.8 | 10.4.0 | | +----------------------------------+----------------------------+------------------+--------------+ | macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.4.0 |arm | +----------------------------------+----------------------------+------------------+--------------+ diff --git a/docs/reference/ImageCms.rst b/docs/reference/ImageCms.rst index 96bd14dd3..4a1f5a3ee 100644 --- a/docs/reference/ImageCms.rst +++ b/docs/reference/ImageCms.rst @@ -286,6 +286,14 @@ can be easily displayed in a chromaticity diagram, for example). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. + .. py:attribute:: media_white_point + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None + + This tag specifies the media white point and is used for + generating absolute colorimetry. + + The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. + .. py:attribute:: media_white_point_temperature :type: float | None diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 3e9aa73f8..b2f1bdc93 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -387,8 +387,11 @@ Methods the number of pixels between lines. :param align: If the text is passed on to :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_text`, - ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. - Use the ``anchor`` parameter to specify the alignment to ``xy``. + ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines + the relative alignment of lines. Use the ``anchor`` parameter to + specify the alignment to ``xy``. + + .. versionadded:: 11.2.0 ``"justify"`` :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. @@ -455,8 +458,11 @@ Methods of Pillow, but implemented only in version 8.0.0. :param spacing: The number of pixels between lines. - :param align: ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. - Use the ``anchor`` parameter to specify the alignment to ``xy``. + :param align: ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines + the relative alignment of lines. Use the ``anchor`` parameter to + specify the alignment to ``xy``. + + .. versionadded:: 11.2.0 ``"justify"`` :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. @@ -599,8 +605,11 @@ Methods the number of pixels between lines. :param align: If the text is passed on to :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textbbox`, - ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. - Use the ``anchor`` parameter to specify the alignment to ``xy``. + ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines + the relative alignment of lines. Use the ``anchor`` parameter to + specify the alignment to ``xy``. + + .. versionadded:: 11.2.0 ``"justify"`` :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. @@ -650,8 +659,11 @@ Methods vertical text. See :ref:`text-anchors` for details. This parameter is ignored for non-TrueType fonts. :param spacing: The number of pixels between lines. - :param align: ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. - Use the ``anchor`` parameter to specify the alignment to ``xy``. + :param align: ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines + the relative alignment of lines. Use the ``anchor`` parameter to + specify the alignment to ``xy``. + + .. versionadded:: 11.2.0 ``"justify"`` :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. diff --git a/docs/reference/ImageFile.rst b/docs/reference/ImageFile.rst index fdfeb60f9..64abd71d1 100644 --- a/docs/reference/ImageFile.rst +++ b/docs/reference/ImageFile.rst @@ -19,7 +19,7 @@ Example: Parse an image from PIL import ImageFile - fp = open("hopper.pgm", "rb") + fp = open("hopper.ppm", "rb") p = ImageFile.Parser() diff --git a/docs/reference/features.rst b/docs/reference/features.rst index fcff96735..0e173fe87 100644 --- a/docs/reference/features.rst +++ b/docs/reference/features.rst @@ -54,6 +54,8 @@ Feature version numbers are available only where stated. Support for the following features can be checked: * ``libjpeg_turbo``: (compile time) Whether Pillow was compiled against the libjpeg-turbo version of libjpeg. Compile-time version number is available. +* ``mozjpeg``: (compile time) Whether Pillow was compiled against the MozJPEG version of libjpeg. Compile-time version number is available. +* ``zlib_ng``: (compile time) Whether Pillow was compiled against the zlib-ng version of zlib. Compile-time version number is available. * ``raqm``: Raqm library, required for ``ImageFont.Layout.RAQM`` in :py:func:`PIL.ImageFont.truetype`. Run-time version number is available for Raqm 0.7.0 or newer. * ``libimagequant``: (compile time) ImageQuant quantization support in :py:func:`PIL.Image.Image.quantize`. Run-time version number is available. * ``xcb``: (compile time) Support for X11 in :py:func:`PIL.ImageGrab.grab` via the XCB library. diff --git a/docs/releasenotes/11.1.0.rst b/docs/releasenotes/11.1.0.rst new file mode 100644 index 000000000..0d56cb420 --- /dev/null +++ b/docs/releasenotes/11.1.0.rst @@ -0,0 +1,80 @@ +11.1.0 +------ + +Deprecations +============ + +ExifTags.IFD.Makernote +^^^^^^^^^^^^^^^^^^^^^^ + +``ExifTags.IFD.Makernote`` has been deprecated. Instead, use +``ExifTags.IFD.MakerNote``. + +API Changes +=========== + +Writing XMP bytes to JPEG and MPO +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Pillow 11.0.0 added writing XMP data to JPEG and MPO images:: + + im.info["xmp"] = b"test" + im.save("out.jpg") + +However, this meant that XMP data was automatically kept from an opened image, +which is inconsistent with the rest of Pillow's behaviour. This functionality +has been removed. To write XMP data, the ``xmp`` argument can still be used for +JPEG files:: + + im.save("out.jpg", xmp=b"test") + +To save XMP data to the second frame of an MPO image, ``encoderinfo`` can now +be used:: + + second_im.encoderinfo = {"xmp": b"test"} + im.save("out.mpo", save_all=True, append_images=[second_im]) + +API Additions +============= + +Check for zlib-ng +^^^^^^^^^^^^^^^^^ + +You can check if Pillow has been built against the zlib-ng version of the +zlib library, and what version of zlib-ng is being used:: + + from PIL import features + features.check_feature("zlib_ng") # True or False + features.version_feature("zlib_ng") # "2.2.2" for example, or None + +Saving TIFF as BigTIFF +^^^^^^^^^^^^^^^^^^^^^^ + +TIFF images can now be saved as BigTIFF using a ``big_tiff`` argument:: + + im.save("out.tiff", big_tiff=True) + +Other Changes +============= + +Reading JPEG 2000 comments +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When opening a JPEG 2000 image, the comment may now be read into +:py:attr:`~PIL.Image.Image.info` for J2K images, not just JP2 images. + +Saving JPEG 2000 CMYK images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +With OpenJPEG 2.5.3 or later, Pillow can now save CMYK images as JPEG 2000 files. + +Minimum C version +^^^^^^^^^^^^^^^^^ + +C99 is now the minimum version of C required to compile Pillow from source. + +zlib-ng in wheels +^^^^^^^^^^^^^^^^^ + +Wheels are now built against zlib-ng for improved speed. In tests, saving a PNG +was found to be more than twice as fast at higher compression levels. diff --git a/docs/releasenotes/11.2.0.rst b/docs/releasenotes/11.2.0.rst new file mode 100644 index 000000000..d40d86f21 --- /dev/null +++ b/docs/releasenotes/11.2.0.rst @@ -0,0 +1,70 @@ +11.2.0 +------ + +Security +======== + +Undefined shift when loading compressed DDS images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When loading some compressed DDS formats, an integer was bitshifted by 24 places to +generate the 32 bits of the lookup table. This was undefined behaviour, and has been +present since Pillow 3.4.0. + +Deprecations +============ + +Image.Image.get_child_images() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.2.0 + +``Image.Image.get_child_images()`` has been deprecated. and will be removed in Pillow +13 (2026-10-15). It will be moved to ``ImageFile.ImageFile.get_child_images()``. The +method uses an image's file pointer, and so child images could only be retrieved from +an :py:class:`PIL.ImageFile.ImageFile` instance. + +API Changes +=========== + +``append_images`` no longer requires ``save_all`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Previously, ``save_all`` was required to in order to use ``append_images``. Now, +``save_all`` will default to ``True`` if ``append_images`` is not empty and the format +supports saving multiple frames:: + + im.save("out.gif", append_images=ims) + +API Additions +============= + +``"justify"`` multiline text alignment +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In addition to ``"left"``, ``"center"`` and ``"right"``, multiline text can also be +aligned using ``"justify"`` in :py:mod:`~PIL.ImageDraw`:: + + from PIL import Image, ImageDraw + im = Image.new("RGB", (50, 25)) + draw = ImageDraw.Draw(im) + draw.multiline_text((0, 0), "Multiline\ntext 1", align="justify") + draw.multiline_textbbox((0, 0), "Multiline\ntext 2", align="justify") + +Check for MozJPEG +^^^^^^^^^^^^^^^^^ + +You can check if Pillow has been built against the MozJPEG version of the +libjpeg library, and what version of MozJPEG is being used:: + + from PIL import features + features.check_feature("mozjpeg") # True or False + features.version_feature("mozjpeg") # "4.1.1" for example, or None + +Saving compressed DDS images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Compressed DDS images can now be saved using a ``pixel_format`` argument. DXT1, DXT3, +DXT5, BC2, BC3 and BC5 are supported:: + + im.save("out.dds", pixel_format="DXT1") diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index 641cda4ef..be9f623d0 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -14,6 +14,8 @@ expected to be backported to earlier versions. .. toctree:: :maxdepth: 2 + 11.2.0 + 11.1.0 11.0.0 10.4.0 10.3.0 diff --git a/pyproject.toml b/pyproject.toml index c55be7693..780a938a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dynamic = [ optional-dependencies.docs = [ "furo", "olefile", - "sphinx>=8.1", + "sphinx>=8.2", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph", @@ -56,7 +56,7 @@ optional-dependencies.mic = [ ] optional-dependencies.tests = [ "check-manifest", - "coverage", + "coverage>=7.4.2", "defusedxml", "markdown2", "olefile", @@ -65,6 +65,7 @@ optional-dependencies.tests = [ "pytest", "pytest-cov", "pytest-timeout", + "trove-classifiers>=2024.10.12", ] optional-dependencies.typing = [ "typing-extensions; python_version<'3.10'", @@ -72,10 +73,10 @@ optional-dependencies.typing = [ optional-dependencies.xmp = [ "defusedxml", ] -urls.Changelog = "https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst" +urls.Changelog = "https://github.com/python-pillow/Pillow/releases" urls.Documentation = "https://pillow.readthedocs.io" urls.Funding = "https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=pypi" -urls.Homepage = "https://python-pillow.org" +urls.Homepage = "https://python-pillow.github.io" urls.Mastodon = "https://fosstodon.org/@pillow" urls."Release notes" = "https://pillow.readthedocs.io/en/stable/releasenotes/index.html" urls.Source = "https://github.com/python-pillow/Pillow" @@ -93,10 +94,17 @@ version = { attr = "PIL.__version__" } [tool.cibuildwheel] before-all = ".github/workflows/wheels-dependencies.sh" build-verbosity = 1 + config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable" +# Disable platform guessing on macOS +macos.config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable platform-guessing=disable" + test-command = "cd {project} && .github/workflows/wheels-test.sh" test-extras = "tests" +[tool.cibuildwheel.macos.environment] +PATH = "$(pwd)/build/deps/darwin/bin:$(dirname $(which python3)):/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin" + [tool.black] exclude = "wheels/multibuild" @@ -113,6 +121,7 @@ lint.select = [ "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "PGH", # pygrep-hooks + "PIE", # flake8-pie "PT", # flake8-pytest-style "PYI", # flake8-pyi "RUF100", # unused noqa (yesqa) @@ -125,6 +134,7 @@ lint.ignore = [ "E221", # Multiple spaces before operator "E226", # Missing whitespace around arithmetic operator "E241", # Multiple spaces after ',' + "PIE790", # flake8-pie: unnecessary-placeholder "PT001", # pytest-fixture-incorrect-parentheses-style "PT007", # pytest-parametrize-values-wrong-type "PT011", # pytest-raises-too-broad diff --git a/setup.py b/setup.py index 1a8c03eb3..9fac993b1 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ _LIB_IMAGING = ( "Reduce", "Bands", "BcnDecode", + "BcnEncode", "BitDecode", "Blend", "Chops", @@ -344,7 +345,7 @@ class pil_build_ext(build_ext): for x in ("raqm", "fribidi") ] + [ - ("disable-platform-guessing", None, "Disable platform guessing on Linux"), + ("disable-platform-guessing", None, "Disable platform guessing"), ("debug", None, "Debug logging"), ] + [("add-imaging-libs=", None, "Add libs to _imaging build")] @@ -393,13 +394,14 @@ class pil_build_ext(build_ext): self.feature.required.discard(x) _dbg("Disabling %s", x) if getattr(self, f"enable_{x}"): - msg = f"Conflicting options: --enable-{x} and --disable-{x}" + msg = f"Conflicting options: '-C {x}=enable' and '-C {x}=disable'" raise ValueError(msg) if x == "freetype": - _dbg("--disable-freetype implies --disable-raqm") + _dbg("'-C freetype=disable' implies '-C raqm=disable'") if getattr(self, "enable_raqm"): msg = ( - "Conflicting options: --enable-raqm and --disable-freetype" + "Conflicting options: " + "'-C raqm=enable' and '-C freetype=disable'" ) raise ValueError(msg) setattr(self, "disable_raqm", True) @@ -407,15 +409,17 @@ class pil_build_ext(build_ext): _dbg("Requiring %s", x) self.feature.required.add(x) if x == "raqm": - _dbg("--enable-raqm implies --enable-freetype") + _dbg("'-C raqm=enable' implies '-C freetype=enable'") self.feature.required.add("freetype") for x in ("raqm", "fribidi"): if getattr(self, f"vendor_{x}"): if getattr(self, "disable_raqm"): - msg = f"Conflicting options: --vendor-{x} and --disable-raqm" + msg = f"Conflicting options: '-C {x}=vendor' and '-C raqm=disable'" raise ValueError(msg) if x == "fribidi" and not getattr(self, "vendor_raqm"): - msg = f"Conflicting options: --vendor-{x} and not --vendor-raqm" + msg = ( + f"Conflicting options: '-C {x}=vendor' and not '-C raqm=vendor'" + ) raise ValueError(msg) _dbg("Using vendored version of %s", x) self.feature.vendor.add(x) @@ -448,7 +452,7 @@ class pil_build_ext(build_ext): def get_macos_sdk_path(self) -> str | None: try: sdk_path = ( - subprocess.check_output(["xcrun", "--show-sdk-path"]) + subprocess.check_output(["xcrun", "--show-sdk-path", "--sdk", "macosx"]) .strip() .decode("latin1") ) @@ -606,6 +610,7 @@ class pil_build_ext(build_ext): _add_directory(library_dirs, "/usr/X11/lib") _add_directory(include_dirs, "/usr/X11/include") + # Add the macOS SDK path. sdk_path = self.get_macos_sdk_path() if sdk_path: _add_directory(library_dirs, os.path.join(sdk_path, "usr", "lib")) @@ -690,6 +695,8 @@ class pil_build_ext(build_ext): feature.set("zlib", "z") elif sys.platform == "win32" and _find_library_file(self, "zlib"): feature.set("zlib", "zlib") # alternative name + elif sys.platform == "win32" and _find_library_file(self, "zdll"): + feature.set("zlib", "zdll") # dll import library if feature.want("jpeg"): _dbg("Looking for jpeg") @@ -1044,7 +1051,7 @@ except DependencyException as err: msg = f""" The headers or library files could not be found for {str(err)}, -which was requested by the option flag --enable-{str(err)} +which was requested by the option flag '-C {str(err)}=enable' """ sys.stderr.write(msg) diff --git a/src/PIL/BdfFontFile.py b/src/PIL/BdfFontFile.py index bc1416c74..f175e2f4f 100644 --- a/src/PIL/BdfFontFile.py +++ b/src/PIL/BdfFontFile.py @@ -26,17 +26,6 @@ from typing import BinaryIO from . import FontFile, Image -bdf_slant = { - "R": "Roman", - "I": "Italic", - "O": "Oblique", - "RI": "Reverse Italic", - "RO": "Reverse Oblique", - "OT": "Other", -} - -bdf_spacing = {"P": "Proportional", "M": "Monospaced", "C": "Cell"} - def bdf_char( f: BinaryIO, @@ -54,7 +43,7 @@ def bdf_char( s = f.readline() if not s: return None - if s[:9] == b"STARTCHAR": + if s.startswith(b"STARTCHAR"): break id = s[9:].strip().decode("ascii") @@ -62,7 +51,7 @@ def bdf_char( props = {} while True: s = f.readline() - if not s or s[:6] == b"BITMAP": + if not s or s.startswith(b"BITMAP"): break i = s.find(b" ") props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii") @@ -71,7 +60,7 @@ def bdf_char( bitmap = bytearray() while True: s = f.readline() - if not s or s[:7] == b"ENDCHAR": + if not s or s.startswith(b"ENDCHAR"): break bitmap += s[:-1] @@ -107,7 +96,7 @@ class BdfFontFile(FontFile.FontFile): super().__init__() s = fp.readline() - if s[:13] != b"STARTFONT 2.1": + if not s.startswith(b"STARTFONT 2.1"): msg = "not a valid BDF file" raise SyntaxError(msg) @@ -116,7 +105,7 @@ class BdfFontFile(FontFile.FontFile): while True: s = fp.readline() - if not s or s[:13] == b"ENDPROPERTIES": + if not s or s.startswith(b"ENDPROPERTIES"): break i = s.find(b" ") props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii") diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index e5605635e..f7be7746d 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -246,7 +246,7 @@ class BLPFormatError(NotImplementedError): def _accept(prefix: bytes) -> bool: - return prefix[:4] in (b"BLP1", b"BLP2") + return prefix.startswith((b"BLP1", b"BLP2")) class BlpImageFile(ImageFile.ImageFile): @@ -259,29 +259,44 @@ class BlpImageFile(ImageFile.ImageFile): def _open(self) -> None: self.magic = self.fp.read(4) - - self.fp.seek(5, os.SEEK_CUR) - (self._blp_alpha_depth,) = struct.unpack(" tuple[int, int]: try: - self._read_blp_header() + self._read_header() self._load() except struct.error as e: msg = "Truncated BLP file" @@ -292,25 +307,9 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): def _load(self) -> None: pass - def _read_blp_header(self) -> None: - assert self.fd is not None - self.fd.seek(4) - (self._blp_compression,) = struct.unpack(" None: + self._offsets = struct.unpack("<16I", self._safe_read(16 * 4)) + self._lengths = struct.unpack("<16I", self._safe_read(16 * 4)) def _safe_read(self, length: int) -> bytes: assert self.fd is not None @@ -326,9 +325,11 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): ret.append((b, g, r, a)) return ret - def _read_bgra(self, palette: list[tuple[int, int, int, int]]) -> bytearray: + def _read_bgra( + self, palette: list[tuple[int, int, int, int]], alpha: bool + ) -> bytearray: data = bytearray() - _data = BytesIO(self._safe_read(self._blp_lengths[0])) + _data = BytesIO(self._safe_read(self._lengths[0])) while True: try: (offset,) = struct.unpack(" None: - if self._blp_compression == Format.JPEG: + self._compression, self._encoding, alpha = self.args + + if self._compression == Format.JPEG: self._decode_jpeg_stream() - elif self._blp_compression == 1: - if self._blp_encoding in (4, 5): + elif self._compression == 1: + if self._encoding in (4, 5): palette = self._read_palette() - data = self._read_bgra(palette) + data = self._read_bgra(palette, alpha) self.set_as_raw(data) else: - msg = f"Unsupported BLP encoding {repr(self._blp_encoding)}" + msg = f"Unsupported BLP encoding {repr(self._encoding)}" raise BLPFormatError(msg) else: - msg = f"Unsupported BLP compression {repr(self._blp_encoding)}" + msg = f"Unsupported BLP compression {repr(self._encoding)}" raise BLPFormatError(msg) def _decode_jpeg_stream(self) -> None: @@ -365,65 +368,61 @@ class BLP1Decoder(_BLPBaseDecoder): (jpeg_header_size,) = struct.unpack(" None: + self._compression, self._encoding, alpha, self._alpha_encoding = self.args + palette = self._read_palette() assert self.fd is not None - self.fd.seek(self._blp_offsets[0]) + self.fd.seek(self._offsets[0]) - if self._blp_compression == 1: + if self._compression == 1: # Uncompressed or DirectX compression - if self._blp_encoding == Encoding.UNCOMPRESSED: - data = self._read_bgra(palette) + if self._encoding == Encoding.UNCOMPRESSED: + data = self._read_bgra(palette, alpha) - elif self._blp_encoding == Encoding.DXT: + elif self._encoding == Encoding.DXT: data = bytearray() - if self._blp_alpha_encoding == AlphaEncoding.DXT1: - linesize = (self.size[0] + 3) // 4 * 8 - for yb in range((self.size[1] + 3) // 4): - for d in decode_dxt1( - self._safe_read(linesize), alpha=bool(self._blp_alpha_depth) - ): + if self._alpha_encoding == AlphaEncoding.DXT1: + linesize = (self.state.xsize + 3) // 4 * 8 + for yb in range((self.state.ysize + 3) // 4): + for d in decode_dxt1(self._safe_read(linesize), alpha): data += d - elif self._blp_alpha_encoding == AlphaEncoding.DXT3: - linesize = (self.size[0] + 3) // 4 * 16 - for yb in range((self.size[1] + 3) // 4): + elif self._alpha_encoding == AlphaEncoding.DXT3: + linesize = (self.state.xsize + 3) // 4 * 16 + for yb in range((self.state.ysize + 3) // 4): for d in decode_dxt3(self._safe_read(linesize)): data += d - elif self._blp_alpha_encoding == AlphaEncoding.DXT5: - linesize = (self.size[0] + 3) // 4 * 16 - for yb in range((self.size[1] + 3) // 4): + elif self._alpha_encoding == AlphaEncoding.DXT5: + linesize = (self.state.xsize + 3) // 4 * 16 + for yb in range((self.state.ysize + 3) // 4): for d in decode_dxt5(self._safe_read(linesize)): data += d else: - msg = f"Unsupported alpha encoding {repr(self._blp_alpha_encoding)}" + msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}" raise BLPFormatError(msg) else: - msg = f"Unknown BLP encoding {repr(self._blp_encoding)}" + msg = f"Unknown BLP encoding {repr(self._encoding)}" raise BLPFormatError(msg) else: - msg = f"Unknown BLP compression {repr(self._blp_compression)}" + msg = f"Unknown BLP compression {repr(self._compression)}" raise BLPFormatError(msg) self.set_as_raw(data) @@ -472,10 +471,15 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: assert im.palette is not None fp.write(struct.pack(" bool: - return prefix[:2] == b"BM" + return prefix.startswith(b"BM") def _dib_accept(prefix: bytes) -> bool: @@ -242,7 +244,9 @@ class BmpImageFile(ImageFile.ImageFile): msg = "Unsupported BMP bitfields layout" raise OSError(msg) elif file_info["compression"] == self.COMPRESSIONS["RAW"]: - if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset + if file_info["bits"] == 32 and ( + header == 22 or USE_RAW_ALPHA # 32-bit .cur offset + ): raw_mode, self._mode = "BGRA", "RGBA" elif file_info["compression"] in ( self.COMPRESSIONS["RLE8"], diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 0ee2f653b..8c5da14f5 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -10,6 +10,7 @@ # from __future__ import annotations +import os from typing import IO from . import Image, ImageFile @@ -32,7 +33,7 @@ def register_handler(handler: ImageFile.StubHandler | None) -> None: def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"BUFR" or prefix[:4] == b"ZCZC" + return prefix.startswith((b"BUFR", b"ZCZC")) class BufrStubImageFile(ImageFile.StubImageFile): @@ -40,13 +41,11 @@ class BufrStubImageFile(ImageFile.StubImageFile): format_description = "BUFR" def _open(self) -> None: - offset = self.fp.tell() - if not _accept(self.fp.read(4)): msg = "Not a BUFR file" raise SyntaxError(msg) - self.fp.seek(offset) + self.fp.seek(-4, os.SEEK_CUR) # make something up self._mode = "F" diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py index c4be0ceca..b817dbc87 100644 --- a/src/PIL/CurImagePlugin.py +++ b/src/PIL/CurImagePlugin.py @@ -26,7 +26,7 @@ from ._binary import i32le as i32 def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"\0\0\2\0" + return prefix.startswith(b"\0\0\2\0") ## diff --git a/src/PIL/DcxImagePlugin.py b/src/PIL/DcxImagePlugin.py index f67f27d73..aea661b9c 100644 --- a/src/PIL/DcxImagePlugin.py +++ b/src/PIL/DcxImagePlugin.py @@ -24,6 +24,7 @@ from __future__ import annotations from . import Image from ._binary import i32le as i32 +from ._util import DeferredError from .PcxImagePlugin import PcxImageFile MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then? @@ -66,6 +67,8 @@ class DcxImageFile(PcxImageFile): def seek(self, frame: int) -> None: if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.frame = frame self.fp = self._fp self.fp.seek(self._offset[frame]) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 1b6408237..26307817c 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -419,6 +419,14 @@ class DdsImageFile(ImageFile.ImageFile): self._mode = "RGBA" self.pixel_format = "BC1" n = 1 + elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM): + self._mode = "RGBA" + self.pixel_format = "BC2" + n = 2 + elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM): + self._mode = "RGBA" + self.pixel_format = "BC3" + n = 3 elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM): self._mode = "L" self.pixel_format = "BC4" @@ -518,30 +526,68 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: msg = f"cannot write mode {im.mode} as DDS" raise OSError(msg) - alpha = im.mode[-1] == "A" - if im.mode[0] == "L": - pixel_flags = DDPF.LUMINANCE - rawmode = im.mode - if alpha: - rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF] - else: - rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000] - else: - pixel_flags = DDPF.RGB - rawmode = im.mode[::-1] - rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF] - - if alpha: - r, g, b, a = im.split() - im = Image.merge("RGBA", (a, r, g, b)) - if alpha: - pixel_flags |= DDPF.ALPHAPIXELS - rgba_mask.append(0xFF000000 if alpha else 0) - - flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PITCH | DDSD.PIXELFORMAT + flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT bitcount = len(im.getbands()) * 8 - pitch = (im.width * bitcount + 7) // 8 + pixel_format = im.encoderinfo.get("pixel_format") + args: tuple[int] | str + if pixel_format: + codec_name = "bcn" + flags |= DDSD.LINEARSIZE + pitch = (im.width + 3) * 4 + rgba_mask = [0, 0, 0, 0] + pixel_flags = DDPF.FOURCC + if pixel_format == "DXT1": + fourcc = D3DFMT.DXT1 + args = (1,) + elif pixel_format == "DXT3": + fourcc = D3DFMT.DXT3 + args = (2,) + elif pixel_format == "DXT5": + fourcc = D3DFMT.DXT5 + args = (3,) + else: + fourcc = D3DFMT.DX10 + if pixel_format == "BC2": + args = (2,) + dxgi_format = DXGI_FORMAT.BC2_TYPELESS + elif pixel_format == "BC3": + args = (3,) + dxgi_format = DXGI_FORMAT.BC3_TYPELESS + elif pixel_format == "BC5": + args = (5,) + dxgi_format = DXGI_FORMAT.BC5_TYPELESS + if im.mode != "RGB": + msg = "only RGB mode can be written as BC5" + raise OSError(msg) + else: + msg = f"cannot write pixel format {pixel_format}" + raise OSError(msg) + else: + codec_name = "raw" + flags |= DDSD.PITCH + pitch = (im.width * bitcount + 7) // 8 + alpha = im.mode[-1] == "A" + if im.mode[0] == "L": + pixel_flags = DDPF.LUMINANCE + args = im.mode + if alpha: + rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF] + else: + rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000] + else: + pixel_flags = DDPF.RGB + args = im.mode[::-1] + rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF] + + if alpha: + r, g, b, a = im.split() + im = Image.merge("RGBA", (a, r, g, b)) + if alpha: + pixel_flags |= DDPF.ALPHAPIXELS + rgba_mask.append(0xFF000000 if alpha else 0) + + fourcc = D3DFMT.UNKNOWN fp.write( o32(DDS_MAGIC) + struct.pack( @@ -556,17 +602,20 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ) + struct.pack("11I", *((0,) * 11)) # reserved # pfsize, pfflags, fourcc, bitcount - + struct.pack("<4I", 32, pixel_flags, 0, bitcount) + + struct.pack("<4I", 32, pixel_flags, fourcc, bitcount) + struct.pack("<4I", *rgba_mask) # dwRGBABitMask + struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0) ) - ImageFile._save( - im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))] - ) + if fourcc == D3DFMT.DX10: + fp.write( + # dxgi_format, 2D resource, misc, array size, straight alpha + struct.pack("<5I", dxgi_format, 3, 0, 0, 1) + ) + ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)]) def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"DDS " + return prefix.startswith(b"DDS ") Image.register_open(DdsImageFile.format, DdsImageFile, _accept) diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index fb1e301c0..5e2ddad99 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -170,7 +170,9 @@ def Ghostscript( def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"%!PS" or (len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5) + return prefix.startswith(b"%!PS") or ( + len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5 + ) ## @@ -295,7 +297,7 @@ class EpsImageFile(ImageFile.ImageFile): m = field.match(s) if m: k = m.group(1) - if k[:8] == "PS-Adobe": + if k.startswith("PS-Adobe"): self.info["PS-Adobe"] = k[9:] else: self.info[k] = "" @@ -454,7 +456,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) - if hasattr(fp, "flush"): fp.flush() - ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size, 0, None)]) + ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)]) fp.write(b"\n%%%%EndBinary\n") fp.write(b"grestore end\n") diff --git a/src/PIL/ExifTags.py b/src/PIL/ExifTags.py index 39b4aa552..2280d5ce8 100644 --- a/src/PIL/ExifTags.py +++ b/src/PIL/ExifTags.py @@ -303,38 +303,38 @@ TAGS = { class GPS(IntEnum): - GPSVersionID = 0 - GPSLatitudeRef = 1 - GPSLatitude = 2 - GPSLongitudeRef = 3 - GPSLongitude = 4 - GPSAltitudeRef = 5 - GPSAltitude = 6 - GPSTimeStamp = 7 - GPSSatellites = 8 - GPSStatus = 9 - GPSMeasureMode = 10 - GPSDOP = 11 - GPSSpeedRef = 12 - GPSSpeed = 13 - GPSTrackRef = 14 - GPSTrack = 15 - GPSImgDirectionRef = 16 - GPSImgDirection = 17 - GPSMapDatum = 18 - GPSDestLatitudeRef = 19 - GPSDestLatitude = 20 - GPSDestLongitudeRef = 21 - GPSDestLongitude = 22 - GPSDestBearingRef = 23 - GPSDestBearing = 24 - GPSDestDistanceRef = 25 - GPSDestDistance = 26 - GPSProcessingMethod = 27 - GPSAreaInformation = 28 - GPSDateStamp = 29 - GPSDifferential = 30 - GPSHPositioningError = 31 + GPSVersionID = 0x00 + GPSLatitudeRef = 0x01 + GPSLatitude = 0x02 + GPSLongitudeRef = 0x03 + GPSLongitude = 0x04 + GPSAltitudeRef = 0x05 + GPSAltitude = 0x06 + GPSTimeStamp = 0x07 + GPSSatellites = 0x08 + GPSStatus = 0x09 + GPSMeasureMode = 0x0A + GPSDOP = 0x0B + GPSSpeedRef = 0x0C + GPSSpeed = 0x0D + GPSTrackRef = 0x0E + GPSTrack = 0x0F + GPSImgDirectionRef = 0x10 + GPSImgDirection = 0x11 + GPSMapDatum = 0x12 + GPSDestLatitudeRef = 0x13 + GPSDestLatitude = 0x14 + GPSDestLongitudeRef = 0x15 + GPSDestLongitude = 0x16 + GPSDestBearingRef = 0x17 + GPSDestBearing = 0x18 + GPSDestDistanceRef = 0x19 + GPSDestDistance = 0x1A + GPSProcessingMethod = 0x1B + GPSAreaInformation = 0x1C + GPSDateStamp = 0x1D + GPSDifferential = 0x1E + GPSHPositioningError = 0x1F """Maps EXIF GPS tags to tag names.""" @@ -342,40 +342,41 @@ GPSTAGS = {i.value: i.name for i in GPS} class Interop(IntEnum): - InteropIndex = 1 - InteropVersion = 2 - RelatedImageFileFormat = 4096 - RelatedImageWidth = 4097 - RelatedImageHeight = 4098 + InteropIndex = 0x0001 + InteropVersion = 0x0002 + RelatedImageFileFormat = 0x1000 + RelatedImageWidth = 0x1001 + RelatedImageHeight = 0x1002 class IFD(IntEnum): - Exif = 34665 - GPSInfo = 34853 - Makernote = 37500 - Interop = 40965 + Exif = 0x8769 + GPSInfo = 0x8825 + MakerNote = 0x927C + Makernote = 0x927C # Deprecated + Interop = 0xA005 IFD1 = -1 class LightSource(IntEnum): - Unknown = 0 - Daylight = 1 - Fluorescent = 2 - Tungsten = 3 - Flash = 4 - Fine = 9 - Cloudy = 10 - Shade = 11 - DaylightFluorescent = 12 - DayWhiteFluorescent = 13 - CoolWhiteFluorescent = 14 - WhiteFluorescent = 15 - StandardLightA = 17 - StandardLightB = 18 - StandardLightC = 19 - D55 = 20 - D65 = 21 - D75 = 22 - D50 = 23 - ISO = 24 - Other = 255 + Unknown = 0x00 + Daylight = 0x01 + Fluorescent = 0x02 + Tungsten = 0x03 + Flash = 0x04 + Fine = 0x09 + Cloudy = 0x0A + Shade = 0x0B + DaylightFluorescent = 0x0C + DayWhiteFluorescent = 0x0D + CoolWhiteFluorescent = 0x0E + WhiteFluorescent = 0x0F + StandardLightA = 0x11 + StandardLightB = 0x12 + StandardLightC = 0x13 + D55 = 0x14 + D65 = 0x15 + D75 = 0x16 + D50 = 0x17 + ISO = 0x18 + Other = 0xFF diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index 6bbd2641a..a3fdc0efe 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -17,7 +17,7 @@ from . import Image, ImageFile def _accept(prefix: bytes) -> bool: - return prefix[:6] == b"SIMPLE" + return prefix.startswith(b"SIMPLE") class FitsImageFile(ImageFile.ImageFile): diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index 666390be9..7c5bfeefa 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -22,6 +22,7 @@ from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 from ._binary import i32le as i32 from ._binary import o8 +from ._util import DeferredError # # decoder @@ -134,6 +135,8 @@ class FliImageFile(ImageFile.ImageFile): self._seek(f) def _seek(self, frame: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex if frame == 0: self.__frame = -1 self._fp.seek(self.__rewind) @@ -159,7 +162,7 @@ class FliImageFile(ImageFile.ImageFile): framesize = i32(s) self.decodermaxblock = framesize - self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset, None)] + self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)] self.__offset += framesize diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index 8fef51076..fd992cd9e 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -42,7 +42,7 @@ MODES = { def _accept(prefix: bytes) -> bool: - return prefix[:8] == olefile.MAGIC + return prefix.startswith(olefile.MAGIC) ## @@ -170,7 +170,7 @@ class FpxImageFile(ImageFile.ImageFile): "raw", (x, y, x1, y1), i32(s, i) + 28, - (self.rawmode,), + self.rawmode, ) ) diff --git a/src/PIL/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py index ddb469bc3..d60e75bb6 100644 --- a/src/PIL/FtexImagePlugin.py +++ b/src/PIL/FtexImagePlugin.py @@ -79,8 +79,6 @@ class FtexImageFile(ImageFile.ImageFile): self._size = struct.unpack("<2i", self.fp.read(8)) mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8)) - self._mode = "RGB" - # Only support single-format files. # I don't know of any multi-format file. assert format_count == 1 @@ -95,7 +93,8 @@ class FtexImageFile(ImageFile.ImageFile): self._mode = "RGBA" self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))] elif format == Format.UNCOMPRESSED: - self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, ("RGB", 0, 1))] + self._mode = "RGB" + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")] else: msg = f"Invalid texture compression format: {repr(format)}" raise ValueError(msg) @@ -108,7 +107,7 @@ class FtexImageFile(ImageFile.ImageFile): def _accept(prefix: bytes) -> bool: - return prefix[:4] == MAGIC + return prefix.startswith(MAGIC) Image.register_open(FtexImageFile.format, FtexImageFile, _accept) diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py index f1b4969f2..891225ce2 100644 --- a/src/PIL/GdImageFile.py +++ b/src/PIL/GdImageFile.py @@ -56,7 +56,7 @@ class GdImageFile(ImageFile.ImageFile): msg = "Not a valid GD 2.x .gd file" raise SyntaxError(msg) - self._mode = "L" # FIXME: "P" + self._mode = "P" self._size = i16(s, 2), i16(s, 4) true_color = s[6] @@ -68,15 +68,15 @@ class GdImageFile(ImageFile.ImageFile): self.info["transparency"] = tindex self.palette = ImagePalette.raw( - "XBGR", s[7 + true_color_offset + 4 : 7 + true_color_offset + 4 + 256 * 4] + "RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4] ) self.tile = [ ImageFile._Tile( "raw", (0, 0) + self.size, - 7 + true_color_offset + 4 + 256 * 4, - ("L", 0, 1), + 7 + true_color_offset + 6 + 256 * 4, + "L", ) ] diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index a7c4f8b2c..045ab1027 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -45,6 +45,7 @@ from . import ( from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 +from ._util import DeferredError if TYPE_CHECKING: from . import _imaging @@ -67,7 +68,7 @@ LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST def _accept(prefix: bytes) -> bool: - return prefix[:6] in [b"GIF87a", b"GIF89a"] + return prefix.startswith((b"GIF87a", b"GIF89a")) ## @@ -167,6 +168,8 @@ class GifImageFile(ImageFile.ImageFile): raise EOFError(msg) from e def _seek(self, frame: int, update_image: bool = True) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex if frame == 0: # rewind self.__offset = 0 @@ -257,7 +260,7 @@ class GifImageFile(ImageFile.ImageFile): # application extension # info["extension"] = block, self.fp.tell() - if block[:11] == b"NETSCAPE2.0": + if block.startswith(b"NETSCAPE2.0"): block = self.data() if block and len(block) >= 3 and block[0] == 1: self.info["loop"] = i16(block, 1) @@ -689,15 +692,21 @@ def _write_multiple_frames( im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"] continue if im_frames[-1].encoderinfo.get("disposal") == 2: - if background_im is None: - color = im.encoderinfo.get( - "transparency", im.info.get("transparency", (0, 0, 0)) - ) - background = _get_background(im_frame, color) - 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) - bbox = _getbbox(background_im, im_frame)[1] + # To appear correctly in viewers using a convention, + # only consider transparency, and not background color + color = im.encoderinfo.get( + "transparency", im.info.get("transparency") + ) + if color is not None: + if background_im is None: + background = _get_background(im_frame, color) + background_im = Image.new("P", im_frame.size, background) + first_palette = im_frames[0].im.palette + assert first_palette is not None + background_im.putpalette(first_palette, first_palette.mode) + bbox = _getbbox(background_im, im_frame)[1] + else: + bbox = (0, 0) + im_frame.size elif encoderinfo.get("optimize") and im_frame.mode != "1": if "transparency" not in encoderinfo: assert im_frame.palette is not None @@ -763,7 +772,8 @@ def _write_multiple_frames( if not palette: frame_data.encoderinfo["include_color_table"] = True - im_frame = im_frame.crop(frame_data.bbox) + if frame_data.bbox != (0, 0) + im_frame.size: + im_frame = im_frame.crop(frame_data.bbox) offset = frame_data.bbox[:2] _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo) return True diff --git a/src/PIL/GimpGradientFile.py b/src/PIL/GimpGradientFile.py index 220eac57e..ec62f8e4e 100644 --- a/src/PIL/GimpGradientFile.py +++ b/src/PIL/GimpGradientFile.py @@ -116,7 +116,7 @@ class GimpGradientFile(GradientFile): """File handler for GIMP's gradient format.""" def __init__(self, fp: IO[bytes]) -> None: - if fp.readline()[:13] != b"GIMP Gradient": + if not fp.readline().startswith(b"GIMP Gradient"): msg = "not a GIMP gradient file" raise SyntaxError(msg) diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index 4cad0ebee..379ffd739 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -16,24 +16,27 @@ from __future__ import annotations import re +from io import BytesIO from typing import IO -from ._binary import o8 - class GimpPaletteFile: """File handler for GIMP's palette format.""" rawmode = "RGB" - def __init__(self, fp: IO[bytes]) -> None: - palette = [o8(i) * 3 for i in range(256)] - - if fp.readline()[:12] != b"GIMP Palette": + def _read(self, fp: IO[bytes], limit: bool = True) -> None: + if not fp.readline().startswith(b"GIMP Palette"): msg = "not a GIMP palette file" raise SyntaxError(msg) - for i in range(256): + palette: list[int] = [] + i = 0 + while True: + if limit and i == 256 + 3: + break + + i += 1 s = fp.readline() if not s: break @@ -41,18 +44,29 @@ class GimpPaletteFile: # skip fields and comment lines if re.match(rb"\w+:|#", s): continue - if len(s) > 100: + if limit and len(s) > 100: msg = "bad palette file" raise SyntaxError(msg) - v = tuple(map(int, s.split()[:3])) - if len(v) != 3: + v = s.split(maxsplit=3) + if len(v) < 3: msg = "bad palette entry" raise ValueError(msg) - palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) + palette += (int(v[i]) for i in range(3)) + if limit and len(palette) == 768: + break - self.palette = b"".join(palette) + self.palette = bytes(palette) + + def __init__(self, fp: IO[bytes]) -> None: + self._read(fp) + + @classmethod + def frombytes(cls, data: bytes) -> GimpPaletteFile: + self = cls.__new__(cls) + self._read(BytesIO(data), False) + return self def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index e9aa084b2..439fc5a3e 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -10,6 +10,7 @@ # from __future__ import annotations +import os from typing import IO from . import Image, ImageFile @@ -32,7 +33,7 @@ def register_handler(handler: ImageFile.StubHandler | None) -> None: def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"GRIB" and prefix[7] == 1 + return prefix.startswith(b"GRIB") and prefix[7] == 1 class GribStubImageFile(ImageFile.StubImageFile): @@ -40,13 +41,11 @@ class GribStubImageFile(ImageFile.StubImageFile): format_description = "GRIB" def _open(self) -> None: - offset = self.fp.tell() - if not _accept(self.fp.read(8)): msg = "Not a GRIB file" raise SyntaxError(msg) - self.fp.seek(offset) + self.fp.seek(-8, os.SEEK_CUR) # make something up self._mode = "F" diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index cc9e73deb..76e640f15 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -10,6 +10,7 @@ # from __future__ import annotations +import os from typing import IO from . import Image, ImageFile @@ -32,7 +33,7 @@ def register_handler(handler: ImageFile.StubHandler | None) -> None: def _accept(prefix: bytes) -> bool: - return prefix[:8] == b"\x89HDF\r\n\x1a\n" + return prefix.startswith(b"\x89HDF\r\n\x1a\n") class HDF5StubImageFile(ImageFile.StubImageFile): @@ -40,13 +41,11 @@ class HDF5StubImageFile(ImageFile.StubImageFile): format_description = "HDF5" def _open(self) -> None: - offset = self.fp.tell() - if not _accept(self.fp.read(8)): msg = "Not an HDF file" raise SyntaxError(msg) - self.fp.seek(offset) + self.fp.seek(-8, os.SEEK_CUR) # make something up self._mode = "F" diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 9757b2b14..5a88429e5 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -117,14 +117,13 @@ def read_png_or_jpeg2000( sig = fobj.read(12) im: Image.Image - if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a": + if sig.startswith(b"\x89PNG\x0d\x0a\x1a\x0a"): fobj.seek(start) im = PngImagePlugin.PngImageFile(fobj) Image._decompression_bomb_check(im.size) return {"RGBA": im} elif ( - sig[:4] == b"\xff\x4f\xff\x51" - or sig[:4] == b"\x0d\x0a\x87\x0a" + sig.startswith((b"\xff\x4f\xff\x51", b"\x0d\x0a\x87\x0a")) or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" ): if not enable_jpeg2k: @@ -387,7 +386,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def _accept(prefix: bytes) -> bool: - return prefix[:4] == MAGIC + return prefix.startswith(MAGIC) Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept) diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index e879f1801..55c57f203 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -118,7 +118,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def _accept(prefix: bytes) -> bool: - return prefix[:4] == _MAGIC + return prefix.startswith(_MAGIC) class IconHeader(NamedTuple): diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index f9f47348c..71b999678 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -31,6 +31,7 @@ import re from typing import IO, Any from . import Image, ImageFile, ImagePalette +from ._util import DeferredError # -------------------------------------------------------------------- # Standard tags @@ -145,7 +146,7 @@ class ImImageFile(ImageFile.ImageFile): if s == b"\r": continue - if not s or s == b"\0" or s == b"\x1A": + if not s or s == b"\0" or s == b"\x1a": break # FIXME: this may read whole file if not a text file @@ -155,9 +156,9 @@ class ImImageFile(ImageFile.ImageFile): msg = "not an IM file" raise SyntaxError(msg) - if s[-2:] == b"\r\n": + if s.endswith(b"\r\n"): s = s[:-2] - elif s[-1:] == b"\n": + elif s.endswith(b"\n"): s = s[:-1] try: @@ -209,7 +210,7 @@ class ImImageFile(ImageFile.ImageFile): self._mode = self.info[MODE] # Skip forward to start of image data - while s and s[:1] != b"\x1A": + while s and not s.startswith(b"\x1a"): s = self.fp.read(1) if not s: msg = "File truncated" @@ -247,7 +248,7 @@ class ImImageFile(ImageFile.ImageFile): self._fp = self.fp # FIXME: hack - if self.rawmode[:2] == "F;": + if self.rawmode.startswith("F;"): # ifunc95 formats try: # use bit decoder (if necessary) @@ -290,6 +291,8 @@ class ImImageFile(ImageFile.ImageFile): def seek(self, frame: int) -> None: if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.frame = frame @@ -357,7 +360,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: name = "".join([name[: 92 - len(ext)], ext]) fp.write(f"Name: {name}\r\n".encode("ascii")) - fp.write(("Image size (x*y): %d*%d\r\n" % im.size).encode("ascii")) + fp.write(f"Image size (x*y): {im.size[0]}*{im.size[1]}\r\n".encode("ascii")) fp.write(f"File size (no of images): {frames}\r\n".encode("ascii")) if im.mode in ["P", "PA"]: fp.write(b"Lut: 1\r\n") diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 44270392c..662afadf4 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -514,7 +514,7 @@ class ImagePointTransform: def _getscaleoffset( - expr: Callable[[ImagePointTransform], ImagePointTransform | float] + expr: Callable[[ImagePointTransform], ImagePointTransform | float], ) -> tuple[float, float]: a = expr(ImagePointTransform(1, 0)) return (a.scale, a.offset) if isinstance(a, ImagePointTransform) else (0, a) @@ -548,7 +548,6 @@ class Image: def __init__(self) -> None: # FIXME: take "new" parameters / other image? - # FIXME: turn mode and size into delegating properties? self._im: core.ImagingCore | DeferredError | None = None self._mode = "" self._size = (0, 0) @@ -603,24 +602,16 @@ class Image: def __enter__(self): return self - def _close_fp(self): - if getattr(self, "_fp", False): - if self._fp != self.fp: - self._fp.close() - self._fp = DeferredError(ValueError("Operation on closed image")) - if self.fp: - self.fp.close() - def __exit__(self, *args): - if hasattr(self, "fp"): + from . import ImageFile + + if isinstance(self, ImageFile.ImageFile): if getattr(self, "_exclusive_fp", False): self._close_fp() self.fp = None def close(self) -> None: """ - Closes the file pointer, if possible. - This operation will destroy the image core and release its memory. The image data will be unusable afterward. @@ -629,14 +620,9 @@ class Image: :py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for more information. """ - if hasattr(self, "fp"): - try: - self._close_fp() - self.fp = None - except Exception as msg: - logger.debug("Error closing: %s", msg) - if getattr(self, "map", None): + if sys.platform == "win32" and hasattr(sys, "pypy_version_info"): + self.map.close() self.map: mmap.mmap | None = None # Instead of simply setting to None, we're setting up a @@ -692,13 +678,10 @@ class Image: ) def __repr__(self) -> str: - return "<%s.%s image mode=%s size=%dx%d at 0x%X>" % ( - self.__class__.__module__, - self.__class__.__name__, - self.mode, - self.size[0], - self.size[1], - id(self), + return ( + f"<{self.__class__.__module__}.{self.__class__.__name__} " + f"image mode={self.mode} size={self.size[0]}x{self.size[1]} " + f"at 0x{id(self):X}>" ) def _repr_pretty_(self, p: PrettyPrinter, cycle: bool) -> None: @@ -707,14 +690,8 @@ class Image: # Same as __repr__ but without unpredictable id(self), # to keep Jupyter notebook `text/plain` output stable. p.text( - "<%s.%s image mode=%s size=%dx%d>" - % ( - self.__class__.__module__, - self.__class__.__name__, - self.mode, - self.size[0], - self.size[1], - ) + f"<{self.__class__.__module__}.{self.__class__.__name__} " + f"image mode={self.mode} size={self.size[0]}x{self.size[1]}>" ) def _repr_image(self, image_format: str, **kwargs: Any) -> bytes | None: @@ -763,7 +740,7 @@ class Image: def __setstate__(self, state: list[Any]) -> None: Image.__init__(self) - info, mode, size, palette, data = state + info, mode, size, palette, data = state[:5] self.info = info self._mode = mode self._size = size @@ -1025,7 +1002,7 @@ class Image: elif len(mode) == 3: transparency = tuple( convert_transparency(matrix[i * 4 : i * 4 + 4], transparency) - for i in range(0, len(transparency)) + for i in range(len(transparency)) ) new_im.info["transparency"] = transparency return new_im @@ -1563,50 +1540,10 @@ class Image: self.getexif() def get_child_images(self) -> list[ImageFile.ImageFile]: - child_images = [] - exif = self.getexif() - ifds = [] - if ExifTags.Base.SubIFDs in exif: - subifd_offsets = exif[ExifTags.Base.SubIFDs] - if subifd_offsets: - if not isinstance(subifd_offsets, tuple): - subifd_offsets = (subifd_offsets,) - for subifd_offset in subifd_offsets: - ifds.append((exif._get_ifd_dict(subifd_offset), subifd_offset)) - ifd1 = exif.get_ifd(ExifTags.IFD.IFD1) - if ifd1 and ifd1.get(513): - assert exif._info is not None - ifds.append((ifd1, exif._info.next)) + from . import ImageFile - offset = None - for ifd, ifd_offset in ifds: - current_offset = self.fp.tell() - if offset is None: - offset = current_offset - - fp = self.fp - if ifd is not None: - thumbnail_offset = ifd.get(513) - if thumbnail_offset is not None: - thumbnail_offset += getattr(self, "_exif_offset", 0) - self.fp.seek(thumbnail_offset) - data = self.fp.read(ifd.get(514)) - fp = io.BytesIO(data) - - with open(fp) as im: - from . import TiffImagePlugin - - if thumbnail_offset is None and isinstance( - im, TiffImagePlugin.TiffImageFile - ): - im._frame_pos = [ifd_offset] - im._seek(0) - im.load() - child_images.append(im) - - if offset is not None: - self.fp.seek(offset) - return child_images + deprecate("Image.Image.get_child_images", 13) + return ImageFile.ImageFile.get_child_images(self) # type: ignore[arg-type] def getim(self) -> CapsuleType: """ @@ -2539,7 +2476,21 @@ class Image: format to use is determined from the filename extension. If a file object was used instead of a filename, this parameter should always be used. - :param params: Extra parameters to the image writer. + :param params: Extra parameters to the image writer. These can also be + set on the image itself through ``encoderinfo``. This is useful when + saving multiple images:: + + # Saving XMP data to a single image + from PIL import Image + red = Image.new("RGB", (1, 1), "#f00") + red.save("out.mpo", xmp=b"test") + + # Saving XMP data to the second frame of an image + from PIL import Image + black = Image.new("RGB", (1, 1)) + red = Image.new("RGB", (1, 1), "#f00") + red.encoderinfo = {"xmp": b"test"} + black.save("out.mpo", save_all=True, append_images=[red]) :returns: None :exception ValueError: If the output format could not be determined from the file name. Use the format option to solve this. @@ -2550,7 +2501,7 @@ class Image: filename: str | bytes = "" open_fp = False if is_path(fp): - filename = os.path.realpath(os.fspath(fp)) + filename = os.fspath(fp) open_fp = True elif fp == sys.stdout: try: @@ -2559,14 +2510,7 @@ class Image: pass if not filename and hasattr(fp, "name") and is_path(fp.name): # only set the name for metadata purposes - filename = os.path.realpath(os.fspath(fp.name)) - - # may mutate self! - self._ensure_mutable() - - save_all = params.pop("save_all", False) - self.encoderinfo = params - self.encoderconfig: tuple[Any, ...] = () + filename = os.fspath(fp.name) preinit() @@ -2582,9 +2526,20 @@ class Image: msg = f"unknown file extension: {ext}" raise ValueError(msg) from e + # may mutate self! + self._ensure_mutable() + + save_all = params.pop("save_all", None) + self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params} + self.encoderconfig: tuple[Any, ...] = () + if format.upper() not in SAVE: init() - if save_all: + if save_all or ( + save_all is None + and params.get("append_images") + and format.upper() in SAVE_ALL + ): save_handler = SAVE_ALL[format.upper()] else: save_handler = SAVE[format.upper()] @@ -2612,6 +2567,11 @@ class Image: except PermissionError: pass raise + finally: + try: + del self.encoderinfo + except AttributeError: + pass if open_fp: fp.close() @@ -3025,7 +2985,7 @@ class Image: # Abstract handlers. -class ImagePointHandler: +class ImagePointHandler(abc.ABC): """ Used as a mixin by point transforms (for use with :py:meth:`~PIL.Image.Image.point`) @@ -3036,7 +2996,7 @@ class ImagePointHandler: pass -class ImageTransformHandler: +class ImageTransformHandler(abc.ABC): """ Used as a mixin by geometry transforms (for use with :py:meth:`~PIL.Image.Image.transform`) @@ -3055,15 +3015,6 @@ class ImageTransformHandler: # -------------------------------------------------------------------- # Factories -# -# Debugging - - -def _wedge() -> Image: - """Create grayscale wedge (for debugging only)""" - - return Image()._new(core.wedge("L")) - def _check_size(size: Any) -> None: """ @@ -3463,7 +3414,7 @@ def open( exclusive_fp = False filename: str | bytes = "" if is_path(fp): - filename = os.path.realpath(os.fspath(fp)) + filename = os.fspath(fp) if filename: fp = builtins.open(filename, "rb") @@ -3893,7 +3844,7 @@ class Exif(_ExifBase): gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) print(gps_ifd) - Other IFDs include ``ExifTags.IFD.Exif``, ``ExifTags.IFD.Makernote``, + Other IFDs include ``ExifTags.IFD.Exif``, ``ExifTags.IFD.MakerNote``, ``ExifTags.IFD.Interop`` and ``ExifTags.IFD.IFD1``. :py:mod:`~PIL.ExifTags` also has enum classes to provide names for data:: @@ -3943,7 +3894,7 @@ class Exif(_ExifBase): return self._fixup_dict(dict(info)) def _get_head(self) -> bytes: - version = b"\x2B" if self.bigtiff else b"\x2A" + version = b"\x2b" if self.bigtiff else b"\x2a" if self.endian == "<": head = b"II" + version + b"\x00" + o32le(8) else: @@ -4027,6 +3978,9 @@ class Exif(_ExifBase): head = self._get_head() ifd = TiffImagePlugin.ImageFileDirectory_v2(ifh=head) + for tag, ifd_dict in self._ifds.items(): + if tag not in self: + ifd[tag] = ifd_dict for tag, value in self.items(): if tag in [ ExifTags.IFD.Exif, @@ -4056,19 +4010,19 @@ class Exif(_ExifBase): 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: self.get_ifd(ExifTags.IFD.Exif) tag_data = self._ifds[ExifTags.IFD.Exif][tag] - if tag == ExifTags.IFD.Makernote: + if tag == ExifTags.IFD.MakerNote: from .TiffImagePlugin import ImageFileDirectory_v2 - if tag_data[:8] == b"FUJIFILM": + if tag_data.startswith(b"FUJIFILM"): ifd_offset = i32le(tag_data, 8) ifd_data = tag_data[ifd_offset:] makernote = {} - for i in range(0, struct.unpack("H", tag_data[:2])[0]): + for i in range(struct.unpack(">H", tag_data[:2])[0]): ifd_tag, typ, count, data = struct.unpack( ">HHL4s", tag_data[i * 12 + 2 : (i + 1) * 12 + 2] ) @@ -4147,7 +4101,7 @@ class Exif(_ExifBase): ifd = { k: v for (k, v) in ifd.items() - if k not in (ExifTags.IFD.Interop, ExifTags.IFD.Makernote) + if k not in (ExifTags.IFD.Interop, ExifTags.IFD.MakerNote) } return ifd diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index d8e4c0c60..c2ed9034d 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -42,11 +42,7 @@ from ._deprecate import deprecate from ._typing import Coords # experimental access to the outline API -Outline: Callable[[], Image.core._Outline] | None -try: - Outline = Image.core.outline -except AttributeError: - Outline = None +Outline: Callable[[], Image.core._Outline] = Image.core.outline if TYPE_CHECKING: from . import ImageDraw2, ImageFont @@ -557,21 +553,6 @@ class ImageDraw: return split_character in text - def _multiline_split(self, text: AnyStr) -> list[AnyStr]: - return text.split("\n" if isinstance(text, str) else b"\n") - - def _multiline_spacing( - self, - font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont, - spacing: float, - stroke_width: float, - ) -> float: - return ( - self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3] - + stroke_width - + spacing - ) - def text( self, xy: tuple[float, float], @@ -643,6 +624,7 @@ class ImageDraw: features=features, language=language, stroke_width=stroke_width, + stroke_filled=True, anchor=anchor, ink=ink, start=start, @@ -692,11 +674,125 @@ class ImageDraw: draw_text(stroke_ink, stroke_width) # Draw normal text - draw_text(ink, 0) + if ink != stroke_ink: + draw_text(ink) else: # Only draw normal text draw_text(ink) + def _prepare_multiline_text( + self, + xy: tuple[float, float], + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ), + anchor: str | None, + spacing: float, + align: str, + direction: str | None, + features: list[str] | None, + language: str | None, + stroke_width: float, + embedded_color: bool, + font_size: float | None, + ) -> tuple[ + ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont, + str, + list[tuple[tuple[float, float], AnyStr]], + ]: + if direction == "ttb": + msg = "ttb direction is unsupported for multiline text" + raise ValueError(msg) + + if anchor is None: + anchor = "la" + elif len(anchor) != 2: + msg = "anchor must be a 2 character string" + raise ValueError(msg) + elif anchor[1] in "tb": + msg = "anchor not supported for multiline text" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) + + widths = [] + max_width: float = 0 + lines = text.split("\n" if isinstance(text, str) else b"\n") + line_spacing = ( + self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3] + + stroke_width + + spacing + ) + + for line in lines: + line_width = self.textlength( + line, + font, + direction=direction, + features=features, + language=language, + embedded_color=embedded_color, + ) + widths.append(line_width) + max_width = max(max_width, line_width) + + top = xy[1] + if anchor[1] == "m": + top -= (len(lines) - 1) * line_spacing / 2.0 + elif anchor[1] == "d": + top -= (len(lines) - 1) * line_spacing + + parts = [] + for idx, line in enumerate(lines): + left = xy[0] + width_difference = max_width - widths[idx] + + # first align left by anchor + if anchor[0] == "m": + left -= width_difference / 2.0 + elif anchor[0] == "r": + left -= width_difference + + # then align by align parameter + if align in ("left", "justify"): + pass + elif align == "center": + left += width_difference / 2.0 + elif align == "right": + left += width_difference + else: + msg = 'align must be "left", "center", "right" or "justify"' + raise ValueError(msg) + + if align == "justify" and width_difference != 0: + words = line.split(" " if isinstance(text, str) else b" ") + word_widths = [ + self.textlength( + word, + font, + direction=direction, + features=features, + language=language, + embedded_color=embedded_color, + ) + for word in words + ] + width_difference = max_width - sum(word_widths) + for i, word in enumerate(words): + parts.append(((left, top), word)) + left += word_widths[i] + width_difference / (len(words) - 1) + else: + parts.append(((left, top), line)) + + top += line_spacing + + return font, anchor, parts + def multiline_text( self, xy: tuple[float, float], @@ -720,62 +816,24 @@ class ImageDraw: *, font_size: float | None = None, ) -> None: - if direction == "ttb": - msg = "ttb direction is unsupported for multiline text" - raise ValueError(msg) - - if anchor is None: - anchor = "la" - elif len(anchor) != 2: - msg = "anchor must be a 2 character string" - raise ValueError(msg) - elif anchor[1] in "tb": - msg = "anchor not supported for multiline text" - raise ValueError(msg) - - if font is None: - font = self._getfont(font_size) - - widths = [] - max_width: float = 0 - lines = self._multiline_split(text) - line_spacing = self._multiline_spacing(font, spacing, stroke_width) - for line in lines: - line_width = self.textlength( - line, font, direction=direction, features=features, language=language - ) - widths.append(line_width) - max_width = max(max_width, line_width) - - top = xy[1] - if anchor[1] == "m": - top -= (len(lines) - 1) * line_spacing / 2.0 - elif anchor[1] == "d": - top -= (len(lines) - 1) * line_spacing - - for idx, line in enumerate(lines): - left = xy[0] - width_difference = max_width - widths[idx] - - # first align left by anchor - if anchor[0] == "m": - left -= width_difference / 2.0 - elif anchor[0] == "r": - left -= width_difference - - # then align by align parameter - if align == "left": - pass - elif align == "center": - left += width_difference / 2.0 - elif align == "right": - left += width_difference - else: - msg = 'align must be "left", "center" or "right"' - raise ValueError(msg) + font, anchor, lines = self._prepare_multiline_text( + xy, + text, + font, + anchor, + spacing, + align, + direction, + features, + language, + stroke_width, + embedded_color, + font_size, + ) + for xy, line in lines: self.text( - (left, top), + xy, line, fill, font, @@ -787,7 +845,6 @@ class ImageDraw: stroke_fill=stroke_fill, embedded_color=embedded_color, ) - top += line_spacing def textlength( self, @@ -889,69 +946,26 @@ class ImageDraw: *, font_size: float | None = None, ) -> tuple[float, float, float, float]: - if direction == "ttb": - msg = "ttb direction is unsupported for multiline text" - raise ValueError(msg) - - if anchor is None: - anchor = "la" - elif len(anchor) != 2: - msg = "anchor must be a 2 character string" - raise ValueError(msg) - elif anchor[1] in "tb": - msg = "anchor not supported for multiline text" - raise ValueError(msg) - - if font is None: - font = self._getfont(font_size) - - widths = [] - max_width: float = 0 - lines = self._multiline_split(text) - line_spacing = self._multiline_spacing(font, spacing, stroke_width) - for line in lines: - line_width = self.textlength( - line, - font, - direction=direction, - features=features, - language=language, - embedded_color=embedded_color, - ) - widths.append(line_width) - max_width = max(max_width, line_width) - - top = xy[1] - if anchor[1] == "m": - top -= (len(lines) - 1) * line_spacing / 2.0 - elif anchor[1] == "d": - top -= (len(lines) - 1) * line_spacing + font, anchor, lines = self._prepare_multiline_text( + xy, + text, + font, + anchor, + spacing, + align, + direction, + features, + language, + stroke_width, + embedded_color, + font_size, + ) bbox: tuple[float, float, float, float] | None = None - for idx, line in enumerate(lines): - left = xy[0] - width_difference = max_width - widths[idx] - - # first align left by anchor - if anchor[0] == "m": - left -= width_difference / 2.0 - elif anchor[0] == "r": - left -= width_difference - - # then align by align parameter - if align == "left": - pass - elif align == "center": - left += width_difference / 2.0 - elif align == "right": - left += width_difference - else: - msg = 'align must be "left", "center" or "right"' - raise ValueError(msg) - + for xy, line in lines: bbox_line = self.textbbox( - (left, top), + xy, line, font, anchor, @@ -971,8 +985,6 @@ class ImageDraw: max(bbox[3], bbox_line[3]), ) - top += line_spacing - if bbox is None: return xy[0], xy[1], xy[0], xy[1] return bbox @@ -1192,7 +1204,7 @@ def _compute_regular_polygon_vertices( degrees = 360 / n_sides # Start with the bottom left polygon vertex current_angle = (270 - 0.5 * degrees) + rotation - for _ in range(0, n_sides): + for _ in range(n_sides): angles.append(current_angle) current_angle += degrees if current_angle > 360: @@ -1215,4 +1227,4 @@ def _color_diff( first = color1 if isinstance(color1, tuple) else (color1,) second = color2 if isinstance(color2, tuple) else (color2,) - return sum(abs(first[i] - second[i]) for i in range(0, len(second))) + return sum(abs(first[i] - second[i]) for i in range(len(second))) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index d69d84568..a7848c369 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -31,18 +31,20 @@ from __future__ import annotations import abc import io import itertools +import logging import os import struct -import sys from typing import IO, TYPE_CHECKING, Any, NamedTuple, cast -from . import Image +from . import ExifTags, Image from ._deprecate import deprecate -from ._util import is_path +from ._util import DeferredError, is_path if TYPE_CHECKING: from ._typing import StrOrBytesPath +logger = logging.getLogger(__name__) + MAXBLOCK = 65536 SAFEBLOCK = 1024 * 1024 @@ -98,8 +100,8 @@ def _tilesort(t: _Tile) -> int: class _Tile(NamedTuple): codec_name: str extents: tuple[int, int, int, int] | None - offset: int - args: tuple[Any, ...] | str | None + offset: int = 0 + args: tuple[Any, ...] | str | None = None # @@ -120,7 +122,7 @@ class ImageFile(Image.Image): self.custom_mimetype: str | None = None self.tile: list[_Tile] = [] - """ A list of tile descriptors, or ``None`` """ + """ A list of tile descriptors """ self.readonly = 1 # until we know better @@ -130,7 +132,7 @@ class ImageFile(Image.Image): if is_path(fp): # filename self.fp = open(fp, "rb") - self.filename = os.path.realpath(os.fspath(fp)) + self.filename = os.fspath(fp) self._exclusive_fp = True else: # stream @@ -163,6 +165,85 @@ class ImageFile(Image.Image): def _open(self) -> None: pass + def _close_fp(self): + if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError): + if self._fp != self.fp: + self._fp.close() + self._fp = DeferredError(ValueError("Operation on closed image")) + if self.fp: + self.fp.close() + + def close(self) -> None: + """ + Closes the file pointer, if possible. + + This operation will destroy the image core and release its memory. + The image data will be unusable afterward. + + This function is required to close images that have multiple frames or + have not had their file read and closed by the + :py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for + more information. + """ + try: + self._close_fp() + self.fp = None + except Exception as msg: + logger.debug("Error closing: %s", msg) + + super().close() + + def get_child_images(self) -> list[ImageFile]: + child_images = [] + exif = self.getexif() + ifds = [] + if ExifTags.Base.SubIFDs in exif: + subifd_offsets = exif[ExifTags.Base.SubIFDs] + if subifd_offsets: + if not isinstance(subifd_offsets, tuple): + subifd_offsets = (subifd_offsets,) + for subifd_offset in subifd_offsets: + ifds.append((exif._get_ifd_dict(subifd_offset), subifd_offset)) + ifd1 = exif.get_ifd(ExifTags.IFD.IFD1) + if ifd1 and ifd1.get(ExifTags.Base.JpegIFOffset): + assert exif._info is not None + ifds.append((ifd1, exif._info.next)) + + offset = None + for ifd, ifd_offset in ifds: + assert self.fp is not None + current_offset = self.fp.tell() + if offset is None: + offset = current_offset + + fp = self.fp + if ifd is not None: + thumbnail_offset = ifd.get(ExifTags.Base.JpegIFOffset) + if thumbnail_offset is not None: + thumbnail_offset += getattr(self, "_exif_offset", 0) + self.fp.seek(thumbnail_offset) + + length = ifd.get(ExifTags.Base.JpegIFByteCount) + assert isinstance(length, int) + data = self.fp.read(length) + fp = io.BytesIO(data) + + with Image.open(fp) as im: + from . import TiffImagePlugin + + if thumbnail_offset is None and isinstance( + im, TiffImagePlugin.TiffImageFile + ): + im._frame_pos = [ifd_offset] + im._seek(0) + im.load() + child_images.append(im) + + if offset is not None: + assert self.fp is not None + self.fp.seek(offset) + return child_images + def get_format_mimetype(self) -> str | None: if self.custom_mimetype: return self.custom_mimetype @@ -196,8 +277,6 @@ class ImageFile(Image.Image): self.map: mmap.mmap | None = None use_mmap = self.filename and len(self.tile) == 1 - # As of pypy 2.1.0, memory mapping was failing here. - use_mmap = use_mmap and not hasattr(sys, "pypy_version_info") readonly = 0 @@ -263,7 +342,7 @@ class ImageFile(Image.Image): self.tile, lambda tile: (tile[0], tile[1], tile[3]) ) ] - for decoder_name, extents, offset, args in self.tile: + for i, (decoder_name, extents, offset, args) in enumerate(self.tile): seek(offset) decoder = Image._getdecoder( self.mode, decoder_name, args, self.decoderconfig @@ -276,8 +355,13 @@ class ImageFile(Image.Image): else: b = prefix while True: + read_bytes = self.decodermaxblock + if i + 1 < len(self.tile): + next_offset = self.tile[i + 1].offset + if next_offset > offset: + read_bytes = next_offset - offset try: - s = read(self.decodermaxblock) + s = read(read_bytes) except (IndexError, struct.error) as e: # truncated png/gif if LOAD_TRUNCATED_IMAGES: @@ -356,7 +440,7 @@ class ImageFile(Image.Image): return self.tell() != frame -class StubHandler: +class StubHandler(abc.ABC): def open(self, im: StubImageFile) -> None: pass @@ -365,7 +449,7 @@ class StubHandler: pass -class StubImageFile(ImageFile): +class StubImageFile(ImageFile, metaclass=abc.ABCMeta): """ Base class for stub image loaders. @@ -373,9 +457,9 @@ class StubImageFile(ImageFile): certain format, but relies on external code to load the file. """ + @abc.abstractmethod def _open(self) -> None: - msg = "StubImageFile subclass must implement _open" - raise NotImplementedError(msg) + pass def load(self) -> Image.core.PixelAccess | None: loader = self._load() @@ -389,10 +473,10 @@ class StubImageFile(ImageFile): self.__dict__ = image.__dict__ return image.load() + @abc.abstractmethod def _load(self) -> StubHandler | None: """(Hook) Find actual image loader.""" - msg = "StubImageFile subclass must implement _load" - raise NotImplementedError(msg) + pass class Parser: diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 8b0974b2c..05829d0c6 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from ._typing import NumpyArray -class Filter: +class Filter(abc.ABC): @abc.abstractmethod def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: pass @@ -553,7 +553,7 @@ class Color3DLUT(MultibandFilter): ch_out = channels or ch_in size_1d, size_2d, size_3d = self.size - table = [0] * (size_1d * size_2d * size_3d * ch_out) + table: list[float] = [0] * (size_1d * size_2d * size_3d * ch_out) idx_in = 0 idx_out = 0 for b in range(size_3d): @@ -598,8 +598,6 @@ class Color3DLUT(MultibandFilter): self.mode or image.mode, Image.Resampling.BILINEAR, self.channels, - self.size[0], - self.size[1], - self.size[2], + self.size, self.table, ) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index b694b817e..c8f05fbb7 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -270,7 +270,7 @@ class FreeTypeFont: ) if is_path(font): - font = os.path.realpath(os.fspath(font)) + font = os.fspath(font) if sys.platform == "win32": font_bytes_path = font if isinstance(font, bytes) else font.encode() try: @@ -644,10 +644,10 @@ class FreeTypeFont: features, language, stroke_width, + kwargs.get("stroke_filled", False), anchor, ink, - start[0], - start[1], + start, ) def font_variant( diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 4dcaaa6c3..0113ebcbf 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -113,28 +113,17 @@ def grab( def grabclipboard() -> Image.Image | list[str] | None: if sys.platform == "darwin": - fh, filepath = tempfile.mkstemp(".png") - os.close(fh) - commands = [ - 'set theFile to (open for access POSIX file "' - + filepath - + '" with write permission)', - "try", - " write (the clipboard as «class PNGf») to theFile", - "end try", - "close access theFile", - ] - script = ["osascript"] - for command in commands: - script += ["-e", command] - subprocess.call(script) + p = subprocess.run( + ["osascript", "-e", "get the clipboard as «class PNGf»"], + capture_output=True, + ) + if p.returncode != 0: + return None - im = None - if os.stat(filepath).st_size != 0: - im = Image.open(filepath) - im.load() - os.unlink(filepath) - return im + import binascii + + data = io.BytesIO(binascii.unhexlify(p.stdout[11:-3])) + return Image.open(data) elif sys.platform == "win32": fmt, data = Image.core.grabclipboard_win32() if fmt == "file": # CF_HDROP diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 44aad0c3c..da28854b5 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -22,7 +22,7 @@ import functools import operator import re from collections.abc import Sequence -from typing import Protocol, cast +from typing import Literal, Protocol, cast, overload from . import ExifTags, Image, ImagePalette @@ -213,14 +213,14 @@ def colorize( blue = [] # Create the low-end values - for i in range(0, blackpoint): + for i in range(blackpoint): red.append(rgb_black[0]) green.append(rgb_black[1]) blue.append(rgb_black[2]) # Create the mapping (2-color) if rgb_mid is None: - range_map = range(0, whitepoint - blackpoint) + range_map = range(whitepoint - blackpoint) for i in range_map: red.append( @@ -235,8 +235,8 @@ def colorize( # Create the mapping (3-color) else: - range_map1 = range(0, midpoint - blackpoint) - range_map2 = range(0, whitepoint - midpoint) + range_map1 = range(midpoint - blackpoint) + range_map2 = range(whitepoint - midpoint) for i in range_map1: red.append( @@ -256,7 +256,7 @@ def colorize( blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2)) # Create the high-end values - for i in range(0, 256 - whitepoint): + for i in range(256 - whitepoint): red.append(rgb_white[0]) green.append(rgb_white[1]) blue.append(rgb_white[2]) @@ -673,6 +673,16 @@ def solarize(image: Image.Image, threshold: int = 128) -> Image.Image: return _lut(image, lut) +@overload +def exif_transpose(image: Image.Image, *, in_place: Literal[True]) -> None: ... + + +@overload +def exif_transpose( + image: Image.Image, *, in_place: Literal[False] = False +) -> Image.Image: ... + + def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None: """ If an image has an EXIF Orientation tag, other than 1, transpose the image @@ -698,10 +708,11 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image 8: Image.Transpose.ROTATE_90, }.get(orientation) if method is not None: - transposed_image = image.transpose(method) if in_place: - image.im = transposed_image.im - image._size = transposed_image._size + image.im = image.im.transpose(method) + image._size = image.im.size + else: + transposed_image = image.transpose(method) exif_image = image if in_place else transposed_image exif = exif_image.getexif() @@ -718,11 +729,15 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image r"([0-9])", ): value = exif_image.info[key] - exif_image.info[key] = ( - re.sub(pattern, "", value) - if isinstance(value, str) - else re.sub(pattern.encode(), b"", value) - ) + if isinstance(value, str): + value = re.sub(pattern, "", value) + elif isinstance(value, tuple): + value = tuple( + re.sub(pattern.encode(), b"", v) for v in value + ) + else: + value = re.sub(pattern.encode(), b"", value) + exif_image.info[key] = value if not in_place: return transposed_image elif not in_place: diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index d62893d9c..dd240fb55 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -192,7 +192,7 @@ if sys.platform == "darwin": register(MacViewer) -class UnixViewer(Viewer): +class UnixViewer(abc.ABC, Viewer): format = "PNG" options = {"compress_level": 1, "save_all": True} diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index bf29fdba5..e6a9d8eea 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -28,7 +28,7 @@ from __future__ import annotations import tkinter from io import BytesIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from . import Image, ImageFile @@ -263,28 +263,3 @@ def getimage(photo: PhotoImage) -> Image.Image: _pyimagingtkcall("PyImagingPhotoGet", photo, im.getim()) return im - - -def _show(image: Image.Image, title: str | None) -> None: - """Helper for the Image.show method.""" - - class UI(tkinter.Label): - def __init__(self, master: tkinter.Toplevel, im: Image.Image) -> None: - self.image: BitmapImage | PhotoImage - if im.mode == "1": - self.image = BitmapImage(im, foreground="white", master=master) - else: - self.image = PhotoImage(im, master=master) - if TYPE_CHECKING: - image = cast(tkinter._Image, self.image) - else: - image = self.image - super().__init__(master, image=image, bg="black", bd=0) - - if not getattr(tkinter, "_default_root"): - msg = "tkinter not initialized" - raise OSError(msg) - top = tkinter.Toplevel() - if title: - top.title(title) - UI(top, image).pack() diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py index a3d8f441a..fb144ff38 100644 --- a/src/PIL/ImageTransform.py +++ b/src/PIL/ImageTransform.py @@ -48,9 +48,9 @@ class AffineTransform(Transform): Define an affine image transform. This function takes a 6-tuple (a, b, c, d, e, f) which contain the first - two rows from an affine transform matrix. For each pixel (x, y) in the - output image, the new value is taken from a position (a x + b y + c, - d x + e y + f) in the input image, rounded to nearest pixel. + two rows from the inverse of an affine transform matrix. For each pixel + (x, y) in the output image, the new value is taken from a position (a x + + b y + c, d x + e y + f) in the input image, rounded to nearest pixel. This function can be used to scale, translate, rotate, and shear the original image. @@ -58,7 +58,7 @@ class AffineTransform(Transform): See :py:meth:`.Image.transform` :param matrix: A 6-tuple (a, b, c, d, e, f) containing the first two rows - from an affine transform matrix. + from the inverse of an affine transform matrix. """ method = Image.Transform.AFFINE diff --git a/src/PIL/ImtImagePlugin.py b/src/PIL/ImtImagePlugin.py index 594c56513..c4eccee34 100644 --- a/src/PIL/ImtImagePlugin.py +++ b/src/PIL/ImtImagePlugin.py @@ -55,14 +55,14 @@ class ImtImageFile(ImageFile.ImageFile): if not s: break - if s == b"\x0C": + if s == b"\x0c": # image data begins self.tile = [ ImageFile._Tile( "raw", (0, 0) + self.size, self.fp.tell() - len(buffer), - (self.mode, 0, 1), + self.mode, ) ] diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index b6ebd562b..e0f4ecae5 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -252,6 +252,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): if sig == b"\xff\x4f\xff\x51": self.codec = "j2k" self._size, self._mode = _parse_codestream(self.fp) + self._parse_comment() else: sig = sig + self.fp.read(8) @@ -262,6 +263,9 @@ class Jpeg2KImageFile(ImageFile.ImageFile): if dpi is not None: self.info["dpi"] = dpi if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"): + hdr = self.fp.read(2) + length = _binary.i16be(hdr) + self.fp.seek(length - 2, os.SEEK_CUR) self._parse_comment() else: msg = "not a JPEG 2000 file" @@ -296,10 +300,6 @@ class Jpeg2KImageFile(ImageFile.ImageFile): ] def _parse_comment(self) -> None: - hdr = self.fp.read(2) - length = _binary.i16be(hdr) - self.fp.seek(length - 2, os.SEEK_CUR) - while True: marker = self.fp.read(2) if not marker: @@ -352,9 +352,8 @@ class Jpeg2KImageFile(ImageFile.ImageFile): def _accept(prefix: bytes) -> bool: - return ( - prefix[:4] == b"\xff\x4f\xff\x51" - or prefix[:12] == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" + return prefix.startswith( + (b"\xff\x4f\xff\x51", b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a") ) diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 6510e072e..9465d8e2d 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -72,12 +72,12 @@ def APP(self: JpegImageFile, marker: int) -> None: n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) - app = "APP%d" % (marker & 15) + app = f"APP{marker & 15}" self.app[app] = s # compatibility self.applist.append((app, s)) - if marker == 0xFFE0 and s[:4] == b"JFIF": + if marker == 0xFFE0 and s.startswith(b"JFIF"): # extract JFIF information self.info["jfif"] = version = i16(s, 5) # version self.info["jfif_version"] = divmod(version, 256) @@ -90,21 +90,24 @@ def APP(self: JpegImageFile, marker: int) -> None: else: if jfif_unit == 1: self.info["dpi"] = jfif_density + elif jfif_unit == 2: # cm + # 1 dpcm = 2.54 dpi + self.info["dpi"] = tuple(d * 2.54 for d in jfif_density) self.info["jfif_unit"] = jfif_unit self.info["jfif_density"] = jfif_density - elif marker == 0xFFE1 and s[:6] == b"Exif\0\0": + elif marker == 0xFFE1 and s.startswith(b"Exif\0\0"): # extract EXIF information if "exif" in self.info: self.info["exif"] += s[6:] else: self.info["exif"] = s self._exif_offset = self.fp.tell() - n + 6 - elif marker == 0xFFE1 and s[:29] == b"http://ns.adobe.com/xap/1.0/\x00": + elif marker == 0xFFE1 and s.startswith(b"http://ns.adobe.com/xap/1.0/\x00"): self.info["xmp"] = s.split(b"\x00", 1)[1] - elif marker == 0xFFE2 and s[:5] == b"FPXR\0": + elif marker == 0xFFE2 and s.startswith(b"FPXR\0"): # extract FlashPix information (incomplete) self.info["flashpix"] = s # FIXME: value will change - elif marker == 0xFFE2 and s[:12] == b"ICC_PROFILE\0": + elif marker == 0xFFE2 and s.startswith(b"ICC_PROFILE\0"): # Since an ICC profile can be larger than the maximum size of # a JPEG marker (64K), we need provisions to split it into # multiple markers. The format defined by the ICC specifies @@ -117,7 +120,7 @@ def APP(self: JpegImageFile, marker: int) -> None: # reassemble the profile, rather than assuming that the APP2 # markers appear in the correct sequence. self.icclist.append(s) - elif marker == 0xFFED and s[:14] == b"Photoshop 3.0\x00": + elif marker == 0xFFED and s.startswith(b"Photoshop 3.0\x00"): # parse the image resource block offset = 14 photoshop = self.info.setdefault("photoshop", {}) @@ -150,7 +153,7 @@ def APP(self: JpegImageFile, marker: int) -> None: except struct.error: break # insufficient data - elif marker == 0xFFEE and s[:5] == b"Adobe": + elif marker == 0xFFEE and s.startswith(b"Adobe"): self.info["adobe"] = i16(s, 5) # extract Adobe custom properties try: @@ -159,7 +162,7 @@ def APP(self: JpegImageFile, marker: int) -> None: pass else: self.info["adobe_transform"] = adobe_transform - elif marker == 0xFFE2 and s[:4] == b"MPF\0": + elif marker == 0xFFE2 and s.startswith(b"MPF\0"): # extract MPO information self.info["mp"] = s[4:] # offset is current location minus buffer size @@ -322,7 +325,7 @@ MARKER = { def _accept(prefix: bytes) -> bool: # Magic number was taken from https://en.wikipedia.org/wiki/JPEG - return prefix[:3] == b"\xFF\xD8\xFF" + return prefix.startswith(b"\xff\xd8\xff") ## @@ -339,7 +342,7 @@ class JpegImageFile(ImageFile.ImageFile): if not _accept(s): msg = "not a JPEG file" raise SyntaxError(msg) - s = b"\xFF" + s = b"\xff" # Create attributes self.bits = self.layers = 0 @@ -395,6 +398,13 @@ class JpegImageFile(ImageFile.ImageFile): return getattr(self, "_" + name) raise AttributeError(name) + def __getstate__(self) -> list[Any]: + return super().__getstate__() + [self.layers, self.layer] + + def __setstate__(self, state: list[Any]) -> None: + super().__setstate__(state) + self.layers, self.layer = state[5:] + def load_read(self, read_bytes: int) -> bytes: """ internal: read more image data @@ -407,7 +417,7 @@ class JpegImageFile(ImageFile.ImageFile): # Premature EOF. # Pretend file is finished adding EOI marker self._ended = True - return b"\xFF\xD9" + return b"\xff\xd9" return s @@ -537,7 +547,7 @@ def _getmp(self: JpegImageFile) -> dict[int, Any] | None: return None file_contents = io.BytesIO(data) head = file_contents.read(8) - endianness = ">" if head[:4] == b"\x4d\x4d\x00\x2a" else "<" + endianness = ">" if head.startswith(b"\x4d\x4d\x00\x2a") else "<" # process dictionary from . import TiffImagePlugin @@ -559,7 +569,7 @@ def _getmp(self: JpegImageFile) -> dict[int, Any] | None: mpentries = [] try: rawmpentries = mp[0xB002] - for entrynum in range(0, quant): + for entrynum in range(quant): unpackedentry = struct.unpack_from( f"{endianness}LLLHH", rawmpentries, entrynum * 16 ) @@ -702,7 +712,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def validate_qtables( qtables: ( str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None - ) + ), ) -> list[list[int]] | None: if qtables is None: return qtables @@ -751,7 +761,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: extra = info.get("extra", b"") MAX_BYTES_IN_MARKER = 65533 - xmp = info.get("xmp", im.info.get("xmp")) + xmp = 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 @@ -759,7 +769,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: 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 + extra += b"\xff\xe1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp icc_profile = info.get("icc_profile") if icc_profile: @@ -773,7 +783,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: for marker in markers: size = o16(2 + overhead_len + len(marker)) extra += ( - b"\xFF\xE2" + b"\xff\xe2" + size + b"ICC_PROFILE\0" + o8(i) @@ -806,8 +816,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: optimize, info.get("keep_rgb", False), info.get("streamtype", 0), - dpi[0], - dpi[1], + dpi, subsampling, info.get("restart_marker_blocks", 0), info.get("restart_marker_rows", 0), diff --git a/src/PIL/McIdasImagePlugin.py b/src/PIL/McIdasImagePlugin.py index 5dd031be3..b4460a9a5 100644 --- a/src/PIL/McIdasImagePlugin.py +++ b/src/PIL/McIdasImagePlugin.py @@ -23,7 +23,7 @@ from . import Image, ImageFile def _accept(prefix: bytes) -> bool: - return prefix[:8] == b"\x00\x00\x00\x00\x00\x00\x00\x04" + return prefix.startswith(b"\x00\x00\x00\x00\x00\x00\x00\x04") ## diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index 5f23a34b9..9ce38c427 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -26,7 +26,7 @@ from . import Image, TiffImagePlugin def _accept(prefix: bytes) -> bool: - return prefix[:8] == olefile.MAGIC + return prefix.startswith(olefile.MAGIC) ## @@ -54,7 +54,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self.images = [ path for path in self.ole.listdir() - if path[1:] and path[0][-4:] == ".ACI" and path[1] == "Image" + if path[1:] and path[0].endswith(".ACI") and path[1] == "Image" ] # if we didn't find any images, this is probably not @@ -73,12 +73,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): def seek(self, frame: int) -> None: if not self._seek_check(frame): return - try: - filename = self.images[frame] - except IndexError as e: - msg = "no such frame" - raise EOFError(msg) from e - + filename = self.images[frame] self.fp = self.ole.openstream(filename) TiffImagePlugin.TiffImageFile._open(self) diff --git a/src/PIL/MpegImagePlugin.py b/src/PIL/MpegImagePlugin.py index ad4d3e937..5aa00d05b 100644 --- a/src/PIL/MpegImagePlugin.py +++ b/src/PIL/MpegImagePlugin.py @@ -54,7 +54,7 @@ class BitStream: def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"\x00\x00\x01\xb3" + return prefix.startswith(b"\x00\x00\x01\xb3") ## diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 71f89a09a..f7393eac0 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -32,6 +32,7 @@ from . import ( TiffImagePlugin, ) from ._binary import o32le +from ._util import DeferredError def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: @@ -51,7 +52,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if not offsets: # APP2 marker im_frame.encoderinfo["extra"] = ( - b"\xFF\xE2" + struct.pack(">H", 6 + 82) + b"MPF\0" + b" " * 82 + b"\xff\xe2" + struct.pack(">H", 6 + 82) + b"MPF\0" + b" " * 82 ) exif = im_frame.encoderinfo.get("exif") if isinstance(exif, Image.Exif): @@ -84,7 +85,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ifd[0xB002] = mpentries fp.seek(mpf_offset) - fp.write(b"II\x2A\x00" + o32le(8) + ifd.tobytes(8)) + fp.write(b"II\x2a\x00" + o32le(8) + ifd.tobytes(8)) fp.seek(0, os.SEEK_END) @@ -125,11 +126,15 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): self.readonly = 1 def load_seek(self, pos: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex self._fp.seek(pos) def seek(self, frame: int) -> None: if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.fp = self._fp self.offset = self.__mpoffsets[frame] diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index f3460a787..277087a86 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -37,7 +37,7 @@ from ._binary import o16le as o16 def _accept(prefix: bytes) -> bool: - return prefix[:4] in [b"DanM", b"LinS"] + return prefix.startswith((b"DanM", b"LinS")) ## @@ -69,10 +69,10 @@ class MspImageFile(ImageFile.ImageFile): self._mode = "1" self._size = i16(s, 4), i16(s, 6) - if s[:4] == b"DanM": - self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, ("1", 0, 1))] + if s.startswith(b"DanM"): + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, "1")] else: - self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32, None)] + self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32)] class MspDecoder(ImageFile.PyDecoder): @@ -188,7 +188,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(o16(h)) # image body - ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 32, ("1", 0, 1))]) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 32, "1")]) # diff --git a/src/PIL/PaletteFile.py b/src/PIL/PaletteFile.py index 81652e5ee..2a26e5d4e 100644 --- a/src/PIL/PaletteFile.py +++ b/src/PIL/PaletteFile.py @@ -32,7 +32,7 @@ class PaletteFile: if not s: break - if s[:1] == b"#": + if s.startswith(b"#"): continue if len(s) > 100: msg = "bad palette file" diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index b33245376..15f712908 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -116,9 +116,6 @@ _COMPRESSION_TYPES = {"none": 0xFF, "rle": 0x01, "scanline": 0x00} def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "P": - # we assume this is a color Palm image with the standard colormap, - # unless the "info" dict has a "custom-colormap" field - rawmode = "P" bpp = 8 version = 1 @@ -172,12 +169,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: compression_type = _COMPRESSION_TYPES["none"] flags = 0 - if im.mode == "P" and "custom-colormap" in im.info: - assert im.palette is not None - flags = flags & _FLAGS["custom-colormap"] - colormapsize = 4 * 256 + 2 - colormapmode = im.palette.mode - colormap = im.getdata().getpalette() + if im.mode == "P": + flags |= _FLAGS["custom-colormap"] + colormap = im.im.getpalette() + colors = len(colormap) // 3 + colormapsize = 4 * colors + 2 else: colormapsize = 0 @@ -196,22 +192,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # now write colormap if necessary - if colormapsize > 0: - fp.write(o16b(256)) - for i in range(256): + if colormapsize: + fp.write(o16b(colors)) + for i in range(colors): fp.write(o8(i)) - if colormapmode == "RGB": - fp.write( - o8(colormap[3 * i]) - + o8(colormap[3 * i + 1]) - + o8(colormap[3 * i + 2]) - ) - elif colormapmode == "RGBA": - fp.write( - o8(colormap[4 * i]) - + o8(colormap[4 * i + 1]) - + o8(colormap[4 * i + 2]) - ) + fp.write(colormap[3 * i : 3 * i + 3]) # now convert data to raw form ImageFile._save( diff --git a/src/PIL/PcdImagePlugin.py b/src/PIL/PcdImagePlugin.py index e8ea800a4..3aa249988 100644 --- a/src/PIL/PcdImagePlugin.py +++ b/src/PIL/PcdImagePlugin.py @@ -34,7 +34,7 @@ class PcdImageFile(ImageFile.ImageFile): self.fp.seek(2048) s = self.fp.read(2048) - if s[:4] != b"PCD_": + if not s.startswith(b"PCD_"): msg = "not a PCD file" raise SyntaxError(msg) @@ -47,7 +47,7 @@ class PcdImageFile(ImageFile.ImageFile): self._mode = "RGB" self._size = 768, 512 # FIXME: not correct for rotated images! - self.tile = [ImageFile._Tile("pcd", (0, 0) + self.size, 96 * 2048, None)] + self.tile = [ImageFile._Tile("pcd", (0, 0) + self.size, 96 * 2048)] def load_end(self) -> None: if self.tile_post_rotate: diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 8445d5cc7..299405ae0 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -86,7 +86,7 @@ class PcxImageFile(ImageFile.ImageFile): elif bits == 1 and planes in (2, 4): mode = "P" - rawmode = "P;%dL" % planes + rawmode = f"P;{planes}L" self.palette = ImagePalette.raw("RGB", s[16:64]) elif version == 5 and bits == 8 and planes == 1: @@ -188,7 +188,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + o16(dpi[0]) + o16(dpi[1]) + b"\0" * 24 - + b"\xFF" * 24 + + b"\xff" * 24 + b"\0" + o8(planes) + o16(stride) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 7cb2d241b..41b38ebbf 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -19,14 +19,14 @@ def encode_text(s: str) -> bytes: PDFDocEncoding = { 0x16: "\u0017", - 0x18: "\u02D8", - 0x19: "\u02C7", - 0x1A: "\u02C6", - 0x1B: "\u02D9", - 0x1C: "\u02DD", - 0x1D: "\u02DB", - 0x1E: "\u02DA", - 0x1F: "\u02DC", + 0x18: "\u02d8", + 0x19: "\u02c7", + 0x1A: "\u02c6", + 0x1B: "\u02d9", + 0x1C: "\u02dd", + 0x1D: "\u02db", + 0x1E: "\u02da", + 0x1F: "\u02dc", 0x80: "\u2022", 0x81: "\u2020", 0x82: "\u2021", @@ -36,29 +36,29 @@ PDFDocEncoding = { 0x86: "\u0192", 0x87: "\u2044", 0x88: "\u2039", - 0x89: "\u203A", + 0x89: "\u203a", 0x8A: "\u2212", 0x8B: "\u2030", - 0x8C: "\u201E", - 0x8D: "\u201C", - 0x8E: "\u201D", + 0x8C: "\u201e", + 0x8D: "\u201c", + 0x8E: "\u201d", 0x8F: "\u2018", 0x90: "\u2019", - 0x91: "\u201A", + 0x91: "\u201a", 0x92: "\u2122", - 0x93: "\uFB01", - 0x94: "\uFB02", + 0x93: "\ufb01", + 0x94: "\ufb02", 0x95: "\u0141", 0x96: "\u0152", 0x97: "\u0160", 0x98: "\u0178", - 0x99: "\u017D", + 0x99: "\u017d", 0x9A: "\u0131", 0x9B: "\u0142", 0x9C: "\u0153", 0x9D: "\u0161", - 0x9E: "\u017E", - 0xA0: "\u20AC", + 0x9E: "\u017e", + 0xA0: "\u20ac", } diff --git a/src/PIL/PixarImagePlugin.py b/src/PIL/PixarImagePlugin.py index 36f565f1c..d2b6d0a97 100644 --- a/src/PIL/PixarImagePlugin.py +++ b/src/PIL/PixarImagePlugin.py @@ -28,7 +28,7 @@ from ._binary import i16le as i16 def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"\200\350\000\000" + return prefix.startswith(b"\200\350\000\000") ## @@ -61,9 +61,7 @@ class PixarImageFile(ImageFile.ImageFile): # FIXME: to be continued... # create tile descriptor (assuming "dumped") - self.tile = [ - ImageFile._Tile("raw", (0, 0) + self.size, 1024, (self.mode, 0, 1)) - ] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1024, self.mode)] # diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 4e1227204..3e3cf6526 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -48,6 +48,7 @@ from ._binary import i32be as i32 from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 +from ._util import DeferredError if TYPE_CHECKING: from . import _imaging @@ -523,7 +524,7 @@ class PngStream(ChunkStream): assert self.fp is not None s = ImageFile._safe_read(self.fp, length) - raw_vals = struct.unpack(">%dI" % (len(s) // 4), s) + raw_vals = struct.unpack(f">{len(s) // 4}I", s) self.im_info["chromaticity"] = tuple(elt / 100000.0 for elt in raw_vals) return s @@ -740,7 +741,7 @@ class PngStream(ChunkStream): def _accept(prefix: bytes) -> bool: - return prefix[:8] == _MAGIC + return prefix.startswith(_MAGIC) ## @@ -869,6 +870,8 @@ class PngImageFile(ImageFile.ImageFile): def _seek(self, frame: int, rewind: bool = False) -> None: assert self.png is not None + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.dispose: _imaging.ImagingCore | None dispose_extent = None @@ -1382,7 +1385,7 @@ def _save( b"\0", # 12: interlace flag ) - chunks = [b"cHRM", b"gAMA", b"sBIT", b"sRGB", b"tIME"] + chunks = [b"cHRM", b"cICP", b"gAMA", b"sBIT", b"sRGB", b"tIME"] icc = im.encoderinfo.get("icc_profile", im.info.get("icc_profile")) if icc: @@ -1433,7 +1436,7 @@ def _save( chunk(fp, b"tRNS", transparency[:alpha_bytes]) else: transparency = max(0, min(255, transparency)) - alpha = b"\xFF" * transparency + b"\0" + alpha = b"\xff" * transparency + b"\0" chunk(fp, b"tRNS", alpha[:alpha_bytes]) elif im.mode in ("1", "L", "I", "I;16"): transparency = max(0, min(65535, transparency)) diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 4e779df17..03afa2d2e 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -47,7 +47,7 @@ MODES = { def _accept(prefix: bytes) -> bool: - return prefix[0:1] == b"P" and prefix[1] in b"0123456fy" + return prefix.startswith(b"P") and prefix[1] in b"0123456fy" ## @@ -230,7 +230,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder): msg = b"Invalid token for this mode: %s" % bytes([token]) raise ValueError(msg) data = (data + tokens)[:total_bytes] - invert = bytes.maketrans(b"01", b"\xFF\x00") + invert = bytes.maketrans(b"01", b"\xff\x00") return data.translate(invert) def _decode_blocks(self, maxval: int) -> bytearray: diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 8ff5e3908..f49aaeeb1 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -27,6 +27,7 @@ from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import si16be as si16 from ._binary import si32be as si32 +from ._util import DeferredError MODES = { # (photoshop mode, bits) -> (pil mode, required channels) @@ -47,7 +48,7 @@ MODES = { def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"8BPS" + return prefix.startswith(b"8BPS") ## @@ -148,6 +149,8 @@ class PsdImageFile(ImageFile.ImageFile): ) -> list[tuple[str, str, tuple[int, int, int, int], list[ImageFile._Tile]]]: layers = [] if self._layers_position is not None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex self._fp.seek(self._layers_position) _layer_data = io.BytesIO(ImageFile._safe_read(self._fp, self._layers_size)) layers = _layerinfo(_layer_data, self._layers_size) @@ -167,17 +170,15 @@ class PsdImageFile(ImageFile.ImageFile): def seek(self, layer: int) -> None: if not self._seek_check(layer): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex # seek to given layer (1..max) - try: - _, mode, _, tile = self.layers[layer - 1] - self._mode = mode - self.tile = tile - self.frame = layer - self.fp = self._fp - except IndexError as e: - msg = "no such layer" - raise EOFError(msg) from e + _, mode, _, tile = self.layers[layer - 1] + self._mode = mode + self.tile = tile + self.frame = layer + self.fp = self._fp def tell(self) -> int: # return layer number (0=image, 1..max=layers) diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index 010d3f941..df552243e 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -14,7 +14,7 @@ from ._binary import i32be as i32 def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"qoif" + return prefix.startswith(b"qoif") class QoiImageFile(ImageFile.ImageFile): @@ -32,7 +32,7 @@ class QoiImageFile(ImageFile.ImageFile): self._mode = "RGB" if channels == 3 else "RGBA" self.fp.seek(1, os.SEEK_CUR) # colorspace - self.tile = [ImageFile._Tile("qoi", (0, 0) + self._size, self.fp.tell(), None)] + self.tile = [ImageFile._Tile("qoi", (0, 0) + self._size, self.fp.tell())] class QoiDecoder(ImageFile.PyDecoder): diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 075073f9f..62fa7be03 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -40,6 +40,7 @@ import sys from typing import IO, TYPE_CHECKING, Any, cast from . import Image, ImageFile +from ._util import DeferredError def isInt(f: Any) -> int: @@ -154,9 +155,7 @@ class SpiderImageFile(ImageFile.ImageFile): self.rawmode = "F;32F" self._mode = "F" - self.tile = [ - ImageFile._Tile("raw", (0, 0) + self.size, offset, (self.rawmode, 0, 1)) - ] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, offset, self.rawmode)] self._fp = self.fp # FIXME: hack @property @@ -180,6 +179,8 @@ class SpiderImageFile(ImageFile.ImageFile): raise EOFError(msg) if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.stkoffset = self.hdrlen + frame * (self.hdrlen + self.imgbytes) self.fp = self._fp self.fp.seek(self.stkoffset) @@ -211,26 +212,27 @@ class SpiderImageFile(ImageFile.ImageFile): # given a list of filenames, return a list of images -def loadImageSeries(filelist: list[str] | None = None) -> list[SpiderImageFile] | None: +def loadImageSeries(filelist: list[str] | None = None) -> list[Image.Image] | None: """create a list of :py:class:`~PIL.Image.Image` objects for use in a montage""" if filelist is None or len(filelist) < 1: return None - imglist = [] + byte_imgs = [] for img in filelist: if not os.path.exists(img): print(f"unable to find {img}") continue try: with Image.open(img) as im: - im = im.convert2byte() + assert isinstance(im, SpiderImageFile) + byte_im = im.convert2byte() except Exception: if not isSpiderImage(img): print(f"{img} is not a Spider image file") continue - im.info["filename"] = img - imglist.append(im) - return imglist + byte_im.info["filename"] = img + byte_imgs.append(byte_im) + return byte_imgs # -------------------------------------------------------------------- @@ -268,7 +270,7 @@ def makeSpiderHeader(im: Image.Image) -> list[bytes]: def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - if im.mode[0] != "F": + if im.mode != "F": im = im.convert("F") hdr = makeSpiderHeader(im) @@ -280,9 +282,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.writelines(hdr) rawmode = "F;32NF" # 32-bit native floating point - ImageFile._save( - im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))] - ) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)]) def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 6bf39b75a..ebe599cca 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -58,7 +58,7 @@ from ._binary import i32be as i32 from ._binary import o8 from ._deprecate import deprecate from ._typing import StrOrBytesPath -from ._util import is_path +from ._util import DeferredError, is_path from .TiffTags import TYPES if TYPE_CHECKING: @@ -275,12 +275,12 @@ OPEN_INFO = { MAX_SAMPLESPERPIXEL = max(len(key_tp[4]) for key_tp in OPEN_INFO) PREFIXES = [ - b"MM\x00\x2A", # Valid TIFF header with big-endian byte order - b"II\x2A\x00", # Valid TIFF header with little-endian byte order - b"MM\x2A\x00", # Invalid TIFF header, assume big-endian - b"II\x00\x2A", # Invalid TIFF header, assume little-endian - b"MM\x00\x2B", # BigTIFF with big-endian byte order - b"II\x2B\x00", # BigTIFF with little-endian byte order + b"MM\x00\x2a", # Valid TIFF header with big-endian byte order + b"II\x2a\x00", # Valid TIFF header with little-endian byte order + b"MM\x2a\x00", # Invalid TIFF header, assume big-endian + b"II\x00\x2a", # Invalid TIFF header, assume little-endian + b"MM\x00\x2b", # BigTIFF with big-endian byte order + b"II\x2b\x00", # BigTIFF with little-endian byte order ] if not getattr(Image.core, "libtiff_support_custom_tags", True): @@ -288,7 +288,7 @@ if not getattr(Image.core, "libtiff_support_custom_tags", True): def _accept(prefix: bytes) -> bool: - return prefix[:4] in PREFIXES + return prefix.startswith(tuple(PREFIXES)) def _limit_rational( @@ -404,7 +404,7 @@ class IFDRational(Rational): def __repr__(self) -> str: return str(float(self._val)) - def __hash__(self) -> int: + def __hash__(self) -> int: # type: ignore[override] return self._val.__hash__() def __eq__(self, other: object) -> bool: @@ -582,7 +582,7 @@ class ImageFileDirectory_v2(_IFDv2Base): def __init__( self, - ifh: bytes = b"II\052\0\0\0\0\0", + ifh: bytes = b"II\x2a\x00\x00\x00\x00\x00", prefix: bytes | None = None, group: int | None = None, ) -> None: @@ -935,9 +935,9 @@ class ImageFileDirectory_v2(_IFDv2Base): self._tagdata[tag] = data self.tagtype[tag] = typ - msg += " - value: " + ( - "" % size if size > 32 else repr(data) - ) + msg += " - value: " + msg += f"" if size > 32 else repr(data) + logger.debug(msg) (self.next,) = ( @@ -949,12 +949,25 @@ class ImageFileDirectory_v2(_IFDv2Base): warnings.warn(str(msg)) return + def _get_ifh(self) -> bytes: + ifh = self._prefix + self._pack("H", 43 if self._bigtiff else 42) + if self._bigtiff: + ifh += self._pack("HH", 8, 0) + ifh += self._pack("Q", 16) if self._bigtiff else self._pack("L", 8) + + return ifh + def tobytes(self, offset: int = 0) -> bytes: # FIXME What about tagdata? - result = self._pack("H", len(self._tags_v2)) + result = self._pack("Q" if self._bigtiff else "H", len(self._tags_v2)) entries: list[tuple[int, int, int, bytes, bytes]] = [] - offset = offset + len(result) + len(self._tags_v2) * 12 + 4 + + fmt = "Q" if self._bigtiff else "L" + fmt_size = 8 if self._bigtiff else 4 + offset += ( + len(result) + len(self._tags_v2) * (20 if self._bigtiff else 12) + fmt_size + ) stripoffsets = None # pass 1: convert tags to binary format @@ -966,11 +979,7 @@ class ImageFileDirectory_v2(_IFDv2Base): logger.debug("Tag %s, Type: %s, Value: %s", tag, typ, repr(value)) is_ifd = typ == TiffTags.LONG and isinstance(value, dict) if is_ifd: - if self._endian == "<": - ifh = b"II\x2A\x00\x08\x00\x00\x00" - else: - ifh = b"MM\x00\x2A\x00\x00\x00\x08" - ifd = ImageFileDirectory_v2(ifh, group=tag) + ifd = ImageFileDirectory_v2(self._get_ifh(), group=tag) values = self._tags_v2[tag] for ifd_tag, ifd_value in values.items(): ifd[ifd_tag] = ifd_value @@ -981,10 +990,8 @@ class ImageFileDirectory_v2(_IFDv2Base): tagname = TiffTags.lookup(tag, self.group).name typname = "ifd" if is_ifd else TYPES.get(typ, "unknown") - msg = f"save: {tagname} ({tag}) - type: {typname} ({typ})" - msg += " - value: " + ( - "" % len(data) if len(data) >= 16 else str(values) - ) + msg = f"save: {tagname} ({tag}) - type: {typname} ({typ}) - value: " + msg += f"" if len(data) >= 16 else str(values) logger.debug(msg) # count is sum of lengths for string and arbitrary data @@ -995,10 +1002,10 @@ class ImageFileDirectory_v2(_IFDv2Base): else: count = len(values) # figure out if data fits into the entry - if len(data) <= 4: - entries.append((tag, typ, count, data.ljust(4, b"\0"), b"")) + if len(data) <= fmt_size: + entries.append((tag, typ, count, data.ljust(fmt_size, b"\0"), b"")) else: - entries.append((tag, typ, count, self._pack("L", offset), data)) + entries.append((tag, typ, count, self._pack(fmt, offset), data)) offset += (len(data) + 1) // 2 * 2 # pad to word # update strip offset data to point beyond auxiliary data @@ -1009,16 +1016,18 @@ class ImageFileDirectory_v2(_IFDv2Base): values = [val + offset for val in handler(self, data, self.legacy_api)] data = self._write_dispatch[typ](self, *values) else: - value = self._pack("L", self._unpack("L", value)[0] + offset) + value = self._pack(fmt, self._unpack(fmt, value)[0] + offset) entries[stripoffsets] = tag, typ, count, value, data # pass 2: write entries to file for tag, typ, count, value, data in entries: logger.debug("%s %s %s %s %s", tag, typ, count, repr(value), repr(data)) - result += self._pack("HHL4s", tag, typ, count, value) + result += self._pack( + "HHQ8s" if self._bigtiff else "HHL4s", tag, typ, count, value + ) # -- overwrite here for multi-page -- - result += b"\0\0\0\0" # end of entries + result += self._pack(fmt, 0) # end of entries # pass 3: write auxiliary data to file for tag, typ, count, value, data in entries: @@ -1030,8 +1039,7 @@ class ImageFileDirectory_v2(_IFDv2Base): def save(self, fp: IO[bytes]) -> int: if fp.tell() == 0: # skip TIFF header on subsequent pages - # tiff header -- PIL always starts the first IFD at offset 8 - fp.write(self._prefix + self._pack("HL", 42, 8)) + fp.write(self._get_ifh()) offset = fp.tell() result = self.tobytes(offset) @@ -1214,12 +1222,10 @@ class TiffImageFile(ImageFile.ImageFile): self._im = None def _seek(self, frame: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.fp = self._fp - # reset buffered io handle in case fp - # was passed to libtiff, invalidating the buffer - self.fp.tell() - while len(self._frame_pos) <= frame: if not self.__next: msg = "no more images in TIFF file" @@ -1276,7 +1282,7 @@ class TiffImageFile(ImageFile.ImageFile): blocks = {} val = self.tag_v2.get(ExifTags.Base.ImageResources) if val: - while val[:4] == b"8BIM": + while val.startswith(b"8BIM"): id = i16(val[4:6]) n = math.ceil((val[6] + 1) / 2) * 2 size = i32(val[6 + n : 10 + n]) @@ -1303,10 +1309,6 @@ class TiffImageFile(ImageFile.ImageFile): if not self.is_animated: self._close_exclusive_fp_after_loading = True - # reset buffered io handle in case fp - # was passed to libtiff, invalidating the buffer - self.fp.tell() - # load IFD data from fp before it is closed exif = self.getexif() for key in TiffTags.TAGS_V2_GROUPS: @@ -1381,8 +1383,17 @@ class TiffImageFile(ImageFile.ImageFile): logger.debug("have fileno, calling fileno version of the decoder.") if not close_self_fp: self.fp.seek(0) + # Save and restore the file position, because libtiff will move it + # outside of the Python runtime, and that will confuse + # io.BufferedReader and possible others. + # NOTE: This must use os.lseek(), and not fp.tell()/fp.seek(), + # because the buffer read head already may not equal the actual + # file position, and fp.seek() may just adjust it's internal + # pointer and not actually seek the OS file handle. + pos = os.lseek(fp, 0, os.SEEK_CUR) # 4 bytes, otherwise the trace might error out n, err = decoder.decode(b"fpfp") + os.lseek(fp, pos, os.SEEK_SET) else: # we have something else. logger.debug("don't have fileno or getvalue. just reading") @@ -1400,7 +1411,8 @@ class TiffImageFile(ImageFile.ImageFile): self.fp = None # might be shared if err < 0: - raise OSError(err) + msg = f"decoder error {err}" + raise OSError(msg) return Image.Image.load(self) @@ -1433,8 +1445,12 @@ class TiffImageFile(ImageFile.ImageFile): logger.debug("- YCbCr subsampling: %s", self.tag_v2.get(YCBCRSUBSAMPLING)) # size - xsize = self.tag_v2.get(IMAGEWIDTH) - ysize = self.tag_v2.get(IMAGELENGTH) + try: + xsize = self.tag_v2[IMAGEWIDTH] + ysize = self.tag_v2[IMAGELENGTH] + except KeyError as e: + msg = "Missing dimensions" + raise TypeError(msg) from e if not isinstance(xsize, int) or not isinstance(ysize, int): msg = "Invalid dimensions" raise ValueError(msg) @@ -1556,17 +1572,6 @@ class TiffImageFile(ImageFile.ImageFile): # fillorder==2 modes have a corresponding # fillorder=1 mode self._mode, rawmode = OPEN_INFO[key] - # libtiff always returns the bytes in native order. - # we're expecting image byte order. So, if the rawmode - # contains I;16, we need to convert from native to image - # byte order. - if rawmode == "I;16": - rawmode = "I;16N" - if ";16B" in rawmode: - rawmode = rawmode.replace(";16B", ";16N") - if ";16L" in rawmode: - rawmode = rawmode.replace(";16L", ";16N") - # YCbCr images with new jpeg compression with pixels in one plane # unpacked straight into RGB values if ( @@ -1575,6 +1580,14 @@ class TiffImageFile(ImageFile.ImageFile): and self._planar_configuration == 1 ): rawmode = "RGB" + # libtiff always returns the bytes in native order. + # we're expecting image byte order. So, if the rawmode + # contains I;16, we need to convert from native to image + # byte order. + elif rawmode == "I;16": + rawmode = "I;16N" + elif rawmode.endswith((";16B", ";16L")): + rawmode = rawmode[:-1] + "N" # Offset in the tile tuple is 0, we go from 0,0 to # w,h, and we only do this once -- eds @@ -1597,6 +1610,10 @@ class TiffImageFile(ImageFile.ImageFile): raise ValueError(msg) w = tilewidth + if w == xsize and h == ysize and self._planar_configuration != 2: + # Every tile covers the image. Only use the last offset + offsets = offsets[-1:] + for offset in offsets: if x + w > xsize: stride = w * sum(bps_tuple) / 8 # bytes per line @@ -1619,11 +1636,11 @@ class TiffImageFile(ImageFile.ImageFile): args, ) ) - x = x + w + x += w if x >= xsize: x, y = 0, y + h if y >= ysize: - x = y = 0 + y = 0 layer += 1 else: logger.debug("- unsupported data organization") @@ -1680,10 +1697,13 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: msg = f"cannot write mode {im.mode} as TIFF" raise OSError(msg) from e - ifd = ImageFileDirectory_v2(prefix=prefix) - encoderinfo = im.encoderinfo encoderconfig = im.encoderconfig + + ifd = ImageFileDirectory_v2(prefix=prefix) + if encoderinfo.get("big_tiff"): + ifd._bigtiff = True + try: compression = encoderinfo["compression"] except KeyError: @@ -1915,7 +1935,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if not getattr(Image.core, "libtiff_support_custom_tags", False): continue - if tag in ifd.tagtype: + if tag in TiffTags.TAGS_V2_GROUPS: + types[tag] = TiffTags.LONG8 + elif tag in ifd.tagtype: types[tag] = ifd.tagtype[tag] elif not (isinstance(value, (int, float, str, bytes))): continue @@ -2031,20 +2053,21 @@ class AppendingTiffWriter(io.BytesIO): self.offsetOfNewPage = 0 self.IIMM = iimm = self.f.read(4) + self._bigtiff = b"\x2b" in iimm if not iimm: # empty file - first page self.isFirst = True return self.isFirst = False - if iimm == b"II\x2a\x00": - self.setEndian("<") - elif iimm == b"MM\x00\x2a": - self.setEndian(">") - else: + if iimm not in PREFIXES: msg = "Invalid TIFF file header" raise RuntimeError(msg) + self.setEndian("<" if iimm.startswith(II) else ">") + + if self._bigtiff: + self.f.seek(4, os.SEEK_CUR) self.skipIFDs() self.goToEnd() @@ -2064,11 +2087,13 @@ class AppendingTiffWriter(io.BytesIO): msg = "IIMM of new page doesn't match IIMM of first page" raise RuntimeError(msg) - ifd_offset = self.readLong() + if self._bigtiff: + self.f.seek(4, os.SEEK_CUR) + ifd_offset = self._read(8 if self._bigtiff else 4) ifd_offset += self.offsetOfNewPage assert self.whereToWriteNewIFDOffset is not None self.f.seek(self.whereToWriteNewIFDOffset) - self.writeLong(ifd_offset) + self._write(ifd_offset, 8 if self._bigtiff else 4) self.f.seek(ifd_offset) self.fixIFD() @@ -2114,18 +2139,20 @@ class AppendingTiffWriter(io.BytesIO): self.endian = endian self.longFmt = f"{self.endian}L" self.shortFmt = f"{self.endian}H" - self.tagFormat = f"{self.endian}HHL" + self.tagFormat = f"{self.endian}HH" + ("Q" if self._bigtiff else "L") def skipIFDs(self) -> None: while True: - ifd_offset = self.readLong() + ifd_offset = self._read(8 if self._bigtiff else 4) if ifd_offset == 0: - self.whereToWriteNewIFDOffset = self.f.tell() - 4 + self.whereToWriteNewIFDOffset = self.f.tell() - ( + 8 if self._bigtiff else 4 + ) break self.f.seek(ifd_offset) - num_tags = self.readShort() - self.f.seek(num_tags * 12, os.SEEK_CUR) + num_tags = self._read(8 if self._bigtiff else 2) + self.f.seek(num_tags * (20 if self._bigtiff else 12), os.SEEK_CUR) def write(self, data: Buffer, /) -> int: return self.f.write(data) @@ -2155,17 +2182,19 @@ class AppendingTiffWriter(io.BytesIO): msg = f"wrote only {bytes_written} bytes but wanted {expected}" raise RuntimeError(msg) - def rewriteLastShortToLong(self, value: int) -> None: - self.f.seek(-2, os.SEEK_CUR) - bytes_written = self.f.write(struct.pack(self.longFmt, value)) - self._verify_bytes_written(bytes_written, 4) - - def _rewriteLast(self, value: int, field_size: int) -> None: + def _rewriteLast( + self, value: int, field_size: int, new_field_size: int = 0 + ) -> None: self.f.seek(-field_size, os.SEEK_CUR) + if not new_field_size: + new_field_size = field_size bytes_written = self.f.write( - struct.pack(self.endian + self._fmt(field_size), value) + struct.pack(self.endian + self._fmt(new_field_size), value) ) - self._verify_bytes_written(bytes_written, field_size) + self._verify_bytes_written(bytes_written, new_field_size) + + def rewriteLastShortToLong(self, value: int) -> None: + self._rewriteLast(value, 2, 4) def rewriteLastShort(self, value: int) -> None: return self._rewriteLast(value, 2) @@ -2173,13 +2202,17 @@ class AppendingTiffWriter(io.BytesIO): def rewriteLastLong(self, value: int) -> None: return self._rewriteLast(value, 4) + def _write(self, value: int, field_size: int) -> None: + bytes_written = self.f.write( + struct.pack(self.endian + self._fmt(field_size), value) + ) + self._verify_bytes_written(bytes_written, field_size) + def writeShort(self, value: int) -> None: - bytes_written = self.f.write(struct.pack(self.shortFmt, value)) - self._verify_bytes_written(bytes_written, 2) + self._write(value, 2) def writeLong(self, value: int) -> None: - bytes_written = self.f.write(struct.pack(self.longFmt, value)) - self._verify_bytes_written(bytes_written, 4) + self._write(value, 4) def close(self) -> None: self.finalize() @@ -2187,24 +2220,37 @@ class AppendingTiffWriter(io.BytesIO): self.f.close() def fixIFD(self) -> None: - num_tags = self.readShort() + num_tags = self._read(8 if self._bigtiff else 2) for i in range(num_tags): - tag, field_type, count = struct.unpack(self.tagFormat, self.f.read(8)) + tag, field_type, count = struct.unpack( + self.tagFormat, self.f.read(12 if self._bigtiff else 8) + ) field_size = self.fieldSizes[field_type] total_size = field_size * count - is_local = total_size <= 4 + fmt_size = 8 if self._bigtiff else 4 + is_local = total_size <= fmt_size if not is_local: - offset = self.readLong() + self.offsetOfNewPage - self.rewriteLastLong(offset) + offset = self._read(fmt_size) + self.offsetOfNewPage + self._rewriteLast(offset, fmt_size) if tag in self.Tags: cur_pos = self.f.tell() + logger.debug( + "fixIFD: %s (%d) - type: %s (%d) - type size: %d - count: %d", + TiffTags.lookup(tag).name, + tag, + TYPES.get(field_type, "unknown"), + field_type, + field_size, + count, + ) + if is_local: self._fixOffsets(count, field_size) - self.f.seek(cur_pos + 4) + self.f.seek(cur_pos + fmt_size) else: self.f.seek(offset) self._fixOffsets(count, field_size) @@ -2212,24 +2258,33 @@ class AppendingTiffWriter(io.BytesIO): elif is_local: # skip the locally stored value that is not an offset - self.f.seek(4, os.SEEK_CUR) + self.f.seek(fmt_size, os.SEEK_CUR) def _fixOffsets(self, count: int, field_size: int) -> None: for i in range(count): offset = self._read(field_size) offset += self.offsetOfNewPage - if field_size == 2 and offset >= 65536: - # offset is now too large - we must convert shorts to longs + + new_field_size = 0 + if self._bigtiff and field_size in (2, 4) and offset >= 2**32: + # offset is now too large - we must convert long to long8 + new_field_size = 8 + elif field_size == 2 and offset >= 2**16: + # offset is now too large - we must convert short to long + new_field_size = 4 + if new_field_size: if count != 1: msg = "not implemented" raise RuntimeError(msg) # XXX TODO # simple case - the offset is just one and therefore it is # local (not referenced with another offset) - self.rewriteLastShortToLong(offset) - self.f.seek(-10, os.SEEK_CUR) - self.writeShort(TiffTags.LONG) # rewrite the type to LONG - self.f.seek(8, os.SEEK_CUR) + self._rewriteLast(offset, field_size, new_field_size) + # Move back past the new offset, past 'count', and before 'field_type' + rewind = -new_field_size - 4 - 2 + self.f.seek(rewind, os.SEEK_CUR) + self.writeShort(new_field_size) # rewrite the type + self.f.seek(2 - rewind, os.SEEK_CUR) else: self._rewriteLast(offset, field_size) @@ -2246,9 +2301,7 @@ class AppendingTiffWriter(io.BytesIO): def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - encoderinfo = im.encoderinfo.copy() - encoderconfig = im.encoderconfig - append_images = list(encoderinfo.get("append_images", [])) + append_images = list(im.encoderinfo.get("append_images", [])) if not hasattr(im, "n_frames") and not append_images: return _save(im, fp, filename) @@ -2256,12 +2309,11 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: with AppendingTiffWriter(fp) as tf: for ims in [im] + append_images: - ims.encoderinfo = encoderinfo - ims.encoderconfig = encoderconfig - if not hasattr(ims, "n_frames"): - nfr = 1 - else: - nfr = ims.n_frames + if not hasattr(ims, "encoderinfo"): + ims.encoderinfo = {} + if not hasattr(ims, "encoderconfig"): + ims.encoderconfig = () + nfr = getattr(ims, "n_frames", 1) for idx in range(nfr): ims.seek(idx) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index c7f855527..1716a18cc 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -21,7 +21,7 @@ _VP8_MODES_BY_IDENTIFIER = { def _accept(prefix: bytes) -> bool | str: - is_riff_file_format = prefix[:4] == b"RIFF" + is_riff_file_format = prefix.startswith(b"RIFF") is_webp_file = prefix[8:12] == b"WEBP" is_valid_vp8_mode = prefix[12:16] in _VP8_MODES_BY_IDENTIFIER @@ -46,8 +46,7 @@ class WebPImageFile(ImageFile.ImageFile): self._decoder = _webp.WebPAnimDecoder(self.fp.read()) # Get info from decoder - width, height, loop_count, bgcolor, frame_count, mode = self._decoder.get_info() - self._size = width, height + self._size, loop_count, bgcolor, frame_count, mode = self._decoder.get_info() self.info["loop"] = loop_count bg_a, bg_r, bg_g, bg_b = ( (bgcolor >> 24) & 0xFF, @@ -223,8 +222,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Setup the WebP animation encoder enc = _webp.WebPAnimEncoder( - im.size[0], - im.size[1], + im.size, background, loop, minimize_size, @@ -240,7 +238,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: cur_idx = im.tell() try: for ims in [im] + append_images: - # Get # of frames in this image + # Get number of frames in this image nfr = getattr(ims, "n_frames", 1) for idx in range(nfr): diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index cad6c98d5..f709d026b 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -68,9 +68,7 @@ if hasattr(Image.core, "drawwmf"): def _accept(prefix: bytes) -> bool: - return ( - prefix[:6] == b"\xd7\xcd\xc6\x9a\x00\x00" or prefix[:4] == b"\x01\x00\x00\x00" - ) + return prefix.startswith((b"\xd7\xcd\xc6\x9a\x00\x00", b"\x01\x00\x00\x00")) ## @@ -82,16 +80,18 @@ class WmfStubImageFile(ImageFile.StubImageFile): format_description = "Windows Metafile" def _open(self) -> None: - self._inch = None - # check placable header s = self.fp.read(80) - if s[:6] == b"\xd7\xcd\xc6\x9a\x00\x00": + if s.startswith(b"\xd7\xcd\xc6\x9a\x00\x00"): # placeable windows metafile # get units per inch - self._inch = word(s, 14) + inch = word(s, 14) + if inch == 0: + msg = "Invalid inch" + raise ValueError(msg) + self._inch: tuple[float, float] = inch, inch # get bounding box x0 = short(s, 6) @@ -102,8 +102,8 @@ class WmfStubImageFile(ImageFile.StubImageFile): # normalize size to 72 dots per inch self.info["dpi"] = 72 size = ( - (x1 - x0) * self.info["dpi"] // self._inch, - (y1 - y0) * self.info["dpi"] // self._inch, + (x1 - x0) * self.info["dpi"] // inch, + (y1 - y0) * self.info["dpi"] // inch, ) self.info["wmf_bbox"] = x0, y0, x1, y1 @@ -113,7 +113,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): msg = "Unsupported WMF file format" raise SyntaxError(msg) - elif s[:4] == b"\x01\x00\x00\x00" and s[40:44] == b" EMF": + elif s.startswith(b"\x01\x00\x00\x00") and s[40:44] == b" EMF": # enhanced metafile # get bounding box @@ -137,6 +137,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): self.info["dpi"] = xdpi else: self.info["dpi"] = xdpi, ydpi + self._inch = xdpi, ydpi else: msg = "Unsupported file format" @@ -152,13 +153,17 @@ class WmfStubImageFile(ImageFile.StubImageFile): def _load(self) -> ImageFile.StubHandler | None: return _handler - def load(self, dpi: int | None = None) -> Image.core.PixelAccess | None: - if dpi is not None and self._inch is not None: + def load( + self, dpi: float | tuple[float, float] | None = None + ) -> Image.core.PixelAccess | None: + if dpi is not None: self.info["dpi"] = dpi x0, y0, x1, y1 = self.info["wmf_bbox"] + if not isinstance(dpi, tuple): + dpi = dpi, dpi self._size = ( - (x1 - x0) * self.info["dpi"] // self._inch, - (y1 - y0) * self.info["dpi"] // self._inch, + int((x1 - x0) * dpi[0] / self._inch[0]), + int((y1 - y0) * dpi[1] / self._inch[1]), ) return super().load() diff --git a/src/PIL/XVThumbImagePlugin.py b/src/PIL/XVThumbImagePlugin.py index 5d1f201a4..cde28388f 100644 --- a/src/PIL/XVThumbImagePlugin.py +++ b/src/PIL/XVThumbImagePlugin.py @@ -34,7 +34,7 @@ for r in range(8): def _accept(prefix: bytes) -> bool: - return prefix[:6] == _MAGIC + return prefix.startswith(_MAGIC) ## @@ -74,9 +74,7 @@ class XVThumbImageFile(ImageFile.ImageFile): self.palette = ImagePalette.raw("RGB", PALETTE) self.tile = [ - ImageFile._Tile( - "raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1) - ) + ImageFile._Tile("raw", (0, 0) + self.size, self.fp.tell(), self.mode) ] diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index f3d490a84..1e57aa162 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -38,7 +38,7 @@ xbm_head = re.compile( def _accept(prefix: bytes) -> bool: - return prefix.lstrip()[:7] == b"#define" + return prefix.lstrip().startswith(b"#define") ## @@ -67,7 +67,7 @@ class XbmImageFile(ImageFile.ImageFile): self._mode = "1" self._size = xsize, ysize - self.tile = [ImageFile._Tile("xbm", (0, 0) + self.size, m.end(), None)] + self.tile = [ImageFile._Tile("xbm", (0, 0) + self.size, m.end())] def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: @@ -85,7 +85,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(b"static char im_bits[] = {\n") - ImageFile._save(im, fp, [ImageFile._Tile("xbm", (0, 0) + im.size, 0, None)]) + ImageFile._save(im, fp, [ImageFile._Tile("xbm", (0, 0) + im.size)]) fp.write(b"};\n") diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index 1fc6c0c39..3c932c41b 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -25,7 +25,7 @@ xpm_head = re.compile(b'"([0-9]*) ([0-9]*) ([0-9]*) ([0-9]*)') def _accept(prefix: bytes) -> bool: - return prefix[:9] == b"/* XPM */" + return prefix.startswith(b"/* XPM */") ## @@ -67,9 +67,9 @@ class XpmImageFile(ImageFile.ImageFile): for _ in range(pal): s = self.fp.readline() - if s[-2:] == b"\r\n": + if s.endswith(b"\r\n"): s = s[:-2] - elif s[-1:] in b"\r\n": + elif s.endswith((b"\r", b"\n")): s = s[:-1] c = s[1] @@ -81,7 +81,7 @@ class XpmImageFile(ImageFile.ImageFile): rgb = s[i + 1] if rgb == b"None": self.info["transparency"] = c - elif rgb[:1] == b"#": + elif rgb.startswith(b"#"): # FIXME: handle colour names (see ImagePalette.py) rgb = int(rgb[1:], 16) palette[c] = ( @@ -101,9 +101,7 @@ class XpmImageFile(ImageFile.ImageFile): self._mode = "P" self.palette = ImagePalette.raw("RGB", b"".join(palette)) - self.tile = [ - ImageFile._Tile("raw", (0, 0) + self.size, self.fp.tell(), ("P", 0, 1)) - ] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, self.fp.tell(), "P")] def load_read(self, read_bytes: int) -> bytes: # diff --git a/src/PIL/_deprecate.py b/src/PIL/_deprecate.py index 83952b397..9f9d8bbc9 100644 --- a/src/PIL/_deprecate.py +++ b/src/PIL/_deprecate.py @@ -47,6 +47,8 @@ def deprecate( raise RuntimeError(msg) elif when == 12: removed = "Pillow 12 (2025-10-15)" + elif when == 13: + removed = "Pillow 13 (2026-10-15)" else: msg = f"Unknown removal version: {when}. Update {__name__}?" raise ValueError(msg) diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index 9cc9822f5..1cb1429d6 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -28,10 +28,10 @@ class Font: features: list[str] | None, lang: str | None, stroke_width: float, + stroke_filled: bool, anchor: str | None, foreground_ink_long: int, - x_start: float, - y_start: float, + start: tuple[float, float], /, ) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ... def getsize( diff --git a/src/PIL/_tkinter_finder.py b/src/PIL/_tkinter_finder.py index beddfb062..9c0143003 100644 --- a/src/PIL/_tkinter_finder.py +++ b/src/PIL/_tkinter_finder.py @@ -1,5 +1,4 @@ -""" Find compiled module linking to Tcl / Tk libraries -""" +"""Find compiled module linking to Tcl / Tk libraries""" from __future__ import annotations diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 0a7d87cc2..34a9a81e1 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -44,10 +44,10 @@ _T_co = TypeVar("_T_co", covariant=True) class SupportsRead(Protocol[_T_co]): - def read(self, __length: int = ...) -> _T_co: ... + def read(self, length: int = ..., /) -> _T_co: ... -StrOrBytesPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] +StrOrBytesPath = Union[str, bytes, os.PathLike[str], os.PathLike[bytes]] __all__ = ["Buffer", "IntegralLike", "StrOrBytesPath", "SupportsRead", "TypeGuard"] diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 0807f949c..e93c7887b 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "11.1.0.dev0" +__version__ = "11.2.0.dev0" diff --git a/src/PIL/features.py b/src/PIL/features.py index 75d59e01c..ae7ea4255 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -127,6 +127,8 @@ features: dict[str, tuple[str, str | bool, str | None]] = { "fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"), "harfbuzz": ("PIL._imagingft", "HAVE_HARFBUZZ", "harfbuzz_version"), "libjpeg_turbo": ("PIL._imaging", "HAVE_LIBJPEGTURBO", "libjpeg_turbo_version"), + "mozjpeg": ("PIL._imaging", "HAVE_MOZJPEG", "libjpeg_turbo_version"), + "zlib_ng": ("PIL._imaging", "HAVE_ZLIBNG", "zlib_ng_version"), "libimagequant": ("PIL._imaging", "HAVE_LIBIMAGEQUANT", "imagequant_version"), "xcb": ("PIL._imaging", "HAVE_XCB", None), } @@ -299,7 +301,8 @@ def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: if name == "jpg": libjpeg_turbo_version = version_feature("libjpeg_turbo") if libjpeg_turbo_version is not None: - v = "libjpeg-turbo " + libjpeg_turbo_version + v = "mozjpeg" if check_feature("mozjpeg") else "libjpeg-turbo" + v += " " + libjpeg_turbo_version if v is None: v = version(name) if v is not None: @@ -308,7 +311,11 @@ def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: # this check is also in src/_imagingcms.c:setup_module() version_static = tuple(int(x) for x in v.split(".")) < (2, 7) t = "compiled for" if version_static else "loaded" - if name == "raqm": + if name == "zlib": + zlib_ng_version = version_feature("zlib_ng") + if zlib_ng_version is not None: + v += ", compiled for zlib-ng " + zlib_ng_version + elif name == "raqm": for f in ("fribidi", "harfbuzz"): v2 = version_feature(f) if v2 is not None: diff --git a/src/_imaging.c b/src/_imaging.c index 2db4486b2..330a7eef4 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -76,6 +76,13 @@ #ifdef HAVE_LIBJPEG #include "jconfig.h" +#ifdef LIBJPEG_TURBO_VERSION +#define JCONFIG_INCLUDED +#ifdef __CYGWIN__ +#define _BASETSD_H +#endif +#include "jpeglib.h" +#endif #endif #ifdef HAVE_LIBZ @@ -466,8 +473,7 @@ getpixel(Imaging im, ImagingAccess access, int x, int y) { } /* unknown type */ - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static char * @@ -860,7 +866,7 @@ _color_lut_3d(ImagingObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "siiiiiO:color_lut_3d", + "sii(iii)O:color_lut_3d", &mode, &filter, &table_channels, @@ -958,8 +964,7 @@ _convert2(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1008,10 +1013,6 @@ _convert_transparent(ImagingObject *self, PyObject *args) { static PyObject * _copy(ImagingObject *self, PyObject *args) { - if (!PyArg_ParseTuple(args, "")) { - return NULL; - } - return PyImagingNew(ImagingCopy(self->image)); } @@ -1207,8 +1208,7 @@ _getpixel(ImagingObject *self, PyObject *args) { } if (self->access == NULL) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return getpixel(self->image, self->access, x, y); @@ -1410,8 +1410,7 @@ _paste(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1684,8 +1683,7 @@ _putdata(ImagingObject *self, PyObject *args) { Py_XDECREF(seq); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1745,8 +1743,7 @@ _putpalette(ImagingObject *self, PyObject *args) { self->image->palette->size = palettesize * 8 / bits; unpack(self->image->palette->palette, palette, self->image->palette->size); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1770,8 +1767,7 @@ _putpalettealpha(ImagingObject *self, PyObject *args) { strcpy(self->image->palette->mode, "RGBA"); self->image->palette->palette[index * 4 + 3] = (UINT8)alpha; - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1798,8 +1794,7 @@ _putpalettealphas(ImagingObject *self, PyObject *args) { self->image->palette->palette[i * 4 + 3] = (UINT8)values[i]; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1835,8 +1830,7 @@ _putpixel(ImagingObject *self, PyObject *args) { self->access->put_pixel(im, x, y, ink); } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2003,8 +1997,7 @@ im_setmode(ImagingObject *self, PyObject *args) { } self->access = ImagingAccessNew(im); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2067,8 +2060,7 @@ _transform(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2195,8 +2187,7 @@ _getbbox(ImagingObject *self, PyObject *args) { } if (!ImagingGetBBox(self->image, bbox, alpha_only)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return Py_BuildValue("iiii", bbox[0], bbox[1], bbox[2], bbox[3]); @@ -2276,8 +2267,7 @@ _getextrema(ImagingObject *self) { } } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2340,8 +2330,7 @@ _fillband(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2356,8 +2345,7 @@ _putband(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2943,8 +2931,7 @@ _draw_arc(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2981,8 +2968,7 @@ _draw_bitmap(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3038,8 +3024,7 @@ _draw_chord(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3093,8 +3078,7 @@ _draw_ellipse(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3157,8 +3141,7 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) { free(xy); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3189,8 +3172,7 @@ _draw_points(ImagingDrawObject *self, PyObject *args) { free(xy); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } /* from outline.c */ @@ -3218,8 +3200,7 @@ _draw_outline(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3275,8 +3256,7 @@ _draw_pieslice(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3327,8 +3307,7 @@ _draw_polygon(ImagingDrawObject *self, PyObject *args) { free(ixy); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3382,8 +3361,7 @@ _draw_rectangle(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static struct PyMethodDef _draw_methods[] = { @@ -3588,8 +3566,7 @@ _save_ppm(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } /* -------------------------------------------------------------------- */ @@ -3792,102 +3769,26 @@ static PySequenceMethods image_as_sequence = { /* type description */ static PyTypeObject Imaging_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "ImagingCore", /*tp_name*/ - sizeof(ImagingObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - &image_as_sequence, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - methods, /*tp_methods*/ - 0, /*tp_members*/ - getsetters, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "ImagingCore", + .tp_basicsize = sizeof(ImagingObject), + .tp_dealloc = (destructor)_dealloc, + .tp_as_sequence = &image_as_sequence, + .tp_methods = methods, + .tp_getset = getsetters, }; static PyTypeObject ImagingFont_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "ImagingFont", /*tp_name*/ - sizeof(ImagingFontObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)_font_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - _font_methods, /*tp_methods*/ - 0, /*tp_members*/ - 0, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "ImagingFont", + .tp_basicsize = sizeof(ImagingFontObject), + .tp_dealloc = (destructor)_font_dealloc, + .tp_methods = _font_methods, }; static PyTypeObject ImagingDraw_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "ImagingDraw", /*tp_name*/ - sizeof(ImagingDrawObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)_draw_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - _draw_methods, /*tp_methods*/ - 0, /*tp_members*/ - 0, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "ImagingDraw", + .tp_basicsize = sizeof(ImagingDrawObject), + .tp_dealloc = (destructor)_draw_dealloc, + .tp_methods = _draw_methods, }; static PyMappingMethods pixel_access_as_mapping = { @@ -3899,20 +3800,10 @@ static PyMappingMethods pixel_access_as_mapping = { /* type description */ static PyTypeObject PixelAccess_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "PixelAccess", /*tp_name*/ - sizeof(PixelAccessObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)pixel_access_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - &pixel_access_as_mapping, /*tp_as_mapping*/ - 0 /*tp_hash*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "PixelAccess", + .tp_basicsize = sizeof(PixelAccessObject), + .tp_dealloc = (destructor)pixel_access_dealloc, + .tp_as_mapping = &pixel_access_as_mapping, }; /* -------------------------------------------------------------------- */ @@ -3977,8 +3868,7 @@ _reset_stats(PyObject *self, PyObject *args) { arena->stats_freed_blocks = 0; MUTEX_UNLOCK(&ImagingDefaultArena.mutex); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -4038,8 +3928,7 @@ _set_alignment(PyObject *self, PyObject *args) { ImagingDefaultArena.alignment = alignment; MUTEX_UNLOCK(&ImagingDefaultArena.mutex); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -4063,8 +3952,7 @@ _set_block_size(PyObject *self, PyObject *args) { ImagingDefaultArena.block_size = block_size; MUTEX_UNLOCK(&ImagingDefaultArena.mutex); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -4092,8 +3980,7 @@ _set_blocks_max(PyObject *self, PyObject *args) { return ImagingError_MemoryError(); } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -4108,8 +3995,7 @@ _clear_cache(PyObject *self, PyObject *args) { ImagingMemoryClearCache(&ImagingDefaultArena, i); MUTEX_UNLOCK(&ImagingDefaultArena.mutex); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } /* -------------------------------------------------------------------- */ @@ -4155,6 +4041,8 @@ PyImaging_ZipDecoderNew(PyObject *self, PyObject *args); /* Encoders (in encode.c) */ extern PyObject * +PyImaging_BcnEncoderNew(PyObject *self, PyObject *args); +extern PyObject * PyImaging_EpsEncoderNew(PyObject *self, PyObject *args); extern PyObject * PyImaging_GifEncoderNew(PyObject *self, PyObject *args); @@ -4223,6 +4111,7 @@ static PyMethodDef functions[] = { /* Codecs */ {"bcn_decoder", (PyCFunction)PyImaging_BcnDecoderNew, METH_VARARGS}, + {"bcn_encoder", (PyCFunction)PyImaging_BcnEncoderNew, METH_VARARGS}, {"bit_decoder", (PyCFunction)PyImaging_BitDecoderNew, METH_VARARGS}, {"eps_encoder", (PyCFunction)PyImaging_EpsEncoderNew, METH_VARARGS}, {"fli_decoder", (PyCFunction)PyImaging_FliDecoderNew, METH_VARARGS}, @@ -4284,7 +4173,6 @@ static PyMethodDef functions[] = { {"effect_noise", (PyCFunction)_effect_noise, METH_VARARGS}, {"linear_gradient", (PyCFunction)_linear_gradient, METH_VARARGS}, {"radial_gradient", (PyCFunction)_radial_gradient, METH_VARARGS}, - {"wedge", (PyCFunction)_linear_gradient, METH_VARARGS}, /* Compatibility */ /* Drawing support stuff */ {"font", (PyCFunction)_font_new, METH_VARARGS}, @@ -4367,6 +4255,15 @@ setup_module(PyObject *m) { Py_INCREF(have_libjpegturbo); PyModule_AddObject(m, "HAVE_LIBJPEGTURBO", have_libjpegturbo); + PyObject *have_mozjpeg; +#ifdef JPEG_C_PARAM_SUPPORTED + have_mozjpeg = Py_True; +#else + have_mozjpeg = Py_False; +#endif + Py_INCREF(have_mozjpeg); + PyModule_AddObject(m, "HAVE_MOZJPEG", have_mozjpeg); + PyObject *have_libimagequant; #ifdef HAVE_LIBIMAGEQUANT have_libimagequant = Py_True; @@ -4397,6 +4294,20 @@ setup_module(PyObject *m) { } #endif + PyObject *have_zlibng; +#ifdef ZLIBNG_VERSION + have_zlibng = Py_True; + { + PyObject *v = PyUnicode_FromString(ZLIBNG_VERSION); + PyDict_SetItemString(d, "zlib_ng_version", v ? v : Py_None); + Py_XDECREF(v); + } +#else + have_zlibng = Py_False; +#endif + Py_INCREF(have_zlibng); + PyModule_AddObject(m, "HAVE_ZLIBNG", have_zlibng); + #ifdef HAVE_LIBTIFF { extern const char *ImagingTiffVersion(void); @@ -4440,10 +4351,9 @@ PyInit__imaging(void) { static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, - "_imaging", /* m_name */ - NULL, /* m_doc */ - -1, /* m_size */ - functions, /* m_methods */ + .m_name = "_imaging", + .m_size = -1, + .m_methods = functions, }; m = PyModule_Create(&module_def); diff --git a/src/_imagingcms.c b/src/_imagingcms.c index 1823bcf03..ea2f70186 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -346,10 +346,10 @@ pyCMSdoTransform(Imaging im, Imaging imOut, cmsHTRANSFORM hTransform) { return -1; } - Py_BEGIN_ALLOW_THREADS + Py_BEGIN_ALLOW_THREADS; - // transform color channels only - for (i = 0; i < im->ysize; i++) { + // transform color channels only + for (i = 0; i < im->ysize; i++) { cmsDoTransform(hTransform, im->image[i], imOut->image[i], im->xsize); } @@ -362,9 +362,9 @@ pyCMSdoTransform(Imaging im, Imaging imOut, cmsHTRANSFORM hTransform) { // enough available on all platforms, so we polyfill it here for now. pyCMScopyAux(hTransform, imOut, im); - Py_END_ALLOW_THREADS + Py_END_ALLOW_THREADS; - return 0; + return 0; } static cmsHTRANSFORM @@ -378,17 +378,17 @@ _buildTransform( ) { cmsHTRANSFORM hTransform; - Py_BEGIN_ALLOW_THREADS + Py_BEGIN_ALLOW_THREADS; - /* create the transform */ - hTransform = cmsCreateTransform( - hInputProfile, - findLCMStype(sInMode), - hOutputProfile, - findLCMStype(sOutMode), - iRenderingIntent, - cmsFLAGS - ); + /* create the transform */ + hTransform = cmsCreateTransform( + hInputProfile, + findLCMStype(sInMode), + hOutputProfile, + findLCMStype(sOutMode), + iRenderingIntent, + cmsFLAGS + ); Py_END_ALLOW_THREADS; @@ -412,19 +412,19 @@ _buildProofTransform( ) { cmsHTRANSFORM hTransform; - Py_BEGIN_ALLOW_THREADS + Py_BEGIN_ALLOW_THREADS; - /* create the transform */ - hTransform = cmsCreateProofingTransform( - hInputProfile, - findLCMStype(sInMode), - hOutputProfile, - findLCMStype(sOutMode), - hProofProfile, - iRenderingIntent, - iProofIntent, - cmsFLAGS - ); + /* create the transform */ + hTransform = cmsCreateProofingTransform( + hInputProfile, + findLCMStype(sInMode), + hOutputProfile, + findLCMStype(sOutMode), + hProofProfile, + iRenderingIntent, + iProofIntent, + cmsFLAGS + ); Py_END_ALLOW_THREADS; @@ -654,8 +654,7 @@ cms_get_display_profile_win32(PyObject *self, PyObject *args) { return PyUnicode_FromStringAndSize(filename, filename_size - 1); } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } #endif @@ -672,20 +671,17 @@ _profile_read_mlu(CmsProfileObject *self, cmsTagSignature info) { wchar_t *buf; if (!cmsIsTag(self->profile, info)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } mlu = cmsReadTag(self->profile, info); if (!mlu) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } len = cmsMLUgetWide(mlu, lc, cc, NULL, 0); if (len == 0) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } buf = malloc(len); @@ -723,14 +719,12 @@ _profile_read_signature(CmsProfileObject *self, cmsTagSignature info) { unsigned int *sig; if (!cmsIsTag(self->profile, info)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } sig = (unsigned int *)cmsReadTag(self->profile, info); if (!sig) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return _profile_read_int_as_string(*sig); @@ -780,14 +774,12 @@ _profile_read_ciexyz(CmsProfileObject *self, cmsTagSignature info, int multi) { cmsCIEXYZ *XYZ; if (!cmsIsTag(self->profile, info)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } XYZ = (cmsCIEXYZ *)cmsReadTag(self->profile, info); if (!XYZ) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } if (multi) { return _xyz3_py(XYZ); @@ -801,14 +793,12 @@ _profile_read_ciexyy_triple(CmsProfileObject *self, cmsTagSignature info) { cmsCIExyYTRIPLE *triple; if (!cmsIsTag(self->profile, info)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } triple = (cmsCIExyYTRIPLE *)cmsReadTag(self->profile, info); if (!triple) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } /* Note: lcms does all the heavy lifting and error checking (nr of @@ -835,21 +825,18 @@ _profile_read_named_color_list(CmsProfileObject *self, cmsTagSignature info) { PyObject *result; if (!cmsIsTag(self->profile, info)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } ncl = (cmsNAMEDCOLORLIST *)cmsReadTag(self->profile, info); if (ncl == NULL) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } n = cmsNamedColorCount(ncl); result = PyList_New(n); if (!result) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } for (i = 0; i < n; i++) { @@ -858,8 +845,7 @@ _profile_read_named_color_list(CmsProfileObject *self, cmsTagSignature info) { str = PyUnicode_FromString(name); if (str == NULL) { Py_DECREF(result); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } PyList_SET_ITEM(result, i, str); } @@ -926,8 +912,7 @@ _is_intent_supported(CmsProfileObject *self, int clut) { result = PyDict_New(); if (result == NULL) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } n = cmsGetSupportedIntents(INTENTS, intent_ids, intent_descs); @@ -957,8 +942,7 @@ _is_intent_supported(CmsProfileObject *self, int clut) { Py_XDECREF(id); Py_XDECREF(entry); Py_XDECREF(result); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } PyDict_SetItem(result, id, entry); Py_DECREF(id); @@ -1042,8 +1026,7 @@ cms_profile_getattr_creation_date(CmsProfileObject *self, void *closure) { result = cmsGetHeaderCreationDateTime(self->profile, &ct); if (!result) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return PyDateTime_FromDateAndTime( @@ -1141,8 +1124,7 @@ cms_profile_getattr_saturation_rendering_intent_gamut( static PyObject * cms_profile_getattr_red_colorant(CmsProfileObject *self, void *closure) { if (!cmsIsMatrixShaper(self->profile)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return _profile_read_ciexyz(self, cmsSigRedColorantTag, 0); } @@ -1150,8 +1132,7 @@ cms_profile_getattr_red_colorant(CmsProfileObject *self, void *closure) { static PyObject * cms_profile_getattr_green_colorant(CmsProfileObject *self, void *closure) { if (!cmsIsMatrixShaper(self->profile)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return _profile_read_ciexyz(self, cmsSigGreenColorantTag, 0); } @@ -1159,8 +1140,7 @@ cms_profile_getattr_green_colorant(CmsProfileObject *self, void *closure) { static PyObject * cms_profile_getattr_blue_colorant(CmsProfileObject *self, void *closure) { if (!cmsIsMatrixShaper(self->profile)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return _profile_read_ciexyz(self, cmsSigBlueColorantTag, 0); } @@ -1176,21 +1156,18 @@ cms_profile_getattr_media_white_point_temperature( cmsBool result; if (!cmsIsTag(self->profile, info)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } XYZ = (cmsCIEXYZ *)cmsReadTag(self->profile, info); if (XYZ == NULL || XYZ->X == 0) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } cmsXYZ2xyY(&xyY, XYZ); result = cmsTempFromWhitePoint(&tempK, &xyY); if (!result) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return PyFloat_FromDouble(tempK); } @@ -1229,8 +1206,7 @@ cms_profile_getattr_red_primary(CmsProfileObject *self, void *closure) { result = _calculate_rgb_primaries(self, &primaries); } if (!result) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return _xyz_py(&primaries.Red); @@ -1245,8 +1221,7 @@ cms_profile_getattr_green_primary(CmsProfileObject *self, void *closure) { result = _calculate_rgb_primaries(self, &primaries); } if (!result) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return _xyz_py(&primaries.Green); @@ -1261,8 +1236,7 @@ cms_profile_getattr_blue_primary(CmsProfileObject *self, void *closure) { result = _calculate_rgb_primaries(self, &primaries); } if (!result) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return _xyz_py(&primaries.Blue); @@ -1321,14 +1295,12 @@ cms_profile_getattr_icc_measurement_condition(CmsProfileObject *self, void *clos const char *geo; if (!cmsIsTag(self->profile, info)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } mc = (cmsICCMeasurementConditions *)cmsReadTag(self->profile, info); if (!mc) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } if (mc->Geometry == 1) { @@ -1362,14 +1334,12 @@ cms_profile_getattr_icc_viewing_condition(CmsProfileObject *self, void *closure) cmsTagSignature info = cmsSigViewingConditionsTag; if (!cmsIsTag(self->profile, info)) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } vc = (cmsICCViewingConditions *)cmsReadTag(self->profile, info); if (!vc) { - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } return Py_BuildValue( @@ -1440,36 +1410,11 @@ static struct PyGetSetDef cms_profile_getsetters[] = { }; static PyTypeObject CmsProfile_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "PIL.ImageCms.core.CmsProfile", /*tp_name*/ - sizeof(CmsProfileObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)cms_profile_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - cms_profile_methods, /*tp_methods*/ - 0, /*tp_members*/ - cms_profile_getsetters, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "PIL.ImageCms.core.CmsProfile", + .tp_basicsize = sizeof(CmsProfileObject), + .tp_dealloc = (destructor)cms_profile_dealloc, + .tp_methods = cms_profile_methods, + .tp_getset = cms_profile_getsetters, }; static struct PyMethodDef cms_transform_methods[] = { @@ -1477,36 +1422,10 @@ static struct PyMethodDef cms_transform_methods[] = { }; static PyTypeObject CmsTransform_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "PIL.ImageCms.core.CmsTransform", /*tp_name*/ - sizeof(CmsTransformObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)cms_transform_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - cms_transform_methods, /*tp_methods*/ - 0, /*tp_members*/ - 0, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "PIL.ImageCms.core.CmsTransform", + .tp_basicsize = sizeof(CmsTransformObject), + .tp_dealloc = (destructor)cms_transform_dealloc, + .tp_methods = cms_transform_methods, }; static int @@ -1550,10 +1469,9 @@ PyInit__imagingcms(void) { static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, - "_imagingcms", /* m_name */ - NULL, /* m_doc */ - -1, /* m_size */ - pyCMSdll_methods, /* m_methods */ + .m_name = "_imagingcms", + .m_size = -1, + .m_methods = pyCMSdll_methods, }; m = PyModule_Create(&module_def); diff --git a/src/_imagingft.c b/src/_imagingft.c index d38279f3e..62dab73e5 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -339,29 +339,23 @@ text_layout_raqm( len = PySequence_Fast_GET_SIZE(seq); for (j = 0; j < len; j++) { PyObject *item = PySequence_Fast_GET_ITEM(seq, j); - char *feature = NULL; - Py_ssize_t size = 0; - PyObject *bytes; - if (!PyUnicode_Check(item)) { Py_DECREF(seq); PyErr_SetString(PyExc_TypeError, "expected a string"); goto failed; } - bytes = PyUnicode_AsUTF8String(item); - if (bytes == NULL) { + + Py_ssize_t size; + const char *feature = PyUnicode_AsUTF8AndSize(item, &size); + if (feature == NULL) { Py_DECREF(seq); goto failed; } - feature = PyBytes_AS_STRING(bytes); - size = PyBytes_GET_SIZE(bytes); if (!raqm_add_font_feature(rq, feature, size)) { Py_DECREF(seq); - Py_DECREF(bytes); PyErr_SetString(PyExc_ValueError, "raqm_add_font_feature() failed"); goto failed; } - Py_DECREF(bytes); } Py_DECREF(seq); } @@ -840,6 +834,7 @@ font_render(FontObject *self, PyObject *args) { int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ int color = 0; /* is FT_LOAD_COLOR enabled? */ float stroke_width = 0; + int stroke_filled = 0; PY_LONG_LONG foreground_ink_long = 0; unsigned int foreground_ink; const char *mode = NULL; @@ -859,7 +854,7 @@ font_render(FontObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "OO|zzOzfzLffO:render", + "OO|zzOzfpzL(ff):render", &string, &fill, &mode, @@ -867,6 +862,7 @@ font_render(FontObject *self, PyObject *args) { &features, &lang, &stroke_width, + &stroke_filled, &anchor, &foreground_ink_long, &x_start, @@ -1011,7 +1007,8 @@ font_render(FontObject *self, PyObject *args) { if (stroker != NULL) { error = FT_Get_Glyph(glyph_slot, &glyph); if (!error) { - error = FT_Glyph_Stroke(&glyph, stroker, 1); + error = stroke_filled ? FT_Glyph_StrokeBorder(&glyph, stroker, 0, 1) + : FT_Glyph_Stroke(&glyph, stroker, 1); } if (!error) { FT_Vector origin = {0, 0}; @@ -1377,8 +1374,7 @@ font_setvarname(FontObject *self, PyObject *args) { return geterror(error); } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1432,8 +1428,7 @@ font_setvaraxes(FontObject *self, PyObject *args) { return geterror(error); } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } #endif @@ -1523,36 +1518,11 @@ static struct PyGetSetDef font_getsetters[] = { }; static PyTypeObject Font_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "Font", /*tp_name*/ - sizeof(FontObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)font_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - font_methods, /*tp_methods*/ - 0, /*tp_members*/ - font_getsetters, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "Font", + .tp_basicsize = sizeof(FontObject), + .tp_dealloc = (destructor)font_dealloc, + .tp_methods = font_methods, + .tp_getset = font_getsetters, }; static PyMethodDef _functions[] = { @@ -1635,10 +1605,9 @@ PyInit__imagingft(void) { static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, - "_imagingft", /* m_name */ - NULL, /* m_doc */ - -1, /* m_size */ - _functions, /* m_methods */ + .m_name = "_imagingft", + .m_size = -1, + .m_methods = _functions, }; m = PyModule_Create(&module_def); diff --git a/src/_imagingmath.c b/src/_imagingmath.c index dbe636707..4b9bf08ba 100644 --- a/src/_imagingmath.c +++ b/src/_imagingmath.c @@ -192,8 +192,7 @@ _unop(PyObject *self, PyObject *args) { unop(out, im1); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -226,8 +225,7 @@ _binop(PyObject *self, PyObject *args) { binop(out, im1, im2); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyMethodDef _functions[] = { @@ -310,10 +308,9 @@ PyInit__imagingmath(void) { static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, - "_imagingmath", /* m_name */ - NULL, /* m_doc */ - -1, /* m_size */ - _functions, /* m_methods */ + .m_name = "_imagingmath", + .m_size = -1, + .m_methods = _functions, }; m = PyModule_Create(&module_def); diff --git a/src/_imagingmorph.c b/src/_imagingmorph.c index b763e3a6f..a20888294 100644 --- a/src/_imagingmorph.c +++ b/src/_imagingmorph.c @@ -252,10 +252,10 @@ PyInit__imagingmorph(void) { static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, - "_imagingmorph", /* m_name */ - "A module for doing image morphology", /* m_doc */ - -1, /* m_size */ - functions, /* m_methods */ + .m_name = "_imagingmorph", + .m_doc = "A module for doing image morphology", + .m_size = -1, + .m_methods = functions, }; m = PyModule_Create(&module_def); diff --git a/src/_imagingtk.c b/src/_imagingtk.c index c70d044bb..4e06fe9b8 100644 --- a/src/_imagingtk.c +++ b/src/_imagingtk.c @@ -37,8 +37,7 @@ _tkinit(PyObject *self, PyObject *args) { /* This will bomb if interp is invalid... */ TkImaging_Init(interp); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyMethodDef functions[] = { @@ -51,10 +50,9 @@ PyMODINIT_FUNC PyInit__imagingtk(void) { static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, - "_imagingtk", /* m_name */ - NULL, /* m_doc */ - -1, /* m_size */ - functions, /* m_methods */ + .m_name = "_imagingtk", + .m_size = -1, + .m_methods = functions, }; PyObject *m; m = PyModule_Create(&module_def); diff --git a/src/_webp.c b/src/_webp.c index dfda7048d..3aa4c408b 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -164,7 +164,7 @@ _anim_encoder_new(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "iiIiiiiii", + "(ii)Iiiiiii", &width, &height, &bgcolor, @@ -449,7 +449,7 @@ _anim_decoder_get_info(PyObject *self) { WebPAnimInfo *info = &(decp->info); return Py_BuildValue( - "IIIIIs", + "(II)IIIs", info->canvas_width, info->canvas_height, info->loop_count, @@ -530,36 +530,10 @@ static struct PyMethodDef _anim_encoder_methods[] = { // WebPAnimEncoder type definition static PyTypeObject WebPAnimEncoder_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "WebPAnimEncoder", /*tp_name */ - sizeof(WebPAnimEncoderObject), /*tp_basicsize */ - 0, /*tp_itemsize */ - /* methods */ - (destructor)_anim_encoder_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - _anim_encoder_methods, /*tp_methods*/ - 0, /*tp_members*/ - 0, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "WebPAnimEncoder", + .tp_basicsize = sizeof(WebPAnimEncoderObject), + .tp_dealloc = (destructor)_anim_encoder_dealloc, + .tp_methods = _anim_encoder_methods, }; // WebPAnimDecoder methods @@ -573,36 +547,10 @@ static struct PyMethodDef _anim_decoder_methods[] = { // WebPAnimDecoder type definition static PyTypeObject WebPAnimDecoder_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "WebPAnimDecoder", /*tp_name */ - sizeof(WebPAnimDecoderObject), /*tp_basicsize */ - 0, /*tp_itemsize */ - /* methods */ - (destructor)_anim_decoder_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - _anim_decoder_methods, /*tp_methods*/ - 0, /*tp_members*/ - 0, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "WebPAnimDecoder", + .tp_basicsize = sizeof(WebPAnimDecoderObject), + .tp_dealloc = (destructor)_anim_decoder_dealloc, + .tp_methods = _anim_decoder_methods, }; /* -------------------------------------------------------------------- */ @@ -835,10 +783,9 @@ PyInit__webp(void) { static PyModuleDef module_def = { PyModuleDef_HEAD_INIT, - "_webp", /* m_name */ - NULL, /* m_doc */ - -1, /* m_size */ - webpMethods, /* m_methods */ + .m_name = "_webp", + .m_size = -1, + .m_methods = webpMethods, }; m = PyModule_Create(&module_def); diff --git a/src/decode.c b/src/decode.c index 51d0aced2..03db1ce35 100644 --- a/src/decode.c +++ b/src/decode.c @@ -213,8 +213,7 @@ _setimage(ImagingDecoderObject *decoder, PyObject *args) { Py_XDECREF(decoder->lock); decoder->lock = op; - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -231,8 +230,7 @@ _setfd(ImagingDecoderObject *decoder, PyObject *args) { Py_XINCREF(fd); state->fd = fd; - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -258,36 +256,11 @@ static struct PyGetSetDef getseters[] = { }; static PyTypeObject ImagingDecoderType = { - PyVarObject_HEAD_INIT(NULL, 0) "ImagingDecoder", /*tp_name*/ - sizeof(ImagingDecoderObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - methods, /*tp_methods*/ - 0, /*tp_members*/ - getseters, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "ImagingDecoder", + .tp_basicsize = sizeof(ImagingDecoderObject), + .tp_dealloc = (destructor)_dealloc, + .tp_methods = methods, + .tp_getset = getseters, }; /* -------------------------------------------------------------------- */ diff --git a/src/display.c b/src/display.c index b0693bf71..11742a895 100644 --- a/src/display.c +++ b/src/display.c @@ -85,8 +85,7 @@ _expose(ImagingDisplayObject *display, PyObject *args) { ImagingExposeDIB(display->dib, hdc); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -112,8 +111,7 @@ _draw(ImagingDisplayObject *display, PyObject *args) { ImagingDrawDIB(display->dib, hdc, dst, src); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } extern Imaging @@ -143,8 +141,7 @@ _paste(ImagingDisplayObject *display, PyObject *args) { ImagingPasteDIB(display->dib, im, xy); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -190,8 +187,7 @@ _releasedc(ImagingDisplayObject *display, PyObject *args) { ReleaseDC(window, dc); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -211,8 +207,7 @@ _frombytes(ImagingDisplayObject *display, PyObject *args) { memcpy(display->dib->bits, buffer.buf, buffer.len); PyBuffer_Release(&buffer); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -253,36 +248,11 @@ static struct PyGetSetDef getsetters[] = { }; static PyTypeObject ImagingDisplayType = { - PyVarObject_HEAD_INIT(NULL, 0) "ImagingDisplay", /*tp_name*/ - sizeof(ImagingDisplayObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)_delete, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - methods, /*tp_methods*/ - 0, /*tp_members*/ - getsetters, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "ImagingDisplay", + .tp_basicsize = sizeof(ImagingDisplayObject), + .tp_dealloc = (destructor)_delete, + .tp_methods = methods, + .tp_getset = getsetters, }; PyObject * @@ -728,25 +698,26 @@ PyImaging_CreateWindowWin32(PyObject *self, PyObject *args) { SetWindowLongPtr(wnd, 0, (LONG_PTR)callback); SetWindowLongPtr(wnd, sizeof(callback), (LONG_PTR)PyThreadState_Get()); - Py_BEGIN_ALLOW_THREADS ShowWindow(wnd, SW_SHOWNORMAL); + Py_BEGIN_ALLOW_THREADS; + ShowWindow(wnd, SW_SHOWNORMAL); SetForegroundWindow(wnd); /* to make sure it's visible */ - Py_END_ALLOW_THREADS + Py_END_ALLOW_THREADS; - return Py_BuildValue(F_HANDLE, wnd); + return Py_BuildValue(F_HANDLE, wnd); } PyObject * PyImaging_EventLoopWin32(PyObject *self, PyObject *args) { MSG msg; - Py_BEGIN_ALLOW_THREADS while (mainloop && GetMessage(&msg, NULL, 0, 0)) { + Py_BEGIN_ALLOW_THREADS; + while (mainloop && GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } - Py_END_ALLOW_THREADS + Py_END_ALLOW_THREADS; - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } /* -------------------------------------------------------------------- */ @@ -754,6 +725,14 @@ PyImaging_EventLoopWin32(PyObject *self, PyObject *args) { #define GET32(p, o) ((DWORD *)(p + o))[0] +static int CALLBACK +enhMetaFileProc( + HDC hdc, HANDLETABLE *lpht, const ENHMETARECORD *lpmr, int nHandles, LPARAM data +) { + PlayEnhMetaFileRecord(hdc, lpht, lpmr, nHandles); + return 1; +} + PyObject * PyImaging_DrawWmf(PyObject *self, PyObject *args) { HBITMAP bitmap; @@ -834,10 +813,7 @@ PyImaging_DrawWmf(PyObject *self, PyObject *args) { /* FIXME: make background transparent? configurable? */ FillRect(dc, &rect, GetStockObject(WHITE_BRUSH)); - if (!PlayEnhMetaFile(dc, meta, &rect)) { - PyErr_SetString(PyExc_OSError, "cannot render metafile"); - goto error; - } + EnumEnhMetaFile(dc, meta, enhMetaFileProc, NULL, &rect); /* step 4: extract bits from bitmap */ diff --git a/src/encode.c b/src/encode.c index 1a4cd489d..7c365a74f 100644 --- a/src/encode.c +++ b/src/encode.c @@ -27,6 +27,7 @@ #include "thirdparty/pythoncapi_compat.h" #include "libImaging/Imaging.h" +#include "libImaging/Bcn.h" #include "libImaging/Gif.h" #ifdef HAVE_UNISTD_H @@ -278,8 +279,7 @@ _setimage(ImagingEncoderObject *encoder, PyObject *args) { Py_XDECREF(encoder->lock); encoder->lock = op; - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -296,8 +296,7 @@ _setfd(ImagingEncoderObject *encoder, PyObject *args) { Py_XINCREF(fd); state->fd = fd; - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -325,36 +324,11 @@ static struct PyGetSetDef getseters[] = { }; static PyTypeObject ImagingEncoderType = { - PyVarObject_HEAD_INIT(NULL, 0) "ImagingEncoder", /*tp_name*/ - sizeof(ImagingEncoderObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - methods, /*tp_methods*/ - 0, /*tp_members*/ - getseters, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "ImagingEncoder", + .tp_basicsize = sizeof(ImagingEncoderObject), + .tp_dealloc = (destructor)_dealloc, + .tp_methods = methods, + .tp_getset = getseters, }; /* -------------------------------------------------------------------- */ @@ -377,6 +351,31 @@ get_packer(ImagingEncoderObject *encoder, const char *mode, const char *rawmode) return 0; } +/* -------------------------------------------------------------------- */ +/* BCN */ +/* -------------------------------------------------------------------- */ + +PyObject * +PyImaging_BcnEncoderNew(PyObject *self, PyObject *args) { + ImagingEncoderObject *encoder; + + char *mode; + int n; + if (!PyArg_ParseTuple(args, "si", &mode, &n)) { + return NULL; + } + + encoder = PyImaging_EncoderNew(0); + if (encoder == NULL) { + return NULL; + } + + encoder->encode = ImagingBcnEncode; + encoder->state.state = n; + + return (PyObject *)encoder; +} + /* -------------------------------------------------------------------- */ /* EPS */ /* -------------------------------------------------------------------- */ @@ -736,7 +735,7 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { } if (tag_type) { int type_int = PyLong_AsLong(tag_type); - if (type_int >= TIFF_BYTE && type_int <= TIFF_DOUBLE) { + if (type_int >= TIFF_BYTE && type_int <= TIFF_LONG8) { type = (TIFFDataType)type_int; } } @@ -929,7 +928,7 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { ); } else if (type == TIFF_LONG) { status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, PyLong_AsLongLong(value) + &encoder->state, (ttag_t)key_int, (UINT32)PyLong_AsLong(value) ); } else if (type == TIFF_SSHORT) { status = ImagingLibTiffSetField( @@ -959,6 +958,10 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (FLOAT64)PyFloat_AsDouble(value) ); + } else if (type == TIFF_LONG8) { + status = ImagingLibTiffSetField( + &encoder->state, (ttag_t)key_int, (uint64_t)PyLong_AsLongLong(value) + ); } else { TRACE( ("Unhandled type for key %d : %s \n", @@ -1095,7 +1098,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "ss|nnnnpnnnnnnOz#y#y#", + "ss|nnnnpn(nn)nnnOz#y#y#", &mode, &rawmode, &quality, @@ -1251,7 +1254,7 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { PyObject *quality_layers = NULL; Py_ssize_t num_resolutions = 0; PyObject *cblk_size = NULL, *precinct_size = NULL; - PyObject *irreversible = NULL; + int irreversible = 0; char *progression = "LRCP"; OPJ_PROG_ORDER prog_order; char *cinema_mode = "no"; @@ -1265,7 +1268,7 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "ss|OOOsOnOOOssbbnz#p", + "ss|OOOsOnOOpssbbnz#p", &mode, &format, &offset, @@ -1400,7 +1403,7 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { precinct_size, &context->precinct_width, &context->precinct_height ); - context->irreversible = PyObject_IsTrue(irreversible); + context->irreversible = irreversible; context->progression = prog_order; context->cinema_mode = cine_mode; context->mct = mct; diff --git a/src/libImaging/BcnDecode.c b/src/libImaging/BcnDecode.c index 9a41febc7..7b3d8f908 100644 --- a/src/libImaging/BcnDecode.c +++ b/src/libImaging/BcnDecode.c @@ -25,7 +25,6 @@ typedef struct { typedef struct { UINT16 c0, c1; - UINT32 lut; } bc1_color; typedef struct { @@ -40,13 +39,10 @@ typedef struct { #define LOAD16(p) (p)[0] | ((p)[1] << 8) -#define LOAD32(p) (p)[0] | ((p)[1] << 8) | ((p)[2] << 16) | ((p)[3] << 24) - static void bc1_color_load(bc1_color *dst, const UINT8 *src) { dst->c0 = LOAD16(src); dst->c1 = LOAD16(src + 2); - dst->lut = LOAD32(src + 4); } static rgba @@ -70,7 +66,7 @@ static void decode_bc1_color(rgba *dst, const UINT8 *src, int separate_alpha) { bc1_color col; rgba p[4]; - int n, cw; + int n, o, cw; UINT16 r0, g0, b0, r1, g1, b1; bc1_color_load(&col, src); @@ -103,9 +99,11 @@ decode_bc1_color(rgba *dst, const UINT8 *src, int separate_alpha) { p[3].b = 0; p[3].a = 0; } - for (n = 0; n < 16; n++) { - cw = 3 & (col.lut >> (2 * n)); - dst[n] = p[cw]; + for (n = 0; n < 4; n++) { + for (o = 0; o < 4; o++) { + cw = 3 & ((src + 4)[n] >> (2 * o)); + dst[n * 4 + o] = p[cw]; + } } } diff --git a/src/libImaging/BcnEncode.c b/src/libImaging/BcnEncode.c new file mode 100644 index 000000000..2bad73b92 --- /dev/null +++ b/src/libImaging/BcnEncode.c @@ -0,0 +1,298 @@ +/* + * The Python Imaging Library + * + * encoder for DXT1-compressed data + * + * Format documentation: + * https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt + * + */ + +#include "Imaging.h" + +typedef struct { + UINT8 color[3]; +} rgb; + +typedef struct { + UINT8 color[4]; +} rgba; + +static rgb +decode_565(UINT16 x) { + rgb item; + int r, g, b; + r = (x & 0xf800) >> 8; + r |= r >> 5; + item.color[0] = r; + g = (x & 0x7e0) >> 3; + g |= g >> 6; + item.color[1] = g; + b = (x & 0x1f) << 3; + b |= b >> 5; + item.color[2] = b; + return item; +} + +static UINT16 +encode_565(rgba item) { + UINT8 r, g, b; + r = item.color[0] >> (8 - 5); + g = item.color[1] >> (8 - 6); + b = item.color[2] >> (8 - 5); + return (r << (5 + 6)) | (g << 5) | b; +} + +static void +encode_bc1_color(Imaging im, ImagingCodecState state, UINT8 *dst, int separate_alpha) { + int i, j, k; + UINT16 color_min = 0, color_max = 0; + rgb color_min_rgb, color_max_rgb; + rgba block[16], *current_rgba; + + // Determine the min and max colors in this 4x4 block + int first = 1; + int transparency = 0; + for (i = 0; i < 4; i++) { + for (j = 0; j < 4; j++) { + current_rgba = &block[i + j * 4]; + + int x = state->x + i * im->pixelsize; + int y = state->y + j; + if (x >= state->xsize * im->pixelsize || y >= state->ysize) { + // The 4x4 block extends past the edge of the image + for (k = 0; k < 3; k++) { + current_rgba->color[k] = 0; + } + continue; + } + + for (k = 0; k < 3; k++) { + current_rgba->color[k] = + (UINT8)im->image[y][x + (im->pixelsize == 1 ? 0 : k)]; + } + if (separate_alpha) { + if ((UINT8)im->image[y][x + 3] == 0) { + current_rgba->color[3] = 0; + transparency = 1; + continue; + } else { + current_rgba->color[3] = 1; + } + } + + UINT16 color = encode_565(*current_rgba); + if (first || color < color_min) { + color_min = color; + } + if (first || color > color_max) { + color_max = color; + } + first = 0; + } + } + + if (transparency) { + *dst++ = color_min; + *dst++ = color_min >> 8; + } + *dst++ = color_max; + *dst++ = color_max >> 8; + if (!transparency) { + *dst++ = color_min; + *dst++ = color_min >> 8; + } + + color_min_rgb = decode_565(color_min); + color_max_rgb = decode_565(color_max); + for (i = 0; i < 4; i++) { + UINT8 l = 0; + for (j = 3; j > -1; j--) { + current_rgba = &block[i * 4 + j]; + if (transparency && !current_rgba->color[3]) { + l |= 3 << (j * 2); + continue; + } + + float distance = 0; + int total = 0; + for (k = 0; k < 3; k++) { + float denom = + (float)abs(color_max_rgb.color[k] - color_min_rgb.color[k]); + if (denom != 0) { + distance += + abs(current_rgba->color[k] - color_min_rgb.color[k]) / denom; + total += 1; + } + } + if (total == 0) { + continue; + } + if (transparency) { + distance *= 4 / total; + if (distance < 1) { + // color_max + } else if (distance < 3) { + l |= 2 << (j * 2); // 1/2 * color_min + 1/2 * color_max + } else { + l |= 1 << (j * 2); // color_min + } + } else { + distance *= 6 / total; + if (distance < 1) { + l |= 1 << (j * 2); // color_min + } else if (distance < 3) { + l |= 3 << (j * 2); // 1/3 * color_min + 2/3 * color_max + } else if (distance < 5) { + l |= 2 << (j * 2); // 2/3 * color_min + 1/3 * color_max + } else { + // color_max + } + } + } + *dst++ = l; + } +} + +static void +encode_bc2_block(Imaging im, ImagingCodecState state, UINT8 *dst) { + int i, j; + UINT8 block[16], current_alpha; + for (i = 0; i < 4; i++) { + for (j = 0; j < 4; j++) { + int x = state->x + i * im->pixelsize; + int y = state->y + j; + if (x >= state->xsize * im->pixelsize || y >= state->ysize) { + // The 4x4 block extends past the edge of the image + block[i + j * 4] = 0; + continue; + } + + current_alpha = (UINT8)im->image[y][x + 3]; + block[i + j * 4] = current_alpha; + } + } + + for (i = 0; i < 4; i++) { + UINT16 l = 0; + for (j = 3; j > -1; j--) { + current_alpha = block[i * 4 + j]; + l |= current_alpha << (j * 4); + } + *dst++ = l; + *dst++ = l >> 8; + } +} + +static void +encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst, int o) { + int i, j; + UINT8 alpha_min = 0, alpha_max = 0; + UINT8 block[16], current_alpha; + + // Determine the min and max colors in this 4x4 block + int first = 1; + for (i = 0; i < 4; i++) { + for (j = 0; j < 4; j++) { + int x = state->x + i * im->pixelsize; + int y = state->y + j; + if (x >= state->xsize * im->pixelsize || y >= state->ysize) { + // The 4x4 block extends past the edge of the image + block[i + j * 4] = 0; + continue; + } + + current_alpha = (UINT8)im->image[y][x + o]; + block[i + j * 4] = current_alpha; + + if (first || current_alpha < alpha_min) { + alpha_min = current_alpha; + } + if (first || current_alpha > alpha_max) { + alpha_max = current_alpha; + } + first = 0; + } + } + + *dst++ = alpha_min; + *dst++ = alpha_max; + + float denom = (float)abs(alpha_max - alpha_min); + for (i = 0; i < 2; i++) { + UINT32 l = 0; + for (j = 7; j > -1; j--) { + current_alpha = block[i * 8 + j]; + if (!current_alpha) { + l |= 6 << (j * 3); + continue; + } else if (current_alpha == 255) { + l |= 7 << (j * 3); + continue; + } + + float distance = + denom == 0 ? 0 : abs(current_alpha - alpha_min) / denom * 10; + if (distance < 3) { + l |= 2 << (j * 3); // 4/5 * alpha_min + 1/5 * alpha_max + } else if (distance < 5) { + l |= 3 << (j * 3); // 3/5 * alpha_min + 2/5 * alpha_max + } else if (distance < 7) { + l |= 4 << (j * 3); // 2/5 * alpha_min + 3/5 * alpha_max + } else { + l |= 5 << (j * 3); // 1/5 * alpha_min + 4/5 * alpha_max + } + } + *dst++ = l; + *dst++ = l >> 8; + *dst++ = l >> 16; + } +} + +int +ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { + int n = state->state; + int has_alpha_channel = + strcmp(im->mode, "RGBA") == 0 || strcmp(im->mode, "LA") == 0; + + UINT8 *dst = buf; + + for (;;) { + if (n == 5) { + encode_bc3_alpha(im, state, dst, 0); + dst += 8; + + encode_bc3_alpha(im, state, dst, 1); + } else { + if (n == 2 || n == 3) { + if (has_alpha_channel) { + if (n == 2) { + encode_bc2_block(im, state, dst); + } else { + encode_bc3_alpha(im, state, dst, 3); + } + dst += 8; + } else { + for (int i = 0; i < 8; i++) { + *dst++ = 0xff; + } + } + } + encode_bc1_color(im, state, dst, n == 1 && has_alpha_channel); + } + dst += 8; + + state->x += im->pixelsize * 4; + + if (state->x >= state->xsize * im->pixelsize) { + state->x = 0; + state->y += 4; + if (state->y >= state->ysize) { + state->errcode = IMAGING_CODEC_END; + break; + } + } + } + + return dst - buf; +} diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index f1c8ffcff..d5aff8709 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -501,56 +501,49 @@ polygon_generic( // Needed to draw consistent polygons xx[j] = xx[j - 1]; j++; - } else if (current->dx != 0 && roundf(xx[j - 1]) == xx[j - 1]) { + } else if ((ymin == current->ymin || ymin == current->ymax) && + current->dx != 0) { // Connect discontiguous corners for (k = 0; k < i; k++) { Edge *other_edge = edge_table[k]; - if ((current->dx > 0 && other_edge->dx <= 0) || - (current->dx < 0 && other_edge->dx >= 0)) { + if ((ymin != other_edge->ymin && ymin != other_edge->ymax) || + other_edge->dx == 0) { continue; } // Check if the two edges join to make a corner - if (((ymin == current->ymin && ymin == other_edge->ymin) || - (ymin == current->ymax && ymin == other_edge->ymax)) && - xx[j - 1] == (ymin - other_edge->y0) * other_edge->dx + - other_edge->x0) { + if (roundf(xx[j - 1]) == + roundf( + (ymin - other_edge->y0) * other_edge->dx + + other_edge->x0 + )) { // Determine points from the edges on the next row // Or if this is the last row, check the previous row - int offset = ymin == ymax ? -1 : 1; + int offset = ymin == current->ymax ? -1 : 1; adjacent_line_x = (ymin + offset - current->y0) * current->dx + current->x0; - adjacent_line_x_other_edge = - (ymin + offset - other_edge->y0) * other_edge->dx + - other_edge->x0; - if (ymin == current->ymax) { - if (current->dx > 0) { - xx[k] = - fmax( + if (ymin + offset >= other_edge->ymin && + ymin + offset <= other_edge->ymax) { + adjacent_line_x_other_edge = + (ymin + offset - other_edge->y0) * other_edge->dx + + other_edge->x0; + if (xx[j - 1] > adjacent_line_x + 1 && + xx[j - 1] > adjacent_line_x_other_edge + 1) { + xx[j - 1] = + roundf(fmax( adjacent_line_x, adjacent_line_x_other_edge - ) + + )) + 1; - } else { - xx[k] = - fmin( + } else if (xx[j - 1] < adjacent_line_x - 1 && + xx[j - 1] < adjacent_line_x_other_edge - 1) { + xx[j - 1] = + roundf(fmin( adjacent_line_x, adjacent_line_x_other_edge - ) - - 1; - } - } else { - if (current->dx > 0) { - xx[k] = fmin( - adjacent_line_x, adjacent_line_x_other_edge - ); - } else { - xx[k] = - fmax( - adjacent_line_x, adjacent_line_x_other_edge - ) + + )) - 1; } + break; } - break; } } } diff --git a/src/libImaging/Except.c b/src/libImaging/Except.c deleted file mode 100644 index f42ff9aec..000000000 --- a/src/libImaging/Except.c +++ /dev/null @@ -1,72 +0,0 @@ -/* - * The Python Imaging Library - * $Id$ - * - * default exception handling - * - * This module is usually overridden by application code (e.g. - * _imaging.c for PIL's standard Python bindings). If you get - * linking errors, remove this file from your project/library. - * - * history: - * 1995-06-15 fl Created - * 1998-12-29 fl Minor tweaks - * 2003-09-13 fl Added ImagingEnter/LeaveSection() - * - * Copyright (c) 1997-2003 by Secret Labs AB. - * Copyright (c) 1995-2003 by Fredrik Lundh. - * - * See the README file for information on usage and redistribution. - */ - -#include "Imaging.h" - -/* exception state */ - -void * -ImagingError_OSError(void) { - fprintf(stderr, "*** exception: file access error\n"); - return NULL; -} - -void * -ImagingError_MemoryError(void) { - fprintf(stderr, "*** exception: out of memory\n"); - return NULL; -} - -void * -ImagingError_ModeError(void) { - return ImagingError_ValueError("bad image mode"); -} - -void * -ImagingError_Mismatch(void) { - return ImagingError_ValueError("images don't match"); -} - -void * -ImagingError_ValueError(const char *message) { - if (!message) { - message = "exception: bad argument to function"; - } - fprintf(stderr, "*** %s\n", message); - return NULL; -} - -void -ImagingError_Clear(void) { - /* nop */; -} - -/* thread state */ - -void -ImagingSectionEnter(ImagingSectionCookie *cookie) { - /* pass */ -} - -void -ImagingSectionLeave(ImagingSectionCookie *cookie) { - /* pass */ -} diff --git a/src/libImaging/ImPlatform.h b/src/libImaging/ImPlatform.h index c9b7e43b4..2ce282241 100644 --- a/src/libImaging/ImPlatform.h +++ b/src/libImaging/ImPlatform.h @@ -44,8 +44,6 @@ defines their own types with the same names, so we need to be able to undef ours before including the JPEG code. */ -#if __STDC_VERSION__ >= 199901L /* C99+ */ - #include #define INT8 int8_t @@ -55,34 +53,6 @@ #define INT32 int32_t #define UINT32 uint32_t -#else /* < C99 */ - -#define INT8 signed char - -#if SIZEOF_SHORT == 2 -#define INT16 short -#elif SIZEOF_INT == 2 -#define INT16 int -#else -#error Cannot find required 16-bit integer type -#endif - -#if SIZEOF_SHORT == 4 -#define INT32 short -#elif SIZEOF_INT == 4 -#define INT32 int -#elif SIZEOF_LONG == 4 -#define INT32 long -#else -#error Cannot find required 32-bit integer type -#endif - -#define UINT8 unsigned char -#define UINT16 unsigned INT16 -#define UINT32 unsigned INT32 - -#endif /* < C99 */ - #endif /* not WIN */ /* assume IEEE; tweak if necessary (patches are welcome) */ diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 31052c68a..0fc191d15 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -567,6 +567,8 @@ typedef int (*ImagingCodec)( extern int ImagingBcnDecode(Imaging im, ImagingCodecState state, UINT8 *buffer, Py_ssize_t bytes); extern int +ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int bytes); +extern int ImagingBitDecode(Imaging im, ImagingCodecState state, UINT8 *buffer, Py_ssize_t bytes); extern int ImagingEpsEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int bytes); @@ -609,10 +611,6 @@ ImagingLibTiffDecode( extern int ImagingLibTiffEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int bytes); #endif -#ifdef HAVE_LIBMPEG -extern int -ImagingMpegDecode(Imaging im, ImagingCodecState state, UINT8 *buffer, Py_ssize_t bytes); -#endif extern int ImagingMspDecode(Imaging im, ImagingCodecState state, UINT8 *buffer, Py_ssize_t bytes); extern int diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index fc927d2f0..cc6955ca5 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -615,6 +615,7 @@ static const struct j2k_decode_unpacker j2k_unpackers[] = { {"RGBA", OPJ_CLRSPC_GRAY, 2, 0, j2ku_graya_la}, {"RGBA", OPJ_CLRSPC_SRGB, 3, 1, j2ku_srgb_rgb}, {"RGBA", OPJ_CLRSPC_SYCC, 3, 1, j2ku_sycc_rgb}, + {"RGBA", OPJ_CLRSPC_GRAY, 4, 1, j2ku_srgba_rgba}, {"RGBA", OPJ_CLRSPC_SRGB, 4, 1, j2ku_srgba_rgba}, {"RGBA", OPJ_CLRSPC_SYCC, 4, 1, j2ku_sycca_rgba}, {"CMYK", OPJ_CLRSPC_CMYK, 4, 1, j2ku_srgba_rgba}, @@ -640,7 +641,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { opj_dparameters_t params; OPJ_COLOR_SPACE color_space; j2k_unpacker_t unpack = NULL; - size_t buffer_size = 0, tile_bytes = 0; + size_t tile_bytes = 0; unsigned n, tile_height, tile_width; int subsampling; int total_component_width = 0; @@ -870,7 +871,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { tile_info.data_size = tile_bytes; } - if (buffer_size < tile_info.data_size) { + if (tile_info.data_size > 0) { /* malloc check ok, overflow and tile size sanity check above */ UINT8 *new = realloc(state->buffer, tile_info.data_size); if (!new) { @@ -883,7 +884,6 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { to valgrind errors. */ memset(new, 0, tile_info.data_size); state->buffer = new; - buffer_size = tile_info.data_size; } if (!opj_decode_tile_data( diff --git a/src/libImaging/Jpeg2KEncode.c b/src/libImaging/Jpeg2KEncode.c index d30ccde60..34d1a2294 100644 --- a/src/libImaging/Jpeg2KEncode.c +++ b/src/libImaging/Jpeg2KEncode.c @@ -330,6 +330,13 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { components = 4; color_space = OPJ_CLRSPC_SRGB; pack = j2k_pack_rgba; +#if ((OPJ_VERSION_MAJOR == 2 && OPJ_VERSION_MINOR == 5 && OPJ_VERSION_BUILD >= 3) || \ + (OPJ_VERSION_MAJOR == 2 && OPJ_VERSION_MINOR > 5) || OPJ_VERSION_MAJOR > 2) + } else if (strcmp(im->mode, "CMYK") == 0) { + components = 4; + color_space = OPJ_CLRSPC_CMYK; + pack = j2k_pack_rgba; +#endif } else { state->errcode = IMAGING_CODEC_BROKEN; state->state = J2K_STATE_FAILED; diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 4372d51d5..3c11eac22 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -134,7 +134,16 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { return -1; } - /* Compressor configuration */ + /* Compressor configuration */ +#ifdef JPEG_C_PARAM_SUPPORTED + /* MozJPEG */ + if (!context->progressive) { + /* Do not use MozJPEG progressive default */ + jpeg_c_set_int_param( + &context->cinfo, JINT_COMPRESS_PROFILE, JCP_FASTEST + ); + } +#endif jpeg_set_defaults(&context->cinfo); /* Prevent RGB -> YCbCr conversion */ diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index e4da9162d..9a2db95b4 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -299,6 +299,7 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) { return -1; } + img.orientation = ORIENTATION_TOPLEFT; img.req_orientation = ORIENTATION_TOPLEFT; img.col_offset = 0; diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c index c23d5d889..9c3ee2665 100644 --- a/src/libImaging/Unpack.c +++ b/src/libImaging/Unpack.c @@ -1664,6 +1664,7 @@ static struct { {"RGBA", "RGBaXX", 48, unpackRGBaskip2}, {"RGBA", "RGBa;16L", 64, unpackRGBa16L}, {"RGBA", "RGBa;16B", 64, unpackRGBa16B}, + {"RGBA", "BGR", 24, ImagingUnpackBGR}, {"RGBA", "BGRa", 32, unpackBGRa}, {"RGBA", "RGBA;I", 32, unpackRGBAI}, {"RGBA", "RGBA;L", 32, unpackRGBAL}, @@ -1695,6 +1696,7 @@ static struct { #ifdef WORDS_BIGENDIAN {"RGB", "RGB;16N", 48, unpackRGB16B}, + {"RGB", "RGBX;16N", 64, unpackRGBA16B}, {"RGBA", "RGBa;16N", 64, unpackRGBa16B}, {"RGBA", "RGBA;16N", 64, unpackRGBA16B}, {"RGBX", "RGBX;16N", 64, unpackRGBA16B}, @@ -1708,6 +1710,7 @@ static struct { {"RGBA", "A;16N", 16, band316B}, #else {"RGB", "RGB;16N", 48, unpackRGB16L}, + {"RGB", "RGBX;16N", 64, unpackRGBA16L}, {"RGBA", "RGBa;16N", 64, unpackRGBa16L}, {"RGBA", "RGBA;16N", 64, unpackRGBA16L}, {"RGBX", "RGBX;16N", 64, unpackRGBA16L}, diff --git a/src/outline.c b/src/outline.c index 27cc255cf..32ab9109c 100644 --- a/src/outline.c +++ b/src/outline.c @@ -89,8 +89,7 @@ _outline_move(OutlineObject *self, PyObject *args) { ImagingOutlineMove(self->outline, x0, y0); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -102,8 +101,7 @@ _outline_line(OutlineObject *self, PyObject *args) { ImagingOutlineLine(self->outline, x1, y1); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -115,8 +113,7 @@ _outline_curve(OutlineObject *self, PyObject *args) { ImagingOutlineCurve(self->outline, x1, y1, x2, y2, x3, y3); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -127,8 +124,7 @@ _outline_close(OutlineObject *self, PyObject *args) { ImagingOutlineClose(self->outline); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -140,8 +136,7 @@ _outline_transform(OutlineObject *self, PyObject *args) { ImagingOutlineTransform(self->outline, a); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static struct PyMethodDef _outline_methods[] = { @@ -154,34 +149,8 @@ static struct PyMethodDef _outline_methods[] = { }; static PyTypeObject OutlineType = { - PyVarObject_HEAD_INIT(NULL, 0) "Outline", /*tp_name*/ - sizeof(OutlineObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)_outline_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - _outline_methods, /*tp_methods*/ - 0, /*tp_members*/ - 0, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "Outline", + .tp_basicsize = sizeof(OutlineObject), + .tp_dealloc = (destructor)_outline_dealloc, + .tp_methods = _outline_methods, }; diff --git a/src/path.c b/src/path.c index 067f42f62..38300547c 100644 --- a/src/path.c +++ b/src/path.c @@ -109,6 +109,39 @@ path_dealloc(PyPathObject *path) { #define PyPath_Check(op) (Py_TYPE(op) == &PyPathType) +static int +assign_item_to_array(double *xy, Py_ssize_t j, PyObject *op) { + if (PyFloat_Check(op)) { + xy[j++] = PyFloat_AS_DOUBLE(op); + } else if (PyLong_Check(op)) { + xy[j++] = (float)PyLong_AS_LONG(op); + } else if (PyNumber_Check(op)) { + xy[j++] = PyFloat_AsDouble(op); + } else if (PyList_Check(op)) { + for (int k = 0; k < 2; k++) { + PyObject *op1 = PyList_GetItemRef(op, k); + if (op1 == NULL) { + return -1; + } + j = assign_item_to_array(xy, j, op1); + Py_DECREF(op1); + if (j == -1) { + return -1; + } + } + } else { + double x, y; + if (PyArg_ParseTuple(op, "dd", &x, &y)) { + xy[j++] = x; + xy[j++] = y; + } else { + PyErr_SetString(PyExc_ValueError, "incorrect coordinate type"); + return -1; + } + } + return j; +} + Py_ssize_t PyPath_Flatten(PyObject *data, double **pxy) { Py_ssize_t i, j, n; @@ -164,48 +197,32 @@ PyPath_Flatten(PyObject *data, double **pxy) { return -1; } -#define assign_item_to_array(op, decref) \ - if (PyFloat_Check(op)) { \ - xy[j++] = PyFloat_AS_DOUBLE(op); \ - } else if (PyLong_Check(op)) { \ - xy[j++] = (float)PyLong_AS_LONG(op); \ - } else if (PyNumber_Check(op)) { \ - xy[j++] = PyFloat_AsDouble(op); \ - } else if (PyArg_ParseTuple(op, "dd", &x, &y)) { \ - xy[j++] = x; \ - xy[j++] = y; \ - } else { \ - PyErr_SetString(PyExc_ValueError, "incorrect coordinate type"); \ - if (decref) { \ - Py_DECREF(op); \ - } \ - free(xy); \ - return -1; \ - } \ - if (decref) { \ - Py_DECREF(op); \ - } - /* Copy table to path array */ if (PyList_Check(data)) { for (i = 0; i < n; i++) { - double x, y; PyObject *op = PyList_GetItemRef(data, i); if (op == NULL) { free(xy); return -1; } - assign_item_to_array(op, 1); + j = assign_item_to_array(xy, j, op); + Py_DECREF(op); + if (j == -1) { + free(xy); + return -1; + } } } else if (PyTuple_Check(data)) { for (i = 0; i < n; i++) { - double x, y; PyObject *op = PyTuple_GET_ITEM(data, i); - assign_item_to_array(op, 0); + j = assign_item_to_array(xy, j, op); + if (j == -1) { + free(xy); + return -1; + } } } else { for (i = 0; i < n; i++) { - double x, y; PyObject *op = PySequence_GetItem(data, i); if (!op) { /* treat IndexError as end of sequence */ @@ -217,7 +234,12 @@ PyPath_Flatten(PyObject *data, double **pxy) { return -1; } } - assign_item_to_array(op, 1); + j = assign_item_to_array(xy, j, op); + Py_DECREF(op); + if (j == -1) { + free(xy); + return -1; + } } } @@ -415,8 +437,7 @@ path_map(PyPathObject *self, PyObject *args) { } self->mapping = 0; - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static int @@ -528,8 +549,7 @@ path_transform(PyPathObject *self, PyObject *args) { } } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static struct PyMethodDef methods[] = { @@ -600,34 +620,11 @@ static PyMappingMethods path_as_mapping = { }; static PyTypeObject PyPathType = { - PyVarObject_HEAD_INIT(NULL, 0) "Path", /*tp_name*/ - sizeof(PyPathObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - /* methods */ - (destructor)path_dealloc, /*tp_dealloc*/ - 0, /*tp_vectorcall_offset*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_as_async*/ - 0, /*tp_repr*/ - 0, /*tp_as_number*/ - &path_as_sequence, /*tp_as_sequence*/ - &path_as_mapping, /*tp_as_mapping*/ - 0, /*tp_hash*/ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT, /*tp_flags*/ - 0, /*tp_doc*/ - 0, /*tp_traverse*/ - 0, /*tp_clear*/ - 0, /*tp_richcompare*/ - 0, /*tp_weaklistoffset*/ - 0, /*tp_iter*/ - 0, /*tp_iternext*/ - methods, /*tp_methods*/ - 0, /*tp_members*/ - getsetters, /*tp_getset*/ + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "Path", + .tp_basicsize = sizeof(PyPathObject), + .tp_dealloc = (destructor)path_dealloc, + .tp_as_sequence = &path_as_sequence, + .tp_as_mapping = &path_as_mapping, + .tp_methods = methods, + .tp_getset = getsetters, }; diff --git a/src/thirdparty/pythoncapi_compat.h b/src/thirdparty/pythoncapi_compat.h index ca23d5ffa..04fcf61e0 100644 --- a/src/thirdparty/pythoncapi_compat.h +++ b/src/thirdparty/pythoncapi_compat.h @@ -10,7 +10,7 @@ // https://raw.githubusercontent.com/python/pythoncapi-compat/main/pythoncapi_compat.h // // This file was vendored from the following commit: -// https://github.com/python/pythoncapi-compat/commit/0041177c4f348c8952b4c8980b2c90856e61c7c7 +// https://github.com/python/pythoncapi-compat/commit/c84545f0e1e21757d4901f75c47333d25a3fcff0 // // SPDX-License-Identifier: 0BSD @@ -22,11 +22,15 @@ extern "C" { #endif #include +#include // offsetof() // Python 3.11.0b4 added PyFrame_Back() to Python.h #if PY_VERSION_HEX < 0x030b00B4 && !defined(PYPY_VERSION) # include "frameobject.h" // PyFrameObject, PyFrame_GetBack() #endif +#if PY_VERSION_HEX < 0x030C00A3 +# include // T_SHORT, READONLY +#endif #ifndef _Py_CAST @@ -290,7 +294,7 @@ PyFrame_GetVarString(PyFrameObject *frame, const char *name) // bpo-39947 added PyThreadState_GetInterpreter() to Python 3.9.0a5 -#if PY_VERSION_HEX < 0x030900A5 || defined(PYPY_VERSION) +#if PY_VERSION_HEX < 0x030900A5 || (defined(PYPY_VERSION) && PY_VERSION_HEX < 0x030B0000) static inline PyInterpreterState * PyThreadState_GetInterpreter(PyThreadState *tstate) { @@ -583,7 +587,7 @@ static inline int PyWeakref_GetRef(PyObject *ref, PyObject **pobj) return 0; } *pobj = Py_NewRef(obj); - return (*pobj != NULL); + return 1; } #endif @@ -921,7 +925,7 @@ static inline int PyObject_VisitManagedDict(PyObject *obj, visitproc visit, void *arg) { PyObject **dict = _PyObject_GetDictPtr(obj); - if (*dict == NULL) { + if (dict == NULL || *dict == NULL) { return -1; } Py_VISIT(*dict); @@ -932,7 +936,7 @@ static inline void PyObject_ClearManagedDict(PyObject *obj) { PyObject **dict = _PyObject_GetDictPtr(obj); - if (*dict == NULL) { + if (dict == NULL || *dict == NULL) { return; } Py_CLEAR(*dict); @@ -1207,11 +1211,11 @@ static inline int PyTime_PerfCounter(PyTime_t *result) #endif // gh-111389 added hash constants to Python 3.13.0a5. These constants were -// added first as private macros to Python 3.4.0b1 and PyPy 7.3.9. +// added first as private macros to Python 3.4.0b1 and PyPy 7.3.8. #if (!defined(PyHASH_BITS) \ && ((!defined(PYPY_VERSION) && PY_VERSION_HEX >= 0x030400B1) \ || (defined(PYPY_VERSION) && PY_VERSION_HEX >= 0x03070000 \ - && PYPY_VERSION_NUM >= 0x07090000))) + && PYPY_VERSION_NUM >= 0x07030800))) # define PyHASH_BITS _PyHASH_BITS # define PyHASH_MODULUS _PyHASH_MODULUS # define PyHASH_INF _PyHASH_INF @@ -1523,6 +1527,36 @@ static inline int PyLong_GetSign(PyObject *obj, int *sign) } #endif +// gh-126061 added PyLong_IsPositive/Negative/Zero() to Python in 3.14.0a2 +#if PY_VERSION_HEX < 0x030E00A2 +static inline int PyLong_IsPositive(PyObject *obj) +{ + if (!PyLong_Check(obj)) { + PyErr_Format(PyExc_TypeError, "expected int, got %s", Py_TYPE(obj)->tp_name); + return -1; + } + return _PyLong_Sign(obj) == 1; +} + +static inline int PyLong_IsNegative(PyObject *obj) +{ + if (!PyLong_Check(obj)) { + PyErr_Format(PyExc_TypeError, "expected int, got %s", Py_TYPE(obj)->tp_name); + return -1; + } + return _PyLong_Sign(obj) == -1; +} + +static inline int PyLong_IsZero(PyObject *obj) +{ + if (!PyLong_Check(obj)) { + PyErr_Format(PyExc_TypeError, "expected int, got %s", Py_TYPE(obj)->tp_name); + return -1; + } + return _PyLong_Sign(obj) == 0; +} +#endif + // gh-124502 added PyUnicode_Equal() to Python 3.14.0a0 #if PY_VERSION_HEX < 0x030E00A0 @@ -1693,6 +1727,479 @@ static inline int PyLong_AsUInt64(PyObject *obj, uint64_t *pvalue) #endif +// gh-102471 added import and export API for integers to 3.14.0a2. +#if PY_VERSION_HEX < 0x030E00A2 && PY_VERSION_HEX >= 0x03000000 && !defined(PYPY_VERSION) +// Helpers to access PyLongObject internals. +static inline void +_PyLong_SetSignAndDigitCount(PyLongObject *op, int sign, Py_ssize_t size) +{ +#if PY_VERSION_HEX >= 0x030C0000 + op->long_value.lv_tag = (uintptr_t)(1 - sign) | ((uintptr_t)(size) << 3); +#elif PY_VERSION_HEX >= 0x030900A4 + Py_SET_SIZE(op, sign * size); +#else + Py_SIZE(op) = sign * size; +#endif +} + +static inline Py_ssize_t +_PyLong_DigitCount(const PyLongObject *op) +{ +#if PY_VERSION_HEX >= 0x030C0000 + return (Py_ssize_t)(op->long_value.lv_tag >> 3); +#else + return _PyLong_Sign((PyObject*)op) < 0 ? -Py_SIZE(op) : Py_SIZE(op); +#endif +} + +static inline digit* +_PyLong_GetDigits(const PyLongObject *op) +{ +#if PY_VERSION_HEX >= 0x030C0000 + return (digit*)(op->long_value.ob_digit); +#else + return (digit*)(op->ob_digit); +#endif +} + +typedef struct PyLongLayout { + uint8_t bits_per_digit; + uint8_t digit_size; + int8_t digits_order; + int8_t digit_endianness; +} PyLongLayout; + +typedef struct PyLongExport { + int64_t value; + uint8_t negative; + Py_ssize_t ndigits; + const void *digits; + Py_uintptr_t _reserved; +} PyLongExport; + +typedef struct PyLongWriter PyLongWriter; + +static inline const PyLongLayout* +PyLong_GetNativeLayout(void) +{ + static const PyLongLayout PyLong_LAYOUT = { + PyLong_SHIFT, + sizeof(digit), + -1, // least significant first + PY_LITTLE_ENDIAN ? -1 : 1, + }; + + return &PyLong_LAYOUT; +} + +static inline int +PyLong_Export(PyObject *obj, PyLongExport *export_long) +{ + if (!PyLong_Check(obj)) { + memset(export_long, 0, sizeof(*export_long)); + PyErr_Format(PyExc_TypeError, "expected int, got %s", + Py_TYPE(obj)->tp_name); + return -1; + } + + // Fast-path: try to convert to a int64_t + PyLongObject *self = (PyLongObject*)obj; + int overflow; +#if SIZEOF_LONG == 8 + long value = PyLong_AsLongAndOverflow(obj, &overflow); +#else + // Windows has 32-bit long, so use 64-bit long long instead + long long value = PyLong_AsLongLongAndOverflow(obj, &overflow); +#endif + Py_BUILD_ASSERT(sizeof(value) == sizeof(int64_t)); + // the function cannot fail since obj is a PyLongObject + assert(!(value == -1 && PyErr_Occurred())); + + if (!overflow) { + export_long->value = value; + export_long->negative = 0; + export_long->ndigits = 0; + export_long->digits = 0; + export_long->_reserved = 0; + } + else { + export_long->value = 0; + export_long->negative = _PyLong_Sign(obj) < 0; + export_long->ndigits = _PyLong_DigitCount(self); + if (export_long->ndigits == 0) { + export_long->ndigits = 1; + } + export_long->digits = _PyLong_GetDigits(self); + export_long->_reserved = (Py_uintptr_t)Py_NewRef(obj); + } + return 0; +} + +static inline void +PyLong_FreeExport(PyLongExport *export_long) +{ + PyObject *obj = (PyObject*)export_long->_reserved; + + if (obj) { + export_long->_reserved = 0; + Py_DECREF(obj); + } +} + +static inline PyLongWriter* +PyLongWriter_Create(int negative, Py_ssize_t ndigits, void **digits) +{ + if (ndigits <= 0) { + PyErr_SetString(PyExc_ValueError, "ndigits must be positive"); + return NULL; + } + assert(digits != NULL); + + PyLongObject *obj = _PyLong_New(ndigits); + if (obj == NULL) { + return NULL; + } + _PyLong_SetSignAndDigitCount(obj, negative?-1:1, ndigits); + + *digits = _PyLong_GetDigits(obj); + return (PyLongWriter*)obj; +} + +static inline void +PyLongWriter_Discard(PyLongWriter *writer) +{ + PyLongObject *obj = (PyLongObject *)writer; + + assert(Py_REFCNT(obj) == 1); + Py_DECREF(obj); +} + +static inline PyObject* +PyLongWriter_Finish(PyLongWriter *writer) +{ + PyObject *obj = (PyObject *)writer; + PyLongObject *self = (PyLongObject*)obj; + Py_ssize_t j = _PyLong_DigitCount(self); + Py_ssize_t i = j; + int sign = _PyLong_Sign(obj); + + assert(Py_REFCNT(obj) == 1); + + // Normalize and get singleton if possible + while (i > 0 && _PyLong_GetDigits(self)[i-1] == 0) { + --i; + } + if (i != j) { + if (i == 0) { + sign = 0; + } + _PyLong_SetSignAndDigitCount(self, sign, i); + } + if (i <= 1) { + long val = sign * (long)(_PyLong_GetDigits(self)[0]); + Py_DECREF(obj); + return PyLong_FromLong(val); + } + + return obj; +} +#endif + + +#if PY_VERSION_HEX < 0x030C00A3 +# define Py_T_SHORT T_SHORT +# define Py_T_INT T_INT +# define Py_T_LONG T_LONG +# define Py_T_FLOAT T_FLOAT +# define Py_T_DOUBLE T_DOUBLE +# define Py_T_STRING T_STRING +# define _Py_T_OBJECT T_OBJECT +# define Py_T_CHAR T_CHAR +# define Py_T_BYTE T_BYTE +# define Py_T_UBYTE T_UBYTE +# define Py_T_USHORT T_USHORT +# define Py_T_UINT T_UINT +# define Py_T_ULONG T_ULONG +# define Py_T_STRING_INPLACE T_STRING_INPLACE +# define Py_T_BOOL T_BOOL +# define Py_T_OBJECT_EX T_OBJECT_EX +# define Py_T_LONGLONG T_LONGLONG +# define Py_T_ULONGLONG T_ULONGLONG +# define Py_T_PYSSIZET T_PYSSIZET + +# if PY_VERSION_HEX >= 0x03000000 && !defined(PYPY_VERSION) +# define _Py_T_NONE T_NONE +# endif + +# define Py_READONLY READONLY +# define Py_AUDIT_READ READ_RESTRICTED +# define _Py_WRITE_RESTRICTED PY_WRITE_RESTRICTED +#endif + + +// gh-127350 added Py_fopen() and Py_fclose() to Python 3.14a4 +#if PY_VERSION_HEX < 0x030E00A4 +static inline FILE* Py_fopen(PyObject *path, const char *mode) +{ +#if 0x030400A2 <= PY_VERSION_HEX && !defined(PYPY_VERSION) + PyAPI_FUNC(FILE*) _Py_fopen_obj(PyObject *path, const char *mode); + + return _Py_fopen_obj(path, mode); +#else + FILE *f; + PyObject *bytes; +#if PY_VERSION_HEX >= 0x03000000 + if (!PyUnicode_FSConverter(path, &bytes)) { + return NULL; + } +#else + if (!PyString_Check(path)) { + PyErr_SetString(PyExc_TypeError, "except str"); + return NULL; + } + bytes = Py_NewRef(path); +#endif + const char *path_bytes = PyBytes_AS_STRING(bytes); + + f = fopen(path_bytes, mode); + Py_DECREF(bytes); + + if (f == NULL) { + PyErr_SetFromErrnoWithFilenameObject(PyExc_OSError, path); + return NULL; + } + return f; +#endif +} + +static inline int Py_fclose(FILE *file) +{ + return fclose(file); +} +#endif + + +#if 0x03090000 <= PY_VERSION_HEX && PY_VERSION_HEX < 0x030E0000 && !defined(PYPY_VERSION) +static inline PyObject* +PyConfig_Get(const char *name) +{ + typedef enum { + _PyConfig_MEMBER_INT, + _PyConfig_MEMBER_UINT, + _PyConfig_MEMBER_ULONG, + _PyConfig_MEMBER_BOOL, + _PyConfig_MEMBER_WSTR, + _PyConfig_MEMBER_WSTR_OPT, + _PyConfig_MEMBER_WSTR_LIST, + } PyConfigMemberType; + + typedef struct { + const char *name; + size_t offset; + PyConfigMemberType type; + const char *sys_attr; + } PyConfigSpec; + +#define PYTHONCAPI_COMPAT_SPEC(MEMBER, TYPE, sys_attr) \ + {#MEMBER, offsetof(PyConfig, MEMBER), \ + _PyConfig_MEMBER_##TYPE, sys_attr} + + static const PyConfigSpec config_spec[] = { + PYTHONCAPI_COMPAT_SPEC(argv, WSTR_LIST, "argv"), + PYTHONCAPI_COMPAT_SPEC(base_exec_prefix, WSTR_OPT, "base_exec_prefix"), + PYTHONCAPI_COMPAT_SPEC(base_executable, WSTR_OPT, "_base_executable"), + PYTHONCAPI_COMPAT_SPEC(base_prefix, WSTR_OPT, "base_prefix"), + PYTHONCAPI_COMPAT_SPEC(bytes_warning, UINT, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(exec_prefix, WSTR_OPT, "exec_prefix"), + PYTHONCAPI_COMPAT_SPEC(executable, WSTR_OPT, "executable"), + PYTHONCAPI_COMPAT_SPEC(inspect, BOOL, _Py_NULL), +#if 0x030C0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(int_max_str_digits, UINT, _Py_NULL), +#endif + PYTHONCAPI_COMPAT_SPEC(interactive, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(module_search_paths, WSTR_LIST, "path"), + PYTHONCAPI_COMPAT_SPEC(optimization_level, UINT, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(parser_debug, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(platlibdir, WSTR, "platlibdir"), + PYTHONCAPI_COMPAT_SPEC(prefix, WSTR_OPT, "prefix"), + PYTHONCAPI_COMPAT_SPEC(pycache_prefix, WSTR_OPT, "pycache_prefix"), + PYTHONCAPI_COMPAT_SPEC(quiet, BOOL, _Py_NULL), +#if 0x030B0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(stdlib_dir, WSTR_OPT, "_stdlib_dir"), +#endif + PYTHONCAPI_COMPAT_SPEC(use_environment, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(verbose, UINT, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(warnoptions, WSTR_LIST, "warnoptions"), + PYTHONCAPI_COMPAT_SPEC(write_bytecode, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(xoptions, WSTR_LIST, "_xoptions"), + PYTHONCAPI_COMPAT_SPEC(buffered_stdio, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(check_hash_pycs_mode, WSTR, _Py_NULL), +#if 0x030B0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(code_debug_ranges, BOOL, _Py_NULL), +#endif + PYTHONCAPI_COMPAT_SPEC(configure_c_stdio, BOOL, _Py_NULL), +#if 0x030D0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(cpu_count, INT, _Py_NULL), +#endif + PYTHONCAPI_COMPAT_SPEC(dev_mode, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(dump_refs, BOOL, _Py_NULL), +#if 0x030B0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(dump_refs_file, WSTR_OPT, _Py_NULL), +#endif +#ifdef Py_GIL_DISABLED + PYTHONCAPI_COMPAT_SPEC(enable_gil, INT, _Py_NULL), +#endif + PYTHONCAPI_COMPAT_SPEC(faulthandler, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(filesystem_encoding, WSTR, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(filesystem_errors, WSTR, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(hash_seed, ULONG, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(home, WSTR_OPT, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(import_time, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(install_signal_handlers, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(isolated, BOOL, _Py_NULL), +#ifdef MS_WINDOWS + PYTHONCAPI_COMPAT_SPEC(legacy_windows_stdio, BOOL, _Py_NULL), +#endif + PYTHONCAPI_COMPAT_SPEC(malloc_stats, BOOL, _Py_NULL), +#if 0x030A0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(orig_argv, WSTR_LIST, "orig_argv"), +#endif + PYTHONCAPI_COMPAT_SPEC(parse_argv, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(pathconfig_warnings, BOOL, _Py_NULL), +#if 0x030C0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(perf_profiling, UINT, _Py_NULL), +#endif + PYTHONCAPI_COMPAT_SPEC(program_name, WSTR, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(run_command, WSTR_OPT, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(run_filename, WSTR_OPT, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(run_module, WSTR_OPT, _Py_NULL), +#if 0x030B0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(safe_path, BOOL, _Py_NULL), +#endif + PYTHONCAPI_COMPAT_SPEC(show_ref_count, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(site_import, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(skip_source_first_line, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(stdio_encoding, WSTR, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(stdio_errors, WSTR, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(tracemalloc, UINT, _Py_NULL), +#if 0x030B0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(use_frozen_modules, BOOL, _Py_NULL), +#endif + PYTHONCAPI_COMPAT_SPEC(use_hash_seed, BOOL, _Py_NULL), + PYTHONCAPI_COMPAT_SPEC(user_site_directory, BOOL, _Py_NULL), +#if 0x030A0000 <= PY_VERSION_HEX + PYTHONCAPI_COMPAT_SPEC(warn_default_encoding, BOOL, _Py_NULL), +#endif + }; + +#undef PYTHONCAPI_COMPAT_SPEC + + const PyConfigSpec *spec; + int found = 0; + for (size_t i=0; i < sizeof(config_spec) / sizeof(config_spec[0]); i++) { + spec = &config_spec[i]; + if (strcmp(spec->name, name) == 0) { + found = 1; + break; + } + } + if (found) { + if (spec->sys_attr != NULL) { + PyObject *value = PySys_GetObject(spec->sys_attr); + if (value == NULL) { + PyErr_Format(PyExc_RuntimeError, "lost sys.%s", spec->sys_attr); + return NULL; + } + return Py_NewRef(value); + } + + PyAPI_FUNC(const PyConfig*) _Py_GetConfig(void); + + const PyConfig *config = _Py_GetConfig(); + void *member = (char *)config + spec->offset; + switch (spec->type) { + case _PyConfig_MEMBER_INT: + case _PyConfig_MEMBER_UINT: + { + int value = *(int *)member; + return PyLong_FromLong(value); + } + case _PyConfig_MEMBER_BOOL: + { + int value = *(int *)member; + return PyBool_FromLong(value != 0); + } + case _PyConfig_MEMBER_ULONG: + { + unsigned long value = *(unsigned long *)member; + return PyLong_FromUnsignedLong(value); + } + case _PyConfig_MEMBER_WSTR: + case _PyConfig_MEMBER_WSTR_OPT: + { + wchar_t *wstr = *(wchar_t **)member; + if (wstr != NULL) { + return PyUnicode_FromWideChar(wstr, -1); + } + else { + return Py_NewRef(Py_None); + } + } + case _PyConfig_MEMBER_WSTR_LIST: + { + const PyWideStringList *list = (const PyWideStringList *)member; + PyObject *tuple = PyTuple_New(list->length); + if (tuple == NULL) { + return NULL; + } + + for (Py_ssize_t i = 0; i < list->length; i++) { + PyObject *item = PyUnicode_FromWideChar(list->items[i], -1); + if (item == NULL) { + Py_DECREF(tuple); + return NULL; + } + PyTuple_SET_ITEM(tuple, i, item); + } + return tuple; + } + default: + Py_UNREACHABLE(); + } + } + + PyErr_Format(PyExc_ValueError, "unknown config option name: %s", name); + return NULL; +} + +static inline int +PyConfig_GetInt(const char *name, int *value) +{ + PyObject *obj = PyConfig_Get(name); + if (obj == NULL) { + return -1; + } + + if (!PyLong_Check(obj)) { + Py_DECREF(obj); + PyErr_Format(PyExc_TypeError, "config option %s is not an int", name); + return -1; + } + + int as_int = PyLong_AsInt(obj); + Py_DECREF(obj); + if (as_int == -1 && PyErr_Occurred()) { + PyErr_Format(PyExc_OverflowError, + "config option %s value does not fit into a C int", name); + return -1; + } + + *value = as_int; + return 0; +} +#endif // PY_VERSION_HEX > 0x03090000 && !defined(PYPY_VERSION) + + #ifdef __cplusplus } #endif diff --git a/tox.ini b/tox.ini index e79d88500..4065245ee 100644 --- a/tox.ini +++ b/tox.ini @@ -11,12 +11,8 @@ deps = extras = tests commands = - make clean - {envpython} -m pip install . {envpython} selftest.py {envpython} -m pytest -W always {posargs} -allowlist_externals = - make [testenv:lint] skip_install = true diff --git a/wheels/multibuild b/wheels/multibuild index 9a9d1275f..42d761728 160000 --- a/wheels/multibuild +++ b/wheels/multibuild @@ -1 +1 @@ -Subproject commit 9a9d1275f025f737cdaa3c451ba07129dd95f361 +Subproject commit 42d761728d141d8462cd9943f4329f12fe62b155 diff --git a/winbuild/README.md b/winbuild/README.md index f6111c79b..c474f12ce 100644 --- a/winbuild/README.md +++ b/winbuild/README.md @@ -11,10 +11,11 @@ For more extensive info, see the [Windows build instructions](build.rst). * Requires Microsoft Visual Studio 2017 or newer with C++ component. * Requires NASM for libjpeg-turbo, a required dependency when using this script. * Requires CMake 3.15 or newer (available as Visual Studio component). -* Tested on Windows Server 2019 with Visual Studio 2019 Community and Visual Studio 2022 Community (AppVeyor). -* Tested on Windows Server 2022 with Visual Studio 2022 Enterprise (GitHub Actions). +* Tested on Windows Server 2022 with Visual Studio 2022 Enterprise and Windows Server + 2019 with Visual Studio 2019 Enterprise (GitHub Actions). + +Here's an example script to build on Windows: -The following is a simplified version of the script used on AppVeyor: ``` set PYTHON=C:\Python39\bin cd /D C:\Pillow\winbuild diff --git a/winbuild/build.rst b/winbuild/build.rst index 96b8803b4..aae78ce12 100644 --- a/winbuild/build.rst +++ b/winbuild/build.rst @@ -6,7 +6,7 @@ Building Pillow on Windows be sufficient. This page describes the steps necessary to build Pillow using the same -scripts used on GitHub Actions and AppVeyor CIs. +scripts used on GitHub Actions CI. Prerequisites ------------- @@ -112,7 +112,7 @@ directory. Example ------- -The following is a simplified version of the script used on AppVeyor:: +Here's an example script to build on Windows:: set PYTHON=C:\Python39\bin cd /D C:\Pillow\winbuild diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index c8332d11c..2e9e18719 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -7,6 +7,7 @@ import re import shutil import struct import subprocess +import sys from typing import Any @@ -112,27 +113,25 @@ V = { "BROTLI": "1.1.0", "FREETYPE": "2.13.3", "FRIBIDI": "1.0.16", - "HARFBUZZ": "10.0.1", - "JPEGTURBO": "3.0.4", - "LCMS2": "2.16", - "LIBPNG": "1.6.44", - "LIBWEBP": "1.4.0", - "OPENJPEG": "2.5.2", - "TIFF": "4.6.0", - "XZ": "5.6.3", - "ZLIB": "1.3.1", + "HARFBUZZ": "11.0.0", + "JPEGTURBO": "3.1.0", + "LCMS2": "2.17", + "LIBIMAGEQUANT": "4.3.4", + "LIBPNG": "1.6.47", + "LIBWEBP": "1.5.0", + "OPENJPEG": "2.5.3", + "TIFF": "4.7.0", + "XZ": "5.6.4", + "ZLIBNG": "2.2.4", } -V["LIBPNG_DOTLESS"] = V["LIBPNG"].replace(".", "") V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) -V["ZLIB_DOTLESS"] = V["ZLIB"].replace(".", "") # dependencies, listed in order of compilation DEPS: dict[str, dict[str, Any]] = { "libjpeg": { - "url": f"{SF_PROJECTS}/libjpeg-turbo/files/{V['JPEGTURBO']}/FILENAME/download", + "url": f"https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/{V['JPEGTURBO']}/libjpeg-turbo-{V['JPEGTURBO']}.tar.gz", "filename": f"libjpeg-turbo-{V['JPEGTURBO']}.tar.gz", - "dir": f"libjpeg-turbo-{V['JPEGTURBO']}", "license": ["README.ijg", "LICENSE.md"], "license_pattern": ( "(LEGAL ISSUES\n============\n\n.+?)\n\nREFERENCES\n==========" @@ -155,28 +154,30 @@ DEPS: dict[str, dict[str, Any]] = { cmd_copy("cjpeg-static.exe", "cjpeg.exe"), cmd_copy("djpeg-static.exe", "djpeg.exe"), ], - "headers": ["j*.h"], + "headers": ["jconfig.h", r"src\j*.h"], "libs": ["libjpeg.lib"], "bins": ["cjpeg.exe", "djpeg.exe"], }, "zlib": { - "url": "https://zlib.net/FILENAME", - "filename": f"zlib{V['ZLIB_DOTLESS']}.zip", - "dir": f"zlib-{V['ZLIB']}", - "license": "README", - "license_pattern": "Copyright notice:\n\n(.+)$", + "url": f"https://github.com/zlib-ng/zlib-ng/archive/refs/tags/{V['ZLIBNG']}.tar.gz", + "filename": f"zlib-ng-{V['ZLIBNG']}.tar.gz", + "license": "LICENSE.md", + "patch": { + r"CMakeLists.txt": { + "set_target_properties(zlib PROPERTIES OUTPUT_NAME zlibstatic${{SUFFIX}})": "set_target_properties(zlib PROPERTIES OUTPUT_NAME zlib)", # noqa: E501 + }, + }, "build": [ - cmd_nmake(r"win32\Makefile.msc", "clean"), - cmd_nmake(r"win32\Makefile.msc", "zlib.lib"), - cmd_copy("zlib.lib", "z.lib"), + *cmds_cmake( + "zlib", "-DBUILD_SHARED_LIBS:BOOL=OFF", "-DZLIB_COMPAT:BOOL=ON" + ), ], "headers": [r"z*.h"], - "libs": [r"*.lib"], + "libs": [r"zlib.lib"], }, "xz": { "url": f"https://github.com/tukaani-project/xz/releases/download/v{V['XZ']}/FILENAME", "filename": f"xz-{V['XZ']}.tar.gz", - "dir": f"xz-{V['XZ']}", "license": "COPYING", "build": [ *cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"), @@ -189,7 +190,6 @@ DEPS: dict[str, dict[str, Any]] = { "libwebp": { "url": "http://downloads.webmproject.org/releases/webp/FILENAME", "filename": f"libwebp-{V['LIBWEBP']}.tar.gz", - "dir": f"libwebp-{V['LIBWEBP']}", "license": "COPYING", "patch": { r"src\enc\picture_csp_enc.c": { @@ -211,7 +211,6 @@ DEPS: dict[str, dict[str, Any]] = { "libtiff": { "url": "https://download.osgeo.org/libtiff/FILENAME", "filename": f"tiff-{V['TIFF']}.tar.gz", - "dir": f"tiff-{V['TIFF']}", "license": "LICENSE.md", "patch": { r"libtiff\tif_lzma.c": { @@ -242,9 +241,8 @@ DEPS: dict[str, dict[str, Any]] = { }, "libpng": { "url": f"{SF_PROJECTS}/libpng/files/libpng{V['LIBPNG_XY']}/{V['LIBPNG']}/" - f"lpng{V['LIBPNG_DOTLESS']}.zip/download", - "filename": f"lpng{V['LIBPNG_DOTLESS']}.zip", - "dir": f"lpng{V['LIBPNG_DOTLESS']}", + f"FILENAME/download", + "filename": f"libpng-{V['LIBPNG']}.tar.gz", "license": "LICENSE", "build": [ *cmds_cmake("png_static", "-DPNG_SHARED:BOOL=OFF", "-DPNG_TESTS:BOOL=OFF"), @@ -258,7 +256,6 @@ DEPS: dict[str, dict[str, Any]] = { "brotli": { "url": f"https://github.com/google/brotli/archive/refs/tags/v{V['BROTLI']}.tar.gz", "filename": f"brotli-{V['BROTLI']}.tar.gz", - "dir": f"brotli-{V['BROTLI']}", "license": "LICENSE", "build": [ *cmds_cmake(("brotlicommon", "brotlidec"), "-DBUILD_SHARED_LIBS:BOOL=OFF"), @@ -269,7 +266,6 @@ DEPS: dict[str, dict[str, Any]] = { "freetype": { "url": "https://download.savannah.gnu.org/releases/freetype/FILENAME", "filename": f"freetype-{V['FREETYPE']}.tar.gz", - "dir": f"freetype-{V['FREETYPE']}", "license": ["LICENSE.TXT", r"docs\FTL.TXT", r"docs\GPLv2.TXT"], "patch": { r"builds\windows\vc2010\freetype.vcxproj": { @@ -304,7 +300,6 @@ DEPS: dict[str, dict[str, Any]] = { "lcms2": { "url": f"{SF_PROJECTS}/lcms/files/lcms/{V['LCMS2']}/FILENAME/download", "filename": f"lcms2-{V['LCMS2']}.tar.gz", - "dir": f"lcms2-{V['LCMS2']}", "license": "LICENSE", "patch": { r"Projects\VC2022\lcms2_static\lcms2_static.vcxproj": { @@ -330,7 +325,6 @@ DEPS: dict[str, dict[str, Any]] = { "openjpeg": { "url": f"https://github.com/uclouvain/openjpeg/archive/v{V['OPENJPEG']}.tar.gz", "filename": f"openjpeg-{V['OPENJPEG']}.tar.gz", - "dir": f"openjpeg-{V['OPENJPEG']}", "license": "LICENSE", "build": [ *cmds_cmake( @@ -342,30 +336,19 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"bin\*.lib"], }, "libimagequant": { - # commit: Merge branch 'master' into msvc (matches 2.17.0 tag) - "url": "https://github.com/ImageOptim/libimagequant/archive/e4c1334be0eff290af5e2b4155057c2953a313ab.zip", - "filename": "libimagequant-e4c1334be0eff290af5e2b4155057c2953a313ab.zip", - "dir": "libimagequant-e4c1334be0eff290af5e2b4155057c2953a313ab", + "url": "https://github.com/ImageOptim/libimagequant/archive/{V['LIBIMAGEQUANT']}.tar.gz", + "filename": f"libimagequant-{V['LIBIMAGEQUANT']}.tar.gz", "license": "COPYRIGHT", - "patch": { - "CMakeLists.txt": { - "if(OPENMP_FOUND)": "if(false)", - "install": "#install", - # libimagequant does not detect MSVC x86_arm64 cross-compiler correctly - "if(${{CMAKE_SYSTEM_PROCESSOR}} STREQUAL ARM64)": "if({architecture} STREQUAL ARM64)", # noqa: E501 - } - }, "build": [ - *cmds_cmake("imagequant_a"), - cmd_copy("imagequant_a.lib", "imagequant.lib"), + cmd_cd("imagequant-sys"), + "cargo build --release", ], - "headers": [r"*.h"], - "libs": [r"imagequant.lib"], + "headers": ["libimagequant.h"], + "libs": [r"..\target\release\imagequant_sys.lib"], }, "harfbuzz": { "url": f"https://github.com/harfbuzz/harfbuzz/archive/{V['HARFBUZZ']}.zip", "filename": f"harfbuzz-{V['HARFBUZZ']}.zip", - "dir": f"harfbuzz-{V['HARFBUZZ']}", "license": "COPYING", "build": [ *cmds_cmake( @@ -380,7 +363,6 @@ DEPS: dict[str, dict[str, Any]] = { "fribidi": { "url": f"https://github.com/fribidi/fribidi/archive/v{V['FRIBIDI']}.zip", "filename": f"fribidi-{V['FRIBIDI']}.zip", - "dir": f"fribidi-{V['FRIBIDI']}", "license": "COPYING", "build": [ cmd_copy(r"COPYING", rf"{{bin_dir}}\fribidi-{V['FRIBIDI']}-COPYING"), @@ -517,7 +499,10 @@ def extract_dep(url: str, filename: str, prefs: dict[str, str]) -> None: if sources_dir_abs != member_prefix: msg = "Attempted Path Traversal in Tar File" raise RuntimeError(msg) - tgz.extractall(sources_dir) + if sys.version_info >= (3, 12): + tgz.extractall(sources_dir, filter="data") + else: + tgz.extractall(sources_dir) else: msg = "Unknown archive type: " + filename raise RuntimeError(msg) @@ -760,6 +745,8 @@ def main() -> None: } for k, v in DEPS.items(): + if "dir" not in v: + v["dir"] = re.sub(r"\.(tar\.gz|zip)", "", v["filename"]) prefs[f"dir_{k}"] = os.path.join(sources_dir, v["dir"]) print()