diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index e12987a5f..000000000 --- a/.appveyor.yml +++ /dev/null @@ -1,100 +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:/Python312 - 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.3.1 -- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.03.1\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 numpy olefile pyroma' -- c:\"Program Files (x86)"\"Windows Kits"\10\Debuggers\x86\gflags.exe /p /enable %PYTHON%\%EXECUTABLE% -- '%PYTHON%\%EXECUTABLE% -c "from PIL import Image"' -- '%PYTHON%\%EXECUTABLE% -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests' -#- '%PYTHON%\%EXECUTABLE% test-installed.py -v -s %TEST_OPTIONS%' TODO TEST_OPTIONS with pytest? - -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 8e65f64c4..e61752750 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 @@ -21,7 +21,7 @@ set -e if [[ $(uname) != CYGWIN* ]]; then sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ - ghostscript libffi-dev libjpeg-turbo-progs libopenjp2-7-dev\ + ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\ cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ sway wl-clipboard libopenblas-dev fi @@ -30,6 +30,7 @@ python3 -m pip install --upgrade pip python3 -m pip install --upgrade wheel python3 -m pip install coverage python3 -m pip install defusedxml +python3 -m pip install ipython python3 -m pip install olefile python3 -m pip install -U pytest python3 -m pip install -U pytest-cov @@ -37,12 +38,7 @@ python3 -m pip install -U pytest-timeout python3 -m pip install pyroma if [[ $(uname) != CYGWIN* ]]; then - # TODO Update condition when NumPy supports free-threading - if [[ "$PYTHON_GIL" == "0" ]]; then - python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple - else - python3 -m pip install numpy - fi + python3 -m pip install numpy # PyQt6 doesn't support PyPy3 if [[ $GHA_PYTHON_VERSION == 3.* ]]; then @@ -52,10 +48,7 @@ if [[ $(uname) != CYGWIN* ]]; then fi # Pyroma uses non-isolated build and fails with old setuptools - if [[ - $GHA_PYTHON_VERSION == pypy3.9 - || $GHA_PYTHON_VERSION == 3.9 - ]]; then + if [[ $GHA_PYTHON_VERSION == 3.9 ]]; then # To match pyproject.toml python3 -m pip install "setuptools>=67.8" fi diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index a2bf2a7b0..2fd3eb6ff 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.19.2 +cibuildwheel==2.23.0 diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index 776bb0dbb..2e3610478 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1 +1,12 @@ -mypy==1.11.0 +mypy==1.15.0 +IceSpringPySideStubs-PyQt6 +IceSpringPySideStubs-PySide6 +ipython +numpy +packaging +pytest +sphinx +types-atheris +types-defusedxml +types-olefile +types-setuptools diff --git a/.ci/test.cmd b/.ci/test.cmd new file mode 100644 index 000000000..aafc9290c --- /dev/null +++ b/.ci/test.cmd @@ -0,0 +1,3 @@ +python.exe -c "from PIL import Image" +IF ERRORLEVEL 1 EXIT /B +python.exe -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests diff --git a/.ci/test.sh b/.ci/test.sh index 8ff7c5f64..3f0ddc350 100755 --- a/.ci/test.sh +++ b/.ci/test.sh @@ -4,4 +4,4 @@ set -e python3 -c "from PIL import Image" -python3 -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term Tests $REVERSE +python3 -bb -m pytest -v -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests $REVERSE 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 d1d824335..f48b670ec 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,7 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base" + "config:recommended" ], "labels": [ "Dependency" @@ -9,9 +9,13 @@ "packageRules": [ { "groupName": "github-actions", - "matchManagers": ["github-actions"], - "separateMajorMinor": "false" + "matchManagers": [ + "github-actions" + ], + "separateMajorMinor": false } ], - "schedule": ["on the 3rd day of the month"] + "schedule": [ + "on the 3rd day of the month" + ] } diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 033ff98ce..0456bbaba 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -6,11 +6,13 @@ on: - "**" paths: - ".github/workflows/cifuzz.yml" + - ".github/workflows/wheels-dependencies.sh" - "**.c" - "**.h" pull_request: paths: - ".github/workflows/cifuzz.yml" + - ".github/workflows/wheels-dependencies.sh" - "**.c" - "**.h" workflow_dispatch: @@ -24,8 +26,6 @@ concurrency: jobs: Fuzzing: - # Disabled until google/oss-fuzz#11419 upgrades Python to 3.9+ - if: false runs-on: ubuntu-latest steps: - name: Build Fuzzers diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 92e860cb5..626824f38 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -33,6 +33,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cc4760288..8e789a734 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,6 +21,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: pre-commit cache uses: actions/cache@v4 diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index d35cfcd31..6aa59a4ac 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -2,24 +2,24 @@ set -e +if [[ "$ImageOS" == "macos13" ]]; then + brew uninstall gradle maven +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 python3 -m pip install defusedxml +python3 -m pip install ipython python3 -m pip install olefile python3 -m pip install -U pytest python3 -m pip install -U pytest-cov diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 545c2e364..61ccf58e2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: permissions: - issues: write + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -15,6 +15,8 @@ concurrency: jobs: stale: if: github.repository_owner == 'python-pillow' + permissions: + issues: write runs-on: ubuntu-latest diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 8e2827099..abfeaa77f 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -48,9 +48,11 @@ jobs: - name: Checkout Pillow uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install Cygwin - uses: cygwin/cygwin-install-action@v4 + uses: cygwin/cygwin-install-action@v5 with: packages: > gcc-g++ @@ -74,6 +76,7 @@ jobs: perl python3${{ matrix.python-minor-version }}-cython python3${{ matrix.python-minor-version }}-devel + python3${{ matrix.python-minor-version }}-ipython python3${{ matrix.python-minor-version }}-numpy python3${{ matrix.python-minor-version }}-sip python3${{ matrix.python-minor-version }}-tkinter @@ -130,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 21e1275e7..da5e191da 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -29,42 +29,46 @@ 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 alpine, amazon-2-amd64, amazon-2023-amd64, arch, centos-stream-9-amd64, + centos-stream-10-amd64, debian-12-bookworm-x86, debian-12-bookworm-amd64, - fedora-39-amd64, fedora-40-amd64, + fedora-41-amd64, gentoo, ubuntu-22.04-jammy-amd64, ubuntu-24.04-noble-amd64, ] dockerTag: [main] include: - - docker: "ubuntu-22.04-jammy-arm64v8" - qemu-arch: "aarch64" - docker: "ubuntu-24.04-noble-ppc64le" + os: "ubuntu-22.04" qemu-arch: "ppc64le" + dockerTag: main - docker: "ubuntu-24.04-noble-s390x" + os: "ubuntu-22.04" qemu-arch: "s390x" + dockerTag: main + - docker: "ubuntu-24.04-noble-arm64v8" + os: "ubuntu-24.04-arm" + dockerTag: main name: ${{ matrix.docker }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Build system information run: python3 .github/workflows/system-info.py @@ -87,22 +91,21 @@ 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 }} - gcov: true token: ${{ secrets.CODECOV_ORG_TOKEN }} success: diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index e5e1ec32e..bb6d7dc37 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -46,6 +46,8 @@ jobs: steps: - name: Checkout Pillow uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up shell run: echo "C:\msys64\usr\bin\" >> $env:GITHUB_PATH @@ -64,29 +66,28 @@ 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: | python3 selftest.py --installed - python3 -c "from PIL import Image" - python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests + .ci/test.sh - name: Upload coverage - uses: codecov/codecov-action@v4 + 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-valgrind.yml b/.github/workflows/test-valgrind.yml index 63aec586b..8818b3b23 100644 --- a/.github/workflows/test-valgrind.yml +++ b/.github/workflows/test-valgrind.yml @@ -40,6 +40,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Build system information run: python3 .github/workflows/system-info.py diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 5b34d6703..ef49ff332 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -31,29 +31,38 @@ env: jobs: build: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["pypy3.10", "pypy3.9", "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 uses: actions/checkout@v4 + with: + persist-credentials: false - name: Checkout cached dependencies uses: actions/checkout@v4 with: + persist-credentials: false repository: python-pillow/pillow-depends path: winbuild\depends - name: Checkout extra test images uses: actions/checkout@v4 with: + persist-credentials: false repository: python-pillow/test-images path: Tests\test-images @@ -63,22 +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') && matrix.architecture != 'x86'" + run: | + python3 -m pip install PyQt6 - name: Install dependencies id: install @@ -86,8 +94,8 @@ jobs: choco install nasm --no-progress echo "C:\Program Files\NASM" >> $env:GITHUB_PATH - choco install ghostscript --version=10.3.1 --no-progress - echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH + choco install ghostscript --version=10.4.0 --no-progress + echo "C:\Program Files\gs\gs10.04.0\bin" >> $env:GITHUB_PATH # Install extra test images xcopy /S /Y Tests\test-images\* Tests\images @@ -178,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 @@ -190,8 +198,8 @@ jobs: - name: Test Pillow run: | - path %GITHUB_WORKSPACE%\\winbuild\\build\\bin;%PATH% - python.exe -m pytest -vx -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests + path %GITHUB_WORKSPACE%\winbuild\build\bin;%PATH% + .ci\test.cmd shell: cmd - name: Prepare to upload errors @@ -213,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 6e63333b0..c4ad88be9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,12 +37,14 @@ jobs: fail-fast: false matrix: os: [ - "macos-14", + "macos-latest", "ubuntu-latest", ] python-version: [ + "pypy3.11", "pypy3.10", - "pypy3.9", + "3.14", + "3.13t", "3.13", "3.12", "3.11", @@ -53,21 +55,22 @@ 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-14", python-version: "3.9" } + - { os: "macos-latest", python-version: "3.9" } runs-on: ${{ matrix.os }} - name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }} + name: ${{ matrix.os }} Python ${{ matrix.python-version }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - if: "${{ !matrix.disable-gil }}" + uses: Quansight-Labs/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -76,13 +79,6 @@ jobs: ".ci/*.sh" "pyproject.toml" - - name: Set up Python ${{ matrix.python-version }} (free-threaded) - uses: deadsnakes/action@v3.1.0 - if: "${{ matrix.disable-gil }}" - with: - python-version: ${{ matrix.python-version }} - nogil: ${{ matrix.disable-gil }} - - name: Set PYTHON_GIL if: "${{ matrix.disable-gil }}" run: | @@ -115,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 @@ -155,11 +151,10 @@ 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 }} - gcov: true token: ${{ secrets.CODECOV_ORG_TOKEN }} success: diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 97f70ed84..202a8935d 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,111 @@ fi ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds -FREETYPE_VERSION=2.13.2 -HARFBUZZ_VERSION=8.5.0 -LIBPNG_VERSION=1.6.43 -JPEGTURBO_VERSION=3.0.3 -OPENJPEG_VERSION=2.5.2 -XZ_VERSION=5.4.5 +FREETYPE_VERSION=2.13.3 +HARFBUZZ_VERSION=10.4.0 +LIBPNG_VERSION=1.6.47 +JPEGTURBO_VERSION=3.1.0 +OPENJPEG_VERSION=2.5.3 +XZ_VERSION=5.6.4 TIFF_VERSION=4.6.0 -LCMS2_VERSION=2.16 -if [[ -n "$IS_MACOS" ]]; then - GIFLIB_VERSION=5.2.2 -else - GIFLIB_VERSION=5.2.1 -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 +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 -if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then - function build_openjpeg { - local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v${OPENJPEG_VERSION}.tar.gz openjpeg-${OPENJPEG_VERSION}.tar.gz) - (cd $out_dir \ - && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ - && make install) - touch openjpeg-stamp - } -fi +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) - local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-1.1.0.tar.gz) + 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-$HARFBUZZ_VERSION.tar.xz harfbuzz-$HARFBUZZ_VERSION.tar.xz) + (cd $out_dir \ + && meson setup build --prefix=$BUILD_PREFIX --libdir=$BUILD_PREFIX/lib --buildtype=release -Dfreetype=enabled -Dglib=disabled) + (cd $out_dir/build \ + && meson install) + 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 - if [ -f /usr/local/lib64/libopenjp2.so ]; then - cp /usr/local/lib64/libopenjp2.so /usr/local/lib - fi - 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 @@ -109,42 +152,50 @@ function build { build_freetype fi - if [ -z "$IS_MACOS" ]; then - export FREETYPE_LIBS=-lfreetype - export FREETYPE_CFLAGS=-I/usr/local/include/freetype2/ - fi - build_simple harfbuzz $HARFBUZZ_VERSION https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION tar.xz --with-freetype=yes --with-glib=no - if [ -z "$IS_MACOS" ]; then - export FREETYPE_LIBS="" - export FREETYPE_CFLAGS="" - fi + build_harfbuzz } +# 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 - # 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 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 023a33824..ce83a4278 100755 --- a/.github/workflows/wheels-test.sh +++ b/.github/workflows/wheels-test.sh @@ -1,26 +1,31 @@ #!/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 yum install -y fribidi fi -if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then - # TODO Update condition when NumPy supports free-threading - if [ $(python3 -c "import sysconfig;print(sysconfig.get_config_var('Py_GIL_DISABLED'))") == "1" ]; then - python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple - else - python3 -m pip install numpy - fi -fi +python3 -m pip install numpy if [ ! -d "test-images-main" ]; then curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3ed1b5926..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,63 +42,7 @@ env: FORCE_COLOR: 1 jobs: - build-1-QEMU-emulated-wheels: - if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' - name: aarch64 ${{ matrix.python-version }} ${{ matrix.spec }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: - - pp39 - - pp310 - - cp3{9,10,11} - - cp3{12,13} - spec: - - manylinux2014 - - manylinux_2_28 - - musllinux - exclude: - - { python-version: pp39, spec: musllinux } - - { python-version: pp310, spec: musllinux } - - steps: - - uses: actions/checkout@v4 - with: - 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 }} @@ -104,12 +50,23 @@ jobs: fail-fast: false matrix: include: - - name: "macOS x86_64" + - name: "macOS 10.10 x86_64" os: macos-13 cibw_arch: x86_64 + build: "cp3{9,10,11}*" macosx_deployment_target: "10.10" + - name: "macOS 10.13 x86_64" + os: macos-13 + cibw_arch: x86_64 + build: "cp3{12,13}*" + macosx_deployment_target: "10.13" + - name: "macOS 10.15 x86_64" + os: macos-13 + cibw_arch: x86_64 + build: "pp3*" + macosx_deployment_target: "10.15" - name: "macOS arm64" - os: macos-14 + os: macos-latest cibw_arch: arm64 macosx_deployment_target: "11.0" - name: "manylinux2014 and musllinux x86_64" @@ -120,9 +77,18 @@ 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: + persist-credentials: false submodules: true - uses: actions/setup-python@v5 @@ -139,15 +105,17 @@ 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 }} - uses: actions/upload-artifact@v4 with: - name: dist-${{ matrix.os }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }} + name: dist-${{ matrix.os }}${{ matrix.macosx_deployment_target && format('-{0}', matrix.macosx_deployment_target) }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }} path: ./wheelhouse/*.whl windows: @@ -163,10 +131,13 @@ jobs: - cibw_arch: ARM64 steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Checkout extra test images uses: actions/checkout@v4 with: + persist-credentials: false repository: python-pillow/test-images path: Tests\test-images @@ -213,8 +184,8 @@ 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 -v {project}:C:\pillow @@ -242,13 +213,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - cache: pip - cache-dependency-path: "Makefile" - run: make sdist @@ -259,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: @@ -269,14 +240,14 @@ jobs: path: dist merge-multiple: true - name: Upload wheels to scientific-python-nightly-wheels - uses: scientific-python/upload-nightly-action@b67d7fcc0396e1128a474d1ab2b48aa94680f9fc # 0.5.0 + uses: scientific-python/upload-nightly-action@82396a2ed4269ba06c6b2988bb4fd568ef3c3d6b # 0.6.1 with: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} pypi-publish: if: github.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: @@ -292,3 +263,5 @@ jobs: merge-multiple: true - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true 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 659409de4..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.5.0 + rev: v0.9.9 hooks: - id: ruff args: [--exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.4.2 + rev: 25.1.0 hooks: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.7.9 + 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: v18.1.8 + rev: v19.1.7 hooks: - id: clang-format types: [c] @@ -36,7 +36,7 @@ repos: - id: rst-backticks - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-executables-have-shebangs - id: check-shebang-scripts-are-executable @@ -50,29 +50,35 @@ repos: exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.6 + 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: v0.9.1 + rev: v1.0.0 hooks: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: 2.1.3 + rev: v2.5.1 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.18 + 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.3.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 bd8d3af03..dfbbd24b3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,9 +2,157 @@ Changelog (Pillow) ================== -11.0.0 (unreleased) +11.1.0 and newer +---------------- + +See GitHub Releases: + +- https://github.com/python-pillow/Pillow/releases + +11.0.0 (2024-10-15) ------------------- +- Update licence to MIT-CMU #8460 + [hugovk] + +- Conditionally define ImageCms type hint to avoid requiring core #8197 + [radarhere] + +- Support writing LONG8 offsets in AppendingTiffWriter #8417 + [radarhere] + +- Use ImageFile.MAXBLOCK when saving TIFF images #8461 + [radarhere] + +- Do not close provided file handles with libtiff when saving #8458 + [radarhere] + +- Support ImageFilter.BuiltinFilter for I;16* images #8438 + [radarhere] + +- Use ImagingCore.ptr instead of ImagingCore.id #8341 + [homm, radarhere, hugovk] + +- Updated EPS mode when opening images without transparency #8281 + [Yay295, radarhere] + +- Use transparency when combining P frames from APNGs #8443 + [radarhere] + +- Support all resampling filters when resizing I;16* images #8422 + [radarhere] + +- Free memory on early return #8413 + [radarhere] + +- Cast int before potentially exceeding INT_MAX #8402 + [radarhere] + +- Check image value before use #8400 + [radarhere] + +- Improved copying imagequant libraries #8420 + [radarhere] + +- Use Capsule for WebP saving #8386 + [homm, radarhere] + +- Fixed writing multiple StripOffsets to TIFF #8317 + [Yay295, radarhere] + +- Fix dereference before checking for NULL in ImagingTransformAffine #8398 + [PavlNekrasov] + +- Use transposed size after opening for TIFF images #8390 + [radarhere, homm] + +- Improve ImageFont error messages #8338 + [yngvem, radarhere, hugovk] + +- Mention MAX_TEXT_CHUNK limit in PNG error message #8391 + [radarhere] + +- Cast Dib handle to int #8385 + [radarhere] + +- Accept float stroke widths #8369 + [radarhere] + +- Deprecate ICNS (width, height, scale) sizes in favour of load(scale) #8352 + [radarhere] + +- Improved handling of RGBA palettes when saving GIF images #8366 + [radarhere] + +- Deprecate isImageType #8364 + [radarhere] + +- Support converting more modes to LAB by converting to RGBA first #8358 + [radarhere] + +- Deprecate support for FreeType 2.9.0 #8356 + [hugovk, radarhere] + +- Removed unused TiffImagePlugin IFD_LEGACY_API #8355 + [radarhere] + +- Handle duplicate EXIF header #8350 + [zakajd, radarhere] + +- Return early from BoxBlur if either width or height is zero #8347 + [radarhere] + +- Check text is either string or bytes #8308 + [radarhere] + +- Added writing XMP bytes to JPEG #8286 + [radarhere] + +- Support JPEG2000 RGBA palettes #8256 + [radarhere] + +- Expand C image to match GIF frame image size #8237 + [radarhere] + +- Allow saving I;16 images as PPM #8231 + [radarhere] + +- When IFD is missing, connect get_ifd() dictionary to Exif #8230 + [radarhere] + +- Skip truncated ICO mask if LOAD_TRUNCATED_IMAGES is enabled #8180 + [radarhere] + +- Treat unknown JPEG2000 colorspace as unspecified #8343 + [radarhere] + +- Updated error message when saving WebP with invalid width or height #8322 + [radarhere, hugovk] + +- Remove warning if NumPy failed to raise an error during conversion #8326 + [radarhere] + +- If left and right sides meet in ImageDraw.rounded_rectangle(), do not draw rectangle to fill gap #8304 + [radarhere] + +- Remove WebP support without anim, mux/demux, and with buggy alpha #8213 + [homm, radarhere] + +- Add missing TIFF CMYK;16B reader #8298 + [homm] + +- Remove all WITH_* flags from _imaging.c and other flags #8211 + [homm] + +- Improve ImageDraw2 shape methods #8265 + [radarhere] + +- Lock around usages of imaging memory arenas #8238 + [lysnikolaou] + +- Deprecate JpegImageFile huffman_ac and huffman_dc #8274 + [radarhere] + - Deprecate ImageMath lambda_eval and unsafe_eval options argument #8242 [radarhere] diff --git a/LICENSE b/LICENSE index 7990a6e57..10dd42d9e 100644 --- a/LICENSE +++ b/LICENSE @@ -5,9 +5,9 @@ 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 HPND License: +Like PIL, Pillow is licensed under the open source MIT-CMU License: By obtaining, using, and/or copying this software and/or its associated documentation, you agree that you have read, understood, and will comply 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/Makefile b/Makefile index 94f7565d8..53164b08a 100644 --- a/Makefile +++ b/Makefile @@ -17,12 +17,10 @@ coverage: .PHONY: doc .PHONY: html doc html: - python3 -c "import PIL" > /dev/null 2>&1 || python3 -m pip install . $(MAKE) -C docs html .PHONY: htmlview htmlview: - python3 -c "import PIL" > /dev/null 2>&1 || python3 -m pip install . $(MAKE) -C docs htmlview .PHONY: doccheck @@ -117,7 +115,7 @@ lint-fix: python3 -c "import black" > /dev/null 2>&1 || python3 -m pip install black python3 -m black . python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff - python3 -m ruff --fix . + python3 -m ruff check --fix . .PHONY: mypy mypy: diff --git a/README.md b/README.md index b4c6d2987..1cae558ad 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,13 @@ As of 2019, Pillow development is GitHub Actions build status (Test Docker) - AppVeyor CI build status (Windows) GitHub Actions build status (Wheels) Code coverage - Fuzzing Status @@ -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_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..764935f87 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -9,7 +9,6 @@ import os import shutil import subprocess import sys -import sysconfig import tempfile from collections.abc import Sequence from functools import lru_cache @@ -140,18 +139,11 @@ def assert_image_similar_tofile( filename: str, 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 +319,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 +341,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/eps/1.bmp b/Tests/images/eps/1.bmp new file mode 100644 index 000000000..194c85784 Binary files /dev/null and b/Tests/images/eps/1.bmp differ diff --git a/Tests/images/1.eps b/Tests/images/eps/1.eps similarity index 100% rename from Tests/images/1.eps rename to Tests/images/eps/1.eps diff --git a/Tests/images/eps/1_boundingbox_after_imagedata.eps b/Tests/images/eps/1_boundingbox_after_imagedata.eps new file mode 100644 index 000000000..08f8c4681 Binary files /dev/null and b/Tests/images/eps/1_boundingbox_after_imagedata.eps differ diff --git a/Tests/images/eps/1_second_imagedata.eps b/Tests/images/eps/1_second_imagedata.eps new file mode 100644 index 000000000..e6309a3b4 Binary files /dev/null and b/Tests/images/eps/1_second_imagedata.eps differ diff --git a/Tests/images/binary_preview_map.eps b/Tests/images/eps/binary_preview_map.eps similarity index 100% rename from Tests/images/binary_preview_map.eps rename to Tests/images/eps/binary_preview_map.eps diff --git a/Tests/images/create_eps.gnuplot b/Tests/images/eps/create_eps.gnuplot similarity index 100% rename from Tests/images/create_eps.gnuplot rename to Tests/images/eps/create_eps.gnuplot diff --git a/Tests/images/illu10_no_preview.eps b/Tests/images/eps/illu10_no_preview.eps similarity index 100% rename from Tests/images/illu10_no_preview.eps rename to Tests/images/eps/illu10_no_preview.eps diff --git a/Tests/images/illu10_preview.eps b/Tests/images/eps/illu10_preview.eps similarity index 100% rename from Tests/images/illu10_preview.eps rename to Tests/images/eps/illu10_preview.eps diff --git a/Tests/images/illuCS6_no_preview.eps b/Tests/images/eps/illuCS6_no_preview.eps similarity index 100% rename from Tests/images/illuCS6_no_preview.eps rename to Tests/images/eps/illuCS6_no_preview.eps diff --git a/Tests/images/illuCS6_preview.eps b/Tests/images/eps/illuCS6_preview.eps similarity index 100% rename from Tests/images/illuCS6_preview.eps rename to Tests/images/eps/illuCS6_preview.eps diff --git a/Tests/images/non_zero_bb.eps b/Tests/images/eps/non_zero_bb.eps similarity index 100% rename from Tests/images/non_zero_bb.eps rename to Tests/images/eps/non_zero_bb.eps diff --git a/Tests/images/non_zero_bb.png b/Tests/images/eps/non_zero_bb.png similarity index 100% rename from Tests/images/non_zero_bb.png rename to Tests/images/eps/non_zero_bb.png diff --git a/Tests/images/non_zero_bb_scale2.png b/Tests/images/eps/non_zero_bb_scale2.png similarity index 100% rename from Tests/images/non_zero_bb_scale2.png rename to Tests/images/eps/non_zero_bb_scale2.png diff --git a/Tests/images/pil_sample_cmyk.eps b/Tests/images/eps/pil_sample_cmyk.eps similarity index 100% rename from Tests/images/pil_sample_cmyk.eps rename to Tests/images/eps/pil_sample_cmyk.eps diff --git a/Tests/images/reqd_showpage.eps b/Tests/images/eps/reqd_showpage.eps similarity index 100% rename from Tests/images/reqd_showpage.eps rename to Tests/images/eps/reqd_showpage.eps diff --git a/Tests/images/reqd_showpage.png b/Tests/images/eps/reqd_showpage.png similarity index 100% rename from Tests/images/reqd_showpage.png rename to Tests/images/eps/reqd_showpage.png diff --git a/Tests/images/reqd_showpage_transparency.png b/Tests/images/eps/reqd_showpage_transparency.png similarity index 100% rename from Tests/images/reqd_showpage_transparency.png rename to Tests/images/eps/reqd_showpage_transparency.png diff --git a/Tests/images/timeout-d675703545fee17acab56e5fec644c19979175de.eps b/Tests/images/eps/timeout-d675703545fee17acab56e5fec644c19979175de.eps similarity index 100% rename from Tests/images/timeout-d675703545fee17acab56e5fec644c19979175de.eps rename to Tests/images/eps/timeout-d675703545fee17acab56e5fec644c19979175de.eps diff --git a/Tests/images/zero_bb.eps b/Tests/images/eps/zero_bb.eps similarity index 100% rename from Tests/images/zero_bb.eps rename to Tests/images/eps/zero_bb.eps diff --git a/Tests/images/zero_bb.png b/Tests/images/eps/zero_bb.png similarity index 100% rename from Tests/images/zero_bb.png rename to Tests/images/eps/zero_bb.png diff --git a/Tests/images/zero_bb_emptyline.eps b/Tests/images/eps/zero_bb_emptyline.eps similarity index 100% rename from Tests/images/zero_bb_emptyline.eps rename to Tests/images/eps/zero_bb_emptyline.eps diff --git a/Tests/images/zero_bb_eof_before_boundingbox.eps b/Tests/images/eps/zero_bb_eof_before_boundingbox.eps similarity index 100% rename from Tests/images/zero_bb_eof_before_boundingbox.eps rename to Tests/images/eps/zero_bb_eof_before_boundingbox.eps diff --git a/Tests/images/zero_bb_scale2.png b/Tests/images/eps/zero_bb_scale2.png similarity index 100% rename from Tests/images/zero_bb_scale2.png rename to Tests/images/eps/zero_bb_scale2.png diff --git a/Tests/images/zero_bb_trailer.eps b/Tests/images/eps/zero_bb_trailer.eps similarity index 100% rename from Tests/images/zero_bb_trailer.eps rename to Tests/images/eps/zero_bb_trailer.eps diff --git a/Tests/images/imagedraw/discontiguous_corners_polygon.png b/Tests/images/imagedraw/discontiguous_corners_polygon.png index 509c42b26..1b58889c8 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/imagedraw_rounded_rectangle_joined_x_different_corners.png b/Tests/images/imagedraw_rounded_rectangle_joined_x_different_corners.png new file mode 100644 index 000000000..b225afc2d Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_joined_x_different_corners.png differ diff --git a/Tests/images/imagedraw_stroke_float.png b/Tests/images/imagedraw_stroke_float.png new file mode 100644 index 000000000..a590f8af0 Binary files /dev/null and b/Tests/images/imagedraw_stroke_float.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/images/test_extents_transparency.gif b/Tests/images/test_extents_transparency.gif new file mode 100644 index 000000000..739c82ad4 Binary files /dev/null and b/Tests/images/test_extents_transparency.gif differ diff --git a/Tests/oss-fuzz/fuzz_font.py b/Tests/oss-fuzz/fuzz_font.py index 8788d7021..f4e40ea36 100755 --- a/Tests/oss-fuzz/fuzz_font.py +++ b/Tests/oss-fuzz/fuzz_font.py @@ -16,8 +16,9 @@ import atheris +from atheris.import_hook import instrument_imports -with atheris.instrument_imports(): +with instrument_imports(): import sys import fuzzers diff --git a/Tests/oss-fuzz/fuzz_pillow.py b/Tests/oss-fuzz/fuzz_pillow.py index 9137391b6..d58a6f015 100644 --- a/Tests/oss-fuzz/fuzz_pillow.py +++ b/Tests/oss-fuzz/fuzz_pillow.py @@ -14,8 +14,9 @@ import atheris +from atheris.import_hook import instrument_imports -with atheris.instrument_imports(): +with instrument_imports(): import sys import fuzzers diff --git a/Tests/oss-fuzz/python.supp b/Tests/oss-fuzz/python.supp index 94cc87db9..36385d672 100644 --- a/Tests/oss-fuzz/python.supp +++ b/Tests/oss-fuzz/python.supp @@ -1,5 +1,5 @@ { - + Memcheck:Cond ... fun:encode_current_locale 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_bmp_reference.py b/Tests/test_bmp_reference.py index 7f8487921..82cab39c6 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -22,6 +22,8 @@ def test_bad() -> None: for f in get_files("b"): # Assert that there is no unclosed file warning with warnings.catch_warnings(): + warnings.simplefilter("error") + try: with Image.open(f) as im: im.load() diff --git a/Tests/test_box_blur.py b/Tests/test_box_blur.py index 1f6ed6127..cb267b204 100644 --- a/Tests/test_box_blur.py +++ b/Tests/test_box_blur.py @@ -71,6 +71,11 @@ def test_color_modes() -> None: box_blur(sample.convert("YCbCr")) +@pytest.mark.parametrize("size", ((0, 1), (1, 0))) +def test_zero_dimension(size: tuple[int, int]) -> None: + assert box_blur(Image.new("L", size)).size == size + + def test_radius_0() -> None: assert_blur( sample, diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index 0d9c0b419..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,107 +87,81 @@ 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) - - def test_correct_args(self) -> None: - im = Image.new("RGB", (10, 10), 0) - - im.im.color_lut_3d( - "RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) - ) - - im.im.color_lut_3d( - "CMYK", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) - ) - - im.im.color_lut_3d( - "RGB", - Image.Resampling.BILINEAR, - *self.generate_identity_table(3, (2, 3, 3)), - ) - - im.im.color_lut_3d( - "RGB", - Image.Resampling.BILINEAR, - *self.generate_identity_table(3, (65, 3, 3)), - ) - - im.im.color_lut_3d( - "RGB", - Image.Resampling.BILINEAR, - *self.generate_identity_table(3, (3, 65, 3)), - ) - - im.im.color_lut_3d( - "RGB", - Image.Resampling.BILINEAR, - *self.generate_identity_table(3, (3, 3, 65)), - ) - - def test_wrong_mode(self) -> None: - with pytest.raises(ValueError, match="wrong mode"): - im = Image.new("L", (10, 10), 0) - im.im.color_lut_3d( - "RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) - ) - - with pytest.raises(ValueError, match="wrong mode"): - im = Image.new("RGB", (10, 10), 0) - im.im.color_lut_3d( - "L", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) - ) - - with pytest.raises(ValueError, match="wrong mode"): - im = Image.new("L", (10, 10), 0) - im.im.color_lut_3d( - "L", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) - ) - - with pytest.raises(ValueError, match="wrong mode"): - im = Image.new("RGB", (10, 10), 0) - im.im.color_lut_3d( - "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) - ) - - with pytest.raises(ValueError, match="wrong mode"): - im = Image.new("RGB", (10, 10), 0) - im.im.color_lut_3d( - "RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) - ) - - def test_correct_mode(self) -> None: - im = Image.new("RGBA", (10, 10), 0) - im.im.color_lut_3d( - "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) - ) - - im = Image.new("RGBA", (10, 10), 0) - im.im.color_lut_3d( - "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) - ) + im.im.color_lut_3d("RGB", Image.Resampling.BILINEAR, 3, (2, 2, 2), 16) + @pytest.mark.parametrize( + "lut_mode, table_channels, table_size", + [ + ("RGB", 3, 3), + ("CMYK", 4, 3), + ("RGB", 3, (2, 3, 3)), + ("RGB", 3, (65, 3, 3)), + ("RGB", 3, (3, 65, 3)), + ("RGB", 3, (2, 3, 65)), + ], + ) + def test_correct_args( + self, lut_mode: str, table_channels: int, table_size: int | tuple[int, int, int] + ) -> None: im = Image.new("RGB", (10, 10), 0) im.im.color_lut_3d( - "HSV", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) + lut_mode, + Image.Resampling.BILINEAR, + *self.generate_identity_table(table_channels, table_size), ) - im = Image.new("RGB", (10, 10), 0) + @pytest.mark.parametrize( + "image_mode, lut_mode, table_channels, table_size", + [ + ("L", "RGB", 3, 3), + ("RGB", "L", 3, 3), + ("L", "L", 3, 3), + ("RGB", "RGBA", 3, 3), + ("RGB", "RGB", 4, 3), + ], + ) + def test_wrong_mode( + self, image_mode: str, lut_mode: str, table_channels: int, table_size: int + ) -> None: + with pytest.raises(ValueError, match="wrong mode"): + im = Image.new(image_mode, (10, 10), 0) + im.im.color_lut_3d( + lut_mode, + Image.Resampling.BILINEAR, + *self.generate_identity_table(table_channels, table_size), + ) + + @pytest.mark.parametrize( + "image_mode, lut_mode, table_channels, table_size", + [ + ("RGBA", "RGBA", 3, 3), + ("RGBA", "RGBA", 4, 3), + ("RGB", "HSV", 3, 3), + ("RGB", "RGBA", 4, 3), + ], + ) + def test_correct_mode( + self, image_mode: str, lut_mode: str, table_channels: int, table_size: int + ) -> None: + im = Image.new(image_mode, (10, 10), 0) im.im.color_lut_3d( - "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) + lut_mode, + Image.Resampling.BILINEAR, + *self.generate_identity_table(table_channels, table_size), ) def test_identities(self) -> None: @@ -290,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, @@ -312,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, @@ -333,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, @@ -414,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 b7eefa09a..f8f7f6eec 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -10,11 +10,6 @@ from PIL import features from .helper import skip_unless_feature -try: - from PIL import _webp -except ImportError: - pass - def test_check() -> None: # Check the correctness of the convenience function @@ -23,7 +18,11 @@ def test_check() -> None: for codec in features.codecs: assert features.check_codec(codec) == features.check(codec) for feature in features.features: - assert features.check_feature(feature) == features.check(feature) + if "webp" in feature: + with pytest.warns(DeprecationWarning): + assert features.check_feature(feature) == features.check(feature) + else: + assert features.check_feature(feature) == features.check(feature) def test_version() -> None: @@ -37,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: @@ -48,23 +48,26 @@ def test_version() -> None: for codec in features.codecs: test(codec, features.version_codec) for feature in features.features: - test(feature, features.version_feature) + if "webp" in feature: + with pytest.warns(DeprecationWarning): + test(feature, features.version_feature) + else: + test(feature, features.version_feature) -@skip_unless_feature("webp") def test_webp_transparency() -> None: - assert features.check("transp_webp") != _webp.WebPDecoderBuggyAlpha() - assert features.check("transp_webp") == _webp.HAVE_TRANSPARENCY + with pytest.warns(DeprecationWarning): + assert (features.check("transp_webp") or False) == features.check_module("webp") -@skip_unless_feature("webp") def test_webp_mux() -> None: - assert features.check("webp_mux") == _webp.HAVE_WEBPMUX + with pytest.warns(DeprecationWarning): + assert (features.check("webp_mux") or False) == features.check_module("webp") -@skip_unless_feature("webp") def test_webp_anim() -> None: - assert features.check("webp_anim") == _webp.HAVE_WEBPANIM + with pytest.warns(DeprecationWarning): + assert (features.check("webp_anim") or False) == features.check_module("webp") @skip_unless_feature("libjpeg_turbo") diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index e95850212..9d5154fca 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -258,8 +258,8 @@ def test_apng_mode() -> None: assert im.mode == "P" im.seek(im.n_frames - 1) im = im.convert("RGBA") - assert im.getpixel((0, 0)) == (255, 0, 0, 0) - assert im.getpixel((64, 32)) == (255, 0, 0, 0) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: assert im.mode == "P" @@ -307,13 +307,8 @@ def test_apng_syntax_errors() -> None: 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 + im.seek(im.n_frames - 1) with pytest.raises(OSError): with Image.open("Tests/images/apng/syntax_num_frames_high.png") as im: @@ -405,13 +400,8 @@ 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 + im.seek(im.n_frames - 1) + im.load() def test_apng_save_duration_loop(tmp_path: Path) -> None: diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 1e2f20c40..9f2de8f98 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,6 +38,13 @@ 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") diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 77ee5b0ea..fc8920317 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -83,4 +83,4 @@ def test_handler(tmp_path: Path) -> None: 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 65337cad9..ab6b9f983 100644 --- a/Tests/test_file_dcx.py +++ b/Tests/test_file_dcx.py @@ -26,16 +26,18 @@ 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: with warnings.catch_warnings(): + warnings.simplefilter("error") + im = Image.open(TEST_FILE) im.load() im.close() @@ -43,6 +45,8 @@ def test_closed_file() -> None: def test_context_manager() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + with Image.open(TEST_FILE) as im: im.load() diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index ebc0e89a1..7cc4d79d4 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -152,7 +152,7 @@ def test_sanity_ati2_bc5u(image_path: str) -> None: @pytest.mark.parametrize( - ("image_path", "expected_path"), + "image_path, expected_path", ( # hexeditted to be typeless (TEST_FILE_DX10_BC5_TYPELESS, TEST_FILE_DX10_BC5_UNORM), @@ -248,7 +248,7 @@ def test_dx10_r8g8b8a8_unorm_srgb() -> None: @pytest.mark.parametrize( - ("mode", "size", "test_file"), + "mode, size, test_file", [ ("L", (128, 128), TEST_FILE_UNCOMPRESSED_L), ("LA", (128, 128), TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA), @@ -331,11 +331,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 @@ -373,7 +375,7 @@ def test_save_unsupported_mode(tmp_path: Path) -> None: @pytest.mark.parametrize( - ("mode", "test_file"), + "mode, test_file", [ ("L", "Tests/images/linear_gradient.png"), ("LA", "Tests/images/uncompressed_la.png"), diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index b54238132..a0c2f9216 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -8,6 +8,7 @@ import pytest from PIL import EpsImagePlugin, Image, UnidentifiedImageError, features from .helper import ( + assert_image_equal_tofile, assert_image_similar, assert_image_similar_tofile, hopper, @@ -19,18 +20,18 @@ from .helper import ( HAS_GHOSTSCRIPT = EpsImagePlugin.has_ghostscript() # Our two EPS test files (they are identical except for their bounding boxes) -FILE1 = "Tests/images/zero_bb.eps" -FILE2 = "Tests/images/non_zero_bb.eps" +FILE1 = "Tests/images/eps/zero_bb.eps" +FILE2 = "Tests/images/eps/non_zero_bb.eps" # Due to palletization, we'll need to convert these to RGB after load -FILE1_COMPARE = "Tests/images/zero_bb.png" -FILE1_COMPARE_SCALE2 = "Tests/images/zero_bb_scale2.png" +FILE1_COMPARE = "Tests/images/eps/zero_bb.png" +FILE1_COMPARE_SCALE2 = "Tests/images/eps/zero_bb_scale2.png" -FILE2_COMPARE = "Tests/images/non_zero_bb.png" -FILE2_COMPARE_SCALE2 = "Tests/images/non_zero_bb_scale2.png" +FILE2_COMPARE = "Tests/images/eps/non_zero_bb.png" +FILE2_COMPARE_SCALE2 = "Tests/images/eps/non_zero_bb_scale2.png" # EPS test files with binary preview -FILE3 = "Tests/images/binary_preview_map.eps" +FILE3 = "Tests/images/eps/binary_preview_map.eps" # Three unsigned 32bit little-endian values: # 0xC6D3D0C5 magic number @@ -80,9 +81,7 @@ simple_eps_file_with_long_binary_data = ( @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -@pytest.mark.parametrize( - ("filename", "size"), ((FILE1, (460, 352)), (FILE2, (360, 252))) -) +@pytest.mark.parametrize("filename, size", ((FILE1, (460, 352)), (FILE2, (360, 252)))) @pytest.mark.parametrize("scale", (1, 2)) def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: expected_size = tuple(s * scale for s in size) @@ -96,10 +95,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: @@ -128,6 +131,15 @@ def test_binary_header_only() -> None: EpsImagePlugin.EpsImageFile(data) +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_simple_eps_file(prefix: bytes) -> None: + data = io.BytesIO(prefix + b"\n".join(simple_eps_file)) + with Image.open(data) as img: + assert img.mode == "RGB" + assert img.size == (100, 100) + assert img.format == "EPS" + + @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) def test_missing_version_comment(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_without_version)) @@ -143,23 +155,21 @@ def test_missing_boundingbox_comment(prefix: bytes) -> None: @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_invalid_boundingbox_comment(prefix: bytes) -> None: - data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox)) +@pytest.mark.parametrize( + "file_lines", + ( + simple_eps_file_with_invalid_boundingbox, + simple_eps_file_with_invalid_boundingbox_valid_imagedata, + ), +) +def test_invalid_boundingbox_comment( + prefix: bytes, file_lines: tuple[bytes, ...] +) -> None: + data = io.BytesIO(prefix + b"\n".join(file_lines)) with pytest.raises(OSError, match="cannot determine EPS bounding box"): EpsImagePlugin.EpsImageFile(data) -@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_invalid_boundingbox_comment_valid_imagedata_comment(prefix: bytes) -> None: - data = io.BytesIO( - prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox_valid_imagedata) - ) - with Image.open(data) as img: - assert img.mode == "RGB" - assert img.size == (100, 100) - assert img.format == "EPS" - - @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) def test_ascii_comment_too_long(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_ascii_comment)) @@ -179,7 +189,7 @@ def test_load_long_binary_data(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_binary_data)) with Image.open(data) as img: img.load() - assert img.mode == "RGB" + assert img.mode == "1" assert img.size == (100, 100) assert img.format == "EPS" @@ -189,7 +199,7 @@ def test_load_long_binary_data(prefix: bytes) -> None: ) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_cmyk() -> None: - with Image.open("Tests/images/pil_sample_cmyk.eps") as cmyk_image: + with Image.open("Tests/images/eps/pil_sample_cmyk.eps") as cmyk_image: assert cmyk_image.mode == "CMYK" assert cmyk_image.size == (100, 100) assert cmyk_image.format == "EPS" @@ -206,8 +216,8 @@ def test_cmyk() -> None: @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_showpage() -> None: # See https://github.com/python-pillow/Pillow/issues/2615 - with Image.open("Tests/images/reqd_showpage.eps") as plot_image: - with Image.open("Tests/images/reqd_showpage.png") as target: + with Image.open("Tests/images/eps/reqd_showpage.eps") as plot_image: + with Image.open("Tests/images/eps/reqd_showpage.png") as target: # should not crash/hang plot_image.load() # fonts could be slightly different @@ -216,11 +226,11 @@ def test_showpage() -> None: @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_transparency() -> None: - with Image.open("Tests/images/reqd_showpage.eps") as plot_image: + with Image.open("Tests/images/eps/reqd_showpage.eps") as plot_image: plot_image.load(transparency=True) assert plot_image.mode == "RGBA" - with Image.open("Tests/images/reqd_showpage_transparency.png") as target: + with Image.open("Tests/images/eps/reqd_showpage_transparency.png") as target: # fonts could be slightly different assert_image_similar(plot_image, target, 6) @@ -247,9 +257,19 @@ def test_bytesio_object() -> None: assert_image_similar(img, image1_scale1_compare, 5) -def test_1_mode() -> None: - with Image.open("Tests/images/1.eps") as im: - assert im.mode == "1" +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +@pytest.mark.parametrize( + # These images have an "ImageData" descriptor. + "filename", + ( + "Tests/images/eps/1.eps", + "Tests/images/eps/1_boundingbox_after_imagedata.eps", + "Tests/images/eps/1_second_imagedata.eps", + ), +) +def test_1(filename: str) -> None: + with Image.open(filename) as im: + assert_image_equal_tofile(im, "Tests/images/eps/1.bmp") def test_image_mode_not_supported(tmp_path: Path) -> None: @@ -304,7 +324,9 @@ def test_render_scale2() -> None: @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -@pytest.mark.parametrize("filename", (FILE1, FILE2, "Tests/images/illu10_preview.eps")) +@pytest.mark.parametrize( + "filename", (FILE1, FILE2, "Tests/images/eps/illu10_preview.eps") +) def test_resize(filename: str) -> None: with Image.open(filename) as im: new_size = (100, 100) @@ -346,10 +368,10 @@ def test_readline(prefix: bytes, line_ending: bytes) -> None: @pytest.mark.parametrize( "filename", ( - "Tests/images/illu10_no_preview.eps", - "Tests/images/illu10_preview.eps", - "Tests/images/illuCS6_no_preview.eps", - "Tests/images/illuCS6_preview.eps", + "Tests/images/eps/illu10_no_preview.eps", + "Tests/images/eps/illu10_preview.eps", + "Tests/images/eps/illuCS6_no_preview.eps", + "Tests/images/eps/illuCS6_preview.eps", ), ) def test_open_eps(filename: str) -> None: @@ -361,7 +383,7 @@ def test_open_eps(filename: str) -> None: @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_emptyline() -> None: # Test file includes an empty line in the header data - emptyline_file = "Tests/images/zero_bb_emptyline.eps" + emptyline_file = "Tests/images/eps/zero_bb_emptyline.eps" with Image.open(emptyline_file) as image: image.load() @@ -373,7 +395,7 @@ def test_emptyline() -> None: @pytest.mark.timeout(timeout=5) @pytest.mark.parametrize( "test_file", - ["Tests/images/timeout-d675703545fee17acab56e5fec644c19979175de.eps"], + ["Tests/images/eps/timeout-d675703545fee17acab56e5fec644c19979175de.eps"], ) def test_timeout(test_file: str) -> None: with open(test_file, "rb") as f: @@ -386,7 +408,7 @@ def test_bounding_box_in_trailer() -> None: # Check bounding boxes are parsed in the same way # when specified in the header and the trailer with ( - Image.open("Tests/images/zero_bb_trailer.eps") as trailer_image, + Image.open("Tests/images/eps/zero_bb_trailer.eps") as trailer_image, Image.open(FILE1) as header_image, ): assert trailer_image.size == header_image.size @@ -394,12 +416,12 @@ def test_bounding_box_in_trailer() -> None: def test_eof_before_bounding_box() -> None: with pytest.raises(OSError): - with Image.open("Tests/images/zero_bb_eof_before_boundingbox.eps"): + with Image.open("Tests/images/eps/zero_bb_eof_before_boundingbox.eps"): pass def test_invalid_data_after_eof() -> None: - with open("Tests/images/illuCS6_preview.eps", "rb") as f: + with open("Tests/images/eps/illuCS6_preview.eps", "rb") as f: img_bytes = io.BytesIO(f.read() + b"\r\n%" + (b" " * 255)) with Image.open(img_bytes) as img: diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index f86fb8d09..8adbd30f5 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -35,36 +35,35 @@ 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: with warnings.catch_warnings(): + warnings.simplefilter("error") + im = Image.open(static_test_file) im.load() im.close() @@ -81,6 +80,8 @@ def test_seek_after_close() -> None: def test_context_manager() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + with Image.open(static_test_file) as im: im.load() 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 85b017d29..2254178d5 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,16 +34,18 @@ 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: with warnings.catch_warnings(): + warnings.simplefilter("error") + im = Image.open(TEST_GIF) im.load() im.close() @@ -67,6 +67,8 @@ def test_seek_after_close() -> None: def test_context_manager() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + with Image.open(TEST_GIF) as im: im.load() @@ -81,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: @@ -104,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") @@ -114,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: @@ -304,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 @@ -313,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: @@ -482,8 +486,7 @@ 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: @@ -523,6 +526,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 @@ -550,17 +554,15 @@ 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: @@ -759,6 +761,21 @@ 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 = str(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") with Image.open("Tests/images/different_transparency.gif") as im: @@ -978,7 +995,7 @@ def test_webp_background(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") # Test opaque WebP background - if features.check("webp") and features.check("webp_anim"): + if features.check("webp"): with Image.open("Tests/images/hopper.webp") as im: assert im.info["background"] == (255, 255, 255, 255) im.save(out) @@ -1308,6 +1325,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) @@ -1342,32 +1360,30 @@ def test_save_I(tmp_path: Path) -> None: 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: @@ -1378,8 +1394,27 @@ def test_lzw_bits() -> None: im.load() -def test_extents() -> None: - with Image.open("Tests/images/test_extents.gif") as im: +@pytest.mark.parametrize( + "test_file, loading_strategy", + ( + ("test_extents.gif", GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST), + ( + "test_extents.gif", + GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY, + ), + ( + "test_extents_transparency.gif", + GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST, + ), + ), +) +def test_extents( + test_file: str, + loading_strategy: GifImagePlugin.LoadingStrategy, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(GifImagePlugin, "LOADING_STRATEGY", loading_strategy) + with Image.open("Tests/images/" + test_file) as im: assert im.size == (100, 100) # Check that n_frames does not change the size @@ -1389,6 +1424,9 @@ def test_extents() -> None: im.seek(1) assert im.size == (150, 150) + im.load() + assert im.im.size == (150, 150) + def test_missing_background() -> None: # The Global Color Table Flag isn't set, so there is no background color index, @@ -1406,3 +1444,22 @@ def test_saving_rgba(tmp_path: Path) -> None: with Image.open(out) as reloaded: reloaded_rgba = reloaded.convert("RGBA") assert reloaded_rgba.load()[0, 0][3] == 0 + + +@pytest.mark.parametrize("params", ({}, {"disposal": 2, "optimize": False})) +def test_p_rgba(tmp_path: Path, params: dict[str, Any]) -> None: + out = str(tmp_path / "temp.gif") + + im1 = Image.new("P", (100, 100)) + d = ImageDraw.Draw(im1) + d.ellipse([(40, 40), (60, 60)], fill=1) + data = [0, 0, 0, 0, 0, 0, 0, 255] + [0, 0, 0, 0] * 254 + im1.putpalette(data, "RGBA") + + im2 = Image.new("P", (100, 100)) + im2.putpalette(data, "RGBA") + + im1.save(out, save_all=True, append_images=[im2], **params) + + with Image.open(out) as reloaded: + assert reloaded.n_frames == 2 diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index aba473d24..02e464ff1 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -83,4 +83,4 @@ def test_handler(tmp_path: Path) -> None: 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..024be9e80 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -85,4 +85,4 @@ def test_handler(tmp_path: Path) -> None: 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 488984aef..94f16aeec 100644 --- a/Tests/test_file_icns.py +++ b/Tests/test_file_icns.py @@ -21,6 +21,8 @@ def test_sanity() -> None: with Image.open(TEST_FILE) as im: # Assert that there is no unclosed file warning with warnings.catch_warnings(): + warnings.simplefilter("error") + im.load() assert im.mode == "RGBA" @@ -30,10 +32,14 @@ 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: @@ -63,8 +69,8 @@ def test_save_append_images(tmp_path: Path) -> None: assert_image_similar_tofile(im, temp_file, 1) with Image.open(temp_file) as reread: - reread.size = (16, 16, 2) - reread.load() + reread.size = (16, 16) + reread.load(2) assert_image_equal(reread, provided_im) @@ -87,14 +93,21 @@ def test_sizes() -> None: for w, h, r in im.info["sizes"]: wr = w * r hr = h * r - im.size = (w, h, r) + with pytest.warns(DeprecationWarning): + im.size = (w, h, r) im.load() assert im.mode == "RGBA" assert im.size == (wr, hr) + # Test using load() with scale + im.size = (w, h) + im.load(scale=r) + assert im.mode == "RGBA" + assert im.size == (wr, hr) + # Check that we cannot load an incorrect size with pytest.raises(ValueError): - im.size = (1, 1) + im.size = (1, 2) def test_older_icon() -> None: @@ -105,8 +118,8 @@ def test_older_icon() -> None: wr = w * r hr = h * r with Image.open("Tests/images/pillow2.icns") as im2: - im2.size = (w, h, r) - im2.load() + im2.size = (w, h) + im2.load(r) assert im2.mode == "RGBA" assert im2.size == (wr, hr) @@ -122,8 +135,8 @@ def test_jp2_icon() -> None: wr = w * r hr = h * r with Image.open("Tests/images/pillow3.icns") as im2: - im2.size = (w, h, r) - im2.load() + im2.size = (w, h) + im2.load(r) assert im2.mode == "RGBA" assert im2.size == (wr, hr) diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index fa8c11d5a..2f5e4ca5a 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest -from PIL import IcoImagePlugin, Image, ImageDraw +from PIL import IcoImagePlugin, Image, ImageDraw, ImageFile from .helper import assert_image_equal, assert_image_equal_tofile, hopper @@ -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: @@ -241,3 +243,25 @@ def test_draw_reloaded(tmp_path: Path) -> None: with Image.open(outfile) as im: assert_image_equal_tofile(im, "Tests/images/hopper_draw.ico") + + +def test_truncated_mask(monkeypatch: pytest.MonkeyPatch) -> None: + # 1 bpp + with open("Tests/images/hopper_mask.ico", "rb") as fp: + data = fp.read() + + monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) + data = data[:-3] + + 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") + + data = output.getvalue()[:-1] + + 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 036965bf5..d29998801 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -31,16 +31,18 @@ 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: with warnings.catch_warnings(): + warnings.simplefilter("error") + im = Image.open(TEST_IM) im.load() im.close() @@ -48,6 +50,8 @@ def test_closed_file() -> None: def test_context_manager() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + with Image.open(TEST_IM) as im: im.load() diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index b0ea2bf42..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: @@ -77,6 +74,16 @@ def test_getiptcinfo_zero_padding() -> None: assert len(iptc) == 3 +def test_getiptcinfo_tiff() -> None: + # Arrange + with Image.open("Tests/images/hopper.Lab.tif") as im: + # Act + iptc = IptcImagePlugin.getiptcinfo(im) + + # Assert + assert iptc == {(1, 90): b"\x1b%G", (2, 0): b"\xcf\xc0"} + + def test_getiptcinfo_tiff_none() -> None: # Arrange with Image.open("Tests/images/hopper.tif") as im: diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 5c054adfd..14dd9ffb6 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -179,7 +179,7 @@ class TestFileJpeg: assert k > 0.9 def test_rgb(self) -> None: - def getchannels(im: JpegImagePlugin.JpegImageFile) -> tuple[int, int, int]: + def getchannels(im: JpegImagePlugin.JpegImageFile) -> tuple[int, ...]: return tuple(v[0] for v in im.layer) im = hopper() @@ -206,6 +206,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" ) @@ -302,7 +306,10 @@ 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: @@ -374,7 +381,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) == {} @@ -445,8 +451,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") @@ -545,12 +555,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: @@ -566,12 +577,12 @@ class TestFileJpeg: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_qtables(self, tmp_path: Path) -> None: + def test_qtables(self) -> None: def _n_qtables_helper(n: int, test_file: str) -> None: + b = BytesIO() with Image.open(test_file) as im: - f = str(tmp_path / "temp.jpg") - im.save(f, qtables=[[n] * 64] * n) - with Image.open(f) as im: + im.save(b, "JPEG", qtables=[[n] * 64] * n) + with Image.open(b) as im: assert len(im.quantization) == n reloaded = self.roundtrip(im, qtables="keep") assert im.quantization == reloaded.quantization @@ -875,6 +886,8 @@ class TestFileJpeg: out = str(tmp_path / "out.jpg") with warnings.catch_warnings(): + warnings.simplefilter("error") + im.save(out, exif=exif) with Image.open(out) as reloaded: @@ -946,7 +959,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 @@ -1016,23 +1029,44 @@ class TestFileJpeg: else: assert im.getxmp() == {"xmpmeta": None} + def test_save_xmp(self, tmp_path: Path) -> None: + f = str(tmp_path / "temp.jpg") + im = hopper() + im.save(f, xmp=b"XMP test") + with Image.open(f) as reloaded: + assert reloaded.info["xmp"] == b"XMP test" + + # 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 + + with pytest.raises(ValueError): + 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): - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode( + self, buffer: bytes | Image.SupportsArrayInterface + ) -> tuple[int, int]: return 0, 0 Image.register_decoder("INFINITE", InfiniteMockPyDecoder) 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() @@ -1044,13 +1078,16 @@ class TestFileJpeg: # SOI, EOI for marker in b"\xff\xd8", b"\xff\xd9": - assert marker in data[1] and marker in data[2] + assert marker in data[1] + assert marker in data[2] # DHT, DQT for marker in b"\xff\xc4", b"\xff\xdb": - assert marker in data[1] and marker not in data[2] + assert marker in data[1] + assert marker not in data[2] # SOF0, SOS, APP0 (JFIF header) for marker in b"\xff\xc0", b"\xff\xda", b"\xff\xe0": - assert marker not in data[1] and marker in data[2] + assert marker not in data[1] + assert marker in data[2] with Image.open(BytesIO(data[0])) as interchange_im: with Image.open(BytesIO(data[1] + data[2])) as combined_im: @@ -1070,6 +1107,13 @@ class TestFileJpeg: assert im._repr_jpeg_() is None + def test_deprecation(self) -> None: + with Image.open(TEST_FILE) as im: + with pytest.warns(DeprecationWarning): + assert im.huffman_ac == {} + with pytest.warns(DeprecationWarning): + assert im.huffman_dc == {} + @pytest.mark.skipif(not is_win32(), reason="Windows only") @skip_unless_feature("jpg") diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index a5cfa7c6c..5748fa5a1 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import re +from collections.abc import Generator from io import BytesIO from pathlib import Path from typing import Any @@ -29,8 +30,16 @@ EXTRA_DIR = "Tests/images/jpeg2000" pytestmark = skip_unless_feature("jpg_2000") -test_card = Image.open("Tests/images/test-card.png") -test_card.load() + +@pytest.fixture +def card() -> Generator[ImageFile.ImageFile, None, None]: + with Image.open("Tests/images/test-card.png") as im: + im.load() + try: + yield im + finally: + im.close() + # OpenJPEG 2.0.0 outputs this debugging message sometimes; we should # ignore it---it doesn't represent a test failure. @@ -54,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) @@ -74,76 +84,76 @@ def test_invalid_file() -> None: Jpeg2KImagePlugin.Jpeg2KImageFile(invalid_file) -def test_bytesio() -> None: +def test_bytesio(card: ImageFile.ImageFile) -> None: with open("Tests/images/test-card-lossless.jp2", "rb") as f: data = BytesIO(f.read()) with Image.open(data) as im: im.load() - assert_image_similar(im, test_card, 1.0e-3) + assert_image_similar(im, card, 1.0e-3) # These two test pre-written JPEG 2000 files that were not written with # PIL (they were made using Adobe Photoshop) -def test_lossless(tmp_path: Path) -> 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") im.save(outfile) - assert_image_similar(im, test_card, 1.0e-3) + assert_image_similar(im, card, 1.0e-3) -def test_lossy_tiled() -> None: - assert_image_similar_tofile( - test_card, "Tests/images/test-card-lossy-tiled.jp2", 2.0 - ) +def test_lossy_tiled(card: ImageFile.ImageFile) -> None: + assert_image_similar_tofile(card, "Tests/images/test-card-lossy-tiled.jp2", 2.0) -def test_lossless_rt() -> None: - im = roundtrip(test_card) - assert_image_equal(im, test_card) +def test_lossless_rt(card: ImageFile.ImageFile) -> None: + im = roundtrip(card) + assert_image_equal(im, card) -def test_lossy_rt() -> None: - im = roundtrip(test_card, quality_layers=[20]) - assert_image_similar(im, test_card, 2.0) +def test_lossy_rt(card: ImageFile.ImageFile) -> None: + im = roundtrip(card, quality_layers=[20]) + assert_image_similar(im, card, 2.0) -def test_tiled_rt() -> None: - im = roundtrip(test_card, tile_size=(128, 128)) - assert_image_equal(im, test_card) +def test_tiled_rt(card: ImageFile.ImageFile) -> None: + im = roundtrip(card, tile_size=(128, 128)) + assert_image_equal(im, card) -def test_tiled_offset_rt() -> None: - im = roundtrip(test_card, tile_size=(128, 128), tile_offset=(0, 0), offset=(32, 32)) - assert_image_equal(im, test_card) +def test_tiled_offset_rt(card: ImageFile.ImageFile) -> None: + im = roundtrip(card, tile_size=(128, 128), tile_offset=(0, 0), offset=(32, 32)) + assert_image_equal(im, card) -def test_tiled_offset_too_small() -> None: +def test_tiled_offset_too_small(card: ImageFile.ImageFile) -> None: with pytest.raises(ValueError): - roundtrip(test_card, tile_size=(128, 128), tile_offset=(0, 0), offset=(128, 32)) + roundtrip(card, tile_size=(128, 128), tile_offset=(0, 0), offset=(128, 32)) -def test_irreversible_rt() -> None: - im = roundtrip(test_card, irreversible=True, quality_layers=[20]) - assert_image_similar(im, test_card, 2.0) +def test_irreversible_rt(card: ImageFile.ImageFile) -> None: + im = roundtrip(card, irreversible=True, quality_layers=[20]) + assert_image_similar(im, card, 2.0) -def test_prog_qual_rt() -> None: - im = roundtrip(test_card, quality_layers=[60, 40, 20], progression="LRCP") - assert_image_similar(im, test_card, 2.0) +def test_prog_qual_rt(card: ImageFile.ImageFile) -> None: + im = roundtrip(card, quality_layers=[60, 40, 20], progression="LRCP") + assert_image_similar(im, card, 2.0) -def test_prog_res_rt() -> None: - im = roundtrip(test_card, num_resolutions=8, progression="RLCP") - assert_image_equal(im, test_card) +def test_prog_res_rt(card: ImageFile.ImageFile) -> None: + im = roundtrip(card, num_resolutions=8, progression="RLCP") + assert_image_equal(im, card) @pytest.mark.parametrize("num_resolutions", range(2, 6)) -def test_default_num_resolutions(num_resolutions: int) -> None: +def test_default_num_resolutions( + card: ImageFile.ImageFile, num_resolutions: int +) -> None: d = 1 << (num_resolutions - 1) - im = test_card.resize((d - 1, d - 1)) + im = card.resize((d - 1, d - 1)) with pytest.raises(OSError): roundtrip(im, num_resolutions=num_resolutions) reloaded = roundtrip(im) @@ -172,14 +182,20 @@ 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( + not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" +) +def test_unknown_colorspace() -> None: + with Image.open(f"{EXTRA_DIR}/file8.jp2") as im: + im.load() + assert im.mode == "L" def test_header_errors() -> None: @@ -196,31 +212,31 @@ def test_header_errors() -> None: pass -def test_layers_type(tmp_path: Path) -> None: +def test_layers_type(card: ImageFile.ImageFile, tmp_path: Path) -> None: outfile = str(tmp_path / "temp_layers.jp2") for quality_layers in [[100, 50, 10], (100, 50, 10), None]: - test_card.save(outfile, quality_layers=quality_layers) + card.save(outfile, quality_layers=quality_layers) for quality_layers_str in ["quality_layers", ("100", "50", "10")]: with pytest.raises(ValueError): - test_card.save(outfile, quality_layers=quality_layers_str) + card.save(outfile, quality_layers=quality_layers_str) -def test_layers() -> None: +def test_layers(card: ImageFile.ImageFile) -> None: out = BytesIO() - test_card.save(out, "JPEG2000", quality_layers=[100, 50, 10], progression="LRCP") + card.save(out, "JPEG2000", quality_layers=[100, 50, 10], progression="LRCP") out.seek(0) with Image.open(out) as im: im.layers = 1 im.load() - assert_image_similar(im, test_card, 13) + assert_image_similar(im, card, 13) out.seek(0) with Image.open(out) as im: im.layers = 3 im.load() - assert_image_similar(im, test_card, 0.4) + assert_image_similar(im, card, 0.4) @pytest.mark.parametrize( @@ -233,27 +249,33 @@ def test_layers() -> None: ("foo.jp2", {"no_jp2": True}, 0, b"\xff\x4f"), ("foo.j2k", {"no_jp2": False}, 0, b"\xff\x4f"), ("foo.jp2", {"no_jp2": False}, 4, b"jP"), - ("foo.jp2", {"no_jp2": False}, 4, b"jP"), + (None, {"no_jp2": False}, 4, b"jP"), ), ) -def test_no_jp2(name: str, args: dict[str, bool], offset: int, data: bytes) -> None: +def test_no_jp2( + card: ImageFile.ImageFile, + name: str, + args: dict[str, bool], + offset: int, + data: bytes, +) -> None: out = BytesIO() if name: out.name = name - test_card.save(out, "JPEG2000", **args) + card.save(out, "JPEG2000", **args) out.seek(offset) assert out.read(2) == data -def test_mct() -> None: +def test_mct(card: ImageFile.ImageFile) -> None: # Three component for val in (0, 1): out = BytesIO() - test_card.save(out, "JPEG2000", mct=val, no_jp2=True) + card.save(out, "JPEG2000", mct=val, no_jp2=True) assert out.getvalue()[59] == val with Image.open(out) as im: - assert_image_similar(im, test_card, 1.0e-3) + assert_image_similar(im, card, 1.0e-3) # Single component should have MCT disabled for val in (0, 1): @@ -301,6 +323,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: @@ -388,13 +422,23 @@ 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 + with Image.open( + 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: @@ -403,22 +447,22 @@ def test_comment() -> None: pass -def test_save_comment() -> None: +def test_save_comment(card: ImageFile.ImageFile) -> None: for comment in ("Created by Pillow", b"Created by Pillow"): out = BytesIO() - test_card.save(out, "JPEG2000", comment=comment) + card.save(out, "JPEG2000", comment=comment) with Image.open(out) as im: assert im.info["comment"] == b"Created by Pillow" out = BytesIO() long_comment = b" " * 65531 - test_card.save(out, "JPEG2000", comment=long_comment) + card.save(out, "JPEG2000", comment=long_comment) with Image.open(out) as im: assert im.info["comment"] == long_comment with pytest.raises(ValueError): - test_card.save(out, "JPEG2000", comment=long_comment + b" ") + card.save(out, "JPEG2000", comment=long_comment + b" ") @pytest.mark.parametrize( @@ -441,15 +485,14 @@ def test_crashes(test_file: str) -> None: @skip_unless_feature_version("jpg_2000", "2.4.0") -def test_plt_marker() -> None: +def test_plt_marker(card: ImageFile.ImageFile) -> None: # Search the start of the codesteam for PLT out = BytesIO() - test_card.save(out, "JPEG2000", no_jp2=True, plt=True) + card.save(out, "JPEG2000", no_jp2=True, plt=True) 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..369c2db1b 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -36,11 +36,7 @@ class LibTiffTestCase: im.load() im.getdata() - try: - assert im._compression == "group4" - except AttributeError: - print("No _compression") - print(dir(im)) + assert im._compression == "group4" # can we write it back out, in a different form. out = str(tmp_path / "temp.png") @@ -313,7 +309,7 @@ class TestFileLibTiff(LibTiffTestCase): } def check_tags( - tiffinfo: TiffImagePlugin.ImageFileDirectory_v2 | dict[int, str] + tiffinfo: TiffImagePlugin.ImageFileDirectory_v2 | dict[int, str], ) -> None: im = hopper() @@ -1098,6 +1094,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 @@ -1127,7 +1144,7 @@ class TestFileLibTiff(LibTiffTestCase): im.load() # Assert that the error code is IMAGING_CODEC_MEMORY - assert str(e.value) == "-9" + assert str(e.value) == "decoder error -9" @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: @@ -1141,23 +1158,22 @@ 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") 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: diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index e0f42a266..6b4f6423b 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -29,25 +29,32 @@ 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: with warnings.catch_warnings(): + warnings.simplefilter("error") + im = Image.open(test_files[0]) im.load() im.close() @@ -63,6 +70,8 @@ def test_seek_after_close() -> None: def test_context_manager() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + with Image.open(test_files[0]) as im: im.load() @@ -73,8 +82,8 @@ def test_app(test_file: str) -> None: with Image.open(test_file) as im: 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 @@ -293,3 +302,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_pdf.py b/Tests/test_file_pdf.py index 1d5001b1a..815686a52 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -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 4958b2222..efd2e5cd9 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -338,6 +338,8 @@ class TestFilePng: with Image.open(TEST_PNG_FILE) as im: # Assert that there is no unclosed file warning with warnings.catch_warnings(): + warnings.simplefilter("error") + im.verify() with Image.open(TEST_PNG_FILE) as im: @@ -361,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") @@ -371,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 @@ -424,8 +422,10 @@ class TestFilePng: im = roundtrip(im, pnginfo=info) assert im.info == {"spam": "Eggs", "eggs": "Spam"} assert im.text == {"spam": "Eggs", "eggs": "Spam"} + assert isinstance(im.text["spam"], PngImagePlugin.iTXt) assert im.text["spam"].lang == "en" assert im.text["spam"].tkey == "Spam" + assert isinstance(im.text["eggs"], PngImagePlugin.iTXt) assert im.text["eggs"].lang == "en" assert im.text["eggs"].tkey == "Eggs" @@ -596,7 +596,7 @@ 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 "comment" in im.text for k, v in { @@ -610,18 +610,17 @@ class TestFilePng: with pytest.raises(OSError): assert isinstance(im.text, dict) + # Raises an EOFError in load_end + with Image.open("Tests/images/hopper_idat_after_image_end.png") as im: + assert im.text == {"TXT": "VALUE", "ZIP": "VALUE"} + # 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 + im.text + monkeypatch.setattr(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 im.text == {"TXT": "VALUE", "ZIP": "VALUE"} def test_unknown_compression_method(self) -> None: with pytest.raises(SyntaxError, match="Unknown compression method"): @@ -647,15 +646,16 @@ 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: @@ -768,38 +768,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") @@ -808,11 +801,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() @@ -820,7 +813,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 0a61830a4..d87192ca5 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 @@ -95,7 +96,9 @@ def test_16bit_pgm_write(tmp_path: Path) -> None: with Image.open("Tests/images/16_bit_binary.pgm") as im: filename = str(tmp_path / "temp.pgm") im.save(filename, "PPM") + assert_image_equal_tofile(im, filename) + im.convert("I;16").save(filename, "PPM") assert_image_equal_tofile(im, filename) @@ -365,22 +368,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 e6c79e40b..1793c269d 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -25,16 +25,18 @@ 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: with warnings.catch_warnings(): + warnings.simplefilter("error") + im = Image.open(test_file) im.load() im.close() @@ -42,6 +44,8 @@ def test_closed_file() -> None: def test_context_manager() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + with Image.open(test_file) as im: im.load() diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 66c88e9d8..cdb7b3e0b 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,16 +24,18 @@ 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: with warnings.catch_warnings(): + warnings.simplefilter("error") + im = Image.open(TEST_FILE) im.load() im.close() @@ -41,6 +43,8 @@ def test_closed_file() -> None: def test_context_manager() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + with Image.open(TEST_FILE) as im: im.load() @@ -149,8 +153,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 6217ebedd..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): @@ -37,11 +54,15 @@ def test_unclosed_file() -> None: def test_close() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + tar = TarIO.TarIO(TEST_TAR_FILE, "hopper.jpg") tar.close() def test_contextmanager() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + with TarIO.TarIO(TEST_TAR_FILE, "hopper.jpg"): pass diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index a03a6a6e1..b6396bd64 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -72,6 +72,7 @@ def test_palette_depth_8(tmp_path: Path) -> None: 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") @@ -213,10 +214,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: diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 8cad25272..a8a407963 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -63,15 +63,17 @@ 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(): + warnings.simplefilter("error") + im = Image.open("Tests/images/multipage.tiff") im.load() im.close() @@ -88,6 +90,8 @@ class TestFileTiff: def test_context_manager(self) -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + with Image.open("Tests/images/multipage.tiff") as im: im.load() @@ -108,12 +112,22 @@ class TestFileTiff: assert_image_equal_tofile(im, "Tests/images/hopper.tif") with Image.open("Tests/images/hopper_bigtiff.tif") as im: - # multistrip support not yet implemented - del im.tag_v2[273] - outfile = str(tmp_path / "temp.tif") im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2) + def test_bigtiff_save(self, tmp_path: Path) -> None: + outfile = str(tmp_path / "temp.tif") + im = hopper() + im.save(outfile, big_tiff=True) + + with Image.open(outfile) as reloaded: + 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 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") @@ -684,6 +698,13 @@ class TestFileTiff: with Image.open(outfile) as reloaded: assert_image_equal_tofile(reloaded, infile) + def test_invalid_tiled_dimensions(self) -> None: + with open("Tests/images/tiff_tiled_planar_raw.tif", "rb") as fp: + data = fp.read() + b = BytesIO(data[:144] + b"\x02" + data[145:]) + with pytest.raises(ValueError): + Image.open(b) + @pytest.mark.parametrize("mode", ("P", "PA")) def test_palette(self, mode: str, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") @@ -724,6 +745,53 @@ class TestFileTiff: with Image.open(mp) as reread: assert reread.n_frames == 3 + def test_fixoffsets(self) -> None: + b = BytesIO(b"II\x2a\x00\x00\x00\x00\x00") + with TiffImagePlugin.AppendingTiffWriter(b) as a: + b.seek(0) + a.fixOffsets(1, isShort=True) + + b.seek(0) + a.fixOffsets(1, isLong=True) + + # Neither short nor long + b.seek(0) + 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 @@ -873,11 +941,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", diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index 1e0310001..36aabf4f8 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -181,6 +181,29 @@ def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None: assert reloaded.tag_v2.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] == TiffTags.LONG +def test_save_multiple_stripoffsets() -> None: + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[TiffImagePlugin.STRIPOFFSETS] = (123, 456) + assert ifd.tagtype[TiffImagePlugin.STRIPOFFSETS] == TiffTags.LONG + + # all values are in little-endian + assert ifd.tobytes() == ( + # number of tags == 1 + b"\x01\x00" + # tag id (2 bytes), type (2 bytes), count (4 bytes), value (4 bytes) + # TiffImagePlugin.STRIPOFFSETS, TiffTags.LONG, 2, 18 + # where STRIPOFFSETS is 273, LONG is 4 + # and 18 is the offset of the tag data + b"\x11\x01\x04\x00\x02\x00\x00\x00\x12\x00\x00\x00" + # end of entries + b"\x00\x00\x00\x00" + # 26 is the total number of bytes output, + # the offset for any auxiliary strip data that will then be appended + # (123 + 26, 456 + 26) == (149, 482) + b"\x95\x00\x00\x00\xe2\x01\x00\x00" + ) + + def test_no_duplicate_50741_tag() -> None: assert TAG_IDS["MakerNoteSafety"] == 50741 assert TAG_IDS["BestQualityScale"] == 50780 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 cbc905de4..abe888241 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: @@ -48,8 +45,6 @@ class TestFileWebp: self.rgb_mode = "RGB" def test_version(self) -> None: - _webp.WebPDecoderVersion() - _webp.WebPDecoderBuggyAlpha() version = features.version_module("webp") assert version is not None assert re.search(r"\d+\.\d+\.\d+$", version) @@ -74,7 +69,7 @@ class TestFileWebp: def _roundtrip( self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {} ) -> None: - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" hopper(mode).save(temp_file, **args) with Image.open(temp_file) as image: @@ -117,9 +112,8 @@ class TestFileWebp: hopper().save(buffer_method, format="WEBP", method=6) assert buffer_no_args.getbuffer() != buffer_method.getbuffer() - @skip_unless_feature("webp_anim") def test_save_all(self, tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" im = Image.new("RGB", (1, 1)) im2 = Image.new("RGB", (1, 1), "#f00") im.save(temp_file, save_all=True, append_images=[im2]) @@ -130,12 +124,16 @@ class TestFileWebp: reloaded.seek(1) assert_image_similar(im2, reloaded, 1) + def test_unsupported_image_mode(self) -> None: + im = Image.new("1", (1, 1)) + with pytest.raises(ValueError): + _webp.WebPEncode(im.getim(), False, 0, 0, "", 4, 0, b"", "") + def test_icc_profile(self, tmp_path: Path) -> None: self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) - if _webp.HAVE_WEBPANIM: - self._roundtrip( - tmp_path, self.rgb_mode, 12.5, {"icc_profile": None, "save_all": True} - ) + self._roundtrip( + tmp_path, self.rgb_mode, 12.5, {"icc_profile": None, "save_all": True} + ) def test_write_unsupported_mode_L(self, tmp_path: Path) -> None: """ @@ -155,40 +153,44 @@ class TestFileWebp: @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_write_encoding_error_message(self, tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.webp") im = Image.new("RGB", (15000, 15000)) with pytest.raises(ValueError) as e: - im.save(temp_file, method=0) + im.save(tmp_path / "temp.webp", method=0) assert str(e.value) == "encoding error 6" + @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") + def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None: + im = Image.new("L", (16384, 16384)) + with pytest.raises(ValueError) as e: + im.save(tmp_path / "temp.webp") + assert ( + str(e.value) + == "encoding error 5: Image size exceeds WebP limit of 16383 pixels" + ) + def test_WebPEncode_with_invalid_args(self) -> None: """ Calling encoder functions with no arguments should result in an error. """ - - if _webp.HAVE_WEBPANIM: - with pytest.raises(TypeError): - _webp.WebPAnimEncoder() + with pytest.raises(TypeError): + _webp.WebPAnimEncoder() with pytest.raises(TypeError): _webp.WebPEncode() - def test_WebPDecode_with_invalid_args(self) -> None: + def test_WebPAnimDecoder_with_invalid_args(self) -> None: """ Calling decoder functions with no arguments should result in an error. """ - - if _webp.HAVE_WEBPANIM: - with pytest.raises(TypeError): - _webp.WebPAnimDecoder() with pytest.raises(TypeError): - _webp.WebPDecode() + _webp.WebPAnimDecoder() def test_no_resource_warning(self, tmp_path: Path) -> None: file_path = "Tests/images/hopper.webp" with Image.open(file_path) as image: - temp_file = str(tmp_path / "temp.webp") with warnings.catch_warnings(): - image.save(temp_file) + warnings.simplefilter("error") + + image.save(tmp_path / "temp.webp") def test_file_pointer_could_be_reused(self) -> None: file_path = "Tests/images/hopper.webp" @@ -200,20 +202,19 @@ class TestFileWebp: "background", (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), ) - @skip_unless_feature("webp_anim") def test_invalid_background( self, background: int | tuple[int, ...], tmp_path: Path ) -> None: - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" im = hopper() with pytest.raises(OSError): im.save(temp_file, save_all=True, append_images=[im], background=background) - @skip_unless_feature("webp_anim") def test_background_from_gif(self, tmp_path: Path) -> None: + out_webp = tmp_path / "temp.webp" + # Save L mode GIF with background with Image.open("Tests/images/no_palette_with_background.gif") as im: - out_webp = str(tmp_path / "temp.webp") im.save(out_webp, save_all=True) # Save P mode GIF with background @@ -221,11 +222,10 @@ class TestFileWebp: original_value = im.convert("RGB").getpixel((1, 1)) # Save as WEBP - out_webp = str(tmp_path / "temp.webp") im.save(out_webp, save_all=True) # Save as GIF - out_gif = str(tmp_path / "temp.gif") + out_gif = tmp_path / "temp.gif" with Image.open(out_webp) as im: im.save(out_gif) @@ -234,12 +234,11 @@ class TestFileWebp: difference = sum(abs(original_value[i] - reread_value[i]) for i in range(0, 3)) assert difference < 5 - @skip_unless_feature("webp_anim") def test_duration(self, tmp_path: Path) -> None: + out_webp = tmp_path / "temp.webp" + with Image.open("Tests/images/dispose_bgnd.gif") as im: assert im.info["duration"] == 1000 - - out_webp = str(tmp_path / "temp.webp") im.save(out_webp, save_all=True) with Image.open(out_webp) as reloaded: @@ -247,9 +246,10 @@ class TestFileWebp: assert reloaded.info["duration"] == 1000 def test_roundtrip_rgba_palette(self, tmp_path: Path) -> None: - temp_file = str(tmp_path / "temp.webp") + temp_file = tmp_path / "temp.webp" im = Image.new("RGBA", (1, 1)).convert("P") assert im.mode == "P" + assert im.palette is not None assert im.palette.mode == "RGBA" im.save(temp_file) diff --git a/Tests/test_file_webp_alpha.py b/Tests/test_file_webp_alpha.py index c74452121..c88fe3589 100644 --- a/Tests/test_file_webp_alpha.py +++ b/Tests/test_file_webp_alpha.py @@ -13,12 +13,7 @@ from .helper import ( hopper, ) -_webp = pytest.importorskip("PIL._webp", reason="WebP support not installed") - - -def setup_module() -> None: - if _webp.WebPDecoderBuggyAlpha(): - pytest.skip("Buggy early version of WebP installed, not testing transparency") +pytest.importorskip("PIL._webp", reason="WebP support not installed") def test_read_rgba() -> None: @@ -81,9 +76,6 @@ def test_write_rgba(tmp_path: Path) -> None: pil_image = Image.new("RGBA", (10, 10), (255, 0, 0, 20)) pil_image.save(temp_file) - if _webp.WebPDecoderBuggyAlpha(): - return - with Image.open(temp_file) as image: image.load() @@ -93,12 +85,7 @@ def test_write_rgba(tmp_path: Path) -> None: image.load() image.getdata() - # Early versions of WebP are known to produce higher deviations: - # deal with it - if _webp.WebPDecoderVersion() <= 0x201: - assert_image_similar(image, pil_image, 3.0) - else: - assert_image_similar(image, pil_image, 1.0) + assert_image_similar(image, pil_image, 1.0) def test_keep_rgb_values_when_transparent(tmp_path: Path) -> None: diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index e0d7999e3..967a0aae8 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -15,10 +15,7 @@ from .helper import ( skip_unless_feature, ) -pytestmark = [ - skip_unless_feature("webp"), - skip_unless_feature("webp_anim"), -] +pytestmark = skip_unless_feature("webp") def test_n_frames() -> None: diff --git a/Tests/test_file_webp_lossless.py b/Tests/test_file_webp_lossless.py index 32e29de56..80429715e 100644 --- a/Tests/test_file_webp_lossless.py +++ b/Tests/test_file_webp_lossless.py @@ -8,14 +8,11 @@ from PIL import Image from .helper import assert_image_equal, hopper -_webp = pytest.importorskip("PIL._webp", reason="WebP support not installed") +pytest.importorskip("PIL._webp", reason="WebP support not installed") RGB_MODE = "RGB" def test_write_lossless_rgb(tmp_path: Path) -> None: - if _webp.WebPDecoderVersion() < 0x0200: - pytest.skip("lossless not included") - temp_file = str(tmp_path / "temp.webp") hopper(RGB_MODE).save(temp_file, lossless=True) diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index c3df4ad7b..c68a20d7a 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -10,10 +10,7 @@ from PIL import Image from .helper import mark_if_feature_version, skip_unless_feature -pytestmark = [ - skip_unless_feature("webp"), - skip_unless_feature("webp_mux"), -] +pytestmark = skip_unless_feature("webp") ElementTree: ModuleType | None try: @@ -43,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)" @@ -119,7 +116,15 @@ def test_read_no_exif() -> None: def test_getxmp() -> None: with Image.open("Tests/images/flower.webp") as im: assert "xmp" not in im.info - assert im.getxmp() == {} + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + xmp = im.getxmp() + else: + xmp = im.getxmp() + assert xmp == {} with Image.open("Tests/images/flower2.webp") as im: if ElementTree is None: @@ -136,7 +141,6 @@ def test_getxmp() -> None: ) -@skip_unless_feature("webp_anim") def test_write_animated_metadata(tmp_path: Path) -> None: iccp_data = b"" exif_data = b"" diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 79e707263..97469b77e 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,5 +1,6 @@ from __future__ import annotations +from io import BytesIO from pathlib import Path from typing import IO @@ -31,7 +32,16 @@ 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_register_handler(tmp_path: Path) -> None: @@ -61,6 +71,12 @@ def test_load_float_dpi() -> None: with Image.open("Tests/images/drawing.emf") as im: assert im.info["dpi"] == 1423.7668161434979 + with open("Tests/images/drawing.emf", "rb") as fp: + data = fp.read() + 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: diff --git a/Tests/test_font_bdf.py b/Tests/test_font_bdf.py index 136070f9e..2ece5457a 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 4fdc41791..5474f951c 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -42,6 +42,12 @@ try: except ImportError: ElementTree = None +PrettyPrinter: type | None +try: + from IPython.lib.pretty import PrettyPrinter +except ImportError: + PrettyPrinter = None + # Deprecation helper def helper_image_new(mode: str, size: tuple[int, int]) -> Image.Image: @@ -68,12 +74,12 @@ class TestImage: def test_sanity(self) -> None: im = Image.new("L", (100, 100)) - assert repr(im)[:45] == " None: - class Pretty: - def text(self, text: str) -> None: - self.pretty_output = text - im = Image.new("L", (100, 100)) - p = Pretty() - im._repr_pretty_(p, None) - assert p.pretty_output == "" + output = io.StringIO() + assert PrettyPrinter is not None + p = PrettyPrinter(output) + im._repr_pretty_(p, False) + assert output.getvalue() == "" def test_open_formats(self) -> None: PNGFILE = "Tests/images/hopper.png" @@ -184,8 +189,6 @@ class TestImage: 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)) def test_fp_name(self, tmp_path: Path) -> None: @@ -575,9 +578,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 @@ -657,12 +658,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)) @@ -700,6 +702,7 @@ class TestImage: assert new_image.size == image.size assert new_image.info == base_image.info if palette_result is not None: + assert new_image.palette is not None assert new_image.palette.tobytes() == palette_result.tobytes() else: assert new_image.palette is None @@ -731,6 +734,8 @@ class TestImage: # Act/Assert with Image.open(test_file) as im: with warnings.catch_warnings(): + warnings.simplefilter("error") + im.save(temp_file) def test_no_new_file_on_error(self, tmp_path: Path) -> None: @@ -762,13 +767,33 @@ 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 exif.load(b"Exif\x00\x00") assert not dict(exif) + def test_duplicate_exif_header(self) -> None: + with Image.open("Tests/images/exif.png") as im: + im.load() + im.info["exif"] = b"Exif\x00\x00" + im.info["exif"] + + exif = im.getexif() + assert exif[274] == 1 + + def test_empty_get_ifd(self) -> None: + exif = Image.Exif() + ifd = exif.get_ifd(0x8769) + assert ifd == {} + + ifd[36864] = b"0220" + assert exif.get_ifd(0x8769) == {36864: b"0220"} + + 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" ) @@ -817,7 +842,6 @@ class TestImage: assert reloaded_exif[305] == "Pillow test" @skip_unless_feature("webp") - @skip_unless_feature("webp_anim") def test_exif_webp(self, tmp_path: Path) -> None: with Image.open("Tests/images/hopper.webp") as im: exif = im.getexif() @@ -939,7 +963,15 @@ class TestImage: def test_empty_xmp(self) -> None: with Image.open("Tests/images/hopper.gif") as im: - assert im.getxmp() == {} + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + xmp = im.getxmp() + else: + xmp = im.getxmp() + assert xmp == {} def test_getxmp_padded(self) -> None: im = Image.new("RGB", (1, 1)) @@ -956,6 +988,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) @@ -990,12 +1027,14 @@ class TestImage: # P mode with RGBA palette im = Image.new("RGBA", (1, 1)).convert("P") assert im.mode == "P" + assert im.palette is not None assert im.palette.mode == "RGBA" assert im.has_transparency_data def test_apply_transparency(self) -> None: im = Image.new("P", (1, 1)) im.putpalette((0, 0, 0, 1, 1, 1)) + assert im.palette is not None assert im.palette.colors == {(0, 0, 0): 0, (1, 1, 1): 1} # Test that no transformation is applied without transparency @@ -1013,13 +1052,16 @@ class TestImage: im.putpalette((0, 0, 0, 255, 1, 1, 1, 128), "RGBA") im.info["transparency"] = 0 im.apply_transparency() + assert im.palette is not None assert im.palette.colors == {(0, 0, 0, 0): 0, (1, 1, 1, 128): 1} # Test that transparency bytes are applied with Image.open("Tests/images/pil123p.png") as im: assert isinstance(im.info["transparency"], bytes) + assert im.palette is not None assert im.palette.colors[(27, 35, 6)] == 24 im.apply_transparency() + assert im.palette is not None assert im.palette.colors[(27, 35, 6, 214)] == 24 def test_constants(self) -> None: @@ -1052,22 +1094,17 @@ class TestImage: valgrind pytest -qq Tests/test_image.py::TestImage::test_overrun | grep decode.c """ with Image.open(os.path.join("Tests/images", path)) as im: - try: + with pytest.raises(OSError) as e: im.load() - pytest.fail() - except OSError as e: - buffer_overrun = str(e) == "buffer overrun when reading image file" - truncated = "image file is truncated" in str(e) + buffer_overrun = str(e.value) == "buffer overrun when reading image file" + truncated = "image file is truncated" in str(e.value) - assert buffer_overrun or truncated + assert buffer_overrun or truncated def test_fli_overrun2(self) -> None: with Image.open("Tests/images/fli_overrun2.bin") as im: - try: + with pytest.raises(OSError, match="buffer overrun when reading image file"): im.seek(1) - pytest.fail() - except OSError as e: - assert str(e) == "buffer overrun when reading image file" def test_exit_fp(self) -> None: with Image.new("L", (1, 1)) as im: @@ -1083,6 +1120,10 @@ class TestImage: assert len(caplog.records) == 0 assert im.fp is None + def test_deprecation(self) -> None: + with pytest.warns(DeprecationWarning): + assert not Image.isImageType(None) + class TestImageBytes: @pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"]) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 854c79dae..14a5e2e7b 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -230,7 +230,7 @@ class TestImagePutPixelError: im.putpixel((0, 0), v) # type: ignore[arg-type] @pytest.mark.parametrize( - ("mode", "band_numbers", "match"), + "mode, band_numbers, match", ( ("L", (0, 2), "color must be int or single-element tuple"), ("LA", (0, 3), "color must be int, or tuple of one or two elements"), @@ -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_array.py b/Tests/test_image_array.py index bb6064882..eb2309e0f 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -24,7 +24,7 @@ def test_toarray() -> None: def test_with_dtype(dtype: npt.DTypeLike) -> None: ai = numpy.array(im, dtype=dtype) - assert ai.dtype == dtype + assert ai.dtype.type is dtype # assert test("1") == ((100, 128), '|b1', 1600)) assert test("L") == ((100, 128), "|u1", 12800) @@ -47,7 +47,7 @@ def test_toarray() -> None: with pytest.raises(OSError): numpy.array(im_truncated) else: - with pytest.warns(UserWarning): + with pytest.warns(DeprecationWarning): numpy.array(im_truncated) @@ -113,4 +113,5 @@ def test_fromarray_palette() -> None: out = Image.fromarray(a, "P") # Assert that the Python and C palettes match + assert out.palette is not None assert len(out.palette.colors) == len(out.im.getpalette()) / 3 diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index ebb7f2822..5f8b35c79 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -218,12 +218,11 @@ def test_trns_RGB(tmp_path: Path) -> None: def test_l_macro_rounding(convert_mode: str) -> None: for mode in ("P", "PA"): im = Image.new(mode, (1, 1)) + assert im.palette is not None im.palette.getcolor((0, 1, 2)) converted_im = im.convert(convert_mode) - 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] @@ -235,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_copy.py b/Tests/test_image_copy.py index 027e5338b..a0b829cc5 100644 --- a/Tests/test_image_copy.py +++ b/Tests/test_image_copy.py @@ -49,5 +49,7 @@ def test_copy_zero() -> None: @skip_unless_feature("libtiff") def test_deepcopy() -> None: with Image.open("Tests/images/g4_orientation_5.tif") as im: + assert im.size == (590, 88) + out = copy.deepcopy(im) assert out.size == (590, 88) diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py index 412ab44c3..e566cd055 100644 --- a/Tests/test_image_filter.py +++ b/Tests/test_image_filter.py @@ -35,16 +35,25 @@ from .helper import assert_image_equal, hopper ImageFilter.UnsharpMask(10), ), ) -@pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) -def test_sanity(filter_to_apply: ImageFilter.Filter, mode: str) -> None: +@pytest.mark.parametrize( + "mode", ("L", "I", "I;16", "I;16L", "I;16B", "I;16N", "RGB", "CMYK") +) +def test_sanity( + filter_to_apply: ImageFilter.Filter | type[ImageFilter.Filter], mode: str +) -> None: im = hopper(mode) - if mode != "I" or isinstance(filter_to_apply, ImageFilter.BuiltinFilter): + if mode[0] != "I" or ( + callable(filter_to_apply) + and issubclass(filter_to_apply, ImageFilter.BuiltinFilter) + ): out = im.filter(filter_to_apply) assert out.mode == im.mode assert out.size == im.size -@pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) +@pytest.mark.parametrize( + "mode", ("L", "I", "I;16", "I;16L", "I;16B", "I;16N", "RGB", "CMYK") +) def test_sanity_error(mode: str) -> None: im = hopper(mode) with pytest.raises(TypeError): @@ -145,7 +154,9 @@ def test_kernel_not_enough_coefficients() -> None: ImageFilter.Kernel((3, 3), (0, 0)) -@pytest.mark.parametrize("mode", ("L", "LA", "I", "RGB", "CMYK")) +@pytest.mark.parametrize( + "mode", ("L", "LA", "I", "I;16", "I;16L", "I;16B", "I;16N", "RGB", "CMYK") +) def test_consistency_3x3(mode: str) -> None: with Image.open("Tests/images/hopper.bmp") as source: with Image.open("Tests/images/hopper_emboss.bmp") as reference: @@ -161,7 +172,9 @@ def test_consistency_3x3(mode: str) -> None: assert_image_equal(source.filter(kernel), reference) -@pytest.mark.parametrize("mode", ("L", "LA", "I", "RGB", "CMYK")) +@pytest.mark.parametrize( + "mode", ("L", "LA", "I", "I;16", "I;16L", "I;16B", "I;16N", "RGB", "CMYK") +) def test_consistency_5x5(mode: str) -> None: with Image.open("Tests/images/hopper.bmp") as source: with Image.open("Tests/images/hopper_emboss_more.bmp") as reference: diff --git a/Tests/test_image_getim.py b/Tests/test_image_getim.py index 9afa02b0a..fa58492fc 100644 --- a/Tests/test_image_getim.py +++ b/Tests/test_image_getim.py @@ -1,11 +1,19 @@ from __future__ import annotations +import pytest + from .helper import hopper def test_sanity() -> None: im = hopper() - type_repr = repr(type(im.getim())) + type_repr = repr(type(im.getim())) assert "PyCapsule" in type_repr - assert isinstance(im.im.id, int) + + with pytest.warns(DeprecationWarning): + assert isinstance(im.im.id, int) + + with pytest.warns(DeprecationWarning): + ptrs = dict(im.im.unsafe_ptrs) + assert ptrs.keys() == {"image8", "image32", "image"} diff --git a/Tests/test_image_putpalette.py b/Tests/test_image_putpalette.py index 75d9d2fc1..f2c447f71 100644 --- a/Tests/test_image_putpalette.py +++ b/Tests/test_image_putpalette.py @@ -86,6 +86,7 @@ def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None: im = Image.new("P", (1, 1)) im.putpalette(palette, mode) assert im.getpalette() == [1, 2, 3] + assert im.palette is not None assert im.palette.colors == {(1, 2, 3, 4): 0} diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 903cd8550..0ca7ad86e 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -69,6 +69,7 @@ def test_quantize_no_dither() -> None: converted = image.quantize(dither=Image.Dither.NONE, palette=palette) assert converted.mode == "P" + assert converted.palette is not None assert converted.palette.palette == palette.palette.palette @@ -81,6 +82,7 @@ def test_quantize_no_dither2() -> None: palette.putpalette(data) quantized = im.quantize(dither=Image.Dither.NONE, palette=palette) + assert quantized.palette is not None assert tuple(quantized.palette.palette) == data px = quantized.load() @@ -117,6 +119,7 @@ def test_colors() -> None: im = hopper() colors = 2 converted = im.quantize(colors) + assert converted.palette is not None assert len(converted.palette.palette) == colors * len("RGB") @@ -145,9 +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_px[0, 0] == converted.palette.colors[color] + assert converted.palette is not None + 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 c9e304512..1166371b8 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -10,7 +10,7 @@ from pathlib import Path import pytest -from PIL import Image +from PIL import Image, ImageFile from .helper import ( assert_image_equal, @@ -44,9 +44,19 @@ class TestImagingCoreResize: self.resize(hopper("1"), (15, 12), Image.Resampling.BILINEAR) with pytest.raises(ValueError): self.resize(hopper("P"), (15, 12), Image.Resampling.BILINEAR) - with pytest.raises(ValueError): - self.resize(hopper("I;16"), (15, 12), Image.Resampling.BILINEAR) - for mode in ["L", "I", "F", "RGB", "RGBA", "CMYK", "YCbCr"]: + for mode in [ + "L", + "I", + "I;16", + "I;16L", + "I;16B", + "I;16N", + "F", + "RGB", + "RGBA", + "CMYK", + "YCbCr", + ]: im = hopper(mode) r = self.resize(im, (15, 12), Image.Resampling.BILINEAR) assert r.mode == mode @@ -169,7 +179,7 @@ class TestImagingCoreResize: @pytest.fixture -def gradients_image() -> Generator[Image.Image, None, None]: +def gradients_image() -> Generator[ImageFile.ImageFile, None, None]: with Image.open("Tests/images/radial_gradients.png") as im: im.load() try: @@ -179,7 +189,7 @@ def gradients_image() -> Generator[Image.Image, None, None]: class TestReducingGapResize: - def test_reducing_gap_values(self, gradients_image: Image.Image) -> None: + def test_reducing_gap_values(self, gradients_image: ImageFile.ImageFile) -> None: ref = gradients_image.resize( (52, 34), Image.Resampling.BICUBIC, reducing_gap=None ) @@ -200,7 +210,7 @@ class TestReducingGapResize: ) def test_reducing_gap_1( self, - gradients_image: Image.Image, + gradients_image: ImageFile.ImageFile, box: tuple[float, float, float, float], epsilon: float, ) -> None: @@ -220,7 +230,7 @@ class TestReducingGapResize: ) def test_reducing_gap_2( self, - gradients_image: Image.Image, + gradients_image: ImageFile.ImageFile, box: tuple[float, float, float, float], epsilon: float, ) -> None: @@ -240,7 +250,7 @@ class TestReducingGapResize: ) def test_reducing_gap_3( self, - gradients_image: Image.Image, + gradients_image: ImageFile.ImageFile, box: tuple[float, float, float, float], epsilon: float, ) -> None: @@ -256,7 +266,9 @@ class TestReducingGapResize: @pytest.mark.parametrize("box", (None, (1.1, 2.2, 510.8, 510.9), (3, 10, 410, 256))) def test_reducing_gap_8( - self, gradients_image: Image.Image, box: tuple[float, float, float, float] + self, + gradients_image: ImageFile.ImageFile, + box: tuple[float, float, float, float], ) -> None: ref = gradients_image.resize((52, 34), Image.Resampling.BICUBIC, box=box) im = gradients_image.resize( @@ -271,7 +283,7 @@ class TestReducingGapResize: ) def test_box_filter( self, - gradients_image: Image.Image, + gradients_image: ImageFile.ImageFile, box: tuple[float, float, float, float], epsilon: float, ) -> None: @@ -297,24 +309,22 @@ 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_load_first(self) -> None: - # load() may change the size of the image - # Test that resize() is calling it before getting the size + def test_transposed(self) -> None: with Image.open("Tests/images/g4_orientation_5.tif") as im: im = im.resize((64, 64)) assert im.size == (64, 64) - @pytest.mark.parametrize("mode", ("L", "RGB", "I", "F")) + @pytest.mark.parametrize( + "mode", ("L", "RGB", "I", "I;16", "I;16L", "I;16B", "I;16N", "F") + ) def test_default_filter_bicubic(self, mode: str) -> None: im = hopper(mode) assert im.resize((20, 20), Image.Resampling.BICUBIC) == im.resize((20, 20)) - @pytest.mark.parametrize( - "mode", ("1", "P", "I;16", "I;16L", "I;16B", "BGR;15", "BGR;16") - ) + @pytest.mark.parametrize("mode", ("1", "P", "BGR;15", "BGR;16")) def test_default_filter_nearest(self, mode: str) -> None: im = hopper(mode) assert im.resize((20, 20), Image.Resampling.NEAREST) == im.resize((20, 20)) diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index bdbf09c40..1181f6fca 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -92,34 +92,32 @@ def test_no_resize() -> None: @skip_unless_feature("libtiff") -def test_load_first() -> None: - # load() may change the size of the image - # Test that thumbnail() is calling it before performing size calculations +def test_transposed() -> None: with Image.open("Tests/images/g4_orientation_5.tif") as im: + assert im.size == (590, 88) + im.thumbnail((64, 64)) assert im.size == (64, 10) - # Test thumbnail(), without draft(), - # on an image that is large enough once load() has changed the size with Image.open("Tests/images/g4_orientation_5.tif") as im: im.thumbnail((590, 88), reducing_gap=None) assert im.size == (590, 88) -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_imagechops.py b/Tests/test_imagechops.py index 4fc28cdb9..4309214f5 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -398,7 +398,8 @@ def test_logical() -> None: for y in (a, b): imy = Image.new("1", (1, 1), y) value = op(imx, imy).getpixel((0, 0)) - assert not isinstance(value, tuple) and value is not None + assert not isinstance(value, tuple) + assert value is not None out.append(value) return out diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 5ee5fcedf..f062651f0 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -696,6 +696,12 @@ def test_rgb_lab(mode: str) -> None: assert value[:3] == (0, 255, 255) +def test_cmyk_lab() -> None: + im = Image.new("CMYK", (1, 1)) + converted_im = im.convert("LAB") + assert converted_im.getpixel((0, 0)) == (255, 128, 128) + + def test_deprecation() -> None: with pytest.warns(DeprecationWarning): assert ImageCms.DESCRIPTION.strip().startswith("pyCMS") diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index e397978cb..232cbb16c 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -448,7 +448,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 +469,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 +487,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 +809,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)) @@ -857,6 +854,27 @@ def test_rounded_rectangle_corners( ) +def test_rounded_rectangle_joined_x_different_corners() -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im, "RGBA") + + # Act + draw.rounded_rectangle( + (20, 10, 80, 90), + 30, + fill="red", + outline="green", + width=5, + corners=(True, False, False, False), + ) + + # Assert + assert_image_equal_tofile( + im, "Tests/images/imagedraw_rounded_rectangle_joined_x_different_corners.png" + ) + + @pytest.mark.parametrize( "xy, radius, type", [ @@ -1347,6 +1365,20 @@ def test_stroke() -> None: ) +@skip_unless_feature("freetype2") +def test_stroke_float() -> None: + # Arrange + im = Image.new("RGB", (120, 130)) + draw = ImageDraw.Draw(im) + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 120) + + # Act + draw.text((12, 12), "A", "#f00", font, stroke_width=0.5) + + # Assert + assert_image_similar_tofile(im, "Tests/images/imagedraw_stroke_float.png", 3.1) + + @skip_unless_feature("freetype2") def test_stroke_descender() -> None: # Arrange @@ -1361,6 +1393,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 @@ -1469,7 +1523,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) @@ -1639,6 +1692,9 @@ 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)), diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index c80aa739c..e0d368228 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -65,6 +65,36 @@ def test_mode() -> None: ImageDraw2.Draw("L") +@pytest.mark.parametrize("bbox", BBOX) +@pytest.mark.parametrize("start, end", ((0, 180), (0.5, 180.4))) +def test_arc(bbox: Coords, start: float, end: float) -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw2.Draw(im) + pen = ImageDraw2.Pen("white", width=1) + + # Act + draw.arc(bbox, pen, start, end) + + # Assert + assert_image_similar_tofile(im, "Tests/images/imagedraw_arc.png", 1) + + +@pytest.mark.parametrize("bbox", BBOX) +def test_chord(bbox: Coords) -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw2.Draw(im) + pen = ImageDraw2.Pen("yellow") + brush = ImageDraw2.Brush("red") + + # Act + draw.chord(bbox, pen, 0, 180, brush) + + # Assert + assert_image_similar_tofile(im, "Tests/images/imagedraw_chord_RGB.png", 1) + + @pytest.mark.parametrize("bbox", BBOX) def test_ellipse(bbox: Coords) -> None: # Arrange @@ -123,6 +153,22 @@ def test_line_pen_as_brush(points: Coords) -> None: assert_image_equal_tofile(im, "Tests/images/imagedraw_line.png") +@pytest.mark.parametrize("bbox", BBOX) +@pytest.mark.parametrize("start, end", ((-92, 46), (-92.2, 46.2))) +def test_pieslice(bbox: Coords, start: float, end: float) -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw2.Draw(im) + pen = ImageDraw2.Pen("blue") + brush = ImageDraw2.Brush("white") + + # Act + draw.pieslice(bbox, pen, start, end, brush) + + # Assert + assert_image_similar_tofile(im, "Tests/images/imagedraw_pieslice.png", 1) + + @pytest.mark.parametrize("points", POINTS) def test_polygon(points: Coords) -> None: # Arrange diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 44a6e6a42..b05d29dae 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -93,8 +93,20 @@ 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") - @skip_unless_feature("webp_anim") def test_incremental_webp(self) -> None: with ImageFile.Parser() as p: with open("Tests/images/hopper.webp", "rb") as f: @@ -179,13 +191,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: @@ -194,13 +203,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): @@ -211,7 +219,7 @@ class MockPyDecoder(ImageFile.PyDecoder): super().__init__(mode, *args) - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: # eof return -1, 0 @@ -239,7 +247,9 @@ class MockImageFile(ImageFile.ImageFile): self.rawmode = "RGBA" self._mode = "RGBA" self._size = (200, 200) - self.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None)] + self.tile = [ + ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None) + ] class CodecsTest: @@ -269,7 +279,7 @@ class TestPyDecoder(CodecsTest): buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [("MOCK", None, 32, None)] + im.tile = [ImageFile._Tile("MOCK", None, 32, None)] im.load() @@ -282,12 +292,12 @@ class TestPyDecoder(CodecsTest): buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)] + im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)] with pytest.raises(ValueError): im.load() - im.tile = [("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)] + im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)] with pytest.raises(ValueError): im.load() @@ -295,12 +305,20 @@ class TestPyDecoder(CodecsTest): buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [("MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None)] + im.tile = [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None + ) + ] with pytest.raises(ValueError): im.load() - im.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None)] + im.tile = [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None + ) + ] with pytest.raises(ValueError): im.load() @@ -318,7 +336,13 @@ class TestPyEncoder(CodecsTest): fp = BytesIO() ImageFile._save( - im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")] + im, + fp, + [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB" + ) + ], ) assert MockPyEncoder.last @@ -331,10 +355,10 @@ class TestPyEncoder(CodecsTest): buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [("MOCK", None, 32, None)] + im.tile = [ImageFile._Tile("MOCK", None, 32, None)] fp = BytesIO() - ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")]) + ImageFile._save(im, fp, [ImageFile._Tile("MOCK", None, 0, "RGB")]) assert MockPyEncoder.last assert MockPyEncoder.last.state.xoff == 0 @@ -351,7 +375,9 @@ class TestPyEncoder(CodecsTest): MockPyEncoder.last = None with pytest.raises(ValueError): ImageFile._save( - im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")] + im, + fp, + [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")], ) last: MockPyEncoder | None = MockPyEncoder.last assert last @@ -359,7 +385,9 @@ class TestPyEncoder(CodecsTest): with pytest.raises(ValueError): ImageFile._save( - im, fp, [("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")] + im, + fp, + [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")], ) def test_oversize(self) -> None: @@ -372,14 +400,22 @@ class TestPyEncoder(CodecsTest): ImageFile._save( im, fp, - [("MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB")], + [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB" + ) + ], ) with pytest.raises(ValueError): ImageFile._save( im, fp, - [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB")], + [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB" + ) + ], ) def test_encode(self) -> None: @@ -395,9 +431,8 @@ class TestPyEncoder(CodecsTest): with pytest.raises(NotImplementedError): encoder.encode_to_pyfd() - fh = BytesIO() with pytest.raises(NotImplementedError): - encoder.encode_to_file(fh, 0) + encoder.encode_to_file(0, 0) def test_zero_height(self) -> None: with pytest.raises(UnidentifiedImageError): diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 340cc4742..4cce8f180 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -5,6 +5,7 @@ import os import re import shutil import sys +import tempfile from io import BytesIO from pathlib import Path from typing import Any, BinaryIO @@ -253,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 @@ -460,17 +462,57 @@ 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 + with pytest.raises(OSError) as e: + ImageFont.load(tmp.name) + + os.unlink(tmp.name) + + root = os.path.splitext(tmp.name)[0] + assert str(e.value) == f"cannot find glyph data file {root}.{{gif|pbm|png}}" + + def test_load_path_not_found() -> None: # Arrange filename = "somefilenamethatdoesntexist.ttf" # Act/Assert - with pytest.raises(OSError): + with pytest.raises(OSError) as e: ImageFont.load_path(filename) + + # The file doesn't exist, so don't suggest `load` + assert filename in str(e.value) + assert "did you mean" not in str(e.value) with pytest.raises(OSError): ImageFont.truetype(filename) +def test_load_path_existing_path() -> None: + with tempfile.NamedTemporaryFile() as tmp: + with pytest.raises(OSError) as e: + ImageFont.load_path(tmp.name) + + # The file exists, so the error message suggests to use `load` instead + assert tmp.name in str(e.value) + assert " did you mean" in str(e.value) + + def test_load_non_font_bytes() -> None: with open("Tests/images/hopper.jpg", "rb") as f: with pytest.raises(OSError): @@ -516,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( @@ -985,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 @@ -1002,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 @@ -1113,6 +1155,9 @@ def test_bytes(font: ImageFont.FreeTypeFont) -> None: ) assert font.getmask2(b"test")[1] == font.getmask2("test")[1] + with pytest.raises(TypeError): + font.getlength((0, 0)) # type: ignore[arg-type] + @pytest.mark.parametrize( "test_file", @@ -1147,3 +1192,15 @@ def test_invalid_truetype_sizes_raise_valueerror( ) -> None: with pytest.raises(ValueError): ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine) + + +def test_freetype_deprecation(monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange: mock features.version_module to return fake FreeType version + def fake_version_module(module: str) -> str: + return "2.9.0" + + monkeypatch.setattr(features, "version_module", fake_version_module) + + # Act / Assert + with pytest.warns(DeprecationWarning): + ImageFont.truetype(FONT_PATH, FONT_SIZE) 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 4363f456e..6180a7b5d 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -46,7 +46,8 @@ def img_to_string(im: Image.Image) -> str: line = "" for c in range(im.width): value = im.getpixel((c, r)) - assert not isinstance(value, tuple) and value is not None + assert not isinstance(value, tuple) + assert value is not None line += chars[value > 0] result.append(line) return "\n".join(result) @@ -323,17 +324,17 @@ def test_set_lut() -> None: def test_wrong_mode() -> None: lut = ImageMorph.LutBuilder(op_name="corner").build_lut() - imrgb = Image.new("RGB", (10, 10)) - iml = Image.new("L", (10, 10)) + imrgb_ptr = Image.new("RGB", (10, 10)).getim() + iml_ptr = Image.new("L", (10, 10)).getim() with pytest.raises(RuntimeError): - _imagingmorph.apply(bytes(lut), imrgb.im.id, iml.im.id) + _imagingmorph.apply(bytes(lut), imrgb_ptr, iml_ptr) with pytest.raises(RuntimeError): - _imagingmorph.apply(bytes(lut), iml.im.id, imrgb.im.id) + _imagingmorph.apply(bytes(lut), iml_ptr, imrgb_ptr) with pytest.raises(RuntimeError): - _imagingmorph.match(bytes(lut), imrgb.im.id) + _imagingmorph.match(bytes(lut), imrgb_ptr) # Should not raise - _imagingmorph.match(bytes(lut), iml.im.id) + _imagingmorph.match(bytes(lut), iml_ptr) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index e33e6d4c8..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")) @@ -390,7 +386,7 @@ def test_colorize_3color_offset() -> None: def test_exif_transpose() -> None: exts = [".jpg"] - if features.check("webp") and features.check("webp_anim"): + if features.check("webp"): exts.append(".webp") for ext in exts: with Image.open("Tests/images/hopper" + ext) as base_im: @@ -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..e0b6359b0 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")) @@ -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 cda2584e7..1b1ee6bac 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -79,7 +79,7 @@ def test_path_constructors( ), ) def test_invalid_path_constructors( - coords: tuple[str, str] | Sequence[Sequence[int]] + coords: tuple[str, str] | Sequence[Sequence[int]], ) -> None: # Act with pytest.raises(ValueError) as e: @@ -204,6 +204,17 @@ def test_overflow_segfault() -> None: x[i] = b"0" * 16 +def test_compact_within_map() -> None: + p = ImagePath.Path([0, 1]) + + def map_func(x: float, y: float) -> tuple[float, float]: + p.compact() + return 0, 0 + + with pytest.raises(ValueError): + p.map(map_func) + + class Evil: def __init__(self) -> None: self.corrupt = Image.core.path(0x4000000000000000) diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index 22cd674ce..2d7ca0ae0 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -52,4 +52,6 @@ def test_image(mode: str) -> None: def test_closed_file() -> None: with warnings.catch_warnings(): + warnings.simplefilter("error") + ImageQt.ImageQt("Tests/images/hopper.gif") diff --git a/Tests/test_imageshow.py b/Tests/test_imageshow.py index a4f7e5cc5..7a2f58767 100644 --- a/Tests/test_imageshow.py +++ b/Tests/test_imageshow.py @@ -115,7 +115,7 @@ def test_ipythonviewer() -> None: test_viewer = viewer break else: - pytest.fail() + pytest.fail("IPythonViewer not found") - im = hopper() - assert test_viewer.show(im) == 1 + with hopper() as im: + assert test_viewer.show(im) == 1 diff --git a/Tests/test_imagewin.py b/Tests/test_imagewin.py index a836bb90b..e7af160dd 100644 --- a/Tests/test_imagewin.py +++ b/Tests/test_imagewin.py @@ -60,6 +60,18 @@ class TestImageWinDib: with pytest.raises(ValueError): ImageWin.Dib(mode) + def test_dib_hwnd(self) -> None: + mode = "RGBA" + size = (128, 128) + wnd = 0 + + dib = ImageWin.Dib(mode, size) + hwnd = ImageWin.HWND(wnd) + + dib.expose(hwnd) + dib.draw(hwnd, (0, 0) + size) + assert isinstance(dib.query_palette(hwnd), int) + def test_dib_paste(self) -> None: # Arrange im = hopper() 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_numpy.py b/Tests/test_numpy.py index 312e32e0c..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( @@ -238,8 +236,10 @@ def test_zero_size() -> None: @skip_unless_feature("libtiff") -def test_load_first() -> None: +def test_transposed() -> None: with Image.open("Tests/images/g4_orientation_5.tif") as im: + assert im.size == (590, 88) + a = numpy.array(im) assert a.shape == (88, 590) @@ -262,4 +262,6 @@ def test_no_resource_warning_for_numpy_array() -> None: with Image.open(test_file) as im: # Act/Assert with warnings.catch_warnings(): + warnings.simplefilter("error") + array(im) 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 ed415953f..c4f8de013 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -46,7 +46,7 @@ def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> Non @pytest.mark.parametrize( - ("test_file", "test_mode"), + "test_file, test_mode", [ ("Tests/images/hopper.jpg", None), ("Tests/images/hopper.jpg", "L"), @@ -56,10 +56,10 @@ def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> Non ), ("Tests/images/hopper.tif", None), ("Tests/images/test-card.png", None), - ("Tests/images/zero_bb.png", None), - ("Tests/images/zero_bb_scale2.png", None), - ("Tests/images/non_zero_bb.png", None), - ("Tests/images/non_zero_bb_scale2.png", None), + ("Tests/images/eps/zero_bb.png", None), + ("Tests/images/eps/zero_bb_scale2.png", None), + ("Tests/images/eps/non_zero_bb.png", None), + ("Tests/images/eps/non_zero_bb_scale2.png", None), ("Tests/images/p_trns_single.png", None), ("Tests/images/pil123p.png", None), ("Tests/images/itxt_chunks.png", None), @@ -74,6 +74,17 @@ 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") diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index c3afa9089..a743d831f 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -5,8 +5,6 @@ import sys from io import BytesIO from pathlib import Path -import pytest - from PIL import Image, PSDraw @@ -49,15 +47,14 @@ def test_draw_postscript(tmp_path: Path) -> None: assert os.path.getsize(tempfile) > 0 -@pytest.mark.parametrize("buffer", (True, False)) -def test_stdout(buffer: bool) -> None: +def test_stdout() -> None: # Temporarily redirect stdout old_stdout = sys.stdout class MyStdOut: buffer = BytesIO() - mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() + mystdout = MyStdOut() sys.stdout = mystdout @@ -67,6 +64,4 @@ def test_stdout(buffer: bool) -> None: # Reset stdout sys.stdout = old_stdout - if isinstance(mystdout, MyStdOut): - mystdout = mystdout.buffer - assert mystdout.getvalue() != b"" + assert mystdout.buffer.getvalue() != b"" diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py index 28f66891c..0ed9fbfa5 100644 --- a/Tests/test_qt_image_qapplication.py +++ b/Tests/test_qt_image_qapplication.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING, Union import pytest @@ -8,6 +9,20 @@ from PIL import Image, ImageQt from .helper import assert_image_equal_tofile, assert_image_similar, hopper +if TYPE_CHECKING: + import PyQt6 + import PySide6 + + QApplication = Union[PyQt6.QtWidgets.QApplication, PySide6.QtWidgets.QApplication] + QHBoxLayout = Union[PyQt6.QtWidgets.QHBoxLayout, PySide6.QtWidgets.QHBoxLayout] + QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage] + QLabel = Union[PyQt6.QtWidgets.QLabel, PySide6.QtWidgets.QLabel] + QPainter = Union[PyQt6.QtGui.QPainter, PySide6.QtGui.QPainter] + QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap] + QPoint = Union[PyQt6.QtCore.QPoint, PySide6.QtCore.QPoint] + QRegion = Union[PyQt6.QtGui.QRegion, PySide6.QtGui.QRegion] + QWidget = Union[PyQt6.QtWidgets.QWidget, PySide6.QtWidgets.QWidget] + if ImageQt.qt_is_installed: from PIL.ImageQt import QPixmap @@ -20,7 +35,7 @@ if ImageQt.qt_is_installed: from PySide6.QtGui import QImage, QPainter, QRegion from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget - class Example(QWidget): + class Example(QWidget): # type: ignore[misc] def __init__(self) -> None: super().__init__() @@ -28,11 +43,12 @@ if ImageQt.qt_is_installed: qimage = ImageQt.ImageQt(img) - pixmap1 = ImageQt.QPixmap.fromImage(qimage) + pixmap1 = getattr(ImageQt.QPixmap, "fromImage")(qimage) - QHBoxLayout(self) # hbox + # hbox + QHBoxLayout(self) # type: ignore[operator] - lbl = QLabel(self) + lbl = QLabel(self) # type: ignore[operator] # Segfault in the problem lbl.setPixmap(pixmap1.copy()) @@ -46,7 +62,7 @@ def roundtrip(expected: Image.Image) -> None: @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") def test_sanity(tmp_path: Path) -> None: # Segfault test - app: QApplication | None = QApplication([]) + app: QApplication | None = QApplication([]) # type: ignore[operator] ex = Example() assert app # Silence warning assert ex # Silence warning @@ -56,7 +72,7 @@ def test_sanity(tmp_path: Path) -> None: im = hopper(mode) data = ImageQt.toqpixmap(im) - assert isinstance(data, QPixmap) + assert data.__class__.__name__ == "QPixmap" assert not data.isNull() # Test saving the file @@ -64,14 +80,14 @@ def test_sanity(tmp_path: Path) -> None: data.save(tempfile) # Render the image - qimage = ImageQt.ImageQt(im) - data = QPixmap.fromImage(qimage) - qt_format = QImage.Format if ImageQt.qt_version == "6" else QImage - qimage = QImage(128, 128, qt_format.Format_ARGB32) - painter = QPainter(qimage) - image_label = QLabel() + imageqt = ImageQt.ImageQt(im) + data = getattr(QPixmap, "fromImage")(imageqt) + qt_format = getattr(QImage, "Format") if ImageQt.qt_version == "6" else QImage + qimage = QImage(128, 128, getattr(qt_format, "Format_ARGB32")) # type: ignore[operator] + painter = QPainter(qimage) # type: ignore[operator] + image_label = QLabel() # type: ignore[operator] image_label.setPixmap(data) - image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) + image_label.render(painter, QPoint(0, 0), QRegion(0, 0, 128, 128)) # type: ignore[operator] painter.end() rendered_tempfile = str(tmp_path / f"temp_rendered_{mode}.png") qimage.save(rendered_tempfile) diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py index 6110be707..8cb7ffb9b 100644 --- a/Tests/test_qt_image_toqimage.py +++ b/Tests/test_qt_image_toqimage.py @@ -21,7 +21,7 @@ def test_sanity(mode: str, tmp_path: Path) -> None: src = hopper(mode) data = ImageQt.toqimage(src) - assert isinstance(data, QImage) + assert isinstance(data, QImage) # type: ignore[arg-type, misc] assert not data.isNull() # reload directly from the qimage diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index 9d06a9332..13f1f9c80 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -54,8 +54,8 @@ def test_nonetype() -> None: assert xres.denominator is not None assert yres._val is not None - assert xres and 1 - assert xres and yres + assert xres + assert yres @pytest.mark.parametrize( 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 9dd7742ed..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.1 +archive_version=4.3.4 archive=$archive_name-$archive_version @@ -23,14 +23,14 @@ else cargo cinstall --prefix=/usr --destdir=. # Copy into place - sudo cp usr/lib/libimagequant.so* /usr/lib/ + sudo find usr -name libimagequant.so* -exec cp {} /usr/lib/ \; sudo cp usr/include/libimagequant.h /usr/include/ if [ -n "$GITHUB_ACTIONS" ]; then # Copy to cache rm -rf ~/cache-$archive_name mkdir ~/cache-$archive_name - cp usr/lib/libimagequant.so* ~/cache-$archive_name/ + find usr -name libimagequant.so* -exec cp {} ~/cache-$archive_name/ \; cp usr/include/libimagequant.h ~/cache-$archive_name/ fi 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_raqm.sh b/depends/install_raqm.sh index 070ba23a1..5d862403e 100755 --- a/depends/install_raqm.sh +++ b/depends/install_raqm.sh @@ -2,7 +2,7 @@ # install raqm -archive=libraqm-0.10.1 +archive=libraqm-0.10.2 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz 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/Makefile b/docs/Makefile index 8f13f1aea..e90af0519 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -46,7 +46,7 @@ clean: -rm -rf $(BUILDDIR)/* install-sphinx: - $(PYTHON) -m pip install --quiet furo olefile sphinx sphinx-copybutton sphinx-inline-tabs sphinxext-opengraph + $(PYTHON) -m pip install -e ..[docs] .PHONY: html html: diff --git a/docs/about.rst b/docs/about.rst index 98cdd8e5a..7df895b8f 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -6,19 +6,18 @@ 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/ License ------- -Like PIL, Pillow is `licensed under the open source HPND License `_ +Like PIL, Pillow is `licensed under the open source MIT-CMU License `_ Why a fork? ----------- diff --git a/docs/conf.py b/docs/conf.py index 41f9c0e38..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 = "7.3" +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 = [] +nitpick_ignore = [("py:class", "_CmsProfileCompatible")] # -- Options for HTML output ---------------------------------------------- @@ -338,8 +338,6 @@ linkcheck_allowed_redirects = { # https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html _repo = "https://github.com/python-pillow/Pillow/" extlinks = { - "cve": ("https://www.cve.org/CVERecord?id=CVE-%s", "CVE-%s"), - "cwe": ("https://cwe.mitre.org/data/definitions/%s.html", "CWE-%s"), "issue": (_repo + "issues/%s", "#%s"), "pr": (_repo + "pull/%s", "#%s"), "pypi": ("https://pypi.org/project/%s/", "%s"), diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 2f5800e07..634cee689 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -109,6 +109,35 @@ ImageDraw.getdraw hints parameter The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. +FreeType 2.9.0 +^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +Support for FreeType 2.9.0 is deprecated and will be removed in Pillow 12.0.0 +(2025-10-15), when FreeType 2.9.1 will be the minimum supported. + +We recommend upgrading to at least FreeType `2.10.4`_, which fixed a severe +vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`). + +.. _2.10.4: https://sourceforge.net/projects/freetype/files/freetype2/2.10.4/ + +ICNS (width, height, scale) sizes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +Setting an ICNS image size to ``(width, height, scale)`` before loading has been +deprecated. Instead, ``load(scale)`` can be used. + +Image isImageType() +^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +``Image.isImageType(im)`` has been deprecated. Use ``isinstance(im, Image.Image)`` +instead. + ImageMath.lambda_eval and ImageMath.unsafe_eval options parameter ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -118,12 +147,65 @@ The ``options`` parameter in :py:meth:`~PIL.ImageMath.lambda_eval()` and :py:meth:`~PIL.ImageMath.unsafe_eval()` has been deprecated. One or more keyword arguments can be used instead. +JpegImageFile.huffman_ac and JpegImageFile.huffman_dc +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +The ``huffman_ac`` and ``huffman_dc`` dictionaries on JPEG images were unused. They +have been deprecated, and will be removed in Pillow 12 (2025-10-15). + +Specific WebP Feature Checks +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +``features.check("transp_webp")``, ``features.check("webp_mux")`` and +``features.check("webp_anim")`` are now deprecated. They will always return +``True`` if the WebP module is installed, until they are removed in Pillow +12.0.0 (2025-10-15). + +Get internal pointers to objects +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +``Image.core.ImagingCore.id`` and ``Image.core.ImagingCore.unsafe_ptrs`` have been +deprecated and will be removed in Pillow 12 (2025-10-15). They were used for obtaining +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 ---------------- Deprecated features are only removed in major releases after an appropriate period of deprecation has passed. +TiffImagePlugin IFD_LEGACY_API +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionremoved:: 11.0.0 + +``TiffImagePlugin.IFD_LEGACY_API`` was removed, as it was an unused setting. + PSFile ~~~~~~ diff --git a/docs/example/DdsImagePlugin.py b/docs/example/DdsImagePlugin.py index 2a2a0ba29..e4b6b9c01 100644 --- a/docs/example/DdsImagePlugin.py +++ b/docs/example/DdsImagePlugin.py @@ -14,6 +14,7 @@ from __future__ import annotations import struct from io import BytesIO +from typing import IO from PIL import Image, ImageFile @@ -94,26 +95,26 @@ DXT3_FOURCC = 0x33545844 DXT5_FOURCC = 0x35545844 -def _decode565(bits): +def _decode565(bits: int) -> tuple[int, int, int]: a = ((bits >> 11) & 0x1F) << 3 b = ((bits >> 5) & 0x3F) << 2 c = (bits & 0x1F) << 3 return a, b, c -def _c2a(a, b): +def _c2a(a: int, b: int) -> int: return (2 * a + b) // 3 -def _c2b(a, b): +def _c2b(a: int, b: int) -> int: return (a + b) // 2 -def _c3(a, b): +def _c3(a: int, b: int) -> int: return (2 * b + a) // 3 -def _dxt1(data, width, height): +def _dxt1(data: IO[bytes], width: int, height: int) -> bytes: # TODO implement this function as pixel format in decode.c ret = bytearray(4 * width * height) @@ -151,7 +152,7 @@ def _dxt1(data, width, height): return bytes(ret) -def _dxtc_alpha(a0, a1, ac0, ac1, ai): +def _dxtc_alpha(a0: int, a1: int, ac0: int, ac1: int, ai: int) -> int: if ai <= 12: ac = (ac0 >> ai) & 7 elif ai == 15: @@ -175,7 +176,7 @@ def _dxtc_alpha(a0, a1, ac0, ac1, ai): return alpha -def _dxt5(data, width, height): +def _dxt5(data: IO[bytes], width: int, height: int) -> bytes: # TODO implement this function as pixel format in decode.c ret = bytearray(4 * width * height) @@ -211,7 +212,7 @@ class DdsImageFile(ImageFile.ImageFile): format = "DDS" format_description = "DirectDraw Surface" - def _open(self): + def _open(self) -> None: if not _accept(self.fp.read(4)): msg = "not a DDS file" raise SyntaxError(msg) @@ -242,19 +243,22 @@ class DdsImageFile(ImageFile.ImageFile): elif fourcc == b"DXT5": self.decoder = "DXT5" else: - msg = f"Unimplemented pixel format {fourcc}" + msg = f"Unimplemented pixel format {repr(fourcc)}" raise NotImplementedError(msg) - self.tile = [(self.decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))] + self.tile = [ + ImageFile._Tile(self.decoder, (0, 0) + self.size, 0, (self.mode, 0, 1)) + ] - def load_seek(self, pos): + def load_seek(self, pos: int) -> None: pass class DXT1Decoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None try: self.set_as_raw(_dxt1(self.fd, self.state.xsize, self.state.ysize)) except struct.error as e: @@ -266,7 +270,8 @@ class DXT1Decoder(ImageFile.PyDecoder): class DXT5Decoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None try: self.set_as_raw(_dxt5(self.fd, self.state.xsize, self.state.ysize)) except struct.error as e: @@ -279,8 +284,8 @@ Image.register_decoder("DXT1", DXT1Decoder) Image.register_decoder("DXT5", DXT5Decoder) -def _accept(prefix): - return prefix[:4] == b"DDS " +def _accept(prefix: bytes) -> bool: + 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 e7242e24c..4ea37dc66 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -324,12 +324,19 @@ sets the following :py:attr:`~PIL.Image.Image.info` property: **sizes** A list of supported sizes found in this icon file; these are a 3-tuple, ``(width, height, scale)``, where ``scale`` is 2 for a retina - icon and 1 for a standard icon. You *are* permitted to use this 3-tuple - format for the :py:attr:`~PIL.Image.Image.size` property if you set it - before calling :py:meth:`~PIL.Image.Image.load`; after loading, the size - will be reset to a 2-tuple containing pixel dimensions (so, e.g. if you - ask for ``(512, 512, 2)``, the final value of - :py:attr:`~PIL.Image.Image.size` will be ``(1024, 1024)``). + icon and 1 for a standard icon. + +.. _icns-loading: + +Loading +~~~~~~~ + +You can call the :py:meth:`~PIL.Image.Image.load` method with the following parameter. + +**scale** + Affects the scale of the resultant image. If the size is set to ``(512, 512)``, + after loading at scale 2, the final value of :py:attr:`~PIL.Image.Image.size` will + be ``(1024, 1024)``. .. _icns-saving: @@ -447,7 +454,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 @@ -692,6 +709,30 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: you fail to do this, you will get errors about not being able to load the ``_imaging`` DLL). +MPO +^^^ + +Pillow reads and writes Multi Picture Object (MPO) files. When first opened, it loads +the primary image. The :py:meth:`~PIL.Image.Image.seek` and +:py:meth:`~PIL.Image.Image.tell` methods may be used to read other pictures from the +file. The pictures are zero-indexed and random access is supported. + +.. _mpo-saving: + +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. + +**append_images** + A list of images to append as additional pictures. Each of the + images in the list can be single or multiframe images. + + .. versionadded:: 9.3.0 + MSP ^^^ @@ -1175,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 @@ -1227,8 +1273,7 @@ using the general tags available through tiffinfo. WebP ^^^^ -Pillow reads and writes WebP files. The specifics of Pillow's capabilities with -this format are currently undocumented. +Pillow reads and writes WebP files. Requires libwebp v0.5.0 or later. .. _webp-saving: @@ -1256,29 +1301,19 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: **exact** If true, preserve the transparent RGB values. Otherwise, discard invisible RGB values for better compression. Defaults to false. - Requires libwebp 0.5.0 or later. **icc_profile** - The ICC Profile to include in the saved file. Only supported if - the system WebP library was built with webpmux support. + The ICC Profile to include in the saved file. **exif** - The exif data to include in the saved file. Only supported if - the system WebP library was built with webpmux support. + The exif data to include in the saved file. **xmp** - The XMP data to include in the saved file. Only supported if - the system WebP library was built with webpmux support. + The XMP data to include in the saved file. Saving sequences ~~~~~~~~~~~~~~~~ -.. note:: - - Support for animated WebP files will only be enabled if the system WebP - library is v0.5.0 or later. You can check webp animation support at - runtime by calling ``features.check("webp_anim")``. - When calling :py:meth:`~PIL.Image.Image.save` to write a WebP file, by default only the first frame of a multiframe image will be saved. If the ``save_all`` argument is present and true, then all frames will be saved, and the following @@ -1446,30 +1481,6 @@ Note that there may be an embedded gamma of 2.2 in MIC files. To enable MIC support, you must install :pypi:`olefile`. -MPO -^^^ - -Pillow identifies and reads Multi Picture Object (MPO) files, loading the primary -image when first opened. The :py:meth:`~PIL.Image.Image.seek` and :py:meth:`~PIL.Image.Image.tell` -methods may be used to read other pictures from the file. The pictures are -zero-indexed and random access is supported. - -.. _mpo-saving: - -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. - -**append_images** - A list of images to append as additional pictures. Each of the - images in the list can be single or multiframe images. - - .. versionadded:: 9.3.0 - PCD ^^^ @@ -1535,19 +1546,21 @@ To add other read or write support, use :py:func:`PIL.WmfImagePlugin.register_handler` to register a WMF and EMF handler. :: - from PIL import Image + from typing import IO + + from PIL import Image, ImageFile from PIL import WmfImagePlugin - class WmfHandler: - def open(self, im): + class WmfHandler(ImageFile.StubHandler): + def open(self, im: ImageFile.StubImageFile) -> None: ... - def load(self, im): + def load(self, im: ImageFile.StubImageFile) -> Image.Image: ... return image - def save(self, im, fp, filename): + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: ... diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst index c36011362..f771ae7ae 100644 --- a/docs/handbook/tutorial.rst +++ b/docs/handbook/tutorial.rst @@ -186,7 +186,7 @@ Rolling an image :: - def roll(im, delta): + def roll(im: Image.Image, delta: int) -> Image.Image: """Roll an image sideways.""" xsize, ysize = im.size @@ -211,7 +211,7 @@ Merging images :: - def merge(im1, im2): + def merge(im1: Image.Image, im2: Image.Image) -> Image.Image: w = im1.size[0] + im2.size[0] h = max(im1.size[1], im2.size[1]) im = Image.new("RGBA", (w, h)) @@ -678,7 +678,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)) @@ -704,7 +704,7 @@ in the current directory can be saved as JPEGs at reduced quality. import glob from PIL import Image - def compress_image(source_path, dest_path): + def compress_image(source_path: str, dest_path: str) -> None: with Image.open(source_path) as img: if img.mode != "RGB": img = img.convert("RGB") diff --git a/docs/handbook/writing-your-own-image-plugin.rst b/docs/handbook/writing-your-own-image-plugin.rst index 956d63aa7..9e7d14c57 100644 --- a/docs/handbook/writing-your-own-image-plugin.rst +++ b/docs/handbook/writing-your-own-image-plugin.rst @@ -53,8 +53,8 @@ true color. from PIL import Image, ImageFile - def _accept(prefix): - return prefix[:4] == b"SPAM" + def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"SPAM") class SpamImageFile(ImageFile.ImageFile): @@ -62,7 +62,7 @@ true color. format = "SPAM" format_description = "Spam raster image" - def _open(self): + def _open(self) -> None: header = self.fp.read(128).split() @@ -82,7 +82,7 @@ true color. raise SyntaxError(msg) # data descriptor - self.tile = [("raw", (0, 0) + self.size, 128, (self.mode, 0, 1))] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 128, (self.mode, 0, 1))] Image.register_open(SpamImageFile.format, SpamImageFile, _accept) diff --git a/docs/index.rst b/docs/index.rst index 3a12953f0..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 @@ -199,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 @@ -275,18 +260,18 @@ Build Options * Config settings: ``-C zlib=disable``, ``-C jpeg=disable``, ``-C tiff=disable``, ``-C freetype=disable``, ``-C raqm=disable``, - ``-C lcms=disable``, ``-C webp=disable``, ``-C webpmux=disable``, + ``-C lcms=disable``, ``-C webp=disable``, ``-C jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``. Disable building the corresponding feature even if the development libraries are present on the building machine. * Config settings: ``-C zlib=enable``, ``-C jpeg=enable``, ``-C tiff=enable``, ``-C freetype=enable``, ``-C raqm=enable``, - ``-C lcms=enable``, ``-C webp=enable``, ``-C webpmux=enable``, + ``-C lcms=enable``, ``-C webp=enable``, ``-C jpeg2000=enable``, ``-C imagequant=enable``, ``-C xcb=enable``. Require that the corresponding feature is built. The build will raise - an exception if the libraries are not found. Webpmux (WebP metadata) - relies on WebP support. Tcl and Tk also must be used together. + an exception if the libraries are not found. Tcl and Tk must be used + together. * Config settings: ``-C raqm=vendor``, ``-C fribidi=vendor``. These flags are used to compile a modified version of libraqm and diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index f2ef9cacb..9eafad3c4 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -17,23 +17,25 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Operating system | Tested Python versions | Tested architecture | +==================================+============================+=====================+ -| Alpine | 3.9 | x86-64 | +| Alpine | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Amazon Linux 2 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Amazon Linux 2023 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Arch | 3.9 | x86-64 | +| Arch | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ | CentOS Stream 9 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Debian 12 Bookworm | 3.11 | x86, x86-64 | +| CentOS Stream 10 | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Fedora 39 | 3.12 | x86-64 | +| Debian 12 Bookworm | 3.11 | x86, x86-64 | +----------------------------------+----------------------------+---------------------+ | Fedora 40 | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Gentoo | 3.9 | x86-64 | +| Fedora 41 | 3.13 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Gentoo | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ | macOS 13 Ventura | 3.9 | 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 2016 | 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.12 | x86 | -| +----------------------------+---------------------+ -| | 3.9 (MinGW) | x86-64 | +| | 3.12 (MinGW) | x86-64 | | +----------------------------+---------------------+ | | 3.9 (Cygwin) | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -75,6 +73,10 @@ 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.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 | +----------------------------------+----------------------------+------------------+--------------+ | macOS 13 Ventura | 3.8, 3.9, 3.10, 3.11 | 10.0.1 |arm | @@ -146,7 +148,7 @@ These platforms have been reported to work at the versions mentioned. +----------------------------------+----------------------------+------------------+--------------+ | FreeBSD 10.2 | 2.7, 3.4 | 3.1.0 |x86-64 | +----------------------------------+----------------------------+------------------+--------------+ -| Windows 11 | 3.9, 3.10, 3.11, 3.12 | 10.2.0 |arm64 | +| Windows 11 23H2 | 3.9, 3.10, 3.11, 3.12, 3.13| 11.0.0 |arm64 | +----------------------------------+----------------------------+------------------+--------------+ | Windows 11 Pro | 3.11, 3.12 | 10.2.0 |x86-64 | +----------------------------------+----------------------------+------------------+--------------+ diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 66c5e5422..bc3758218 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -362,6 +362,7 @@ Classes :undoc-members: :show-inheritance: .. autoclass:: PIL.Image.ImagePointHandler +.. autoclass:: PIL.Image.ImagePointTransform .. autoclass:: PIL.Image.ImageTransformHandler Protocols 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 c66193061..0e173fe87 100644 --- a/docs/reference/features.rst +++ b/docs/reference/features.rst @@ -54,12 +54,14 @@ 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. -* ``transp_webp``: Support for transparency in WebP images. -* ``webp_mux``: (compile time) Support for EXIF data in WebP images. -* ``webp_anim``: (compile time) Support for animated WebP images. +* ``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. +* ``transp_webp``: Deprecated. Always ``True`` if WebP module is installed. +* ``webp_mux``: Deprecated. Always ``True`` if WebP module is installed. +* ``webp_anim``: Deprecated. Always ``True`` if WebP module is installed. .. autofunction:: PIL.features.check_feature .. autofunction:: PIL.features.version_feature diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index 2fb4ff8c0..93fd82cf9 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -33,6 +33,14 @@ Internal Modules Provides a convenient way to import type hints that are not available on some Python versions. +.. py:class:: Buffer + + Typing alias. + +.. py:class:: IntegralLike + + Typing alias. + .. py:class:: NumpyArray Typing alias. diff --git a/docs/reference/plugins.rst b/docs/reference/plugins.rst index 18cd99cf3..454b94d8c 100644 --- a/docs/reference/plugins.rst +++ b/docs/reference/plugins.rst @@ -185,6 +185,14 @@ Plugin reference :undoc-members: :show-inheritance: +:mod:`~PIL.MpoImagePlugin` Module +---------------------------------- + +.. automodule:: PIL.MpoImagePlugin + :members: + :undoc-members: + :show-inheritance: + :mod:`~PIL.MspImagePlugin` Module --------------------------------- diff --git a/docs/releasenotes/11.0.0.rst b/docs/releasenotes/11.0.0.rst index bb6179de8..c3f18140f 100644 --- a/docs/releasenotes/11.0.0.rst +++ b/docs/releasenotes/11.0.0.rst @@ -1,19 +1,6 @@ 11.0.0 ------ -Security -======== - -TODO -^^^^ - -TODO - -:cve:`YYYY-XXXXX`: TODO -^^^^^^^^^^^^^^^^^^^^^^^ - -TODO - Backwards Incompatible Changes ============================== @@ -23,6 +10,13 @@ Python 3.8 Pillow has dropped support for Python 3.8, which reached end-of-life in October 2024. +Python 3.12 on macOS <= 10.12 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The latest version of Python 3.12 only supports macOS versions 10.13 and later, +and so Pillow has also updated the deployment target for its prebuilt Python 3.12 +wheels. + PSFile ^^^^^^ @@ -40,31 +34,109 @@ removed. Pillow's C API will now be used on PyPy instead. ``Image.USE_CFFI_ACCESS``, for switching from the C API to PyAccess, was similarly removed. +TiffImagePlugin IFD_LEGACY_API +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +An unused setting, ``TiffImagePlugin.IFD_LEGACY_API``, has been removed. + +WebP 0.4 +^^^^^^^^ + +Support for WebP 0.4 and earlier has been removed; WebP 0.5 is the minimum supported. + Deprecations ============ +FreeType 2.9.0 +^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +Support for FreeType 2.9.0 is deprecated and will be removed in Pillow 12.0.0 +(2025-10-15), when FreeType 2.9.1 will be the minimum supported. + +We recommend upgrading to at least FreeType `2.10.4`_, which fixed a severe +vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`). + +.. _2.10.4: https://sourceforge.net/projects/freetype/files/freetype2/2.10.4/ + +Get internal pointers to objects +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +``Image.core.ImagingCore.id`` and ``Image.core.ImagingCore.unsafe_ptrs`` have been +deprecated and will be removed in Pillow 12 (2025-10-15). They were used for obtaining +raw pointers to ``ImagingCore`` internals. To interact with C code, you can use +``Image.Image.getim()``, which returns a ``Capsule`` object. + +ICNS (width, height, scale) sizes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +Setting an ICNS image size to ``(width, height, scale)`` before loading has been +deprecated. Instead, ``load(scale)`` can be used. + +Image isImageType() +^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +``Image.isImageType(im)`` has been deprecated. Use ``isinstance(im, Image.Image)`` +instead. + ImageMath.lambda_eval and ImageMath.unsafe_eval options parameter ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. deprecated:: 11.0.0 + The ``options`` parameter in :py:meth:`~PIL.ImageMath.lambda_eval()` and :py:meth:`~PIL.ImageMath.unsafe_eval()` has been deprecated. One or more keyword arguments can be used instead. +JpegImageFile.huffman_ac and JpegImageFile.huffman_dc +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +The ``huffman_ac`` and ``huffman_dc`` dictionaries on JPEG images were unused. They +have been deprecated, and will be removed in Pillow 12 (2025-10-15). + +Specific WebP Feature Checks +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.0.0 + +``features.check("transp_webp")``, ``features.check("webp_mux")`` and +``features.check("webp_anim")`` are now deprecated. They will always return +``True`` if the WebP module is installed, until they are removed in Pillow +12.0.0 (2025-10-15). + API Changes =========== -TODO -^^^^ +Default resampling filter for I;16* image modes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +The default resampling filter for I;16, I;16L, I;16B and I;16N has been changed from +``Image.NEAREST`` to ``Image.BICUBIC``, to match the majority of modes. API Additions ============= -TODO -^^^^ +Writing XMP bytes to JPEG and MPO +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +XMP data can now be saved to JPEG files using an ``xmp`` argument:: + + im.save("out.jpg", xmp=b"test") + +The data can also be set through :py:attr:`~PIL.Image.Image.info`, for use when saving +either JPEG or MPO images:: + + im.info["xmp"] = b"test" + im.save("out.jpg") Other Changes ============= @@ -74,6 +146,17 @@ Python 3.13 Pillow 10.4.0 had wheels built against Python 3.13 beta, available as a preview to help others prepare for 3.13, and to ensure Pillow could be used immediately at the release -of 3.13.0 final (2024-10-01, :pep:`719`). +of 3.13.0 final (2024-10-07, :pep:`719`). Pillow 11.0.0 now officially supports Python 3.13. + +Support has also been added for the experimental free-threaded mode of :pep:`703`. + +Python 3.13 only supports macOS versions 10.13 and later. + +C-level Flags +^^^^^^^^^^^^^ + +Some compiling flags like ``WITH_THREADING``, ``WITH_IMAGECHOPS``, and other +``WITH_*`` were removed. These flags were not available through the build system, +but they could be edited in the C source. 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..f7e644cf3 --- /dev/null +++ b/docs/releasenotes/11.2.0.rst @@ -0,0 +1,75 @@ +11.2.0 +------ + +Security +======== + +TODO +^^^^ + +TODO + +:cve:`YYYY-XXXXX`: TODO +^^^^^^^^^^^^^^^^^^^^^^^ + +TODO + +Backwards Incompatible Changes +============================== + +TODO +^^^^ + +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 +=========== + +TODO +^^^^ + +TODO + +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 + +Other Changes +============= + +TODO +^^^^ + +TODO 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 0940664f3..2ffd9faca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,14 +14,14 @@ readme = "README.md" keywords = [ "Imaging", ] -license = { text = "HPND" } +license = { text = "MIT-CMU" } authors = [ { name = "Jeffrey A. Clark", email = "aclark@aclark.net" }, ] requires-python = ">=3.9" classifiers = [ "Development Status :: 6 - Mature", - "License :: OSI Approved :: Historical Permission Notice and Disclaimer (HPND)", + "License :: OSI Approved :: CMU License (MIT-CMU)", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -43,7 +43,7 @@ dynamic = [ optional-dependencies.docs = [ "furo", "olefile", - "sphinx>=7.3", + "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,13 +94,24 @@ 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.ruff] -fix = true +[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" + +[tool.ruff] +exclude = [ "wheels/multibuild" ] + +fix = true lint.select = [ "C4", # flake8-comprehensions "E", # pycodestyle errors @@ -109,6 +121,7 @@ lint.select = [ "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "PGH", # pygrep-hooks + "PT", # flake8-pytest-style "PYI", # flake8-pyi "RUF100", # unused noqa (yesqa) "UP", # pyupgrade @@ -120,6 +133,11 @@ lint.ignore = [ "E221", # Multiple spaces before operator "E226", # Missing whitespace around arithmetic operator "E241", # Multiple spaces after ',' + "PT001", # pytest-fixture-incorrect-parentheses-style + "PT007", # pytest-parametrize-values-wrong-type + "PT011", # pytest-raises-too-broad + "PT012", # pytest-raises-with-multiple-statements + "PT017", # pytest-assert-in-except "PYI026", # flake8-pyi: typing.TypeAlias added in Python 3.10 "PYI034", # flake8-pyi: typing.Self added in Python 3.11 ] @@ -129,6 +147,7 @@ lint.per-file-ignores."Tests/oss-fuzz/fuzz_font.py" = [ lint.per-file-ignores."Tests/oss-fuzz/fuzz_pillow.py" = [ "I002", ] +lint.flake8-pytest-style.parametrize-names-type = "csv" lint.isort.known-first-party = [ "PIL", ] @@ -155,8 +174,3 @@ follow_imports = "silent" warn_redundant_casts = true warn_unreachable = true warn_unused_ignores = true -exclude = [ - '^Tests/oss-fuzz/fuzz_font.py$', - '^Tests/oss-fuzz/fuzz_pillow.py$', - '^Tests/test_qt_image_qapplication.py$', -] diff --git a/selftest.py b/selftest.py index 9e049367e..e9b5689a0 100755 --- a/selftest.py +++ b/selftest.py @@ -52,7 +52,7 @@ def testimage() -> None: or you call the "load" method: >>> im = Image.open("Tests/images/hopper.ppm") - >>> print(im.im) # internal image attribute + >>> print(im._im) # internal image attribute None >>> a = im.load() >>> type(im.im) # doctest: +ELLIPSIS diff --git a/setup.py b/setup.py index b26852b0b..a85731db9 100644 --- a/setup.py +++ b/setup.py @@ -15,18 +15,20 @@ import struct import subprocess import sys import warnings +from collections.abc import Iterator +from typing import Any from setuptools import Extension, setup from setuptools.command.build_ext import build_ext -def get_version(): +def get_version() -> str: version_file = "src/PIL/_version.py" with open(version_file, encoding="utf-8") as f: return f.read().split('"')[1] -configuration = {} +configuration: dict[str, list[str]] = {} PILLOW_VERSION = get_version() @@ -143,7 +145,7 @@ class RequiredDependencyException(Exception): PLATFORM_MINGW = os.name == "nt" and "GCC" in sys.version -def _dbg(s, tp=None): +def _dbg(s: str, tp: Any = None) -> None: if DEBUG: if tp: print(s % tp) @@ -151,10 +153,13 @@ def _dbg(s, tp=None): print(s) -def _find_library_dirs_ldconfig(): +def _find_library_dirs_ldconfig() -> list[str]: # Based on ctypes.util from Python 2 ldconfig = "ldconfig" if shutil.which("ldconfig") else "/sbin/ldconfig" + args: list[str] + env: dict[str, str] + expr: str if sys.platform.startswith("linux") or sys.platform.startswith("gnu"): if struct.calcsize("l") == 4: machine = os.uname()[4] + "-32" @@ -184,13 +189,11 @@ def _find_library_dirs_ldconfig(): try: p = subprocess.Popen( - args, stderr=subprocess.DEVNULL, stdout=subprocess.PIPE, env=env + args, stderr=subprocess.DEVNULL, stdout=subprocess.PIPE, env=env, text=True ) except OSError: # E.g. command not found return [] - [data, _] = p.communicate() - if isinstance(data, bytes): - data = data.decode("latin1") + data = p.communicate()[0] dirs = [] for dll in re.findall(expr, data): @@ -200,7 +203,9 @@ def _find_library_dirs_ldconfig(): return dirs -def _add_directory(path, subdir, where=None): +def _add_directory( + path: list[str], subdir: str | None, where: int | None = None +) -> None: if subdir is None: return subdir = os.path.realpath(subdir) @@ -216,7 +221,7 @@ def _add_directory(path, subdir, where=None): path.insert(where, subdir) -def _find_include_file(self, include): +def _find_include_file(self: pil_build_ext, include: str) -> int: for directory in self.compiler.include_dirs: _dbg("Checking for include file %s in %s", (include, directory)) if os.path.isfile(os.path.join(directory, include)): @@ -225,7 +230,7 @@ def _find_include_file(self, include): return 0 -def _find_library_file(self, library): +def _find_library_file(self: pil_build_ext, library: str) -> str | None: ret = self.compiler.find_library_file(self.compiler.library_dirs, library) if ret: _dbg("Found library %s at %s", (library, ret)) @@ -234,7 +239,7 @@ def _find_library_file(self, library): return ret -def _find_include_dir(self, dirname, include): +def _find_include_dir(self: pil_build_ext, dirname: str, include: str) -> bool | str: for directory in self.compiler.include_dirs: _dbg("Checking for include file %s in %s", (include, directory)) if os.path.isfile(os.path.join(directory, include)): @@ -245,6 +250,7 @@ def _find_include_dir(self, dirname, include): if os.path.isfile(os.path.join(subdir, include)): _dbg("Found %s in %s", (include, subdir)) return subdir + return False def _cmd_exists(cmd: str) -> bool: @@ -256,7 +262,7 @@ def _cmd_exists(cmd: str) -> bool: ) -def _pkg_config(name): +def _pkg_config(name: str) -> tuple[list[str], list[str]] | None: command = os.environ.get("PKG_CONFIG", "pkg-config") for keep_system in (True, False): try: @@ -283,10 +289,11 @@ def _pkg_config(name): return libs, cflags except Exception: pass + return None class pil_build_ext(build_ext): - class feature: + class ext_feature: features = [ "zlib", "jpeg", @@ -295,32 +302,38 @@ class pil_build_ext(build_ext): "raqm", "lcms", "webp", - "webpmux", "jpeg2000", "imagequant", "xcb", ] required = {"jpeg", "zlib"} - vendor = set() + vendor: set[str] = set() - def __init__(self): + def __init__(self) -> None: + self._settings: dict[str, str | bool | None] = {} for f in self.features: - setattr(self, f, None) + self.set(f, None) - def require(self, feat): + def require(self, feat: str) -> bool: return feat in self.required - def want(self, feat): - return getattr(self, feat) is None + def get(self, feat: str) -> str | bool | None: + return self._settings[feat] - def want_vendor(self, feat): + def set(self, feat: str, value: str | bool | None) -> None: + self._settings[feat] = value + + def want(self, feat: str) -> bool: + return self._settings[feat] is None + + def want_vendor(self, feat: str) -> bool: return feat in self.vendor - def __iter__(self): + def __iter__(self) -> Iterator[str]: yield from self.features - feature = feature() + feature = ext_feature() user_options = ( build_ext.user_options @@ -331,17 +344,17 @@ 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")] ) @staticmethod - def check_configuration(option, value): + def check_configuration(option: str, value: str) -> bool | None: return True if value in configuration.get(option, []) else None - def initialize_options(self): + def initialize_options(self) -> None: self.disable_platform_guessing = self.check_configuration( "platform-guessing", "disable" ) @@ -356,7 +369,7 @@ class pil_build_ext(build_ext): self.debug = True self.parallel = configuration.get("parallel", [None])[-1] - def finalize_options(self): + def finalize_options(self) -> None: build_ext.finalize_options(self) if self.debug: global DEBUG @@ -364,25 +377,30 @@ class pil_build_ext(build_ext): if not self.parallel: # If --parallel (or -j) wasn't specified, we want to reproduce the same # behavior as before, that is, auto-detect the number of jobs. - try: - self.parallel = int( - os.environ.get("MAX_CONCURRENCY", min(4, os.cpu_count())) - ) - except TypeError: - self.parallel = None + self.parallel = None + + cpu_count = os.cpu_count() + if cpu_count is not None: + try: + self.parallel = int( + os.environ.get("MAX_CONCURRENCY", min(4, cpu_count)) + ) + except TypeError: + pass for x in self.feature: if getattr(self, f"disable_{x}"): - setattr(self.feature, x, False) + self.feature.set(x, False) 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) @@ -390,20 +408,28 @@ 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) - def _update_extension(self, name, libraries, define_macros=None, sources=None): + def _update_extension( + self, + name: str, + libraries: list[str] | list[str | bool | None], + define_macros: list[tuple[str, str | None]] | None = None, + sources: list[str] | None = None, + ) -> None: for extension in self.extensions: if extension.name == name: extension.libraries += libraries @@ -416,16 +442,16 @@ class pil_build_ext(build_ext): extension.extra_link_args = ["--stdlib=libc++"] break - def _remove_extension(self, name): + def _remove_extension(self, name: str) -> None: for extension in self.extensions: if extension.name == name: self.extensions.remove(extension) break - def get_macos_sdk_path(self): + 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") ) @@ -443,9 +469,9 @@ class pil_build_ext(build_ext): sdk_path = commandlinetools_sdk_path return sdk_path - def build_extensions(self): - library_dirs = [] - include_dirs = [] + def build_extensions(self) -> None: + library_dirs: list[str] = [] + include_dirs: list[str] = [] pkg_config = None if _cmd_exists(os.environ.get("PKG_CONFIG", "pkg-config")): @@ -469,19 +495,22 @@ class pil_build_ext(build_ext): root = globals()[root_name] if root is None and root_name in os.environ: - prefix = os.environ[root_name] - root = (os.path.join(prefix, "lib"), os.path.join(prefix, "include")) + root_prefix = os.environ[root_name] + root = ( + os.path.join(root_prefix, "lib"), + os.path.join(root_prefix, "include"), + ) if root is None and pkg_config: - if isinstance(lib_name, tuple): + if isinstance(lib_name, str): + _dbg(f"Looking for `{lib_name}` using pkg-config.") + root = pkg_config(lib_name) + else: for lib_name2 in lib_name: _dbg(f"Looking for `{lib_name2}` using pkg-config.") root = pkg_config(lib_name2) if root: break - else: - _dbg(f"Looking for `{lib_name}` using pkg-config.") - root = pkg_config(lib_name) if isinstance(root, tuple): lib_root, include_root = root @@ -580,6 +609,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")) @@ -661,22 +691,24 @@ class pil_build_ext(build_ext): _dbg("Looking for zlib") if _find_include_file(self, "zlib.h"): if _find_library_file(self, "z"): - feature.zlib = "z" + feature.set("zlib", "z") elif sys.platform == "win32" and _find_library_file(self, "zlib"): - feature.zlib = "zlib" # alternative name + 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") if _find_include_file(self, "jpeglib.h"): if _find_library_file(self, "jpeg"): - feature.jpeg = "jpeg" + feature.set("jpeg", "jpeg") elif sys.platform == "win32" and _find_library_file(self, "libjpeg"): - feature.jpeg = "libjpeg" # alternative name + feature.set("jpeg", "libjpeg") # alternative name - feature.openjpeg_version = None + feature.set("openjpeg_version", None) if feature.want("jpeg2000"): _dbg("Looking for jpeg2000") - best_version = None + best_version: tuple[int, ...] | None = None best_path = None # Find the best version @@ -706,26 +738,26 @@ class pil_build_ext(build_ext): # rather than having to cope with the versioned # include path _add_directory(self.compiler.include_dirs, best_path, 0) - feature.jpeg2000 = "openjp2" - feature.openjpeg_version = ".".join(str(x) for x in best_version) + feature.set("jpeg2000", "openjp2") + feature.set("openjpeg_version", ".".join(str(x) for x in best_version)) if feature.want("imagequant"): _dbg("Looking for imagequant") if _find_include_file(self, "libimagequant.h"): if _find_library_file(self, "imagequant"): - feature.imagequant = "imagequant" + feature.set("imagequant", "imagequant") elif _find_library_file(self, "libimagequant"): - feature.imagequant = "libimagequant" + feature.set("imagequant", "libimagequant") if feature.want("tiff"): _dbg("Looking for tiff") if _find_include_file(self, "tiff.h"): if _find_library_file(self, "tiff"): - feature.tiff = "tiff" + feature.set("tiff", "tiff") if sys.platform in ["win32", "darwin"] and _find_library_file( self, "libtiff" ): - feature.tiff = "libtiff" + feature.set("tiff", "libtiff") if feature.want("freetype"): _dbg("Looking for freetype") @@ -746,31 +778,31 @@ class pil_build_ext(build_ext): freetype_version = 21 break if freetype_version: - feature.freetype = "freetype" + feature.set("freetype", "freetype") if subdir: _add_directory(self.compiler.include_dirs, subdir, 0) - if feature.freetype and feature.want("raqm"): + if feature.get("freetype") and feature.want("raqm"): if not feature.want_vendor("raqm"): # want system Raqm _dbg("Looking for Raqm") if _find_include_file(self, "raqm.h"): if _find_library_file(self, "raqm"): - feature.raqm = "raqm" + feature.set("raqm", "raqm") elif _find_library_file(self, "libraqm"): - feature.raqm = "libraqm" + feature.set("raqm", "libraqm") else: # want to build Raqm from src/thirdparty _dbg("Looking for HarfBuzz") - feature.harfbuzz = None + feature.set("harfbuzz", None) hb_dir = _find_include_dir(self, "harfbuzz", "hb.h") if hb_dir: if isinstance(hb_dir, str): _add_directory(self.compiler.include_dirs, hb_dir, 0) if _find_library_file(self, "harfbuzz"): - feature.harfbuzz = "harfbuzz" - if feature.harfbuzz: + feature.set("harfbuzz", "harfbuzz") + if feature.get("harfbuzz"): if not feature.want_vendor("fribidi"): # want system FriBiDi _dbg("Looking for FriBiDi") - feature.fribidi = None + feature.set("fribidi", None) fribidi_dir = _find_include_dir(self, "fribidi", "fribidi.h") if fribidi_dir: if isinstance(fribidi_dir, str): @@ -778,53 +810,43 @@ class pil_build_ext(build_ext): self.compiler.include_dirs, fribidi_dir, 0 ) if _find_library_file(self, "fribidi"): - feature.fribidi = "fribidi" - feature.raqm = True + feature.set("fribidi", "fribidi") + feature.set("raqm", True) else: # want to build FriBiDi shim from src/thirdparty - feature.raqm = True + feature.set("raqm", True) if feature.want("lcms"): _dbg("Looking for lcms") if _find_include_file(self, "lcms2.h"): if _find_library_file(self, "lcms2"): - feature.lcms = "lcms2" + feature.set("lcms", "lcms2") elif _find_library_file(self, "lcms2_static"): # alternate Windows name. - feature.lcms = "lcms2_static" + feature.set("lcms", "lcms2_static") if feature.want("webp"): _dbg("Looking for webp") - if _find_include_file(self, "webp/encode.h") and _find_include_file( - self, "webp/decode.h" + if all( + _find_include_file(self, "webp/" + include) + for include in ("encode.h", "decode.h", "mux.h", "demux.h") ): - # In Google's precompiled zip it is call "libwebp": - if _find_library_file(self, "webp"): - feature.webp = "webp" - elif _find_library_file(self, "libwebp"): - feature.webp = "libwebp" - - if feature.want("webpmux"): - _dbg("Looking for webpmux") - if _find_include_file(self, "webp/mux.h") and _find_include_file( - self, "webp/demux.h" - ): - if _find_library_file(self, "webpmux") and _find_library_file( - self, "webpdemux" - ): - feature.webpmux = "webpmux" - if _find_library_file(self, "libwebpmux") and _find_library_file( - self, "libwebpdemux" - ): - feature.webpmux = "libwebpmux" + # In Google's precompiled zip it is called "libwebp" + for prefix in ("", "lib"): + if all( + _find_library_file(self, prefix + library) + for library in ("webp", "webpmux", "webpdemux") + ): + feature.set("webp", prefix + "webp") + break if feature.want("xcb"): _dbg("Looking for xcb") if _find_include_file(self, "xcb/xcb.h"): if _find_library_file(self, "xcb"): - feature.xcb = "xcb" + feature.set("xcb", "xcb") for f in feature: - if not getattr(feature, f) and feature.require(f): + if not feature.get(f) and feature.require(f): if f in ("jpeg", "zlib"): raise RequiredDependencyException(f) raise DependencyException(f) @@ -832,10 +854,11 @@ class pil_build_ext(build_ext): # # core library - libs = self.add_imaging_libs.split() - defs = [] - if feature.tiff: - libs.append(feature.tiff) + libs: list[str | bool | None] = [] + libs.extend(self.add_imaging_libs.split()) + defs: list[tuple[str, str | None]] = [] + if feature.get("tiff"): + libs.append(feature.get("tiff")) defs.append(("HAVE_LIBTIFF", None)) if sys.platform == "win32": # This define needs to be defined if-and-only-if it was defined @@ -843,22 +866,22 @@ class pil_build_ext(build_ext): # so we have to guess; by default it is defined in all Windows builds. # See #4237, #5243, #5359 for more information. defs.append(("USE_WIN32_FILEIO", None)) - if feature.jpeg: - libs.append(feature.jpeg) + if feature.get("jpeg"): + libs.append(feature.get("jpeg")) defs.append(("HAVE_LIBJPEG", None)) - if feature.jpeg2000: - libs.append(feature.jpeg2000) + if feature.get("jpeg2000"): + libs.append(feature.get("jpeg2000")) defs.append(("HAVE_OPENJPEG", None)) if sys.platform == "win32" and not PLATFORM_MINGW: defs.append(("OPJ_STATIC", None)) - if feature.zlib: - libs.append(feature.zlib) + if feature.get("zlib"): + libs.append(feature.get("zlib")) defs.append(("HAVE_LIBZ", None)) - if feature.imagequant: - libs.append(feature.imagequant) + if feature.get("imagequant"): + libs.append(feature.get("imagequant")) defs.append(("HAVE_LIBIMAGEQUANT", None)) - if feature.xcb: - libs.append(feature.xcb) + if feature.get("xcb"): + libs.append(feature.get("xcb")) defs.append(("HAVE_XCB", None)) if sys.platform == "win32": libs.extend(["kernel32", "user32", "gdi32"]) @@ -872,22 +895,22 @@ class pil_build_ext(build_ext): # # additional libraries - if feature.freetype: + if feature.get("freetype"): srcs = [] libs = ["freetype"] defs = [] - if feature.raqm: + if feature.get("raqm"): if not feature.want_vendor("raqm"): # using system Raqm defs.append(("HAVE_RAQM", None)) defs.append(("HAVE_RAQM_SYSTEM", None)) - libs.append(feature.raqm) + libs.append(feature.get("raqm")) else: # building Raqm from src/thirdparty defs.append(("HAVE_RAQM", None)) srcs.append("src/thirdparty/raqm/raqm.c") - libs.append(feature.harfbuzz) + libs.append(feature.get("harfbuzz")) if not feature.want_vendor("fribidi"): # using system FriBiDi defs.append(("HAVE_FRIBIDI_SYSTEM", None)) - libs.append(feature.fribidi) + libs.append(feature.get("fribidi")) else: # building FriBiDi shim from src/thirdparty srcs.append("src/thirdparty/fribidi-shim/fribidi.c") self._update_extension("PIL._imagingft", libs, defs, srcs) @@ -895,24 +918,18 @@ class pil_build_ext(build_ext): else: self._remove_extension("PIL._imagingft") - if feature.lcms: - extra = [] + if feature.get("lcms"): + libs = [feature.get("lcms")] if sys.platform == "win32": - extra.extend(["user32", "gdi32"]) - self._update_extension("PIL._imagingcms", [feature.lcms] + extra) + libs.extend(["user32", "gdi32"]) + self._update_extension("PIL._imagingcms", libs) else: self._remove_extension("PIL._imagingcms") - if feature.webp: - libs = [feature.webp] - defs = [] - - if feature.webpmux: - defs.append(("HAVE_WEBPMUX", None)) - libs.append(feature.webpmux) - libs.append(feature.webpmux.replace("pmux", "pdemux")) - - self._update_extension("PIL._webp", libs, defs) + webp = feature.get("webp") + if isinstance(webp, str): + libs = [webp, webp + "mux", webp + "demux"] + self._update_extension("PIL._webp", libs) else: self._remove_extension("PIL._webp") @@ -926,14 +943,14 @@ class pil_build_ext(build_ext): self.summary_report(feature) - def summary_report(self, feature): + def summary_report(self, feature: ext_feature) -> None: print("-" * 68) print("PIL SETUP SUMMARY") print("-" * 68) print(f"version Pillow {PILLOW_VERSION}") - v = sys.version.split("[") - print(f"platform {sys.platform} {v[0].strip()}") - for v in v[1:]: + version = sys.version.split("[") + print(f"platform {sys.platform} {version[0].strip()}") + for v in version[1:]: print(f" [{v.strip()}") print("-" * 68) @@ -944,17 +961,20 @@ class pil_build_ext(build_ext): raqm_extra_info += ", FriBiDi shim" options = [ - (feature.jpeg, "JPEG"), - (feature.jpeg2000, "OPENJPEG (JPEG2000)", feature.openjpeg_version), - (feature.zlib, "ZLIB (PNG/ZIP)"), - (feature.imagequant, "LIBIMAGEQUANT"), - (feature.tiff, "LIBTIFF"), - (feature.freetype, "FREETYPE2"), - (feature.raqm, "RAQM (Text shaping)", raqm_extra_info), - (feature.lcms, "LITTLECMS2"), - (feature.webp, "WEBP"), - (feature.webpmux, "WEBPMUX"), - (feature.xcb, "XCB (X protocol)"), + (feature.get("jpeg"), "JPEG"), + ( + feature.get("jpeg2000"), + "OPENJPEG (JPEG2000)", + feature.get("openjpeg_version"), + ), + (feature.get("zlib"), "ZLIB (PNG/ZIP)"), + (feature.get("imagequant"), "LIBIMAGEQUANT"), + (feature.get("tiff"), "LIBTIFF"), + (feature.get("freetype"), "FREETYPE2"), + (feature.get("raqm"), "RAQM (Text shaping)", raqm_extra_info), + (feature.get("lcms"), "LITTLECMS2"), + (feature.get("webp"), "WEBP"), + (feature.get("xcb"), "XCB (X protocol)"), ] all = 1 @@ -983,11 +1003,11 @@ class pil_build_ext(build_ext): print("") -def debug_build(): +def debug_build() -> bool: return hasattr(sys, "gettotalrefcount") or FUZZING_BUILD -files = ["src/_imaging.c"] +files: list[str | os.PathLike[str]] = ["src/_imaging.c"] for src_file in _IMAGING: files.append("src/" + src_file + ".c") for src_file in _LIB_IMAGING: @@ -1030,7 +1050,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 6d71049a9..5747c1252 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]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> 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,62 +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) @@ -467,17 +469,23 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2" fp.write(magic) + 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: @@ -170,6 +170,8 @@ class BmpImageFile(ImageFile.ImageFile): # ------------------ Special case : header is reported 40, which # ---------------------- is shorter than real size for bpp >= 16 + assert isinstance(file_info["width"], int) + assert isinstance(file_info["height"], int) self._size = file_info["width"], file_info["height"] # ------- If color count was not found in the header, compute from bits @@ -294,7 +296,7 @@ class BmpImageFile(ImageFile.ImageFile): args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3)) args.append(file_info["direction"]) self.tile = [ - ( + ImageFile._Tile( decoder_name, (0, 0, file_info["width"], file_info["height"]), offset or self.fp.tell(), @@ -319,7 +321,7 @@ class BmpImageFile(ImageFile.ImageFile): class BmpRleDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: assert self.fd is not None rle4 = self.args[1] data = bytearray() @@ -385,7 +387,7 @@ class BmpRleDecoder(ImageFile.PyDecoder): if self.fd.tell() % 2 != 0: self.fd.seek(1, os.SEEK_CUR) rawmode = "L" if self.mode == "L" else "P" - self.set_as_raw(bytes(data), (rawmode, 0, self.args[-1])) + self.set_as_raw(bytes(data), rawmode, (0, self.args[-1])) return -1, 0 @@ -482,7 +484,9 @@ def _save( if palette: fp.write(palette) - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))] + ) # 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 85e2145e7..b817dbc87 100644 --- a/src/PIL/CurImagePlugin.py +++ b/src/PIL/CurImagePlugin.py @@ -17,7 +17,7 @@ # from __future__ import annotations -from . import BmpImagePlugin, Image +from . import BmpImagePlugin, Image, ImageFile from ._binary import i16le as i16 from ._binary import i32le as i32 @@ -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") ## @@ -64,7 +64,7 @@ class CurImageFile(BmpImagePlugin.BmpImageFile): # patch up the bitmap height self._size = self.size[0], self.size[1] // 2 d, e, o, a = self.tile[0] - self.tile[0] = d, (0, 0) + self.size, o, a + self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a) # diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index a57e4aea2..cdae8dfee 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -367,7 +367,7 @@ class DdsImageFile(ImageFile.ImageFile): mask_count = 3 masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4)) - self.tile = [("dds_rgb", extents, 0, (bitcount, masks))] + self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))] return elif pfflags & DDPF.LUMINANCE: if bitcount == 8: @@ -481,7 +481,7 @@ class DdsImageFile(ImageFile.ImageFile): class DdsRgbDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: assert self.fd is not None bitcount, masks = self.args @@ -560,13 +560,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + 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))] - ) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)]) 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 7a73d1f69..5e2ddad99 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -65,16 +65,24 @@ def has_ghostscript() -> bool: return gs_binary is not False -def Ghostscript(tile, size, fp, scale=1, transparency: bool = False) -> Image.Image: +def Ghostscript( + tile: list[ImageFile._Tile], + size: tuple[int, int], + fp: IO[bytes], + scale: int = 1, + transparency: bool = False, +) -> Image.core.ImagingCore: """Render an image using Ghostscript""" global gs_binary if not has_ghostscript(): msg = "Unable to locate Ghostscript on paths" raise OSError(msg) + assert isinstance(gs_binary, str) # Unpack decoder tile - decoder, tile, offset, data = tile[0] - length, bbox = data + args = tile[0].args + assert isinstance(args, tuple) + length, bbox = args # Hack to support hi-res rendering scale = int(scale) or 1 @@ -113,7 +121,13 @@ def Ghostscript(tile, size, fp, scale=1, transparency: bool = False) -> Image.Im lengthfile -= len(s) f.write(s) - device = "pngalpha" if transparency else "ppmraw" + if transparency: + # "RGBA" + device = "pngalpha" + else: + # "pnmraw" automatically chooses between + # PBM ("1"), PGM ("L"), and PPM ("RGB"). + device = "pnmraw" # Build Ghostscript command command = [ @@ -143,8 +157,9 @@ def Ghostscript(tile, size, fp, scale=1, transparency: bool = False) -> Image.Im startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW subprocess.check_call(command, startupinfo=startupinfo) - out_im = Image.open(outfile) - out_im.load() + with Image.open(outfile) as out_im: + out_im.load() + return out_im.im.copy() finally: try: os.unlink(outfile) @@ -153,13 +168,11 @@ def Ghostscript(tile, size, fp, scale=1, transparency: bool = False) -> Image.Im except OSError: pass - im = out_im.im.copy() - out_im.close() - return im - 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 + ) ## @@ -182,7 +195,11 @@ class EpsImageFile(ImageFile.ImageFile): self.fp.seek(offset) self._mode = "RGB" - self._size = None + + # When reading header comments, the first comment is used. + # When reading trailer comments, the last comment is used. + bounding_box: list[int] | None = None + imagedata_size: tuple[int, int] | None = None byte_arr = bytearray(255) bytes_mv = memoryview(byte_arr) @@ -204,8 +221,8 @@ class EpsImageFile(ImageFile.ImageFile): msg = 'EPS header missing "%%BoundingBox" comment' raise SyntaxError(msg) - def _read_comment(s: str) -> bool: - nonlocal reading_trailer_comments + def read_comment(s: str) -> bool: + nonlocal bounding_box, reading_trailer_comments try: m = split.match(s) except re.error as e: @@ -220,14 +237,12 @@ class EpsImageFile(ImageFile.ImageFile): if k == "BoundingBox": if v == "(atend)": reading_trailer_comments = True - elif not self._size or (trailer_reached and reading_trailer_comments): + elif not bounding_box or (trailer_reached and reading_trailer_comments): try: # Note: The DSC spec says that BoundingBox # fields should be integers, but some drivers # put floating point values there anyway. - box = [int(float(i)) for i in v.split()] - self._size = box[2] - box[0], box[3] - box[1] - self.tile = [("eps", (0, 0) + self.size, offset, (length, box))] + bounding_box = [int(float(i)) for i in v.split()] except Exception: pass return True @@ -278,11 +293,11 @@ class EpsImageFile(ImageFile.ImageFile): continue s = str(bytes_mv[:bytes_read], "latin-1") - if not _read_comment(s): + if not read_comment(s): 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] = "" @@ -297,6 +312,12 @@ class EpsImageFile(ImageFile.ImageFile): # Check for an "ImageData" descriptor # https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096 + # If we've already read an "ImageData" descriptor, + # don't read another one. + if imagedata_size: + bytes_read = 0 + continue + # Values: # columns # rows @@ -322,22 +343,35 @@ class EpsImageFile(ImageFile.ImageFile): else: break - self._size = columns, rows - return + # Parse the columns and rows after checking the bit depth and mode + # in case the bit depth and/or mode are invalid. + imagedata_size = columns, rows elif bytes_mv[:5] == b"%%EOF": break elif trailer_reached and reading_trailer_comments: # Load EPS trailer s = str(bytes_mv[:bytes_read], "latin-1") - _read_comment(s) + read_comment(s) elif bytes_mv[:9] == b"%%Trailer": trailer_reached = True bytes_read = 0 - if not self._size: + # A "BoundingBox" is always required, + # even if an "ImageData" descriptor size exists. + if not bounding_box: msg = "cannot determine EPS bounding box" raise OSError(msg) + # An "ImageData" size takes precedence over the "BoundingBox". + self._size = imagedata_size or ( + bounding_box[2] - bounding_box[0], + bounding_box[3] - bounding_box[1], + ) + + self.tile = [ + ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box)) + ] + def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]: s = fp.read(4) @@ -422,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, [("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 4846054b1..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): @@ -67,7 +67,7 @@ class FitsImageFile(ImageFile.ImageFile): raise ValueError(msg) offset += self.fp.tell() - 80 - self.tile = [(decoder_name, (0, 0) + self.size, offset, args)] + self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)] def _get_size( self, headers: dict[bytes, bytes], prefix: bytes @@ -126,7 +126,7 @@ class FitsImageFile(ImageFile.ImageFile): class FitsGzipDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: assert self.fd is not None value = gzip.decompress(self.fd.read()) diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index 52d1fce31..b534b30ab 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -159,7 +159,7 @@ class FliImageFile(ImageFile.ImageFile): framesize = i32(s) self.decodermaxblock = framesize - self.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 386e37233..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) ## @@ -81,6 +81,8 @@ class FpxImageFile(ImageFile.ImageFile): # size (highest resolution) + assert isinstance(prop[0x1000002], int) + assert isinstance(prop[0x1000003], int) self._size = prop[0x1000002], prop[0x1000003] size = max(self.size) @@ -164,18 +166,18 @@ class FpxImageFile(ImageFile.ImageFile): if compression == 0: self.tile.append( - ( + ImageFile._Tile( "raw", (x, y, x1, y1), i32(s, i) + 28, - (self.rawmode,), + self.rawmode, ) ) elif compression == 1: # FIXME: the fill decoder is not implemented self.tile.append( - ( + ImageFile._Tile( "fill", (x, y, x1, y1), i32(s, i) + 28, @@ -203,7 +205,7 @@ class FpxImageFile(ImageFile.ImageFile): jpegmode = rawmode self.tile.append( - ( + ImageFile._Tile( "jpeg", (x, y, x1, y1), i32(s, i) + 28, diff --git a/src/PIL/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py index 5acbb4912..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 @@ -93,9 +91,10 @@ class FtexImageFile(ImageFile.ImageFile): if format == Format.DXT1: self._mode = "RGBA" - self.tile = [("bcn", (0, 0) + self.size, 0, 1)] + self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))] elif format == Format.UNCOMPRESSED: - self.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/GbrImagePlugin.py b/src/PIL/GbrImagePlugin.py index 3c8feea5f..f319d7e84 100644 --- a/src/PIL/GbrImagePlugin.py +++ b/src/PIL/GbrImagePlugin.py @@ -89,7 +89,7 @@ class GbrImageFile(ImageFile.ImageFile): self._data_size = width * height * color_depth def load(self) -> Image.core.PixelAccess | None: - if not self.im: + if self._im is None: self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self._data_size)) return Image.Image.load(self) diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py index 88b87a22c..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 bf74f9356..259e93f09 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -29,7 +29,6 @@ import itertools import math import os import subprocess -import sys from enum import IntEnum from functools import cached_property from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, Union @@ -49,6 +48,7 @@ from ._binary import o16le as o16 if TYPE_CHECKING: from . import _imaging + from ._typing import Buffer class LoadingStrategy(IntEnum): @@ -67,7 +67,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")) ## @@ -103,7 +103,6 @@ class GifImageFile(ImageFile.ImageFile): self.info["version"] = s[:6] self._size = i16(s, 6), i16(s, 8) - self.tile = [] flags = s[10] bits = (flags & 7) + 1 @@ -155,7 +154,7 @@ class GifImageFile(ImageFile.ImageFile): if not self._seek_check(frame): return if frame < self.__frame: - self.im = None + self._im = None self._seek(0) last_frame = self.__frame @@ -258,7 +257,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) @@ -320,11 +319,14 @@ class GifImageFile(ImageFile.ImageFile): else: self._mode = "L" - if not palette and self.global_palette: + if palette: + self.palette = palette + elif self.global_palette: from copy import copy - palette = copy(self.global_palette) - self.palette = palette + self.palette = copy(self.global_palette) + else: + self.palette = None else: if self.mode == "P": if ( @@ -376,7 +378,7 @@ class GifImageFile(ImageFile.ImageFile): self.dispose = Image.core.fill(dispose_mode, dispose_size, color) else: # replace with previous contents - if self.im is not None: + if self._im is not None: # only dispose the extent in this frame self.dispose = self._crop(self.im, self.dispose_extent) elif frame_transparency is not None: @@ -404,7 +406,7 @@ class GifImageFile(ImageFile.ImageFile): elif self.mode not in ("RGB", "RGBA"): transparency = frame_transparency self.tile = [ - ( + ImageFile._Tile( "gif", (x0, y0, x1, y1), self.__offset, @@ -434,7 +436,14 @@ class GifImageFile(ImageFile.ImageFile): self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) self.im.putpalette("RGB", *self._frame_palette.getdata()) else: - self.im = None + self._im = None + if not self._prev_im and self._im is not None and self.size != self.im.size: + expanded_im = Image.core.fill(self.im.mode, self.size) + if self._frame_palette: + expanded_im.putpalette("RGB", *self._frame_palette.getdata()) + expanded_im.paste(self.im, (0, 0) + self.im.size) + + self.im = expanded_im self._mode = temp_mode self._frame_palette = None @@ -452,6 +461,17 @@ class GifImageFile(ImageFile.ImageFile): return if not self._prev_im: return + if self.size != self._prev_im.size: + if self._frame_transparency is not None: + expanded_im = Image.core.fill("RGBA", self.size) + else: + expanded_im = Image.core.fill("P", self.size) + expanded_im.putpalette("RGB", "RGB", self.im.getpalette()) + expanded_im = expanded_im.convert("RGB") + expanded_im.paste(self._prev_im, (0, 0) + self._prev_im.size) + + self._prev_im = expanded_im + assert self._prev_im is not None if self._frame_transparency is not None: self.im.putpalettealpha(self._frame_transparency, 0) frame_im = self.im.convert("RGBA") @@ -495,6 +515,7 @@ def _normalize_mode(im: Image.Image) -> Image.Image: return im if Image.getmodebase(im.mode) == "RGB": im = im.convert("P", palette=Image.Palette.ADAPTIVE) + assert im.palette is not None if im.palette.mode == "RGBA": for rgba in im.palette.colors: if rgba[3] == 0: @@ -531,16 +552,18 @@ def _normalize_palette( if im.mode == "P": if not source_palette: - source_palette = im.im.getpalette("RGB")[:768] + im_palette = im.getpalette(None) + assert im_palette is not None + source_palette = bytearray(im_palette) else: # L-mode if not source_palette: source_palette = bytearray(i // 3 for i in range(768)) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) + assert source_palette is not None - used_palette_colors: list[int] | None if palette: - used_palette_colors = [] - assert source_palette is not None + used_palette_colors: list[int | None] = [] + assert im.palette is not None for i in range(0, len(source_palette), 3): source_color = tuple(source_palette[i : i + 3]) index = im.palette.colors.get(source_color) @@ -553,20 +576,25 @@ def _normalize_palette( if j not in used_palette_colors: used_palette_colors[i] = j break - im = im.remap_palette(used_palette_colors) + dest_map: list[int] = [] + for index in used_palette_colors: + assert index is not None + dest_map.append(index) + im = im.remap_palette(dest_map) else: - used_palette_colors = _get_optimize(im, info) - if used_palette_colors is not None: - im = im.remap_palette(used_palette_colors, source_palette) + optimized_palette_colors = _get_optimize(im, info) + if optimized_palette_colors is not None: + im = im.remap_palette(optimized_palette_colors, source_palette) if "transparency" in info: try: - info["transparency"] = used_palette_colors.index( + info["transparency"] = optimized_palette_colors.index( info["transparency"] ) except ValueError: del info["transparency"] return im + assert im.palette is not None im.palette.palette = source_palette return im @@ -578,7 +606,8 @@ def _write_single_frame( ) -> None: im_out = _normalize_mode(im) for k, v in im_out.info.items(): - im.encoderinfo.setdefault(k, v) + if isinstance(k, str): + im.encoderinfo.setdefault(k, v) im_out = _normalize_palette(im_out, palette, im.encoderinfo) for s in _get_global_header(im_out, im.encoderinfo): @@ -591,7 +620,9 @@ def _write_single_frame( _write_local_header(fp, im, (0, 0), flags) im_out.encoderconfig = (8, get_interlace(im)) - ImageFile._save(im_out, fp, [("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])]) + ImageFile._save( + im_out, fp, [ImageFile._Tile("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])] + ) fp.write(b"\0") # end of image data @@ -599,7 +630,10 @@ def _write_single_frame( def _getbbox( base_im: Image.Image, im_frame: Image.Image ) -> tuple[Image.Image, tuple[int, int, int, int] | None]: - if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): + palette_bytes = [ + bytes(im.palette.palette) if im.palette else b"" for im in (base_im, im_frame) + ] + if palette_bytes[0] != palette_bytes[1]: im_frame = im_frame.convert("RGBA") base_im = base_im.convert("RGBA") delta = ImageChops.subtract_modulo(im_frame, base_im) @@ -630,7 +664,8 @@ def _write_multiple_frames( for k, v in im_frame.info.items(): if k == "transparency": continue - im.encoderinfo.setdefault(k, v) + if isinstance(k, str): + im.encoderinfo.setdefault(k, v) encoderinfo = im.encoderinfo.copy() if "transparency" in im_frame.info: @@ -654,16 +689,24 @@ 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) - 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 try: encoderinfo["transparency"] = ( im_frame.palette._new_color_index(im_frame) @@ -726,7 +769,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 @@ -901,6 +945,7 @@ def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None: if optimise or max(used_palette_colors) >= len(used_palette_colors): return used_palette_colors + assert im.palette is not None num_palette_colors = len(im.palette.palette) // Image.getmodebands( im.palette.mode ) @@ -950,7 +995,13 @@ def _get_palette_bytes(im: Image.Image) -> bytes: :param im: Image object :returns: Bytes, len<=768 suitable for inclusion in gif header """ - return im.palette.palette if im.palette else b"" + if not im.palette: + return b"" + + palette = bytes(im.palette.palette) + if im.palette.mode == "RGBA": + palette = b"".join(palette[i * 4 : i * 4 + 3] for i in range(len(palette) // 3)) + return palette def _get_background( @@ -963,6 +1014,7 @@ def _get_background( # WebPImagePlugin stores an RGBA value in info["background"] # So it must be converted to the same format as GifImagePlugin's # info["background"] - a global color table index + assert im.palette is not None try: background = im.palette.getcolor(info_background, im) except ValueError as e: @@ -1054,7 +1106,9 @@ def _write_frame_data( _write_local_header(fp, im_frame, offset, 0) ImageFile._save( - im_frame, fp, [("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])] + im_frame, + fp, + [ImageFile._Tile("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])], ) fp.write(b"\0") # end of image data @@ -1120,18 +1174,9 @@ def getdata( class Collector(BytesIO): data = [] - if sys.version_info >= (3, 12): - from collections.abc import Buffer - - def write(self, data: Buffer) -> int: - self.data.append(data) - return len(data) - - else: - - def write(self, data: Any) -> int: - self.data.append(data) - return len(data) + def write(self, data: Buffer) -> int: + self.data.append(data) + return len(data) im.load() # make sure raster data is available 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..1b7a394c0 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -29,7 +29,7 @@ class GimpPaletteFile: def __init__(self, fp: IO[bytes]) -> None: palette = [o8(i) * 3 for i in range(256)] - if fp.readline()[:12] != b"GIMP Palette": + if not fp.readline().startswith(b"GIMP Palette"): msg = "not a GIMP palette file" raise SyntaxError(msg) 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 8729f7643..a5d5b93ae 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -25,6 +25,7 @@ import sys from typing import IO from . import Image, ImageFile, PngImagePlugin, features +from ._deprecate import deprecate enable_jpeg2k = features.check_codec("jpg_2000") if enable_jpeg2k: @@ -116,14 +117,14 @@ 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") + or sig.startswith(b"\x0d\x0a\x87\x0a") or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" ): if not enable_jpeg2k: @@ -275,40 +276,40 @@ class IcnsImageFile(ImageFile.ImageFile): self.best_size[1] * self.best_size[2], ) - @property - def size(self): + @property # type: ignore[override] + def size(self) -> tuple[int, int] | tuple[int, int, int]: return self._size @size.setter - def size(self, value) -> None: - info_size = value - if info_size not in self.info["sizes"] and len(info_size) == 2: - info_size = (info_size[0], info_size[1], 1) - if ( - info_size not in self.info["sizes"] - and len(info_size) == 3 - and info_size[2] == 1 - ): - simple_sizes = [ - (size[0] * size[2], size[1] * size[2]) for size in self.info["sizes"] - ] - if value in simple_sizes: - info_size = self.info["sizes"][simple_sizes.index(value)] - if info_size not in self.info["sizes"]: - msg = "This is not one of the allowed sizes of this image" - raise ValueError(msg) - self._size = value + def size(self, value: tuple[int, int] | tuple[int, int, int]) -> None: + if len(value) == 3: + deprecate("Setting size to (width, height, scale)", 12, "load(scale)") + if value in self.info["sizes"]: + self._size = value # type: ignore[assignment] + return + else: + # Check that a matching size exists, + # or that there is a scale that would create a size that matches + for size in self.info["sizes"]: + simple_size = size[0] * size[2], size[1] * size[2] + scale = simple_size[0] // value[0] + if simple_size[1] / value[1] == scale: + self._size = value + return + msg = "This is not one of the allowed sizes of this image" + raise ValueError(msg) - def load(self) -> Image.core.PixelAccess | None: - if len(self.size) == 3: - self.best_size = self.size - self.size = ( - self.best_size[0] * self.best_size[2], - self.best_size[1] * self.best_size[2], - ) + def load(self, scale: int | None = None) -> Image.core.PixelAccess | None: + if scale is not None or len(self.size) == 3: + if scale is None and len(self.size) == 3: + scale = self.size[2] + assert scale is not None + width, height = self.size[:2] + self.size = width * scale, height * scale + self.best_size = width, height, scale px = Image.Image.load(self) - if self.im is not None and self.im.size == self.size: + if self._im is not None and self.im.size == self.size: # Already loaded return px self.load_prepare() @@ -386,7 +387,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 c891024f5..55c57f203 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -97,7 +97,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if bits != 32: and_mask = Image.new("1", size) ImageFile._save( - and_mask, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))] + and_mask, + image_io, + [ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))], ) else: frame.save(image_io, "png") @@ -116,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): @@ -226,7 +228,7 @@ class IcoFile: # change tile dimension to only encompass XOR image im._size = (im.size[0], int(im.size[1] / 2)) d, e, o, a = im.tile[0] - im.tile[0] = d, (0, 0) + im.size, o, a + im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a) # figure out where AND mask image starts if header.bpp == 32: @@ -241,13 +243,19 @@ class IcoFile: alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4] # convert to an 8bpp grayscale image - mask = Image.frombuffer( - "L", # 8bpp - im.size, # (w, h) - alpha_bytes, # source chars - "raw", # raw decoder - ("L", 0, -1), # 8bpp inverted, unpadded, reversed - ) + try: + mask = Image.frombuffer( + "L", # 8bpp + im.size, # (w, h) + alpha_bytes, # source chars + "raw", # raw decoder + ("L", 0, -1), # 8bpp inverted, unpadded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise else: # get AND image from end of bitmap w = im.size[0] @@ -265,19 +273,26 @@ class IcoFile: mask_data = self.buf.read(total_bytes) # convert raw data to image - mask = Image.frombuffer( - "1", # 1 bpp - im.size, # (w, h) - mask_data, # source chars - "raw", # raw decoder - ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed - ) + try: + mask = Image.frombuffer( + "1", # 1 bpp + im.size, # (w, h) + mask_data, # source chars + "raw", # raw decoder + ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise # now we have two images, im is XOR image and mask is AND image # apply mask image as alpha channel - im = im.convert("RGBA") - im.putalpha(mask) + if mask: + im = im.convert("RGBA") + im.putalpha(mask) return im @@ -317,18 +332,18 @@ class IcoImageFile(ImageFile.ImageFile): self.load() @property - def size(self): + def size(self) -> tuple[int, int]: return self._size @size.setter - def size(self, value): + def size(self, value: tuple[int, int]) -> None: if value not in self.info["sizes"]: msg = "This is not one of the allowed sizes of this image" raise ValueError(msg) self._size = value def load(self) -> Image.core.PixelAccess | None: - if self.im is not None and self.im.size == self.size: + if self._im is not None and self.im.size == self.size: # Already loaded return Image.Image.load(self) im = self.ico.getimage(self.size) diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 2fb7ecd52..9f20b30f8 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -145,7 +145,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 +155,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 +209,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,13 +247,17 @@ 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) bits = int(self.rawmode[2:]) if bits not in [8, 16, 32]: - self.tile = [("bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1))] + self.tile = [ + ImageFile._Tile( + "bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1) + ) + ] return except ValueError: pass @@ -263,13 +267,17 @@ class ImImageFile(ImageFile.ImageFile): # ever stumbled upon such a file ;-) size = self.size[0] * self.size[1] self.tile = [ - ("raw", (0, 0) + self.size, offs, ("G", 0, -1)), - ("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), - ("raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1)), + ImageFile._Tile("raw", (0, 0) + self.size, offs, ("G", 0, -1)), + ImageFile._Tile("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), + ImageFile._Tile( + "raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1) + ), ] else: # LabEye/IFUNC files - self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] @property def n_frames(self) -> int: @@ -295,7 +303,9 @@ class ImImageFile(ImageFile.ImageFile): self.fp = self._fp - self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] def tell(self) -> int: return self.frame @@ -347,7 +357,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") @@ -360,7 +370,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: palette += im_palette[colors * i : colors * (i + 1)] palette += b"\x00" * (256 - colors) fp.write(palette) # 768 bytes - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))] + ) # diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 72d167a8a..6a2aa3e4c 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -38,7 +38,7 @@ import struct import sys import tempfile import warnings -from collections.abc import Callable, MutableMapping, Sequence +from collections.abc import Callable, Iterator, MutableMapping, Sequence from enum import IntEnum from types import ModuleType from typing import ( @@ -133,6 +133,7 @@ def isImageType(t: Any) -> TypeGuard[Image]: :param t: object to check if it's an image :returns: True if the object is an image """ + deprecate("Image.isImageType(im)", 12, "isinstance(im, Image.Image)") return hasattr(t, "im") @@ -218,10 +219,13 @@ if hasattr(core, "DEFAULT_STRATEGY"): # Registries if TYPE_CHECKING: + import mmap from xml.etree.ElementTree import Element - from . import ImageFile, ImagePalette - from ._typing import NumpyArray, StrOrBytesPath, TypeGuard + from IPython.lib.pretty import PrettyPrinter + + from . import ImageFile, ImageFilter, ImagePalette, ImageQt, TiffImagePlugin + from ._typing import CapsuleType, NumpyArray, StrOrBytesPath, TypeGuard ID: list[str] = [] OPEN: dict[ str, @@ -467,43 +471,53 @@ def _getencoder( # Simple expression analyzer -class _E: +class ImagePointTransform: + """ + Used with :py:meth:`~PIL.Image.Image.point` for single band images with more than + 8 bits, this represents an affine transformation, where the value is multiplied by + ``scale`` and ``offset`` is added. + """ + def __init__(self, scale: float, offset: float) -> None: self.scale = scale self.offset = offset - def __neg__(self) -> _E: - return _E(-self.scale, -self.offset) + def __neg__(self) -> ImagePointTransform: + return ImagePointTransform(-self.scale, -self.offset) - def __add__(self, other: _E | float) -> _E: - if isinstance(other, _E): - return _E(self.scale + other.scale, self.offset + other.offset) - return _E(self.scale, self.offset + other) + def __add__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return ImagePointTransform( + self.scale + other.scale, self.offset + other.offset + ) + return ImagePointTransform(self.scale, self.offset + other) __radd__ = __add__ - def __sub__(self, other: _E | float) -> _E: + def __sub__(self, other: ImagePointTransform | float) -> ImagePointTransform: return self + -other - def __rsub__(self, other: _E | float) -> _E: + def __rsub__(self, other: ImagePointTransform | float) -> ImagePointTransform: return other + -self - def __mul__(self, other: _E | float) -> _E: - if isinstance(other, _E): + def __mul__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): return NotImplemented - return _E(self.scale * other, self.offset * other) + return ImagePointTransform(self.scale * other, self.offset * other) __rmul__ = __mul__ - def __truediv__(self, other: _E | float) -> _E: - if isinstance(other, _E): + def __truediv__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): return NotImplemented - return _E(self.scale / other, self.offset / other) + return ImagePointTransform(self.scale / other, self.offset / other) -def _getscaleoffset(expr) -> tuple[float, float]: - a = expr(_E(1, 0)) - return (a.scale, a.offset) if isinstance(a, _E) else (0, a) +def _getscaleoffset( + 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) # -------------------------------------------------------------------- @@ -532,16 +546,27 @@ class Image: format_description: str | None = None _close_exclusive_fp_after_loading = True - def __init__(self): + def __init__(self) -> None: # FIXME: take "new" parameters / other image? # FIXME: turn mode and size into delegating properties? - self.im = None + self._im: core.ImagingCore | DeferredError | None = None self._mode = "" self._size = (0, 0) - self.palette = None - self.info = {} + self.palette: ImagePalette.ImagePalette | None = None + self.info: dict[str | tuple[int, int], Any] = {} self.readonly = 0 - self._exif = None + self._exif: Exif | None = None + + @property + def im(self) -> core.ImagingCore: + if isinstance(self._im, DeferredError): + raise self._im.ex + assert self._im is not None + return self._im + + @im.setter + def im(self, im: core.ImagingCore) -> None: + self._im = im @property def width(self) -> int: @@ -578,24 +603,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. @@ -604,20 +621,13 @@ 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): - self.map = None + self.map: mmap.mmap | None = None # Instead of simply setting to None, we're setting up a # deferred error that will better explain that the core image # object is gone. - self.im = DeferredError(ValueError("Operation on closed image")) + self._im = DeferredError(ValueError("Operation on closed image")) def _copy(self) -> None: self.load() @@ -667,29 +677,20 @@ 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, cycle) -> None: + def _repr_pretty_(self, p: PrettyPrinter, cycle: bool) -> None: """IPython plain text display support""" # 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: @@ -723,34 +724,22 @@ class Image: def __array_interface__(self) -> dict[str, str | bytes | int | tuple[int, ...]]: # numpy array interface support new: dict[str, str | bytes | int | tuple[int, ...]] = {"version": 3} - try: - if self.mode == "1": - # Binary images need to be extended from bits to bytes - # See: https://github.com/python-pillow/Pillow/issues/350 - new["data"] = self.tobytes("raw", "L") - else: - new["data"] = self.tobytes() - except Exception as e: - if not isinstance(e, (MemoryError, RecursionError)): - try: - import numpy - from packaging.version import parse as parse_version - except ImportError: - pass - else: - if parse_version(numpy.__version__) < parse_version("1.23"): - warnings.warn(str(e)) - raise + if self.mode == "1": + # Binary images need to be extended from bits to bytes + # See: https://github.com/python-pillow/Pillow/issues/350 + new["data"] = self.tobytes("raw", "L") + else: + new["data"] = self.tobytes() new["shape"], new["typestr"] = _conv_type_shape(self) return new - def __getstate__(self): + def __getstate__(self) -> list[Any]: im_data = self.tobytes() # load image first return [self.info, self.mode, self.size, self.getpalette(), im_data] - def __setstate__(self, state) -> None: + 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 @@ -839,7 +828,10 @@ class Image: ) def frombytes( - self, data: bytes | bytearray, decoder_name: str = "raw", *args: Any + self, + data: bytes | bytearray | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, ) -> None: """ Loads this image with pixel data from a bytes object. @@ -887,7 +879,7 @@ class Image: :returns: An image access object. :rtype: :py:class:`.PixelAccess` """ - if self.im is not None and self.palette and self.palette.dirty: + if self._im is not None and self.palette and self.palette.dirty: # realize palette mode, arr = self.palette.getdata() self.im.putpalette(self.palette.mode, mode, arr) @@ -904,7 +896,7 @@ class Image: self.palette.mode, self.palette.mode ) - if self.im is not None: + if self._im is not None: return self.im.pixel_access(self.readonly) return None @@ -1045,9 +1037,11 @@ class Image: # use existing conversions trns_im = new(self.mode, (1, 1)) if self.mode == "P": - trns_im.putpalette(self.palette) + assert self.palette is not None + trns_im.putpalette(self.palette, self.palette.mode) if isinstance(t, tuple): err = "Couldn't allocate a palette color for transparency" + assert trns_im.palette is not None try: t = trns_im.palette.getcolor(t, self) except ValueError as e: @@ -1108,17 +1102,23 @@ class Image: return new_im if "LAB" in (self.mode, mode): - other_mode = mode if self.mode == "LAB" else self.mode + im = self + if mode == "LAB": + if im.mode not in ("RGB", "RGBA", "RGBX"): + im = im.convert("RGBA") + other_mode = im.mode + else: + other_mode = mode if other_mode in ("RGB", "RGBA", "RGBX"): from . import ImageCms srgb = ImageCms.createProfile("sRGB") lab = ImageCms.createProfile("LAB") - profiles = [lab, srgb] if self.mode == "LAB" else [srgb, lab] + profiles = [lab, srgb] if im.mode == "LAB" else [srgb, lab] transform = ImageCms.buildTransform( - profiles[0], profiles[1], self.mode, mode + profiles[0], profiles[1], im.mode, mode ) - return transform.apply(self) + return transform.apply(im) # colorspace conversion if dither is None: @@ -1149,7 +1149,9 @@ class Image: if trns is not None: if new_im.mode == "P" and new_im.palette: try: - new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) + new_im.info["transparency"] = new_im.palette.getcolor( + cast(tuple[int, ...], trns), new_im # trns was converted to RGB + ) except ValueError as e: del new_im.info["transparency"] if str(e) != "cannot allocate more than 256 colors": @@ -1227,6 +1229,7 @@ class Image: raise ValueError(msg) im = self.im.convert("P", dither, palette.im) new_im = self._new(im) + assert palette.palette is not None new_im.palette = palette.palette.copy() return new_im @@ -1336,9 +1339,6 @@ class Image: self.load() return self._new(self.im.expand(xmargin, ymargin)) - if TYPE_CHECKING: - from . import ImageFilter - def filter(self, filter: ImageFilter.Filter | type[ImageFilter.Filter]) -> Image: """ Filters this image using the given filter. For a list of @@ -1539,50 +1539,12 @@ 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): - 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 + deprecate("Image.Image.get_child_images", 13) + return ImageFile.ImageFile.get_child_images(self) # type: ignore[arg-type] - fp = self.fp - 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 - - def getim(self): + def getim(self) -> CapsuleType: """ Returns a capsule that points to the internal image memory. @@ -1626,11 +1588,15 @@ class Image: :returns: A boolean. """ - return ( + if ( self.mode in ("LA", "La", "PA", "RGBA", "RGBa") - or (self.mode == "P" and self.palette.mode.endswith("A")) or "transparency" in self.info - ) + ): + return True + if self.mode == "P": + assert self.palette is not None + return self.palette.mode.endswith("A") + return False def apply_transparency(self) -> None: """ @@ -1683,7 +1649,9 @@ class Image: x, y = self.im.getprojection() return list(x), list(y) - def histogram(self, mask: Image | None = None, extrema=None) -> list[int]: + def histogram( + self, mask: Image | None = None, extrema: tuple[float, float] | None = None + ) -> list[int]: """ Returns a histogram for the image. The histogram is returned as a list of pixel counts, one for each pixel value in the source @@ -1709,12 +1677,14 @@ class Image: mask.load() return self.im.histogram((0, 0), mask.im) if self.mode in ("I", "F"): - if extrema is None: - extrema = self.getextrema() - return self.im.histogram(extrema) + return self.im.histogram( + extrema if extrema is not None else self.getextrema() + ) return self.im.histogram() - def entropy(self, mask: Image | None = None, extrema=None): + def entropy( + self, mask: Image | None = None, extrema: tuple[float, float] | None = None + ) -> float: """ Calculates and returns the entropy for the image. @@ -1735,9 +1705,9 @@ class Image: mask.load() return self.im.entropy((0, 0), mask.im) if self.mode in ("I", "F"): - if extrema is None: - extrema = self.getextrema() - return self.im.entropy(extrema) + return self.im.entropy( + extrema if extrema is not None else self.getextrema() + ) return self.im.entropy() def paste( @@ -1785,23 +1755,22 @@ class Image: :param mask: An optional mask image. """ - if isImageType(box): + if isinstance(box, Image): if mask is not None: msg = "If using second argument as mask, third argument must be None" raise ValueError(msg) # abbreviated paste(im, mask) syntax mask = box box = None - assert not isinstance(box, Image) if box is None: box = (0, 0) if len(box) == 2: # upper left corner given; get size from image or mask - if isImageType(im): + if isinstance(im, Image): size = im.size - elif isImageType(mask): + elif isinstance(mask, Image): size = mask.size else: # FIXME: use self.size here? @@ -1809,26 +1778,28 @@ class Image: raise ValueError(msg) box += (box[0] + size[0], box[1] + size[1]) + source: core.ImagingCore | str | float | tuple[float, ...] if isinstance(im, str): from . import ImageColor - im = ImageColor.getcolor(im, self.mode) - - elif isImageType(im): + source = ImageColor.getcolor(im, self.mode) + elif isinstance(im, Image): im.load() if self.mode != im.mode: if self.mode != "RGB" or im.mode not in ("LA", "RGBA", "RGBa"): # should use an adapter for this! im = im.convert(self.mode) - im = im.im + source = im.im + else: + source = im self._ensure_mutable() if mask: mask.load() - self.im.paste(im, box, mask.im) + self.im.paste(source, box, mask.im) else: - self.im.paste(im, box) + self.im.paste(source, box) def alpha_composite( self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0) @@ -1888,7 +1859,13 @@ class Image: def point( self, - lut: Sequence[float] | NumpyArray | Callable[[int], float] | ImagePointHandler, + lut: ( + Sequence[float] + | NumpyArray + | Callable[[int], float] + | Callable[[ImagePointTransform], ImagePointTransform | float] + | ImagePointHandler + ), mode: str | None = None, ) -> Image: """ @@ -1905,7 +1882,7 @@ class Image: object:: class Example(Image.ImagePointHandler): - def point(self, data): + def point(self, im: Image) -> Image: # Return result :param mode: Output mode (default is same as input). This can only be used if the source image has mode "L" or "P", and the output has mode "1" or the @@ -1924,10 +1901,10 @@ class Image: # check if the function can be used with point_transform # UNDONE wiredfool -- I think this prevents us from ever doing # a gamma function point transform on > 8bit images. - scale, offset = _getscaleoffset(lut) + scale, offset = _getscaleoffset(lut) # type: ignore[arg-type] return self._new(self.im.point_transform(scale, offset)) # for other modes, convert the function to a table - flatLut = [lut(i) for i in range(256)] * self.im.bands + flatLut = [lut(i) for i in range(256)] * self.im.bands # type: ignore[arg-type] else: flatLut = lut @@ -1975,7 +1952,7 @@ class Image: else: band = 3 - if isImageType(alpha): + if isinstance(alpha, Image): # alpha layer if alpha.mode not in ("1", "L"): msg = "illegal image mode" @@ -1985,7 +1962,6 @@ class Image: alpha = alpha.convert("L") else: # constant alpha - alpha = cast(int, alpha) # see python/typing#1013 try: self.im.fillband(band, alpha) except (AttributeError, ValueError): @@ -2048,7 +2024,11 @@ class Image: msg = "illegal image mode" raise ValueError(msg) if isinstance(data, ImagePalette.ImagePalette): - palette = ImagePalette.raw(data.rawmode, data.palette) + if data.rawmode is not None: + palette = ImagePalette.raw(data.rawmode, data.palette) + else: + palette = ImagePalette.ImagePalette(palette=data.palette) + palette.dirty = 1 else: if not isinstance(data, bytes): data = bytes(data) @@ -2095,7 +2075,8 @@ class Image: if self.mode == "PA": alpha = value[3] if len(value) == 4 else 255 value = value[:3] - palette_index = self.palette.getcolor(value, self) + assert self.palette is not None + palette_index = self.palette.getcolor(tuple(value), self) value = (palette_index, alpha) if self.mode == "PA" else palette_index return self.im.putpixel(xy, value) @@ -2129,6 +2110,9 @@ class Image: source_palette = self.im.getpalette(palette_mode, palette_mode) else: # L-mode source_palette = bytearray(i // 3 for i in range(768)) + elif len(source_palette) > 768: + bands = 4 + palette_mode = "RGBA" palette_bytes = b"" new_positions = [0] * 256 @@ -2225,8 +2209,8 @@ class Image: :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, :py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. If the image has mode "1" or "P", it is always set to - :py:data:`Resampling.NEAREST`. If the image mode specifies a number - of bits, such as "I;16", then the default filter is + :py:data:`Resampling.NEAREST`. If the image mode is "BGR;15", + "BGR;16" or "BGR;24", then the default filter is :py:data:`Resampling.NEAREST`. Otherwise, the default filter is :py:data:`Resampling.BICUBIC`. See: :ref:`concept-filters`. :param box: An optional 4-tuple of floats providing @@ -2249,8 +2233,8 @@ class Image: """ if resample is None: - type_special = ";" in self.mode - resample = Resampling.NEAREST if type_special else Resampling.BICUBIC + bgr = self.mode.startswith("BGR;") + resample = Resampling.NEAREST if bgr else Resampling.BICUBIC elif resample not in ( Resampling.NEAREST, Resampling.BILINEAR, @@ -2279,7 +2263,6 @@ class Image: msg = "reducing_gap must be 1.0 or greater" raise ValueError(msg) - self.load() if box is None: box = (0, 0) + self.size @@ -2503,7 +2486,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: @@ -2512,13 +2495,13 @@ 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)) + filename = os.fspath(fp.name) # may mutate self! self._ensure_mutable() save_all = params.pop("save_all", False) - self.encoderinfo = params + self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params} self.encoderconfig: tuple[Any, ...] = () preinit() @@ -2565,6 +2548,11 @@ class Image: except PermissionError: pass raise + finally: + try: + del self.encoderinfo + except AttributeError: + pass if open_fp: fp.close() @@ -2728,27 +2716,18 @@ class Image: ) return x, y - box = None - final_size: tuple[int, int] - if reducing_gap is not None: - preserved_size = preserve_aspect_ratio() - if preserved_size is None: - return - final_size = preserved_size + preserved_size = preserve_aspect_ratio() + if preserved_size is None: + return + final_size = preserved_size + box = None + if reducing_gap is not None: res = self.draft( None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) ) if res is not None: box = res[1] - if box is None: - self.load() - - # load() may have changed the size of the image - preserved_size = preserve_aspect_ratio() - if preserved_size is None: - return - final_size = preserved_size if self.size != final_size: im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap) @@ -2859,11 +2838,11 @@ class Image: self, box: tuple[int, int, int, int], image: Image, - method, - data, + method: Transform, + data: Sequence[float], resample: int = Resampling.NEAREST, fill: bool = True, - ): + ) -> None: w = box[2] - box[0] h = box[3] - box[1] @@ -2964,7 +2943,7 @@ class Image: self.load() return self._new(self.im.effect_spread(distance)) - def toqimage(self): + def toqimage(self) -> ImageQt.ImageQt: """Returns a QImage copy of this image""" from . import ImageQt @@ -2973,7 +2952,7 @@ class Image: raise ImportError(msg) return ImageQt.toqimage(self) - def toqpixmap(self): + def toqpixmap(self) -> ImageQt.QPixmap: """Returns a QPixmap copy of this image""" from . import ImageQt @@ -3017,15 +2996,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: """ @@ -3101,7 +3071,7 @@ def new( def frombytes( mode: str, size: tuple[int, int], - data: bytes | bytearray, + data: bytes | bytearray | SupportsArrayInterface, decoder_name: str = "raw", *args: Any, ) -> Image: @@ -3145,7 +3115,11 @@ def frombytes( def frombuffer( - mode: str, size: tuple[int, int], data, decoder_name: str = "raw", *args: Any + mode: str, + size: tuple[int, int], + data: bytes | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, ) -> Image: """ Creates an image memory referencing pixel data in a byte buffer. @@ -3300,7 +3274,7 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) -def fromqimage(im) -> ImageFile.ImageFile: +def fromqimage(im: ImageQt.QImage) -> ImageFile.ImageFile: """Creates an image instance from a QImage image""" from . import ImageQt @@ -3310,7 +3284,7 @@ def fromqimage(im) -> ImageFile.ImageFile: return ImageQt.fromqimage(im) -def fromqpixmap(im) -> ImageFile.ImageFile: +def fromqpixmap(im: ImageQt.QPixmap) -> ImageFile.ImageFile: """Creates an image instance from a QPixmap image""" from . import ImageQt @@ -3421,7 +3395,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") @@ -3602,7 +3576,10 @@ def merge(mode: str, bands: Sequence[Image]) -> Image: def register_open( id: str, - factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], + factory: ( + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile] + | type[ImageFile.ImageFile] + ), accept: Callable[[bytes], bool | str] | None = None, ) -> None: """ @@ -3848,7 +3825,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:: @@ -3861,14 +3838,14 @@ class Exif(_ExifBase): bigtiff = False _loaded = False - def __init__(self): - self._data = {} - self._hidden_data = {} - self._ifds = {} - self._info = None - self._loaded_exif = None + def __init__(self) -> None: + self._data: dict[int, Any] = {} + self._hidden_data: dict[int, Any] = {} + self._ifds: dict[int, dict[int, Any]] = {} + self._info: TiffImagePlugin.ImageFileDirectory_v2 | None = None + self._loaded_exif: bytes | None = None - def _fixup(self, value): + def _fixup(self, value: Any) -> Any: try: if len(value) == 1 and isinstance(value, tuple): return value[0] @@ -3876,27 +3853,29 @@ class Exif(_ExifBase): pass return value - def _fixup_dict(self, src_dict): + def _fixup_dict(self, src_dict: dict[int, Any]) -> dict[int, Any]: # Helper function # returns a dict with any single item tuples/lists as individual values return {k: self._fixup(v) for k, v in src_dict.items()} - def _get_ifd_dict(self, offset: int, group=None): + def _get_ifd_dict( + self, offset: int, group: int | None = None + ) -> dict[int, Any] | None: try: # an offset pointer to the location of the nested embedded IFD. # It should be a long, but may be corrupted. self.fp.seek(offset) except (KeyError, TypeError): - pass + return None else: from . import TiffImagePlugin info = TiffImagePlugin.ImageFileDirectory_v2(self.head, group=group) info.load(self.fp) - return self._fixup_dict(info) + 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: @@ -3919,7 +3898,7 @@ class Exif(_ExifBase): self._data.clear() self._hidden_data.clear() self._ifds.clear() - if data and data.startswith(b"Exif\x00\x00"): + while data and data.startswith(b"Exif\x00\x00"): data = data[6:] if not data: self._info = None @@ -3958,7 +3937,7 @@ class Exif(_ExifBase): self.fp.seek(offset) self._info.load(self.fp) - def _get_merged_dict(self): + def _get_merged_dict(self) -> dict[int, Any]: merged_dict = dict(self) # get EXIF extension @@ -3980,6 +3959,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, @@ -3996,23 +3978,27 @@ class Exif(_ExifBase): ifd[tag] = value return b"Exif\x00\x00" + head + ifd.tobytes(offset) - def get_ifd(self, tag): + def get_ifd(self, tag: int) -> dict[int, Any]: if tag not in self._ifds: if tag == ExifTags.IFD.IFD1: if self._info is not None and self._info.next != 0: - self._ifds[tag] = self._get_ifd_dict(self._info.next) + ifd = self._get_ifd_dict(self._info.next) + if ifd is not None: + self._ifds[tag] = ifd elif tag in [ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo]: offset = self._hidden_data.get(tag, self.get(tag)) if offset is not None: - self._ifds[tag] = self._get_ifd_dict(offset, tag) - elif tag in [ExifTags.IFD.Interop, ExifTags.IFD.Makernote]: + 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]: 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:] @@ -4061,7 +4047,9 @@ class Exif(_ExifBase): (offset,) = struct.unpack(">L", data) self.fp.seek(offset) - camerainfo = {"ModelID": self.fp.read(4)} + camerainfo: dict[str, int | bytes] = { + "ModelID": self.fp.read(4) + } self.fp.read(4) # Seconds since 2000 @@ -4077,22 +4065,24 @@ class Exif(_ExifBase): ][1] camerainfo["Parallax"] = handler( ImageFileDirectory_v2(), parallax, False - ) + )[0] self.fp.read(4) camerainfo["Category"] = self.fp.read(2) - makernote = {0x1101: dict(self._fixup_dict(camerainfo))} + makernote = {0x1101: camerainfo} self._ifds[tag] = makernote else: # Interop - self._ifds[tag] = self._get_ifd_dict(tag_data, tag) - ifd = self._ifds.get(tag, {}) + ifd = self._get_ifd_dict(tag_data, tag) + if ifd is not None: + self._ifds[tag] = ifd + ifd = self._ifds.setdefault(tag, {}) if tag == ExifTags.IFD.Exif and self._hidden_data: 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 @@ -4116,7 +4106,7 @@ class Exif(_ExifBase): keys.update(self._info) return len(keys) - def __getitem__(self, tag: int): + def __getitem__(self, tag: int) -> Any: if self._info is not None and tag not in self._data and tag in self._info: self._data[tag] = self._fixup(self._info[tag]) del self._info[tag] @@ -4125,7 +4115,7 @@ class Exif(_ExifBase): def __contains__(self, tag: object) -> bool: return tag in self._data or (self._info is not None and tag in self._info) - def __setitem__(self, tag: int, value) -> None: + def __setitem__(self, tag: int, value: Any) -> None: if self._info is not None and tag in self._info: del self._info[tag] self._data[tag] = value @@ -4136,7 +4126,7 @@ class Exif(_ExifBase): else: del self._data[tag] - def __iter__(self): + def __iter__(self) -> Iterator[int]: keys = set(self._data) if self._info is not None: keys.update(self._info) diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index ec10230f1..fdfbee789 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -31,6 +31,10 @@ from ._typing import SupportsRead try: from . import _imagingcms as core + + _CmsProfileCompatible = Union[ + str, SupportsRead[bytes], core.CmsProfile, "ImageCmsProfile" + ] except ImportError as ex: # Allow error import for doc purposes, but error out when accessing # anything in core. @@ -349,19 +353,17 @@ class ImageCmsTransform(Image.ImagePointHandler): return self.apply(im) def apply(self, im: Image.Image, imOut: Image.Image | None = None) -> Image.Image: - im.load() if imOut is None: imOut = Image.new(self.output_mode, im.size, None) - self.transform.apply(im.im.id, imOut.im.id) + self.transform.apply(im.getim(), imOut.getim()) imOut.info["icc_profile"] = self.output_profile.tobytes() return imOut def apply_in_place(self, im: Image.Image) -> Image.Image: - im.load() if im.mode != self.output_mode: msg = "mode mismatch" raise ValueError(msg) # wrong output mode - self.transform.apply(im.im.id, im.im.id) + self.transform.apply(im.getim(), im.getim()) im.info["icc_profile"] = self.output_profile.tobytes() return im @@ -391,10 +393,6 @@ def get_display_profile(handle: SupportsInt | None = None) -> ImageCmsProfile | # pyCMS compatible layer # --------------------------------------------------------------------. -_CmsProfileCompatible = Union[ - str, SupportsRead[bytes], core.CmsProfile, ImageCmsProfile -] - class PyCMSError(Exception): """(pyCMS) Exception class. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 6f56d0236..c4ebc5931 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -32,7 +32,6 @@ from __future__ import annotations import math -import numbers import struct from collections.abc import Sequence from types import ModuleType @@ -43,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 @@ -160,13 +155,13 @@ class ImageDraw: if ink is not None: if isinstance(ink, str): ink = ImageColor.getcolor(ink, self.mode) - if self.palette and not isinstance(ink, numbers.Number): + if self.palette and isinstance(ink, tuple): ink = self.palette.getcolor(ink, self._image) result_ink = self.draw.draw_ink(ink) if fill is not None: if isinstance(fill, str): fill = ImageColor.getcolor(fill, self.mode) - if self.palette and not isinstance(fill, numbers.Number): + if self.palette and isinstance(fill, tuple): fill = self.palette.getcolor(fill, self._image) result_fill = self.draw.draw_ink(fill) return result_ink, result_fill @@ -505,7 +500,7 @@ class ImageDraw: if full_x: self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1) - else: + elif x1 - r - 1 > x0 + r + 1: self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1) if not full_x and not full_y: left = [x0, y0, x0 + r, y1] @@ -558,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], @@ -644,6 +624,7 @@ class ImageDraw: features=features, language=language, stroke_width=stroke_width, + stroke_filled=True, anchor=anchor, ink=ink, start=start, @@ -693,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], @@ -721,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, @@ -788,7 +845,6 @@ class ImageDraw: stroke_fill=stroke_fill, embedded_color=embedded_color, ) - top += line_spacing def textlength( self, @@ -890,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, @@ -972,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 diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py index c0d89e4aa..3d68658ed 100644 --- a/src/PIL/ImageDraw2.py +++ b/src/PIL/ImageDraw2.py @@ -24,7 +24,7 @@ """ from __future__ import annotations -from typing import AnyStr, BinaryIO +from typing import Any, AnyStr, BinaryIO from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath from ._typing import Coords, StrOrBytesPath @@ -80,7 +80,12 @@ class Draw: return self.image def render( - self, op: str, xy: Coords, pen: Pen | Brush, brush: Brush | Pen | None = None + self, + op: str, + xy: Coords, + pen: Pen | Brush | None, + brush: Brush | Pen | None = None, + **kwargs: Any, ) -> None: # handle color arguments outline = fill = None @@ -101,60 +106,85 @@ class Draw: path.transform(self.transform) xy = path # render the item - if op == "line": - self.draw.line(xy, fill=outline, width=width) + if op in ("arc", "line"): + kwargs.setdefault("fill", outline) else: - getattr(self.draw, op)(xy, fill=fill, outline=outline) + kwargs.setdefault("fill", fill) + kwargs.setdefault("outline", outline) + if op == "line": + kwargs.setdefault("width", width) + getattr(self.draw, op)(xy, **kwargs) def settransform(self, offset: tuple[float, float]) -> None: """Sets a transformation offset.""" (xoffset, yoffset) = offset self.transform = (1, 0, xoffset, 0, 1, yoffset) - def arc(self, xy: Coords, start, end, *options) -> None: + def arc( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: """ Draws an arc (a portion of a circle outline) between the start and end angles, inside the given bounding box. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.arc` """ - self.render("arc", xy, start, end, *options) + self.render("arc", xy, pen, *options, start=start, end=end) - def chord(self, xy: Coords, start, end, *options) -> None: + def chord( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: """ Same as :py:meth:`~PIL.ImageDraw2.Draw.arc`, but connects the end points with a straight line. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.chord` """ - self.render("chord", xy, start, end, *options) + self.render("chord", xy, pen, *options, start=start, end=end) - def ellipse(self, xy: Coords, *options) -> None: + def ellipse(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws an ellipse inside the given bounding box. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.ellipse` """ - self.render("ellipse", xy, *options) + self.render("ellipse", xy, pen, *options) - def line(self, xy: Coords, *options) -> None: + def line(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws a line between the coordinates in the ``xy`` list. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.line` """ - self.render("line", xy, *options) + self.render("line", xy, pen, *options) - def pieslice(self, xy: Coords, start, end, *options) -> None: + def pieslice( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: """ Same as arc, but also draws straight lines between the end points and the center of the bounding box. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.pieslice` """ - self.render("pieslice", xy, start, end, *options) + self.render("pieslice", xy, pen, *options, start=start, end=end) - def polygon(self, xy: Coords, *options) -> None: + def polygon(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws a polygon. @@ -165,15 +195,15 @@ class Draw: .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.polygon` """ - self.render("polygon", xy, *options) + self.render("polygon", xy, pen, *options) - def rectangle(self, xy: Coords, *options) -> None: + def rectangle(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws a rectangle. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.rectangle` """ - self.render("rectangle", xy, *options) + self.render("rectangle", xy, pen, *options) def text(self, xy: tuple[float, float], text: AnyStr, font: Font) -> None: """ diff --git a/src/PIL/ImageEnhance.py b/src/PIL/ImageEnhance.py index d7e99a968..0e7e6dd8a 100644 --- a/src/PIL/ImageEnhance.py +++ b/src/PIL/ImageEnhance.py @@ -55,7 +55,9 @@ class Color(_Enhance): if "A" in image.getbands(): self.intermediate_mode = "LA" - self.degenerate = image.convert(self.intermediate_mode).convert(image.mode) + if self.intermediate_mode != image.mode: + image = image.convert(self.intermediate_mode).convert(image.mode) + self.degenerate = image class Contrast(_Enhance): @@ -68,11 +70,15 @@ class Contrast(_Enhance): def __init__(self, image: Image.Image) -> None: self.image = image - mean = int(ImageStat.Stat(image.convert("L")).mean[0] + 0.5) - self.degenerate = Image.new("L", image.size, mean).convert(image.mode) + if image.mode != "L": + image = image.convert("L") + mean = int(ImageStat.Stat(image).mean[0] + 0.5) + self.degenerate = Image.new("L", image.size, mean) + if self.degenerate.mode != self.image.mode: + self.degenerate = self.degenerate.convert(self.image.mode) - if "A" in image.getbands(): - self.degenerate.putalpha(image.getchannel("A")) + if "A" in self.image.getbands(): + self.degenerate.putalpha(self.image.getchannel("A")) class Brightness(_Enhance): diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index e4a7dba44..c3901d488 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -31,13 +31,20 @@ from __future__ import annotations import abc import io import itertools +import logging +import os import struct import sys -from typing import IO, Any, NamedTuple +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 @@ -86,16 +93,16 @@ def raise_oserror(error: int) -> OSError: raise _get_oserror(error, encoder=False) -def _tilesort(t) -> int: +def _tilesort(t: _Tile) -> int: # sort on offset return t[2] class _Tile(NamedTuple): codec_name: str - extents: tuple[int, int, int, int] - offset: int - args: tuple[Any, ...] | str | None + extents: tuple[int, int, int, int] | None + offset: int = 0 + args: tuple[Any, ...] | str | None = None # @@ -106,32 +113,34 @@ class _Tile(NamedTuple): class ImageFile(Image.Image): """Base class for image file format handlers.""" - def __init__(self, fp=None, filename=None): + def __init__( + self, fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None + ) -> None: super().__init__() self._min_frame = 0 - self.custom_mimetype = None + self.custom_mimetype: str | None = None - self.tile = None - """ A list of tile descriptors, or ``None`` """ + self.tile: list[_Tile] = [] + """ A list of tile descriptors """ self.readonly = 1 # until we know better - self.decoderconfig = () + self.decoderconfig: tuple[Any, ...] = () self.decodermaxblock = MAXBLOCK if is_path(fp): # filename self.fp = open(fp, "rb") - self.filename = fp + self.filename = os.fspath(fp) self._exclusive_fp = True else: # stream - self.fp = fp - self.filename = filename + self.fp = cast(IO[bytes], fp) + self.filename = filename if filename is not None else "" # can be overridden - self._exclusive_fp = None + self._exclusive_fp = False try: try: @@ -154,6 +163,88 @@ class ImageFile(Image.Image): self.fp.close() raise + def _open(self) -> None: + pass + + 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 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 @@ -161,7 +252,7 @@ class ImageFile(Image.Image): return Image.MIME.get(self.format.upper()) return None - def __setstate__(self, state) -> None: + def __setstate__(self, state: list[Any]) -> None: self.tile = [] super().__setstate__(state) @@ -174,10 +265,10 @@ class ImageFile(Image.Image): self.fp.close() self.fp = None - def load(self): + def load(self) -> Image.core.PixelAccess | None: """Load image data based on tile list""" - if self.tile is None: + if not self.tile and self._im is None: msg = "cannot load this image" raise OSError(msg) @@ -185,7 +276,7 @@ class ImageFile(Image.Image): if not self.tile: return pixel - self.map = None + 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") @@ -193,17 +284,17 @@ class ImageFile(Image.Image): readonly = 0 # look for read/seek overrides - try: + if hasattr(self, "load_read"): read = self.load_read # don't use mmap if there are custom read/seek functions use_mmap = False - except AttributeError: + else: read = self.fp.read - try: + if hasattr(self, "load_seek"): seek = self.load_seek use_mmap = False - except AttributeError: + else: seek = self.fp.seek if use_mmap: @@ -213,6 +304,7 @@ class ImageFile(Image.Image): args = (args, 0, 1) if ( decoder_name == "raw" + and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode and args[0] in Image._MAPMODES @@ -243,11 +335,8 @@ class ImageFile(Image.Image): # sort tiles in file order self.tile.sort(key=_tilesort) - try: - # FIXME: This is a hack to handle TIFF's JpegTables tag. - prefix = self.tile_prefix - except AttributeError: - prefix = b"" + # FIXME: This is a hack to handle TIFF's JpegTables tag. + prefix = getattr(self, "tile_prefix", b"") # Remove consecutive duplicates that only differ by their offset self.tile = [ @@ -315,7 +404,7 @@ class ImageFile(Image.Image): def load_prepare(self) -> None: # create image memory if necessary - if not self.im or self.im.mode != self.mode or self.im.size != self.size: + if self._im is None: self.im = Image.core.new(self.mode, self.size) # create palette (optional) if self.mode == "P": @@ -525,7 +614,7 @@ class Parser: # -------------------------------------------------------------------- -def _save(im, fp, tile, bufsize: int = 0) -> None: +def _save(im: Image.Image, fp: IO[bytes], tile: list[_Tile], bufsize: int = 0) -> None: """Helper to save image based on tile list :param im: Image object. @@ -554,7 +643,12 @@ def _save(im, fp, tile, bufsize: int = 0) -> None: def _encode_tile( - im, fp: IO[bytes], tile: list[_Tile], bufsize: int, fh, exc=None + im: Image.Image, + fp: IO[bytes], + tile: list[_Tile], + bufsize: int, + fh: int | None, + exc: BaseException | None = None, ) -> None: for encoder_name, extents, offset, args in tile: if offset > 0: @@ -575,6 +669,7 @@ def _encode_tile( break else: # slight speedup: compress to real file object + assert fh is not None errcode = encoder.encode_to_file(fh, bufsize) if errcode < 0: raise _get_oserror(errcode, encoder=True) from exc @@ -664,7 +759,11 @@ class PyCodec: """ self.fd = fd - def setimage(self, im, extents=None): + def setimage( + self, + im: Image.core.ImagingCore, + extents: tuple[int, int, int, int] | None = None, + ) -> None: """ Called from ImageFile to set the core output image for the codec @@ -716,7 +815,7 @@ class PyDecoder(PyCodec): def pulls_fd(self) -> bool: return self._pulls_fd - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: """ Override to perform the decoding process. @@ -728,19 +827,22 @@ class PyDecoder(PyCodec): msg = "unavailable in base decoder" raise NotImplementedError(msg) - def set_as_raw(self, data: bytes, rawmode=None) -> None: + def set_as_raw( + self, data: bytes, rawmode: str | None = None, extra: tuple[Any, ...] = () + ) -> None: """ Convenience method to set the internal image from a stream of raw data :param data: Bytes to be set :param rawmode: The rawmode to be used for the decoder. If not specified, it will default to the mode of the image + :param extra: Extra arguments for the decoder. :returns: None """ if not rawmode: rawmode = self.mode - d = Image._getdecoder(self.mode, "raw", rawmode) + d = Image._getdecoder(self.mode, "raw", rawmode, extra) assert self.im is not None d.setimage(self.im, self.state.extents()) s = d.decode(data) @@ -795,7 +897,7 @@ class PyEncoder(PyCodec): self.fd.write(data) return bytes_consumed, errcode - def encode_to_file(self, fh: IO[bytes], bufsize: int) -> int: + def encode_to_file(self, fh: int, bufsize: int) -> int: """ :param fh: File handle. :param bufsize: Buffer size. @@ -808,5 +910,5 @@ class PyEncoder(PyCodec): while errcode == 0: status, errcode, buf = self.encode(bufsize) if status > 0: - fh.write(buf[status:]) + os.write(fh, buf[status:]) return errcode diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 8b0974b2c..1c8b29b11 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -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 24490348c..c8f05fbb7 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -34,9 +34,9 @@ import warnings from enum import IntEnum from io import BytesIO from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict +from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict, cast -from . import Image +from . import Image, features from ._typing import StrOrBytesPath from ._util import DeferredError, is_path @@ -98,11 +98,13 @@ class ImageFont: def _load_pilfont(self, filename: str) -> None: with open(filename, "rb") as fp: image: ImageFile.ImageFile | None = None + root = os.path.splitext(filename)[0] + for ext in (".png", ".gif", ".pbm"): if image: image.close() try: - fullname = os.path.splitext(filename)[0] + ext + fullname = root + ext image = Image.open(fullname) except Exception: pass @@ -112,7 +114,8 @@ class ImageFont: else: if image: image.close() - msg = "cannot find glyph data file" + + msg = f"cannot find glyph data file {root}.{{gif|pbm|png}}" raise OSError(msg) self.file = fullname @@ -212,7 +215,7 @@ class FreeTypeFont: def __init__( self, - font: StrOrBytesPath | BinaryIO | None = None, + font: StrOrBytesPath | BinaryIO, size: float = 10, index: int = 0, encoding: str = "", @@ -224,7 +227,7 @@ class FreeTypeFont: raise core.ex if size <= 0: - msg = "font size must be greater than 0" + msg = f"font size must be greater than 0, not {size}" raise ValueError(msg) self.path = font @@ -232,6 +235,21 @@ class FreeTypeFont: self.index = index self.encoding = encoding + try: + from packaging.version import parse as parse_version + except ImportError: + pass + else: + if freetype_version := features.version_module("freetype2"): + if parse_version(freetype_version) < parse_version("2.9.1"): + warnings.warn( + "Support for FreeType 2.9.0 is deprecated and will be removed " + "in Pillow 12 (2025-10-15). Please upgrade to FreeType 2.9.1 " + "or newer, preferably FreeType 2.10.4 which fixes " + "CVE-2020-15999.", + DeprecationWarning, + ) + if layout_engine not in (Layout.BASIC, Layout.RAQM): layout_engine = Layout.BASIC if core.HAVE_RAQM: @@ -245,14 +263,14 @@ class FreeTypeFont: self.layout_engine = layout_engine - def load_from_bytes(f) -> None: + def load_from_bytes(f: IO[bytes]) -> None: self.font_bytes = f.read() self.font = core.getfont( "", size, index, encoding, self.font_bytes, layout_engine ) 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: @@ -267,14 +285,14 @@ class FreeTypeFont: font, size, index, encoding, layout_engine=layout_engine ) else: - load_from_bytes(font) + load_from_bytes(cast(IO[bytes], font)) - def __getstate__(self): + def __getstate__(self) -> list[Any]: return [self.path, self.size, self.index, self.encoding, self.layout_engine] - def __setstate__(self, state): + def __setstate__(self, state: list[Any]) -> None: path, size, index, encoding, layout_engine = state - self.__init__(path, size, index, encoding, layout_engine) + FreeTypeFont.__init__(self, path, size, index, encoding, layout_engine) def getname(self) -> tuple[str | None, str | None]: """ @@ -626,10 +644,10 @@ class FreeTypeFont: features, language, stroke_width, + kwargs.get("stroke_filled", False), anchor, ink, - start[0], - start[1], + start, ) def font_variant( @@ -768,8 +786,9 @@ class TransposedFont: def load(filename: str) -> ImageFont: """ - Load a font file. This function loads a font object from the given - bitmap font file, and returns the corresponding font object. + Load a font file. This function loads a font object from the given + bitmap font file, and returns the corresponding font object. For loading TrueType + or OpenType fonts instead, see :py:func:`~PIL.ImageFont.truetype`. :param filename: Name of font file. :return: A font object. @@ -781,7 +800,7 @@ def load(filename: str) -> ImageFont: def truetype( - font: StrOrBytesPath | BinaryIO | None = None, + font: StrOrBytesPath | BinaryIO, size: float = 10, index: int = 0, encoding: str = "", @@ -789,9 +808,10 @@ def truetype( ) -> FreeTypeFont: """ Load a TrueType or OpenType font from a file or file-like object, - and create a font object. - This function loads a font object from the given file or file-like - object, and creates a font object for a font of the given size. + and create a font object. This function loads a font object from the given + file or file-like object, and creates a font object for a font of the given + size. For loading bitmap fonts instead, see :py:func:`~PIL.ImageFont.load` + and :py:func:`~PIL.ImageFont.load_path`. Pillow uses FreeType to open font files. On Windows, be aware that FreeType will keep the file open as long as the FreeTypeFont object exists. Windows @@ -852,7 +872,7 @@ def truetype( :exception ValueError: If the font size is not greater than zero. """ - def freetype(font: StrOrBytesPath | BinaryIO | None) -> FreeTypeFont: + def freetype(font: StrOrBytesPath | BinaryIO) -> FreeTypeFont: return FreeTypeFont(font, size, index, encoding, layout_engine) try: @@ -927,7 +947,10 @@ def load_path(filename: str | bytes) -> ImageFont: return load(os.path.join(directory, filename)) except OSError: pass - msg = "cannot find font file" + msg = f'cannot find font file "{filename}" in sys.path' + if os.path.exists(filename): + msg += f', did you mean ImageFont.load("{filename}") instead?' + raise OSError(msg) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index e27ca7e50..fe27bfaeb 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -104,28 +104,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/ImageMath.py b/src/PIL/ImageMath.py index 191cc2a5f..484797f91 100644 --- a/src/PIL/ImageMath.py +++ b/src/PIL/ImageMath.py @@ -59,13 +59,12 @@ class _Operand: if im2 is None: # unary operation out = Image.new(mode or im_1.mode, im_1.size, None) - im_1.load() try: op = getattr(_imagingmath, f"{op}_{im_1.mode}") except AttributeError as e: msg = f"bad operand type for '{op}'" raise TypeError(msg) from e - _imagingmath.unop(op, out.im.id, im_1.im.id) + _imagingmath.unop(op, out.getim(), im_1.getim()) else: # binary operation im_2 = self.__fixup(im2) @@ -86,14 +85,12 @@ class _Operand: if im_2.size != size: im_2 = im_2.crop((0, 0) + size) out = Image.new(mode or im_1.mode, im_1.size, None) - im_1.load() - im_2.load() try: op = getattr(_imagingmath, f"{op}_{im_1.mode}") except AttributeError as e: msg = f"bad operand type for '{op}'" raise TypeError(msg) from e - _imagingmath.binop(op, out.im.id, im_1.im.id, im_2.im.id) + _imagingmath.binop(op, out.getim(), im_1.getim(), im_2.getim()) return _Operand(out) # unary operators @@ -176,10 +173,10 @@ class _Operand: return self.apply("rshift", self, other) # logical - def __eq__(self, other): + def __eq__(self, other: _Operand | float) -> _Operand: # type: ignore[override] return self.apply("eq", self, other) - def __ne__(self, other): + def __ne__(self, other: _Operand | float) -> _Operand: # type: ignore[override] return self.apply("ne", self, other) def __lt__(self, other: _Operand | float) -> _Operand: @@ -268,7 +265,7 @@ def lambda_eval( args.update(options) args.update(kw) for k, v in args.items(): - if hasattr(v, "im"): + if isinstance(v, Image.Image): args[k] = _Operand(v) out = expression(args) @@ -319,7 +316,7 @@ def unsafe_eval( args.update(options) args.update(kw) for k, v in args.items(): - if hasattr(v, "im"): + if isinstance(v, Image.Image): args[k] = _Operand(v) compiled_code = compile(expression, "", "eval") diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index 6a43983d3..f0a066b5b 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -213,7 +213,7 @@ class MorphOp: msg = "Image mode must be L" raise ValueError(msg) outimage = Image.new(image.mode, image.size, None) - count = _imagingmorph.apply(bytes(self.lut), image.im.id, outimage.im.id) + count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim()) return count, outimage def match(self, image: Image.Image) -> list[tuple[int, int]]: @@ -229,7 +229,7 @@ class MorphOp: if image.mode != "L": msg = "Image mode must be L" raise ValueError(msg) - return _imagingmorph.match(bytes(self.lut), image.im.id) + return _imagingmorph.match(bytes(self.lut), image.getim()) def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: """Get a list of all turned on pixels in a binary image @@ -240,7 +240,7 @@ class MorphOp: if image.mode != "L": msg = "Image mode must be L" raise ValueError(msg) - return _imagingmorph.get_on_pixels(image.im.id) + return _imagingmorph.get_on_pixels(image.getim()) def load_lut(self, filename: str) -> None: """Load an operator from an mrl file""" diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 44aad0c3c..75dfbee22 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 @@ -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/ImagePalette.py b/src/PIL/ImagePalette.py index 8ccecbd07..183f85526 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -208,7 +208,7 @@ class ImagePalette: # Internal -def raw(rawmode, data: Sequence[int] | bytes | bytearray) -> ImagePalette: +def raw(rawmode: str, data: Sequence[int] | bytes | bytearray) -> ImagePalette: palette = ImagePalette() palette.rawmode = rawmode palette.palette = data diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index a2d946714..2cc40f855 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -19,14 +19,23 @@ from __future__ import annotations import sys from io import BytesIO -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Any, Callable, Union from . import Image from ._util import is_path if TYPE_CHECKING: + import PyQt6 + import PySide6 + from . import ImageFile + QBuffer: type + QByteArray = Union[PyQt6.QtCore.QByteArray, PySide6.QtCore.QByteArray] + QIODevice = Union[PyQt6.QtCore.QIODevice, PySide6.QtCore.QIODevice] + QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage] + QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap] + qt_version: str | None qt_versions = [ ["6", "PyQt6"], @@ -37,10 +46,6 @@ qt_versions = [ qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True) for version, qt_module in qt_versions: try: - QBuffer: type - QIODevice: type - QImage: type - QPixmap: type qRgba: Callable[[int, int, int, int], int] if qt_module == "PyQt6": from PyQt6.QtCore import QBuffer, QIODevice @@ -65,19 +70,20 @@ def rgb(r: int, g: int, b: int, a: int = 255) -> int: return qRgba(r, g, b, a) & 0xFFFFFFFF -def fromqimage(im): +def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile: """ :param im: QImage or PIL ImageQt object """ buffer = QBuffer() + qt_openmode: object if qt_version == "6": try: - qt_openmode = QIODevice.OpenModeFlag + qt_openmode = getattr(QIODevice, "OpenModeFlag") except AttributeError: - qt_openmode = QIODevice.OpenMode + qt_openmode = getattr(QIODevice, "OpenMode") else: qt_openmode = QIODevice - buffer.open(qt_openmode.ReadWrite) + buffer.open(getattr(qt_openmode, "ReadWrite")) # preserve alpha channel with png # otherwise ppm is more friendly with Image.open if im.hasAlphaChannel(): @@ -93,7 +99,7 @@ def fromqimage(im): return Image.open(b) -def fromqpixmap(im) -> ImageFile.ImageFile: +def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile: return fromqimage(im) @@ -123,7 +129,7 @@ def align8to32(bytes: bytes, width: int, mode: str) -> bytes: return b"".join(new_data) -def _toqclass_helper(im): +def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]: data = None colortable = None exclusive_fp = False @@ -135,30 +141,32 @@ def _toqclass_helper(im): if is_path(im): im = Image.open(im) exclusive_fp = True + assert isinstance(im, Image.Image) - qt_format = QImage.Format if qt_version == "6" else QImage + qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage if im.mode == "1": - format = qt_format.Format_Mono + format = getattr(qt_format, "Format_Mono") elif im.mode == "L": - format = qt_format.Format_Indexed8 + format = getattr(qt_format, "Format_Indexed8") colortable = [rgb(i, i, i) for i in range(256)] elif im.mode == "P": - format = qt_format.Format_Indexed8 + format = getattr(qt_format, "Format_Indexed8") palette = im.getpalette() + assert palette is not None colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)] elif im.mode == "RGB": # Populate the 4th channel with 255 im = im.convert("RGBA") data = im.tobytes("raw", "BGRA") - format = qt_format.Format_RGB32 + format = getattr(qt_format, "Format_RGB32") elif im.mode == "RGBA": data = im.tobytes("raw", "BGRA") - format = qt_format.Format_ARGB32 + format = getattr(qt_format, "Format_ARGB32") elif im.mode == "I;16": im = im.point(lambda i: i * 256) - format = qt_format.Format_Grayscale16 + format = getattr(qt_format, "Format_Grayscale16") else: if exclusive_fp: im.close() @@ -174,8 +182,8 @@ def _toqclass_helper(im): if qt_is_installed: - class ImageQt(QImage): - def __init__(self, im) -> None: + class ImageQt(QImage): # type: ignore[misc] + def __init__(self, im: Image.Image | str | QByteArray) -> None: """ An PIL image wrapper for Qt. This is a subclass of PyQt's QImage class. @@ -199,10 +207,13 @@ if qt_is_installed: self.setColorTable(im_data["colortable"]) -def toqimage(im) -> ImageQt: +def toqimage(im: Image.Image | str | QByteArray) -> ImageQt: return ImageQt(im) -def toqpixmap(im): +def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap: qimage = toqimage(im) - return QPixmap.fromImage(qimage) + pixmap = getattr(QPixmap, "fromImage")(qimage) + if qt_version == "6": + pixmap.detach() + return pixmap diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index 6b13e57a0..e6a9d8eea 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -28,27 +28,16 @@ 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 +if TYPE_CHECKING: + from ._typing import CapsuleType + # -------------------------------------------------------------------- # Check for Tkinter interface hooks -_pilbitmap_ok = None - - -def _pilbitmap_check() -> int: - global _pilbitmap_ok - if _pilbitmap_ok is None: - try: - im = Image.new("1", (1, 1)) - tkinter.BitmapImage(data=f"PIL:{im.im.id}") - _pilbitmap_ok = 1 - except tkinter.TclError: - _pilbitmap_ok = 0 - return _pilbitmap_ok - def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None: source = None @@ -62,18 +51,18 @@ def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None: def _pyimagingtkcall( - command: str, photo: PhotoImage | tkinter.PhotoImage, id: int + command: str, photo: PhotoImage | tkinter.PhotoImage, ptr: CapsuleType ) -> None: tk = photo.tk try: - tk.call(command, photo, id) + tk.call(command, photo, repr(ptr)) except tkinter.TclError: # activate Tkinter hook # may raise an error if it cannot attach to Tkinter from . import _imagingtk _imagingtk.tkinit(tk.interpaddr()) - tk.call(command, photo, id) + tk.call(command, photo, repr(ptr)) # -------------------------------------------------------------------- @@ -127,10 +116,7 @@ class PhotoImage: # palette mapped data image.apply_transparency() image.load() - try: - mode = image.palette.mode - except AttributeError: - mode = "RGB" # default + mode = image.palette.mode if image.palette else "RGB" size = image.size kw["width"], kw["height"] = size @@ -145,7 +131,10 @@ class PhotoImage: self.paste(image) def __del__(self) -> None: - name = self.__photo.name + try: + name = self.__photo.name + except AttributeError: + return self.__photo.name = None try: self.__photo.tk.call("image", "delete", name) @@ -188,15 +177,14 @@ class PhotoImage: the bitmap image. """ # convert to blittable - im.load() + ptr = im.getim() image = im.im - if image.isblock() and im.mode == self.__mode: - block = image - else: - block = image.new_block(self.__mode, im.size) + if not image.isblock() or im.mode != self.__mode: + block = Image.core.new_block(self.__mode, im.size) image.convert2(block, image) # convert directly between buffers + ptr = block.ptr - _pyimagingtkcall("PyImagingPhoto", self.__photo, block.id) + _pyimagingtkcall("PyImagingPhoto", self.__photo, ptr) # -------------------------------------------------------------------- @@ -228,18 +216,13 @@ class BitmapImage: self.__mode = image.mode self.__size = image.size - if _pilbitmap_check(): - # fast way (requires the pilbitmap booster patch) - image.load() - kw["data"] = f"PIL:{image.im.id}" - self.__im = image # must keep a reference - else: - # slow but safe way - kw["data"] = image.tobitmap() - self.__photo = tkinter.BitmapImage(**kw) + self.__photo = tkinter.BitmapImage(data=image.tobitmap(), **kw) def __del__(self) -> None: - name = self.__photo.name + try: + name = self.__photo.name + except AttributeError: + return self.__photo.name = None try: self.__photo.tk.call("image", "delete", name) @@ -276,33 +259,7 @@ class BitmapImage: def getimage(photo: PhotoImage) -> Image.Image: """Copies the contents of a PhotoImage to a PIL image memory.""" im = Image.new("RGBA", (photo.width(), photo.height())) - block = im.im - _pyimagingtkcall("PyImagingPhotoGet", photo, block.id) + _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/ImageWin.py b/src/PIL/ImageWin.py index 6fc7cfaf5..98c28f29f 100644 --- a/src/PIL/ImageWin.py +++ b/src/PIL/ImageWin.py @@ -98,14 +98,15 @@ class Dib: HDC or HWND instance. In PythonWin, you can use ``CDC.GetHandleAttrib()`` to get a suitable handle. """ + handle_int = int(handle) if isinstance(handle, HWND): - dc = self.image.getdc(handle) + dc = self.image.getdc(handle_int) try: self.image.expose(dc) finally: - self.image.releasedc(handle, dc) + self.image.releasedc(handle_int, dc) else: - self.image.expose(handle) + self.image.expose(handle_int) def draw( self, @@ -124,14 +125,15 @@ class Dib: """ if src is None: src = (0, 0) + self.size + handle_int = int(handle) if isinstance(handle, HWND): - dc = self.image.getdc(handle) + dc = self.image.getdc(handle_int) try: self.image.draw(dc, dst, src) finally: - self.image.releasedc(handle, dc) + self.image.releasedc(handle_int, dc) else: - self.image.draw(handle, dst, src) + self.image.draw(handle_int, dst, src) def query_palette(self, handle: int | HDC | HWND) -> int: """ @@ -148,14 +150,15 @@ class Dib: :return: The number of entries that were changed (if one or more entries, this indicates that the image should be redrawn). """ + handle_int = int(handle) if isinstance(handle, HWND): - handle = self.image.getdc(handle) + handle = self.image.getdc(handle_int) try: result = self.image.query_palette(handle) finally: self.image.releasedc(handle, handle) else: - result = self.image.query_palette(handle) + result = self.image.query_palette(handle_int) return result def paste( diff --git a/src/PIL/ImtImagePlugin.py b/src/PIL/ImtImagePlugin.py index abb3fb762..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/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index 17243e705..60ab7c83f 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -147,7 +147,9 @@ class IptcImageFile(ImageFile.ImageFile): # tile if tag == (8, 10): - self.tile = [("iptc", (0, 0) + self.size, offset, compression)] + self.tile = [ + ImageFile._Tile("iptc", (0, 0) + self.size, offset, compression) + ] def load(self) -> Image.core.PixelAccess | None: if len(self.tile) != 1 or self.tile[0][0] != "iptc": @@ -199,9 +201,13 @@ def getiptcinfo( data = None + info: dict[tuple[int, int], bytes | list[bytes]] = {} if isinstance(im, IptcImageFile): # return info dictionary right away - return im.info + for k, v in im.info.items(): + if isinstance(k, tuple): + info[k] = v + return info elif isinstance(im, JpegImagePlugin.JpegImageFile): # extract the IPTC/NAA resource @@ -213,8 +219,8 @@ def getiptcinfo( # get raw data from the IPTC/NAA tag (PhotoShop tags the data # as 4-byte integers, so we cannot use the get method...) try: - data = im.tag.tagdata[TiffImagePlugin.IPTC_NAA_CHUNK] - except (AttributeError, KeyError): + data = im.tag_v2[TiffImagePlugin.IPTC_NAA_CHUNK] + except KeyError: pass if data is None: @@ -237,4 +243,7 @@ def getiptcinfo( except (IndexError, KeyError): pass # expected failure - return iptc_im.info + for k, v in iptc_im.info.items(): + if isinstance(k, tuple): + info[k] = v + return info diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index eeec41686..e0f4ecae5 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -18,6 +18,7 @@ from __future__ import annotations import io import os import struct +from collections.abc import Callable from typing import IO, cast from . import Image, ImageFile, ImagePalette, _binary @@ -205,7 +206,7 @@ def _parse_jp2_header( if bitdepth > max_bitdepth: max_bitdepth = bitdepth if max_bitdepth <= 8: - palette = ImagePalette.ImagePalette() + palette = ImagePalette.ImagePalette("RGBA" if npc == 4 else "RGB") for i in range(ne): color: list[int] = [] for value in header.read_fields(">" + ("B" * npc)): @@ -251,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) @@ -261,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" @@ -286,7 +291,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): length = -1 self.tile = [ - ( + ImageFile._Tile( "jpeg2k", (0, 0) + self.size, 0, @@ -295,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: @@ -316,15 +317,20 @@ class Jpeg2KImageFile(ImageFile.ImageFile): else: self.fp.seek(length - 2, os.SEEK_CUR) - @property - def reduce(self): + @property # type: ignore[override] + def reduce( + self, + ) -> ( + Callable[[int | tuple[int, int], tuple[int, int, int, int] | None], Image.Image] + | int + ): # https://github.com/python-pillow/Pillow/issues/4343 found that the # new Image 'reduce' method was shadowed by this plugin's 'reduce' # property. This attempts to allow for both scenarios return self._reduce or super().reduce @reduce.setter - def reduce(self, value): + def reduce(self, value: int) -> None: self._reduce = value def load(self) -> Image.core.PixelAccess | None: @@ -338,16 +344,16 @@ class Jpeg2KImageFile(ImageFile.ImageFile): # Update the reduce and layers settings t = self.tile[0] + assert isinstance(t[3], tuple) t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) - self.tile = [(t[0], (0, 0) + self.size, t[2], t3)] + self.tile = [ImageFile._Tile(t[0], (0, 0) + self.size, t[2], t3)] return ImageFile.ImageFile.load(self) 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") ) @@ -419,7 +425,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: plt, ) - ImageFile._save(im, fp, [("jpeg2k", (0, 0) + im.size, 0, kind)]) + ImageFile._save(im, fp, [ImageFile._Tile("jpeg2k", (0, 0) + im.size, 0, kind)]) # ------------------------------------------------------------ diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 383cc843b..cd9396b04 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -42,15 +42,19 @@ import subprocess import sys import tempfile import warnings -from typing import IO, Any +from typing import IO, TYPE_CHECKING, Any from . import Image, ImageFile from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import o8 from ._binary import o16be as o16 +from ._deprecate import deprecate from .JpegPresets import presets +if TYPE_CHECKING: + from .MpoImagePlugin import MpoImageFile + # # Parser @@ -68,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) @@ -86,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 @@ -113,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", {}) @@ -146,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: @@ -155,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 @@ -318,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") ## @@ -329,26 +336,26 @@ class JpegImageFile(ImageFile.ImageFile): format = "JPEG" format_description = "JPEG (ISO 10918)" - def _open(self): + def _open(self) -> None: s = self.fp.read(3) 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 self._exif_offset = 0 # JPEG specifics (internal) - self.layer = [] - self.huffman_dc = {} - self.huffman_ac = {} - self.quantization = {} - self.app = {} # compatibility - self.applist = [] - self.icclist = [] + self.layer: list[tuple[int, int, int, int]] = [] + self._huffman_dc: dict[Any, Any] = {} + self._huffman_ac: dict[Any, Any] = {} + self.quantization: dict[int, list[int]] = {} + self.app: dict[str, bytes] = {} # compatibility + self.applist: list[tuple[str, bytes]] = [] + self.icclist: list[bytes] = [] while True: i = s[0] @@ -368,7 +375,9 @@ class JpegImageFile(ImageFile.ImageFile): rawmode = self.mode if self.mode == "CMYK": rawmode = "CMYK;I" # assume adobe conventions - self.tile = [("jpeg", (0, 0) + self.size, 0, (rawmode, ""))] + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, 0, (rawmode, "")) + ] # self.__offset = self.fp.tell() break s = self.fp.read(1) @@ -383,6 +392,19 @@ class JpegImageFile(ImageFile.ImageFile): self._read_dpi_from_exif() + def __getattr__(self, name: str) -> Any: + if name in ("huffman_ac", "huffman_dc"): + deprecate(name, 12) + 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 @@ -395,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 @@ -413,6 +435,7 @@ class JpegImageFile(ImageFile.ImageFile): scale = 1 original_size = self.size + assert isinstance(a, tuple) if a[0] == "RGB" and mode in ["L", "YCbCr"]: self._mode = mode a = mode, "" @@ -422,6 +445,7 @@ class JpegImageFile(ImageFile.ImageFile): for s in [8, 4, 2, 1]: if scale >= s: break + assert e is not None e = ( e[0], e[1], @@ -431,7 +455,7 @@ class JpegImageFile(ImageFile.ImageFile): self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s) scale = s - self.tile = [(d, e, o, a)] + self.tile = [ImageFile._Tile(d, e, o, a)] self.decoderconfig = (scale, 0) box = (0, 0, original_size[0] / scale, original_size[1] / scale) @@ -523,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 @@ -688,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 @@ -737,19 +761,29 @@ 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") + if xmp: + overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00" + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len + if len(xmp) > max_data_bytes_in_marker: + msg = "XMP data is too long" + raise ValueError(msg) + size = o16(2 + overhead_len + len(xmp)) + extra += b"\xff\xe1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp + icc_profile = info.get("icc_profile") if icc_profile: - ICC_OVERHEAD_LEN = 14 - MAX_DATA_BYTES_IN_MARKER = MAX_BYTES_IN_MARKER - ICC_OVERHEAD_LEN + overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers)) + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len markers = [] while icc_profile: - markers.append(icc_profile[:MAX_DATA_BYTES_IN_MARKER]) - icc_profile = icc_profile[MAX_DATA_BYTES_IN_MARKER:] + markers.append(icc_profile[:max_data_bytes_in_marker]) + icc_profile = icc_profile[max_data_bytes_in_marker:] i = 1 for marker in markers: - size = o16(2 + ICC_OVERHEAD_LEN + len(marker)) + size = o16(2 + overhead_len + len(marker)) extra += ( - b"\xFF\xE2" + b"\xff\xe2" + size + b"ICC_PROFILE\0" + o8(i) @@ -783,8 +817,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: info.get("keep_rgb", False), info.get("no_default_app_segments", False), info.get("streamtype", 0), - dpi[0], - dpi[1], + dpi, subsampling, info.get("restart_marker_blocks", 0), info.get("restart_marker_rows", 0), @@ -817,7 +850,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Ensure that our buffer is big enough. Same with the icc_profile block. bufsize = max(bufsize, len(exif) + 5, len(extra) + 1) - ImageFile._save(im, fp, [("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize) + ImageFile._save( + im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize + ) def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: @@ -832,7 +867,9 @@ def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ## # Factory for making JPEG and MPO instances -def jpeg_factory(fp: IO[bytes] | None = None, filename: str | bytes | None = None): +def jpeg_factory( + fp: IO[bytes], filename: str | bytes | None = None +) -> JpegImageFile | MpoImageFile: im = JpegImageFile(fp, filename) try: mpheader = im._getmp() diff --git a/src/PIL/McIdasImagePlugin.py b/src/PIL/McIdasImagePlugin.py index 27972236c..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") ## @@ -67,7 +67,9 @@ class McIdasImageFile(ImageFile.ImageFile): offset = w[34] + w[15] stride = w[15] + w[10] * w[11] * w[14] - self.tile = [("raw", (0, 0) + self.size, offset, (rawmode, stride, 1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) + ] # -------------------------------------------------------------------- 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 5ed9f56a1..e08f80b6b 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -26,6 +26,7 @@ from typing import IO, Any, cast from . import ( Image, + ImageFile, ImageSequence, JpegImagePlugin, TiffImagePlugin, @@ -50,7 +51,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): @@ -83,7 +84,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) @@ -145,7 +146,9 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): if self.info.get("exif") != original_exif: self._reload_exif() - self.tile = [("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])] + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1]) + ] self.__frame = frame def tell(self) -> int: diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index 0a75c868b..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 = [("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 = [("MSP", (0, 0) + self.size, 32, None)] + self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32)] class MspDecoder(ImageFile.PyDecoder): @@ -112,7 +112,7 @@ class MspDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: assert self.fd is not None img = io.BytesIO() @@ -152,7 +152,7 @@ class MspDecoder(ImageFile.PyDecoder): msg = f"Corrupted MSP file in row {x}" raise OSError(msg) from e - self.set_as_raw(img.getvalue(), ("1", 0, 1)) + self.set_as_raw(img.getvalue(), "1") return -1, 0 @@ -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, [("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/PSDraw.py b/src/PIL/PSDraw.py index 673eae1d1..02939d26b 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -17,7 +17,7 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING +from typing import IO, TYPE_CHECKING from . import EpsImagePlugin @@ -28,15 +28,12 @@ from . import EpsImagePlugin class PSDraw: """ Sets up printing to the given file. If ``fp`` is omitted, - ``sys.stdout.buffer`` or ``sys.stdout`` is assumed. + ``sys.stdout.buffer`` is assumed. """ - def __init__(self, fp=None): + def __init__(self, fp: IO[bytes] | None = None) -> None: if not fp: - try: - fp = sys.stdout.buffer - except AttributeError: - fp = sys.stdout + fp = sys.stdout.buffer self.fp = fp def begin_document(self, id: str | None = None) -> None: 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 1735070f8..b33245376 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -173,6 +173,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> 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 @@ -213,7 +214,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ) # now convert data to raw form - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))] + ) if hasattr(fp, "flush"): fp.flush() diff --git a/src/PIL/PcdImagePlugin.py b/src/PIL/PcdImagePlugin.py index 1cd5c4a9d..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,13 +47,11 @@ class PcdImageFile(ImageFile.ImageFile): self._mode = "RGB" self._size = 768, 512 # FIXME: not correct for rotated images! - self.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: # Handle rotated PCDs - assert self.im is not None - self.im = self.im.rotate(self.tile_post_rotate) self._size = self.im.size diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index dd42003b5..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: @@ -128,7 +128,9 @@ class PcxImageFile(ImageFile.ImageFile): bbox = (0, 0) + self.size logger.debug("size: %sx%s", *self.size) - self.tile = [("pcx", bbox, self.fp.tell(), (rawmode, planes * stride))] + self.tile = [ + ImageFile._Tile("pcx", bbox, self.fp.tell(), (rawmode, planes * stride)) + ] # -------------------------------------------------------------------- @@ -186,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) @@ -198,12 +200,12 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: assert fp.tell() == 128 - ImageFile._save(im, fp, [("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))]) + ImageFile._save( + im, fp, [ImageFile._Tile("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))] + ) if im.mode == "P": # colour palette - assert im.im is not None - fp.write(o8(12)) palette = im.im.getpalette("RGB", "RGB") palette += b"\x00" * (768 - len(palette)) diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index e0f732199..e9c20ddc1 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -25,7 +25,7 @@ import io import math import os import time -from typing import IO +from typing import IO, Any from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features @@ -48,7 +48,12 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # (Internal) Image save plugin for the PDF format. -def _write_image(im, filename, existing_pdf, image_refs): +def _write_image( + im: Image.Image, + filename: str | bytes, + existing_pdf: PdfParser.PdfParser, + image_refs: list[PdfParser.IndirectReference], +) -> tuple[PdfParser.IndirectReference, str]: # FIXME: Should replace ASCIIHexDecode with RunLengthDecode # (packbits) or LZWDecode (tiff/lzw compression). Note that # PDF 1.2 also supports Flatedecode (zip compression). @@ -61,10 +66,10 @@ def _write_image(im, filename, existing_pdf, image_refs): width, height = im.size - dict_obj = {"BitsPerComponent": 8} + dict_obj: dict[str, Any] = {"BitsPerComponent": 8} if im.mode == "1": if features.check("libtiff"): - filter = "CCITTFaxDecode" + decode_filter = "CCITTFaxDecode" dict_obj["BitsPerComponent"] = 1 params = PdfParser.PdfArray( [ @@ -79,22 +84,23 @@ def _write_image(im, filename, existing_pdf, image_refs): ] ) else: - filter = "DCTDecode" + decode_filter = "DCTDecode" dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") procset = "ImageB" # grayscale elif im.mode == "L": - filter = "DCTDecode" + decode_filter = "DCTDecode" # params = f"<< /Predictor 15 /Columns {width-2} >>" dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") procset = "ImageB" # grayscale elif im.mode == "LA": - filter = "JPXDecode" + decode_filter = "JPXDecode" # params = f"<< /Predictor 15 /Columns {width-2} >>" procset = "ImageB" # grayscale dict_obj["SMaskInData"] = 1 elif im.mode == "P": - filter = "ASCIIHexDecode" + decode_filter = "ASCIIHexDecode" palette = im.getpalette() + assert palette is not None dict_obj["ColorSpace"] = [ PdfParser.PdfName("Indexed"), PdfParser.PdfName("DeviceRGB"), @@ -110,15 +116,15 @@ def _write_image(im, filename, existing_pdf, image_refs): image_ref = _write_image(smask, filename, existing_pdf, image_refs)[0] dict_obj["SMask"] = image_ref elif im.mode == "RGB": - filter = "DCTDecode" + decode_filter = "DCTDecode" dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB") procset = "ImageC" # color images elif im.mode == "RGBA": - filter = "JPXDecode" + decode_filter = "JPXDecode" procset = "ImageC" # color images dict_obj["SMaskInData"] = 1 elif im.mode == "CMYK": - filter = "DCTDecode" + decode_filter = "DCTDecode" dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK") procset = "ImageC" # color images decode = [1, 0, 1, 0, 1, 0, 1, 0] @@ -131,9 +137,9 @@ def _write_image(im, filename, existing_pdf, image_refs): op = io.BytesIO() - if filter == "ASCIIHexDecode": - ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)]) - elif filter == "CCITTFaxDecode": + if decode_filter == "ASCIIHexDecode": + ImageFile._save(im, op, [ImageFile._Tile("hex", (0, 0) + im.size, 0, im.mode)]) + elif decode_filter == "CCITTFaxDecode": im.save( op, "TIFF", @@ -141,21 +147,22 @@ def _write_image(im, filename, existing_pdf, image_refs): # use a single strip strip_size=math.ceil(width / 8) * height, ) - elif filter == "DCTDecode": + elif decode_filter == "DCTDecode": Image.SAVE["JPEG"](im, op, filename) - elif filter == "JPXDecode": + elif decode_filter == "JPXDecode": del dict_obj["BitsPerComponent"] Image.SAVE["JPEG2000"](im, op, filename) else: - msg = f"unsupported PDF filter ({filter})" + msg = f"unsupported PDF filter ({decode_filter})" raise ValueError(msg) stream = op.getvalue() - if filter == "CCITTFaxDecode": + filter: PdfParser.PdfArray | PdfParser.PdfName + if decode_filter == "CCITTFaxDecode": stream = stream[8:] - filter = PdfParser.PdfArray([PdfParser.PdfName(filter)]) + filter = PdfParser.PdfArray([PdfParser.PdfName(decode_filter)]) else: - filter = PdfParser.PdfName(filter) + filter = PdfParser.PdfName(decode_filter) image_ref = image_refs.pop(0) existing_pdf.write_obj( 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 887b6568b..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,7 +61,7 @@ class PixarImageFile(ImageFile.ImageFile): # FIXME: to be continued... # create tile descriptor (assuming "dumped") - self.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 58db7777c..4fc6217e1 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -38,8 +38,9 @@ import re import struct import warnings import zlib +from collections.abc import Callable from enum import IntEnum -from typing import IO, TYPE_CHECKING, Any, NamedTuple, NoReturn +from typing import IO, TYPE_CHECKING, Any, NamedTuple, NoReturn, cast from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from ._binary import i16be as i16 @@ -135,11 +136,11 @@ class Blend(IntEnum): """ -def _safe_zlib_decompress(s): +def _safe_zlib_decompress(s: bytes) -> bytes: dobj = zlib.decompressobj() plaintext = dobj.decompress(s, MAX_TEXT_CHUNK) if dobj.unconsumed_tail: - msg = "Decompressed Data Too Large" + msg = "Decompressed data too large for PngImagePlugin.MAX_TEXT_CHUNK" raise ValueError(msg) return plaintext @@ -257,7 +258,9 @@ class iTXt(str): tkey: str | bytes | None @staticmethod - def __new__(cls, text, lang=None, tkey=None): + def __new__( + cls, text: str, lang: str | None = None, tkey: str | None = None + ) -> iTXt: """ :param cls: the class to use when creating the instance :param text: value for this key @@ -367,21 +370,27 @@ class PngInfo: # PNG image stream (IHDR/IEND) +class _RewindState(NamedTuple): + info: dict[str | tuple[int, int], Any] + tile: list[ImageFile._Tile] + seq_num: int | None + + class PngStream(ChunkStream): - def __init__(self, fp): + def __init__(self, fp: IO[bytes]) -> None: super().__init__(fp) # local copies of Image attributes - self.im_info = {} - self.im_text = {} + self.im_info: dict[str | tuple[int, int], Any] = {} + self.im_text: dict[str, str | iTXt] = {} self.im_size = (0, 0) - self.im_mode = None - self.im_tile = None - self.im_palette = None - self.im_custom_mimetype = None - self.im_n_frames = None - self._seq_num = None - self.rewind_state = None + self.im_mode = "" + self.im_tile: list[ImageFile._Tile] = [] + self.im_palette: tuple[str, bytes] | None = None + self.im_custom_mimetype: str | None = None + self.im_n_frames: int | None = None + self._seq_num: int | None = None + self.rewind_state = _RewindState({}, [], None) self.text_memory = 0 @@ -395,16 +404,16 @@ class PngStream(ChunkStream): raise ValueError(msg) def save_rewind(self) -> None: - self.rewind_state = { - "info": self.im_info.copy(), - "tile": self.im_tile, - "seq_num": self._seq_num, - } + self.rewind_state = _RewindState( + self.im_info.copy(), + self.im_tile, + self._seq_num, + ) def rewind(self) -> None: - self.im_info = self.rewind_state["info"].copy() - self.im_tile = self.rewind_state["tile"] - self._seq_num = self.rewind_state["seq_num"] + self.im_info = self.rewind_state.info.copy() + self.im_tile = self.rewind_state.tile + self._seq_num = self.rewind_state.seq_num def chunk_iCCP(self, pos: int, length: int) -> bytes: # ICC profile @@ -458,11 +467,11 @@ class PngStream(ChunkStream): def chunk_IDAT(self, pos: int, length: int) -> NoReturn: # image data if "bbox" in self.im_info: - tile = [("zip", self.im_info["bbox"], pos, self.im_rawmode)] + tile = [ImageFile._Tile("zip", self.im_info["bbox"], pos, self.im_rawmode)] else: if self.im_n_frames is not None: self.im_info["default_image"] = True - tile = [("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] + tile = [ImageFile._Tile("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] self.im_tile = tile self.im_idat = length msg = "image data found" @@ -514,7 +523,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 @@ -731,7 +740,7 @@ class PngStream(ChunkStream): def _accept(prefix: bytes) -> bool: - return prefix[:8] == _MAGIC + return prefix.startswith(_MAGIC) ## @@ -783,7 +792,7 @@ class PngImageFile(ImageFile.ImageFile): self._mode = self.png.im_mode self._size = self.png.im_size self.info = self.png.im_info - self._text = None + self._text: dict[str, str | iTXt] | None = None self.tile = self.png.im_tile self.custom_mimetype = self.png.im_custom_mimetype self.n_frames = self.png.im_n_frames or 1 @@ -810,7 +819,7 @@ class PngImageFile(ImageFile.ImageFile): self.is_animated = self.n_frames > 1 @property - def text(self): + def text(self) -> dict[str, str | iTXt]: # experimental if self._text is None: # iTxt, tEXt and zTXt chunks may appear at the end of the file @@ -822,6 +831,7 @@ class PngImageFile(ImageFile.ImageFile): self.load() if self.is_animated: self.seek(frame) + assert self._text is not None return self._text def verify(self) -> None: @@ -861,12 +871,13 @@ class PngImageFile(ImageFile.ImageFile): assert self.png is not None self.dispose: _imaging.ImagingCore | None + dispose_extent = None if frame == 0: if rewind: self._fp.seek(self.__rewind) self.png.rewind() self.__prepare_idat = self.__rewind_idat - self.im = None + self._im = None self.info = self.png.im_info self.tile = self.png.im_tile self.fp = self._fp @@ -875,7 +886,7 @@ class PngImageFile(ImageFile.ImageFile): self.default_image = self.info.get("default_image", False) self.dispose_op = self.info.get("disposal") self.blend_op = self.info.get("blend") - self.dispose_extent = self.info.get("bbox") + dispose_extent = self.info.get("bbox") self.__frame = 0 else: if frame != self.__frame + 1: @@ -933,11 +944,13 @@ class PngImageFile(ImageFile.ImageFile): self.tile = self.png.im_tile self.dispose_op = self.info.get("disposal") self.blend_op = self.info.get("blend") - self.dispose_extent = self.info.get("bbox") + dispose_extent = self.info.get("bbox") if not self.tile: msg = "image not found in APNG frame" raise EOFError(msg) + if dispose_extent: + self.dispose_extent: tuple[float, float, float, float] = dispose_extent # setup frame disposal (actual disposal done when needed in the next _seek()) if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: @@ -1050,6 +1063,12 @@ class PngImageFile(ImageFile.ImageFile): "RGBA", self.info["transparency"] ) else: + if self.im.mode == "P" and "transparency" in self.info: + t = self.info["transparency"] + if isinstance(t, bytes): + updated.putpalettealphas(t) + elif isinstance(t, int): + updated.putpalettealpha(t) mask = updated.convert("RGBA") self._prev_im.paste(updated, self.dispose_extent, mask) self.im = self._prev_im @@ -1105,7 +1124,7 @@ def putchunk(fp: IO[bytes], cid: bytes, *data: bytes) -> None: class _idat: # wrap output from the encoder in IDAT chunks - def __init__(self, fp, chunk) -> None: + def __init__(self, fp: IO[bytes], chunk: Callable[..., None]) -> None: self.fp = fp self.chunk = chunk @@ -1116,7 +1135,7 @@ class _idat: class _fdat: # wrap encoder output in fdAT chunks - def __init__(self, fp: IO[bytes], chunk, seq_num: int) -> None: + def __init__(self, fp: IO[bytes], chunk: Callable[..., None], seq_num: int) -> None: self.fp = fp self.chunk = chunk self.seq_num = seq_num @@ -1135,7 +1154,7 @@ class _Frame(NamedTuple): def _write_multiple_frames( im: Image.Image, fp: IO[bytes], - chunk, + chunk: Callable[..., None], mode: str, rawmode: str, default_image: Image.Image | None, @@ -1221,7 +1240,11 @@ def _write_multiple_frames( if default_image: if im.mode != mode: im = im.convert(mode) - ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) + ImageFile._save( + im, + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + im.size, 0, rawmode)], + ) seq_num = 0 for frame, frame_data in enumerate(im_frames): @@ -1256,15 +1279,15 @@ def _write_multiple_frames( # first frame must be in IDAT chunks for backwards compatibility ImageFile._save( im_frame, - _idat(fp, chunk), - [("zip", (0, 0) + im_frame.size, 0, rawmode)], + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], ) else: fdat_chunks = _fdat(fp, chunk, seq_num) ImageFile._save( im_frame, - fdat_chunks, - [("zip", (0, 0) + im_frame.size, 0, rawmode)], + cast(IO[bytes], fdat_chunks), + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], ) seq_num = fdat_chunks.seq_num return None @@ -1275,7 +1298,11 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def _save( - im: Image.Image, fp, filename: str | bytes, chunk=putchunk, save_all: bool = False + im: Image.Image, + fp: IO[bytes], + filename: str | bytes, + chunk: Callable[..., None] = putchunk, + save_all: bool = False, ) -> None: # save an image to disk (called by the save method) @@ -1355,7 +1382,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: @@ -1406,7 +1433,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)) @@ -1459,7 +1486,9 @@ def _save( ) if single_im: ImageFile._save( - single_im, _idat(fp, chunk), [("zip", (0, 0) + single_im.size, 0, rawmode)] + single_im, + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + single_im.size, 0, rawmode)], ) if info: @@ -1483,22 +1512,16 @@ def _save( def getchunks(im: Image.Image, **params: Any) -> list[tuple[bytes, bytes, bytes]]: """Return a list of PNG chunks representing this image.""" + from io import BytesIO - class collector: - data = [] + chunks = [] - def write(self, data: bytes) -> None: - pass - - def append(self, chunk: tuple[bytes, bytes, bytes]) -> None: - self.data.append(chunk) - - def append(fp: collector, cid: bytes, *data: bytes) -> None: + def append(fp: IO[bytes], cid: bytes, *data: bytes) -> None: byte_data = b"".join(data) crc = o32(_crc32(byte_data, _crc32(cid))) - fp.append((cid, byte_data, crc)) + chunks.append((cid, byte_data, crc)) - fp = collector() + fp = BytesIO() try: im.encoderinfo = params @@ -1506,7 +1529,7 @@ def getchunks(im: Image.Image, **params: Any) -> list[tuple[bytes, bytes, bytes] finally: del im.encoderinfo - return fp.data + return chunks # -------------------------------------------------------------------- diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 16c9ccbba..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" ## @@ -151,7 +151,9 @@ class PpmImageFile(ImageFile.ImageFile): decoder_name = "ppm" args = rawmode if decoder_name == "raw" else (rawmode, maxval) - self.tile = [(decoder_name, (0, 0) + self.size, self.fp.tell(), args)] + self.tile = [ + ImageFile._Tile(decoder_name, (0, 0) + self.size, self.fp.tell(), args) + ] # @@ -228,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: @@ -282,7 +284,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder): break return data - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: self._comment_spans = False if self.mode == "1": data = self._decode_bitonal() @@ -298,7 +300,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder): class PpmDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: assert self.fd is not None data = bytearray() @@ -333,7 +335,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: rawmode, head = "1;I", b"P4" elif im.mode == "L": rawmode, head = "L", b"P5" - elif im.mode == "I": + elif im.mode in ("I", "I;16"): rawmode, head = "I;16B", b"P5" elif im.mode in ("RGB", "RGBA"): rawmode, head = "RGB", b"P6" @@ -353,7 +355,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: elif head == b"Pf": fp.write(b"-1.0\n") row_order = -1 if im.mode == "F" else 1 - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))] + ) # diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index cc99cd6d8..0aada8a06 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -47,7 +47,7 @@ MODES = { def _accept(prefix: bytes) -> bool: - return prefix[:4] == b"8BPS" + return prefix.startswith(b"8BPS") ## @@ -169,15 +169,11 @@ class PsdImageFile(ImageFile.ImageFile): return # 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) @@ -277,8 +273,8 @@ def _layerinfo( def _maketile( file: IO[bytes], mode: str, bbox: tuple[int, int, int, int], channels: int -) -> list[ImageFile._Tile] | None: - tiles = None +) -> list[ImageFile._Tile]: + tiles = [] read = file.read compression = i16(read(2)) @@ -291,7 +287,6 @@ def _maketile( if compression == 0: # # raw compression - tiles = [] for channel in range(channels): layer = mode[channel] if mode == "CMYK": @@ -303,7 +298,6 @@ def _maketile( # # packbits compression i = 0 - tiles = [] bytecount = read(channels * ysize * 2) offset = file.tell() for channel in range(channels): diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index 202ef52d0..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): @@ -26,13 +26,13 @@ class QoiImageFile(ImageFile.ImageFile): msg = "not a QOI file" raise SyntaxError(msg) - self._size = tuple(i32(self.fp.read(4)) for i in range(2)) + self._size = i32(self.fp.read(4)), i32(self.fp.read(4)) channels = self.fp.read(1)[0] self._mode = "RGB" if channels == 3 else "RGBA" self.fp.seek(1, os.SEEK_CUR) # colorspace - self.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): @@ -47,7 +47,7 @@ class QoiDecoder(ImageFile.PyDecoder): hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 self._previously_seen_pixels[hash_value] = value - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: assert self.fd is not None self._previously_seen_pixels = {} diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index 50d979109..44254b7a4 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -109,19 +109,28 @@ class SgiImageFile(ImageFile.ImageFile): pagesize = xsize * ysize * bpc if bpc == 2: self.tile = [ - ("SGI16", (0, 0) + self.size, headlen, (self.mode, 0, orientation)) + ImageFile._Tile( + "SGI16", + (0, 0) + self.size, + headlen, + (self.mode, 0, orientation), + ) ] else: self.tile = [] offset = headlen for layer in self.mode: self.tile.append( - ("raw", (0, 0) + self.size, offset, (layer, 0, orientation)) + ImageFile._Tile( + "raw", (0, 0) + self.size, offset, (layer, 0, orientation) + ) ) offset += pagesize elif compression == 1: self.tile = [ - ("sgi_rle", (0, 0) + self.size, headlen, (rawmode, orientation, bpc)) + ImageFile._Tile( + "sgi_rle", (0, 0) + self.size, headlen, (rawmode, orientation, bpc) + ) ] @@ -205,7 +214,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: class SGI16Decoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer: bytes) -> tuple[int, int]: + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: assert self.fd is not None assert self.im is not None diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index a07101e54..b26e1a996 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -154,7 +154,7 @@ class SpiderImageFile(ImageFile.ImageFile): self.rawmode = "F;32F" self._mode = "F" - self.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 @@ -209,26 +209,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 # -------------------------------------------------------------------- @@ -266,7 +267,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) @@ -278,7 +279,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, [("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/SunImagePlugin.py b/src/PIL/SunImagePlugin.py index 4e098474a..8912379ea 100644 --- a/src/PIL/SunImagePlugin.py +++ b/src/PIL/SunImagePlugin.py @@ -124,9 +124,13 @@ class SunImageFile(ImageFile.ImageFile): # (https://www.fileformat.info/format/sunraster/egff.htm) if file_type in (0, 1, 3, 4, 5): - self.tile = [("raw", (0, 0) + self.size, offset, (rawmode, stride))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride)) + ] elif file_type == 2: - self.tile = [("sun_rle", (0, 0) + self.size, offset, rawmode)] + self.tile = [ + ImageFile._Tile("sun_rle", (0, 0) + self.size, offset, rawmode) + ] else: msg = "Unsupported Sun Raster file type" raise SyntaxError(msg) diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 39104aece..90d5b5cf4 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -137,7 +137,7 @@ class TgaImageFile(ImageFile.ImageFile): if imagetype & 8: # compressed self.tile = [ - ( + ImageFile._Tile( "tga_rle", (0, 0) + self.size, self.fp.tell(), @@ -146,7 +146,7 @@ class TgaImageFile(ImageFile.ImageFile): ] else: self.tile = [ - ( + ImageFile._Tile( "raw", (0, 0) + self.size, self.fp.tell(), @@ -158,7 +158,6 @@ class TgaImageFile(ImageFile.ImageFile): def load_end(self) -> None: if self._flip_horizontally: - assert self.im is not None self.im = self.im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) @@ -200,7 +199,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: warnings.warn("id_section has been trimmed to 255 characters") if colormaptype: - assert im.im is not None palette = im.im.getpalette("RGB", "BGR") colormaplength, colormapentry = len(palette) // 3, 24 else: @@ -238,11 +236,15 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if rle: ImageFile._save( - im, fp, [("tga_rle", (0, 0) + im.size, 0, (rawmode, orientation))] + im, + fp, + [ImageFile._Tile("tga_rle", (0, 0) + im.size, 0, (rawmode, orientation))], ) else: ImageFile._save( - im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, orientation))] + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, orientation))], ) # write targa version 2 footer diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index ad050ffe4..3d36d1abc 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -47,24 +47,28 @@ import math import os import struct import warnings -from collections.abc import MutableMapping +from collections.abc import Iterator, MutableMapping from fractions import Fraction from numbers import Number, Rational -from typing import IO, TYPE_CHECKING, Any, Callable, NoReturn +from typing import IO, TYPE_CHECKING, Any, Callable, NoReturn, cast from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import o8 from ._deprecate import deprecate +from ._typing import StrOrBytesPath +from ._util import is_path from .TiffTags import TYPES +if TYPE_CHECKING: + from ._typing import Buffer, IntegralLike + logger = logging.getLogger(__name__) # Set these to true to force use of libtiff for reading or writing. READ_LIBTIFF = False WRITE_LIBTIFF = False -IFD_LEGACY_API = True STRIP_SIZE = 65536 II = b"II" # little-endian (Intel style) @@ -257,6 +261,7 @@ OPEN_INFO = { (II, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), (MM, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), (II, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16L"), + (MM, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16B"), (II, 6, (1,), 1, (8,), ()): ("L", "L"), (MM, 6, (1,), 1, (8,), ()): ("L", "L"), # JPEG compressed images handled by LibTiff and auto-converted to RGBX @@ -270,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): @@ -283,25 +288,29 @@ 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(val, max_val): +def _limit_rational( + val: float | Fraction | IFDRational, max_val: int +) -> tuple[IntegralLike, IntegralLike]: inv = abs(val) > 1 n_d = IFDRational(1 / val if inv else val).limit_rational(max_val) return n_d[::-1] if inv else n_d -def _limit_signed_rational(val, max_val, min_val): +def _limit_signed_rational( + val: IFDRational, max_val: int, min_val: int +) -> tuple[IntegralLike, IntegralLike]: frac = Fraction(val) - n_d = frac.numerator, frac.denominator + n_d: tuple[IntegralLike, IntegralLike] = frac.numerator, frac.denominator - if min(n_d) < min_val: + if min(float(i) for i in n_d) < min_val: n_d = _limit_rational(val, abs(min_val)) - if max(n_d) > max_val: - val = Fraction(*n_d) - n_d = _limit_rational(val, max_val) + n_d_float = tuple(float(i) for i in n_d) + if max(n_d_float) > max_val: + n_d = _limit_rational(n_d_float[0] / n_d_float[1], max_val) return n_d @@ -313,8 +322,10 @@ _load_dispatch = {} _write_dispatch = {} -def _delegate(op): - def delegate(self, *args): +def _delegate(op: str) -> Any: + def delegate( + self: IFDRational, *args: tuple[float, ...] + ) -> bool | float | Fraction: return getattr(self._val, op)(*args) return delegate @@ -334,7 +345,9 @@ class IFDRational(Rational): __slots__ = ("_numerator", "_denominator", "_val") - def __init__(self, value, denominator: int = 1) -> None: + def __init__( + self, value: float | Fraction | IFDRational, denominator: int = 1 + ) -> None: """ :param value: either an integer numerator, a float/rational/other number, or an IFDRational @@ -351,25 +364,30 @@ class IFDRational(Rational): self._numerator = value.numerator self._denominator = value.denominator else: - self._numerator = value + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, value) + else: + self._numerator = value self._denominator = denominator if denominator == 0: self._val = float("nan") elif denominator == 1: self._val = Fraction(value) + elif int(value) == value: + self._val = Fraction(int(value), denominator) else: - self._val = Fraction(value, denominator) + self._val = Fraction(value / denominator) @property - def numerator(self): + def numerator(self) -> IntegralLike: return self._numerator @property - def denominator(self): + def denominator(self) -> int: return self._denominator - def limit_rational(self, max_denominator): + def limit_rational(self, max_denominator: int) -> tuple[IntegralLike, int]: """ :param max_denominator: Integer, the maximum denominator value @@ -379,13 +397,14 @@ class IFDRational(Rational): if self.denominator == 0: return self.numerator, self.denominator + assert isinstance(self._val, Fraction) f = self._val.limit_denominator(max_denominator) return f.numerator, f.denominator 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: @@ -396,14 +415,19 @@ class IFDRational(Rational): val = float(val) return val == other - def __getstate__(self): + def __getstate__(self) -> list[float | Fraction | IntegralLike]: return [self._val, self._numerator, self._denominator] - def __setstate__(self, state): + def __setstate__(self, state: list[float | Fraction | IntegralLike]) -> None: IFDRational.__init__(self, 0) _val, _numerator, _denominator = state + assert isinstance(_val, (float, Fraction)) self._val = _val - self._numerator = _numerator + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, _numerator) + else: + self._numerator = _numerator + assert isinstance(_denominator, int) self._denominator = _denominator """ a = ['add','radd', 'sub', 'rsub', 'mul', 'rmul', @@ -445,8 +469,11 @@ class IFDRational(Rational): __int__ = _delegate("__int__") -def _register_loader(idx: int, size: int): - def decorator(func): +_LoaderFunc = Callable[["ImageFileDirectory_v2", bytes, bool], Any] + + +def _register_loader(idx: int, size: int) -> Callable[[_LoaderFunc], _LoaderFunc]: + def decorator(func: _LoaderFunc) -> _LoaderFunc: from .TiffTags import TYPES if func.__name__.startswith("load_"): @@ -457,8 +484,8 @@ def _register_loader(idx: int, size: int): return decorator -def _register_writer(idx: int): - def decorator(func): +def _register_writer(idx: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: _write_dispatch[idx] = func # noqa: F821 return func @@ -471,12 +498,13 @@ def _register_basic(idx_fmt_name: tuple[int, str, str]) -> None: idx, fmt, name = idx_fmt_name TYPES[idx] = name size = struct.calcsize(f"={fmt}") - _load_dispatch[idx] = ( # noqa: F821 - size, - lambda self, data, legacy_api=True: ( - self._unpack(f"{len(data) // size}{fmt}", data) - ), - ) + + def basic_handler( + self: ImageFileDirectory_v2, data: bytes, legacy_api: bool = True + ) -> tuple[Any, ...]: + return self._unpack(f"{len(data) // size}{fmt}", data) + + _load_dispatch[idx] = size, basic_handler # noqa: F821 _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 b"".join(self._pack(fmt, value) for value in values) ) @@ -549,12 +577,12 @@ class ImageFileDirectory_v2(_IFDv2Base): """ - _load_dispatch: dict[int, Callable[[ImageFileDirectory_v2, bytes, bool], Any]] = {} + _load_dispatch: dict[int, tuple[int, _LoaderFunc]] = {} _write_dispatch: dict[int, Callable[..., Any]] = {} 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: @@ -609,12 +637,12 @@ class ImageFileDirectory_v2(_IFDv2Base): self._tagdata: dict[int, bytes] = {} self.tagtype = {} # added 2008-06-05 by Florian Hoech self._next = None - self._offset = None + self._offset: int | None = None def __str__(self) -> str: return str(dict(self)) - def named(self): + def named(self) -> dict[str, Any]: """ :returns: dict of name|key: value @@ -628,7 +656,7 @@ class ImageFileDirectory_v2(_IFDv2Base): def __len__(self) -> int: return len(set(self._tagdata) | set(self._tags_v2)) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if tag not in self._tags_v2: # unpack on the fly data = self._tagdata[tag] typ = self.tagtype[tag] @@ -642,10 +670,10 @@ class ImageFileDirectory_v2(_IFDv2Base): def __contains__(self, tag: object) -> bool: return tag in self._tags_v2 or tag in self._tagdata - def __setitem__(self, tag: int, value) -> None: + def __setitem__(self, tag: int, value: Any) -> None: self._setitem(tag, value, self.legacy_api) - def _setitem(self, tag: int, value, legacy_api: bool) -> None: + def _setitem(self, tag: int, value: Any, legacy_api: bool) -> None: basetypes = (Number, bytes, str) info = TiffTags.lookup(tag, self.group) @@ -657,22 +685,33 @@ class ImageFileDirectory_v2(_IFDv2Base): else: self.tagtype[tag] = TiffTags.UNDEFINED if all(isinstance(v, IFDRational) for v in values): - self.tagtype[tag] = ( - TiffTags.RATIONAL - if all(v >= 0 for v in values) - else TiffTags.SIGNED_RATIONAL - ) - elif all(isinstance(v, int) for v in values): - if all(0 <= v < 2**16 for v in values): - self.tagtype[tag] = TiffTags.SHORT - elif all(-(2**15) < v < 2**15 for v in values): - self.tagtype[tag] = TiffTags.SIGNED_SHORT + for v in values: + assert isinstance(v, IFDRational) + if v < 0: + self.tagtype[tag] = TiffTags.SIGNED_RATIONAL + break else: - self.tagtype[tag] = ( - TiffTags.LONG - if all(v >= 0 for v in values) - else TiffTags.SIGNED_LONG - ) + self.tagtype[tag] = TiffTags.RATIONAL + elif all(isinstance(v, int) for v in values): + short = True + signed_short = True + long = True + for v in values: + assert isinstance(v, int) + if short and not (0 <= v < 2**16): + short = False + if signed_short and not (-(2**15) < v < 2**15): + signed_short = False + if long and v < 0: + long = False + if short: + self.tagtype[tag] = TiffTags.SHORT + elif signed_short: + self.tagtype[tag] = TiffTags.SIGNED_SHORT + elif long: + self.tagtype[tag] = TiffTags.LONG + else: + self.tagtype[tag] = TiffTags.SIGNED_LONG elif all(isinstance(v, float) for v in values): self.tagtype[tag] = TiffTags.DOUBLE elif all(isinstance(v, str) for v in values): @@ -690,7 +729,10 @@ class ImageFileDirectory_v2(_IFDv2Base): is_ifd = self.tagtype[tag] == TiffTags.LONG and isinstance(values, dict) if not is_ifd: - values = tuple(info.cvt_enum(value) for value in values) + values = tuple( + info.cvt_enum(value) if isinstance(value, str) else value + for value in values + ) dest = self._tags_v1 if legacy_api else self._tags_v2 @@ -730,13 +772,13 @@ class ImageFileDirectory_v2(_IFDv2Base): self._tags_v1.pop(tag, None) self._tagdata.pop(tag, None) - def __iter__(self): + def __iter__(self) -> Iterator[int]: return iter(set(self._tagdata) | set(self._tags_v2)) - def _unpack(self, fmt: str, data: bytes): + def _unpack(self, fmt: str, data: bytes) -> tuple[Any, ...]: return struct.unpack(self._endian + fmt, data) - def _pack(self, fmt: str, *values): + def _pack(self, fmt: str, *values: Any) -> bytes: return struct.pack(self._endian + fmt, *values) list( @@ -784,16 +826,18 @@ class ImageFileDirectory_v2(_IFDv2Base): return value + b"\0" @_register_loader(5, 8) - def load_rational(self, data, legacy_api: bool = True): + def load_rational( + self, data: bytes, legacy_api: bool = True + ) -> tuple[tuple[int, int] | IFDRational, ...]: vals = self._unpack(f"{len(data) // 4}L", data) - def combine(a, b): + def combine(a: int, b: int) -> tuple[int, int] | IFDRational: return (a, b) if legacy_api else IFDRational(a, b) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @_register_writer(5) - def write_rational(self, *values) -> bytes: + def write_rational(self, *values: IFDRational) -> bytes: return b"".join( self._pack("2L", *_limit_rational(frac, 2**32 - 1)) for frac in values ) @@ -811,16 +855,18 @@ class ImageFileDirectory_v2(_IFDv2Base): return value @_register_loader(10, 8) - def load_signed_rational(self, data: bytes, legacy_api: bool = True): + def load_signed_rational( + self, data: bytes, legacy_api: bool = True + ) -> tuple[tuple[int, int] | IFDRational, ...]: vals = self._unpack(f"{len(data) // 4}l", data) - def combine(a, b): + def combine(a: int, b: int) -> tuple[int, int] | IFDRational: return (a, b) if legacy_api else IFDRational(a, b) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @_register_writer(10) - def write_signed_rational(self, *values) -> bytes: + def write_signed_rational(self, *values: IFDRational) -> bytes: return b"".join( self._pack("2l", *_limit_signed_rational(frac, 2**31 - 1, -(2**31))) for frac in values @@ -836,7 +882,7 @@ class ImageFileDirectory_v2(_IFDv2Base): raise OSError(msg) return ret - def load(self, fp): + def load(self, fp: IO[bytes]) -> None: self.reset() self._offset = fp.tell() @@ -889,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,) = ( @@ -903,12 +949,25 @@ class ImageFileDirectory_v2(_IFDv2Base): warnings.warn(str(msg)) return - def tobytes(self, offset=0): - # FIXME What about tagdata? - result = self._pack("H", len(self._tags_v2)) + 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) - entries = [] - offset = offset + len(result) + len(self._tags_v2) * 12 + 4 + return ifh + + def tobytes(self, offset: int = 0) -> bytes: + # FIXME What about tagdata? + result = self._pack("Q" if self._bigtiff else "H", len(self._tags_v2)) + + entries: list[tuple[int, int, int, bytes, bytes]] = [] + + 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 @@ -916,15 +975,11 @@ class ImageFileDirectory_v2(_IFDv2Base): for tag, value in sorted(self._tags_v2.items()): if tag == STRIPOFFSETS: stripoffsets = len(entries) - typ = self.tagtype.get(tag) + typ = self.tagtype[tag] 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 @@ -935,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 @@ -949,28 +1002,32 @@ 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 if stripoffsets is not None: tag, typ, count, value, data = entries[stripoffsets] if data: - msg = "multistrip support not yet implemented" - raise NotImplementedError(msg) - value = self._pack("L", self._unpack("L", value)[0] + offset) + size, handler = self._load_dispatch[typ] + values = [val + offset for val in handler(self, data, self.legacy_api)] + data = self._write_dispatch[typ](self, *values) + else: + 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: @@ -982,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) @@ -1020,7 +1076,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): .. deprecated:: 3.0.0 """ - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._legacy_api = True @@ -1072,14 +1128,14 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): def __len__(self) -> int: return len(set(self._tagdata) | set(self._tags_v1)) - def __iter__(self): + def __iter__(self) -> Iterator[int]: return iter(set(self._tagdata) | set(self._tags_v1)) - def __setitem__(self, tag: int, value) -> None: + def __setitem__(self, tag: int, value: Any) -> None: for legacy_api in (False, True): self._setitem(tag, value, legacy_api) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if tag not in self._tags_v1: # unpack on the fly data = self._tagdata[tag] typ = self.tagtype[tag] @@ -1092,7 +1148,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): return val -# undone -- switch this pointer when IFD_LEGACY_API == False +# undone -- switch this pointer ImageFileDirectory = ImageFileDirectory_v1 @@ -1105,11 +1161,15 @@ class TiffImageFile(ImageFile.ImageFile): format_description = "Adobe TIFF" _close_exclusive_fp_after_loading = False - def __init__(self, fp=None, filename=None): - self.tag_v2 = None + def __init__( + self, + fp: StrOrBytesPath | IO[bytes], + filename: str | bytes | None = None, + ) -> None: + self.tag_v2: ImageFileDirectory_v2 """ Image file directory (tag dictionary) """ - self.tag = None + self.tag: ImageFileDirectory_v1 """ Legacy tag entries """ super().__init__(fp, filename) @@ -1124,9 +1184,6 @@ class TiffImageFile(ImageFile.ImageFile): self.tag_v2 = ImageFileDirectory_v2(ifh) - # legacy IFD entries will be filled in later - self.ifd: ImageFileDirectory_v1 | None = None - # setup frame pointers self.__first = self.__next = self.tag_v2.next self.__frame = -1 @@ -1142,13 +1199,15 @@ class TiffImageFile(ImageFile.ImageFile): self._seek(0) @property - def n_frames(self): - if self._n_frames is None: + def n_frames(self) -> int: + current_n_frames = self._n_frames + if current_n_frames is None: current = self.tell() self._seek(len(self._frame_pos)) while self._n_frames is None: self._seek(self.tell() + 1) self.seek(current) + assert self._n_frames is not None return self._n_frames def seek(self, frame: int) -> None: @@ -1156,19 +1215,15 @@ class TiffImageFile(ImageFile.ImageFile): if not self._seek_check(frame): return self._seek(frame) - # Create a new core image object on second and - # subsequent frames in the image. Image may be - # different size/mode. - Image._decompression_bomb_check(self.size) - self.im = Image.core.new(self.mode, self.size) + if self._im is not None and ( + self.im.size != self._tile_size or self.im.mode != self.mode + ): + # The core image will no longer be used + self._im = None def _seek(self, frame: int) -> None: 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" @@ -1225,7 +1280,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]) @@ -1240,16 +1295,18 @@ class TiffImageFile(ImageFile.ImageFile): return self._load_libtiff() return super().load() + def load_prepare(self) -> None: + if self._im is None: + Image._decompression_bomb_check(self._tile_size) + self.im = Image.core.new(self.mode, self._tile_size) + ImageFile.ImageFile.load_prepare(self) + def load_end(self) -> None: # allow closing if we're on the first frame, there's no next # This is the ImageFile.load path only, libtiff specific below. 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: @@ -1276,7 +1333,7 @@ class TiffImageFile(ImageFile.ImageFile): # (self._compression, (extents tuple), # 0, (rawmode, self._compression, fp)) extents = self.tile[0][1] - args = list(self.tile[0][3]) + args = self.tile[0][3] # To be nice on memory footprint, if there's a # file descriptor, use that instead of reading @@ -1294,11 +1351,12 @@ class TiffImageFile(ImageFile.ImageFile): fp = False if fp: - args[2] = fp + assert isinstance(args, tuple) + args_list = list(args) + args_list[2] = fp + args = tuple(args_list) - decoder = Image._getdecoder( - self.mode, "libtiff", tuple(args), self.decoderconfig - ) + decoder = Image._getdecoder(self.mode, "libtiff", args, self.decoderconfig) try: decoder.setimage(self.im, extents) except ValueError as e: @@ -1323,8 +1381,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") @@ -1342,7 +1409,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) @@ -1372,12 +1440,24 @@ class TiffImageFile(ImageFile.ImageFile): logger.debug("- photometric_interpretation: %s", photo) logger.debug("- planar_configuration: %s", self._planar_configuration) logger.debug("- fill_order: %s", fillorder) - logger.debug("- YCbCr subsampling: %s", self.tag.get(YCBCRSUBSAMPLING)) + logger.debug("- YCbCr subsampling: %s", self.tag_v2.get(YCBCRSUBSAMPLING)) # size - xsize = int(self.tag_v2.get(IMAGEWIDTH)) - ysize = int(self.tag_v2.get(IMAGELENGTH)) - self._size = xsize, ysize + 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) + self._tile_size = xsize, ysize + orientation = self.tag_v2.get(ExifTags.Base.Orientation) + if orientation in (5, 6, 7, 8): + self._size = ysize, xsize + else: + self._size = xsize, ysize logger.debug("- size: %s", self.size) @@ -1490,17 +1570,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 ( @@ -1509,23 +1578,35 @@ 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") or rawmode.endswith(";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 a = (rawmode, self._compression, False, self.tag_v2.offset) - self.tile.append(("libtiff", (0, 0, xsize, ysize), 0, a)) + self.tile.append(ImageFile._Tile("libtiff", (0, 0, xsize, ysize), 0, a)) elif STRIPOFFSETS in self.tag_v2 or TILEOFFSETS in self.tag_v2: # striped image if STRIPOFFSETS in self.tag_v2: offsets = self.tag_v2[STRIPOFFSETS] h = self.tag_v2.get(ROWSPERSTRIP, ysize) - w = self.size[0] + w = xsize else: # tiled image offsets = self.tag_v2[TILEOFFSETS] - w = self.tag_v2.get(TILEWIDTH) + tilewidth = self.tag_v2.get(TILEWIDTH) h = self.tag_v2.get(TILELENGTH) + if not isinstance(tilewidth, int) or not isinstance(h, int): + msg = "Invalid tile dimensions" + raise ValueError(msg) + w = tilewidth for offset in offsets: if x + w > xsize: @@ -1542,7 +1623,7 @@ class TiffImageFile(ImageFile.ImageFile): args = (tile_rawmode, int(stride), 1) self.tile.append( - ( + ImageFile._Tile( self._compression, (x, y, min(x + w, xsize), min(y + h, ysize)), offset, @@ -1550,9 +1631,9 @@ class TiffImageFile(ImageFile.ImageFile): ) ) x = x + w - if x >= self.size[0]: + if x >= xsize: x, y = 0, y + h - if y >= self.size[1]: + if y >= ysize: x = y = 0 layer += 1 else: @@ -1603,17 +1684,20 @@ SAVE_INFO = { } -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: rawmode, prefix, photo, format, bits, extra = SAVE_INFO[im.mode] except KeyError as e: 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: @@ -1739,10 +1823,11 @@ def _save(im, fp, filename): if im.mode == "1": inverted_im = im.copy() px = inverted_im.load() - for y in range(inverted_im.height): - for x in range(inverted_im.width): - px[x, y] = 0 if px[x, y] == 255 else 255 - im = inverted_im + if px is not None: + for y in range(inverted_im.height): + for x in range(inverted_im.width): + px[x, y] = 0 if px[x, y] == 255 else 255 + im = inverted_im else: im = ImageOps.invert(im) @@ -1784,11 +1869,11 @@ def _save(im, fp, filename): ifd[COMPRESSION] = COMPRESSION_INFO_REV.get(compression, 1) if im.mode == "YCbCr": - for tag, value in { + for tag, default_value in { YCBCRSUBSAMPLING: (1, 1), REFERENCEBLACKWHITE: (0, 255, 128, 255, 128, 255), }.items(): - ifd.setdefault(tag, value) + ifd.setdefault(tag, default_value) blocklist = [TILEWIDTH, TILELENGTH, TILEOFFSETS, TILEBYTECOUNTS] if libtiff: @@ -1808,7 +1893,7 @@ def _save(im, fp, filename): if hasattr(fp, "fileno"): try: fp.seek(0) - _fp = os.dup(fp.fileno()) + _fp = fp.fileno() except io.UnsupportedOperation: pass @@ -1831,7 +1916,7 @@ def _save(im, fp, filename): ] # bits per sample is a single short in the tiff directory, not a list. - atts = {BITSPERSAMPLE: bits[0]} + atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]} # Merge the ones that we have with (optional) more bits from # the original file, e.g x,y resolution so that we can # save(load('')) == original file. @@ -1844,7 +1929,9 @@ def _save(im, fp, filename): 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 @@ -1881,17 +1968,11 @@ def _save(im, fp, filename): encoder = Image._getencoder(im.mode, "libtiff", a, encoderconfig) encoder.setimage(im.im, (0, 0) + im.size) while True: - # undone, change to self.decodermaxblock: - errcode, data = encoder.encode(16 * 1024)[1:] + errcode, data = encoder.encode(ImageFile.MAXBLOCK)[1:] if not _fp: fp.write(data) if errcode: break - if _fp: - try: - os.close(_fp) - except OSError: - pass if errcode < 0: msg = f"encoder error {errcode} when writing image file" raise OSError(msg) @@ -1902,16 +1983,18 @@ def _save(im, fp, filename): offset = ifd.save(fp) ImageFile._save( - im, fp, [("raw", (0, 0) + im.size, offset, (rawmode, stride, 1))] + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, offset, (rawmode, stride, 1))], ) # -- helper for multi-page save -- if "_debug_multipage" in encoderinfo: # just to access o32 and o16 (using correct byte order) - im._debug_multipage = ifd + setattr(im, "_debug_multipage", ifd) -class AppendingTiffWriter: +class AppendingTiffWriter(io.BytesIO): fieldSizes = [ 0, # None 1, # byte @@ -1941,17 +2024,18 @@ class AppendingTiffWriter: 521, # JPEGACTables } - def __init__(self, fn, new: bool = False) -> None: - if hasattr(fn, "read"): - self.f = fn - self.close_fp = False - else: + def __init__(self, fn: StrOrBytesPath | IO[bytes], new: bool = False) -> None: + self.f: IO[bytes] + if is_path(fn): self.name = fn self.close_fp = True try: self.f = open(fn, "w+b" if new else "r+b") except OSError: self.f = open(fn, "w+b") + else: + self.f = cast(IO[bytes], fn) + self.close_fp = False self.beginning = self.f.tell() self.setup() @@ -1959,24 +2043,25 @@ class AppendingTiffWriter: # Reset everything. self.f.seek(self.beginning, os.SEEK_SET) - self.whereToWriteNewIFDOffset = None + self.whereToWriteNewIFDOffset: int | None = None 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() @@ -1996,10 +2081,13 @@ class AppendingTiffWriter: 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() @@ -2018,7 +2106,13 @@ class AppendingTiffWriter: def tell(self) -> int: return self.f.tell() - self.offsetOfNewPage - def seek(self, offset: int, whence=io.SEEK_SET) -> int: + def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: + """ + :param offset: Distance to seek. + :param whence: Whether the distance is relative to the start, + end or current position. + :returns: The resulting position, relative to the start. + """ if whence == os.SEEK_SET: offset += self.offsetOfNewPage @@ -2039,128 +2133,165 @@ class AppendingTiffWriter: 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: bytes) -> int | None: + def write(self, data: Buffer, /) -> int: return self.f.write(data) - def readShort(self) -> int: - (value,) = struct.unpack(self.shortFmt, self.f.read(2)) + def _fmt(self, field_size: int) -> str: + try: + return {2: "H", 4: "L", 8: "Q"}[field_size] + except KeyError: + msg = "offset is not supported" + raise RuntimeError(msg) + + def _read(self, field_size: int) -> int: + (value,) = struct.unpack( + self.endian + self._fmt(field_size), self.f.read(field_size) + ) return value + def readShort(self) -> int: + return self._read(2) + def readLong(self) -> int: - (value,) = struct.unpack(self.longFmt, self.f.read(4)) - return value + return self._read(4) + + @staticmethod + def _verify_bytes_written(bytes_written: int | None, expected: int) -> None: + if bytes_written is not None and bytes_written != expected: + msg = f"wrote only {bytes_written} bytes but wanted {expected}" + raise RuntimeError(msg) + + 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(new_field_size), value) + ) + self._verify_bytes_written(bytes_written, new_field_size) def rewriteLastShortToLong(self, value: int) -> None: - self.f.seek(-2, os.SEEK_CUR) - bytes_written = self.f.write(struct.pack(self.longFmt, value)) - if bytes_written is not None and bytes_written != 4: - msg = f"wrote only {bytes_written} bytes but wanted 4" - raise RuntimeError(msg) + self._rewriteLast(value, 2, 4) def rewriteLastShort(self, value: int) -> None: - self.f.seek(-2, os.SEEK_CUR) - bytes_written = self.f.write(struct.pack(self.shortFmt, value)) - if bytes_written is not None and bytes_written != 2: - msg = f"wrote only {bytes_written} bytes but wanted 2" - raise RuntimeError(msg) + return self._rewriteLast(value, 2) def rewriteLastLong(self, value: int) -> None: - self.f.seek(-4, os.SEEK_CUR) - bytes_written = self.f.write(struct.pack(self.longFmt, value)) - if bytes_written is not None and bytes_written != 4: - msg = f"wrote only {bytes_written} bytes but wanted 4" - raise RuntimeError(msg) + 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)) - if bytes_written is not None and bytes_written != 2: - msg = f"wrote only {bytes_written} bytes but wanted 2" - raise RuntimeError(msg) + self._write(value, 2) def writeLong(self, value: int) -> None: - bytes_written = self.f.write(struct.pack(self.longFmt, value)) - if bytes_written is not None and bytes_written != 4: - msg = f"wrote only {bytes_written} bytes but wanted 4" - raise RuntimeError(msg) + self._write(value, 4) def close(self) -> None: self.finalize() - self.f.close() + if self.close_fp: + 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 - offset: int | None + 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, isShort=(field_size == 2), isLong=(field_size == 4) - ) - self.f.seek(cur_pos + 4) + self._fixOffsets(count, field_size) + self.f.seek(cur_pos + fmt_size) else: self.f.seek(offset) - self.fixOffsets( - count, isShort=(field_size == 2), isLong=(field_size == 4) - ) + self._fixOffsets(count, field_size) self.f.seek(cur_pos) - offset = cur_pos = None - elif is_local: # skip the locally stored value that is not an offset - self.f.seek(4, os.SEEK_CUR) - - def fixOffsets( - self, count: int, isShort: bool = False, isLong: bool = False - ) -> None: - if not isShort and not isLong: - msg = "offset is neither short nor long" - raise RuntimeError(msg) + self.f.seek(fmt_size, os.SEEK_CUR) + def _fixOffsets(self, count: int, field_size: int) -> None: for i in range(count): - offset = self.readShort() if isShort else self.readLong() + offset = self._read(field_size) offset += self.offsetOfNewPage - if isShort 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) - elif isShort: - self.rewriteLastShort(offset) + 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.rewriteLastLong(offset) + self._rewriteLast(offset, field_size) + + def fixOffsets( + self, count: int, isShort: bool = False, isLong: bool = False + ) -> None: + if isShort: + field_size = 2 + elif isLong: + field_size = 4 + else: + field_size = 0 + return self._fixOffsets(count, field_size) def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py index ec5c74900..87e32878b 100644 --- a/src/PIL/WalImageFile.py +++ b/src/PIL/WalImageFile.py @@ -54,7 +54,7 @@ class WalImageFile(ImageFile.ImageFile): self.info["next_name"] = next_name def load(self) -> Image.core.PixelAccess | None: - if not self.im: + if self._im is None: self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self.size[0] * self.size[1])) self.putpalette(quake2palette) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index cec796340..c2dde4431 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -13,10 +13,6 @@ except ImportError: SUPPORTED = False -_VALID_WEBP_MODES = {"RGBX": True, "RGBA": True, "RGB": True} - -_VALID_WEBP_LEGACY_MODES = {"RGB": True, "RGBA": True} - _VP8_MODES_BY_IDENTIFIER = { b"VP8 ": "RGB", b"VP8X": "RGBA", @@ -25,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 @@ -45,29 +41,12 @@ class WebPImageFile(ImageFile.ImageFile): __logical_frame = 0 def _open(self) -> None: - if not _webp.HAVE_WEBPANIM: - # Legacy mode - data, width, height, self._mode, icc_profile, exif = _webp.WebPDecode( - self.fp.read() - ) - if icc_profile: - self.info["icc_profile"] = icc_profile - if exif: - self.info["exif"] = exif - self._size = width, height - self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.mode)] - self.n_frames = 1 - self.is_animated = False - return - # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. 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, @@ -80,7 +59,6 @@ class WebPImageFile(ImageFile.ImageFile): self.is_animated = self.n_frames > 1 self._mode = "RGB" if mode == "RGBX" else mode self.rawmode = mode - self.tile = [] # Attempt to read ICC / EXIF / XMP chunks from file icc_profile = self._decoder.get_chunk("ICCP") @@ -145,21 +123,20 @@ class WebPImageFile(ImageFile.ImageFile): self._get_next() # Advance to the requested frame def load(self) -> Image.core.PixelAccess | None: - if _webp.HAVE_WEBPANIM: - if self.__loaded != self.__logical_frame: - self._seek(self.__logical_frame) + if self.__loaded != self.__logical_frame: + self._seek(self.__logical_frame) - # We need to load the image data for this frame - data, timestamp, duration = self._get_next() - self.info["timestamp"] = timestamp - self.info["duration"] = duration - self.__loaded = self.__logical_frame + # We need to load the image data for this frame + data, timestamp, duration = self._get_next() + self.info["timestamp"] = timestamp + self.info["duration"] = duration + self.__loaded = self.__logical_frame - # Set tile - if self.fp and self._exclusive_fp: - self.fp.close() - self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.rawmode)] + # Set tile + if self.fp and self._exclusive_fp: + self.fp.close() + self.fp = BytesIO(data) + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.rawmode)] return super().load() @@ -167,12 +144,16 @@ class WebPImageFile(ImageFile.ImageFile): pass def tell(self) -> int: - if not _webp.HAVE_WEBPANIM: - return super().tell() - return self.__logical_frame +def _convert_frame(im: Image.Image) -> Image.Image: + # Make sure image mode is supported + if im.mode not in ("RGBX", "RGBA", "RGB"): + im = im.convert("RGBA" if im.has_transparency_data else "RGB") + return im + + def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() append_images = list(encoderinfo.get("append_images", [])) @@ -241,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, @@ -263,31 +243,13 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: for idx in range(nfr): ims.seek(idx) - ims.load() - # Make sure image mode is supported - frame = ims - rawmode = ims.mode - if ims.mode not in _VALID_WEBP_MODES: - alpha = ( - "A" in ims.mode - or "a" in ims.mode - or (ims.mode == "P" and "A" in ims.im.getpalettemode()) - ) - rawmode = "RGBA" if alpha else "RGB" - frame = ims.convert(rawmode) - - if rawmode == "RGB": - # For faster conversion, use RGBX - rawmode = "RGBX" + frame = _convert_frame(ims) # Append the frame to the animation encoder enc.add( - frame.tobytes("raw", rawmode), + frame.getim(), round(timestamp), - frame.size[0], - frame.size[1], - rawmode, lossless, quality, alpha_quality, @@ -305,7 +267,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: im.seek(cur_idx) # Force encoder to flush frames - enc.add(None, round(timestamp), 0, 0, "", lossless, quality, alpha_quality, 0) + enc.add(None, round(timestamp), lossless, quality, alpha_quality, 0) # Get the final output from the encoder data = enc.assemble(icc_profile, exif, xmp) @@ -330,17 +292,13 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: method = im.encoderinfo.get("method", 4) exact = 1 if im.encoderinfo.get("exact") else 0 - if im.mode not in _VALID_WEBP_LEGACY_MODES: - im = im.convert("RGBA" if im.has_transparency_data else "RGB") + im = _convert_frame(im) data = _webp.WebPEncode( - im.tobytes(), - im.size[0], - im.size[1], + im.getim(), lossless, float(quality), float(alpha_quality), - im.mode, icc_profile, method, exact, @@ -357,7 +315,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: Image.register_open(WebPImageFile.format, WebPImageFile, _accept) if SUPPORTED: Image.register_save(WebPImageFile.format, _save) - if _webp.HAVE_WEBPANIM: - Image.register_save_all(WebPImageFile.format, _save_all) + Image.register_save_all(WebPImageFile.format, _save_all) Image.register_extension(WebPImageFile.format, ".webp") Image.register_mime(WebPImageFile.format, "image/webp") diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index 68f8a74f5..04abd52f0 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")) ## @@ -87,11 +85,14 @@ class WmfStubImageFile(ImageFile.StubImageFile): # 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) + if self._inch == 0: + msg = "Invalid inch" + raise ValueError(msg) # get bounding box x0 = short(s, 6) @@ -113,7 +114,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 @@ -128,7 +129,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): size = x1 - x0, y1 - y0 # calculate dots per inch from bbox and frame - xdpi = 2540.0 * (x1 - y0) / (frame[2] - frame[0]) + xdpi = 2540.0 * (x1 - x0) / (frame[2] - frame[0]) ydpi = 2540.0 * (y1 - y0) / (frame[3] - frame[1]) self.info["wmf_bbox"] = x0, y0, x1, y1 diff --git a/src/PIL/XVThumbImagePlugin.py b/src/PIL/XVThumbImagePlugin.py index c84adaca2..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) ## @@ -73,7 +73,9 @@ class XVThumbImageFile(ImageFile.ImageFile): self.palette = ImagePalette.raw("RGB", PALETTE) - self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1))] + self.tile = [ + 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 6d11bbfcf..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 = [("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, [("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 8d56331e6..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,7 +101,7 @@ class XpmImageFile(ImageFile.ImageFile): self._mode = "P" self.palette = ImagePalette.raw("RGB", b"".join(palette)) - self.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/_imagingcms.pyi b/src/PIL/_imagingcms.pyi index 2abd6d0f7..ddcf93ab1 100644 --- a/src/PIL/_imagingcms.pyi +++ b/src/PIL/_imagingcms.pyi @@ -2,6 +2,8 @@ import datetime import sys from typing import Literal, SupportsFloat, TypedDict +from ._typing import CapsuleType + littlecms_version: str | None _Tuple3f = tuple[float, float, float] @@ -108,7 +110,7 @@ class CmsProfile: def is_intent_supported(self, intent: int, direction: int, /) -> int: ... class CmsTransform: - def apply(self, id_in: int, id_out: int) -> int: ... + def apply(self, id_in: CapsuleType, id_out: CapsuleType) -> int: ... def profile_open(profile: str, /) -> CmsProfile: ... def profile_frombytes(profile: bytes, /) -> CmsProfile: ... 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 b6bb8d89a..34a9a81e1 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -6,6 +6,8 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Protocol, TypeVar, Union if TYPE_CHECKING: + from numbers import _IntegralLike as IntegralLike + try: import numpy.typing as npt @@ -13,6 +15,16 @@ if TYPE_CHECKING: except (ImportError, AttributeError): pass +if sys.version_info >= (3, 13): + from types import CapsuleType +else: + CapsuleType = object + +if sys.version_info >= (3, 12): + from collections.abc import Buffer +else: + Buffer = Any + if sys.version_info >= (3, 10): from typing import TypeGuard else: @@ -32,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__ = ["TypeGuard", "StrOrBytesPath", "SupportsRead"] +__all__ = ["Buffer", "IntegralLike", "StrOrBytesPath", "SupportsRead", "TypeGuard"] diff --git a/src/PIL/_version.py b/src/PIL/_version.py index c4a72ad7e..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.0.0.dev0" +__version__ = "11.2.0.dev0" diff --git a/src/PIL/features.py b/src/PIL/features.py index 13908c4eb..ae7ea4255 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -9,6 +9,7 @@ from typing import IO import PIL from . import Image +from ._deprecate import deprecate modules = { "pil": ("PIL._imaging", "PILLOW_VERSION"), @@ -118,14 +119,16 @@ def get_supported_codecs() -> list[str]: return [f for f in codecs if check_codec(f)] -features = { - "webp_anim": ("PIL._webp", "HAVE_WEBPANIM", None), - "webp_mux": ("PIL._webp", "HAVE_WEBPMUX", None), - "transp_webp": ("PIL._webp", "HAVE_TRANSPARENCY", None), +features: dict[str, tuple[str, str | bool, str | None]] = { + "webp_anim": ("PIL._webp", True, None), + "webp_mux": ("PIL._webp", True, None), + "transp_webp": ("PIL._webp", True, None), "raqm": ("PIL._imagingft", "HAVE_RAQM", "raqm_version"), "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), } @@ -145,8 +148,12 @@ def check_feature(feature: str) -> bool | None: module, flag, ver = features[feature] + if isinstance(flag, bool): + deprecate(f'check_feature("{feature}")', 12) try: imported_module = __import__(module, fromlist=["PIL"]) + if isinstance(flag, bool): + return flag return getattr(imported_module, flag) except ModuleNotFoundError: return None @@ -176,7 +183,17 @@ def get_supported_features() -> list[str]: """ :returns: A list of all supported features. """ - return [f for f in features if check_feature(f)] + supported_features = [] + for f, (module, flag, _) in features.items(): + if flag is True: + for feature, (feature_module, _) in modules.items(): + if feature_module == module: + if check_module(feature): + supported_features.append(f) + break + elif check_feature(f): + supported_features.append(f) + return supported_features def check(feature: str) -> bool | None: @@ -271,9 +288,6 @@ def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: ("freetype2", "FREETYPE2"), ("littlecms2", "LITTLECMS2"), ("webp", "WEBP"), - ("transp_webp", "WEBP Transparency"), - ("webp_mux", "WEBPMUX"), - ("webp_anim", "WEBP Animation"), ("jpg", "JPEG"), ("jpg_2000", "OPENJPEG (JPEG2000)"), ("zlib", "ZLIB (PNG/ZIP)"), @@ -287,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: @@ -296,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/Tk/tkImaging.c b/src/Tk/tkImaging.c index 727ee6bed..a36c3e0bd 100644 --- a/src/Tk/tkImaging.c +++ b/src/Tk/tkImaging.c @@ -56,19 +56,34 @@ static Tk_PhotoPutBlock_t TK_PHOTO_PUT_BLOCK; static Imaging ImagingFind(const char *name) { - Py_ssize_t id; + PyObject *capsule; + int direct_pointer = 0; + const char *expected = "capsule object \"" IMAGING_MAGIC "\" at 0x"; - /* FIXME: use CObject instead? */ -#if defined(_WIN64) - id = _atoi64(name); -#else - id = atol(name); -#endif - if (!id) { + if (name[0] == '<') { + name++; + } else { + // Special case for PyPy, where the string representation of a Capsule + // refers directly to the pointer itself, not to the PyCapsule object. + direct_pointer = 1; + } + + if (strncmp(name, expected, strlen(expected))) { return NULL; } - return (Imaging)id; + capsule = (PyObject *)strtoull(name + strlen(expected), NULL, 16); + + if (direct_pointer) { + return (Imaging)capsule; + } + + if (!PyCapsule_IsValid(capsule, IMAGING_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC); + return NULL; + } + + return (Imaging)PyCapsule_GetPointer(capsule, IMAGING_MAGIC); } static int diff --git a/src/_imaging.c b/src/_imaging.c index 5c2f7b4b6..fa38dcc05 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 @@ -92,19 +99,7 @@ #define _USE_MATH_DEFINES #include - -/* Configuration stuff. Feel free to undef things you don't need. */ -#define WITH_IMAGECHOPS /* ImageChops support */ -#define WITH_IMAGEDRAW /* ImageDraw support */ -#define WITH_MAPPING /* use memory mapping to read some file formats */ -#define WITH_IMAGEPATH /* ImagePath stuff */ -#define WITH_ARROW /* arrow graphics stuff (experimental) */ -#define WITH_EFFECTS /* special effects */ -#define WITH_QUANTIZE /* quantization support */ -#define WITH_RANKFILTER /* rank filter */ -#define WITH_MODEFILTER /* mode filter */ -#define WITH_THREADING /* "friendly" threading support */ -#define WITH_UNSHARPMASK /* Kevin Cazabon's unsharpmask module */ +#include #undef VERBOSE @@ -123,8 +118,6 @@ typedef struct { static PyTypeObject Imaging_Type; -#ifdef WITH_IMAGEDRAW - typedef struct { /* to write a character, cut out sxy from glyph data, place at current position plus dxy, and advance by (dx, dy) */ @@ -151,8 +144,6 @@ typedef struct { static PyTypeObject ImagingDraw_Type; -#endif - typedef struct { PyObject_HEAD ImagingObject *image; int readonly; @@ -215,16 +206,12 @@ PyImaging_AsImaging(PyObject *op) { void ImagingSectionEnter(ImagingSectionCookie *cookie) { -#ifdef WITH_THREADING *cookie = (PyThreadState *)PyEval_SaveThread(); -#endif } void ImagingSectionLeave(ImagingSectionCookie *cookie) { -#ifdef WITH_THREADING PyEval_RestoreThread((PyThreadState *)*cookie); -#endif } /* -------------------------------------------------------------------- */ @@ -486,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 * @@ -880,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, @@ -978,8 +964,7 @@ _convert2(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1028,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)); } @@ -1091,7 +1072,6 @@ _filter(ImagingObject *self, PyObject *args) { return imOut; } -#ifdef WITH_UNSHARPMASK static PyObject * _gaussian_blur(ImagingObject *self, PyObject *args) { Imaging imIn; @@ -1116,7 +1096,6 @@ _gaussian_blur(ImagingObject *self, PyObject *args) { return PyImagingNew(imOut); } -#endif static PyObject * _getpalette(ImagingObject *self, PyObject *args) { @@ -1229,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); @@ -1374,7 +1352,6 @@ _entropy(ImagingObject *self, PyObject *args) { return PyFloat_FromDouble(-entropy); } -#ifdef WITH_MODEFILTER static PyObject * _modefilter(ImagingObject *self, PyObject *args) { int size; @@ -1384,7 +1361,6 @@ _modefilter(ImagingObject *self, PyObject *args) { return PyImagingNew(ImagingModeFilter(self->image, size)); } -#endif static PyObject * _offset(ImagingObject *self, PyObject *args) { @@ -1434,8 +1410,7 @@ _paste(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1603,16 +1578,12 @@ _putdata(ImagingObject *self, PyObject *args) { int bigendian = 0; if (image->type == IMAGING_TYPE_SPECIAL) { // I;16* - if (strcmp(image->mode, "I;16N") == 0) { + if (strcmp(image->mode, "I;16B") == 0 #ifdef WORDS_BIGENDIAN - bigendian = 1; -#else - bigendian = 0; + || strcmp(image->mode, "I;16N") == 0 #endif - } else if (strcmp(image->mode, "I;16B") == 0) { + ) { bigendian = 1; - } else { - bigendian = 0; } } for (i = x = y = 0; i < n; i++) { @@ -1712,12 +1683,9 @@ _putdata(ImagingObject *self, PyObject *args) { Py_XDECREF(seq); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } -#ifdef WITH_QUANTIZE - static PyObject * _quantize(ImagingObject *self, PyObject *args) { int colours = 256; @@ -1734,7 +1702,6 @@ _quantize(ImagingObject *self, PyObject *args) { return PyImagingNew(ImagingQuantize(self->image, colours, method, kmeans)); } -#endif static PyObject * _putpalette(ImagingObject *self, PyObject *args) { @@ -1776,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 * @@ -1801,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 * @@ -1829,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 * @@ -1866,11 +1830,9 @@ _putpixel(ImagingObject *self, PyObject *args) { self->access->put_pixel(im, x, y, ink); } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } -#ifdef WITH_RANKFILTER static PyObject * _rankfilter(ImagingObject *self, PyObject *args) { int size, rank; @@ -1880,7 +1842,6 @@ _rankfilter(ImagingObject *self, PyObject *args) { return PyImagingNew(ImagingRankFilter(self->image, size, rank)); } -#endif static PyObject * _resize(ImagingObject *self, PyObject *args) { @@ -2036,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 * @@ -2100,8 +2060,7 @@ _transform(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2162,7 +2121,6 @@ _transpose(ImagingObject *self, PyObject *args) { return PyImagingNew(imOut); } -#ifdef WITH_UNSHARPMASK static PyObject * _unsharp_mask(ImagingObject *self, PyObject *args) { Imaging imIn; @@ -2186,7 +2144,6 @@ _unsharp_mask(ImagingObject *self, PyObject *args) { return PyImagingNew(imOut); } -#endif static PyObject * _box_blur(ImagingObject *self, PyObject *args) { @@ -2230,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]); @@ -2311,8 +2267,7 @@ _getextrema(ImagingObject *self) { } } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2375,8 +2330,7 @@ _fillband(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2391,8 +2345,7 @@ _putband(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -2463,9 +2416,7 @@ _split(ImagingObject *self) { return list; } -/* -------------------------------------------------------------------- */ - -#ifdef WITH_IMAGECHOPS +/* Channel operations (ImageChops) ------------------------------------ */ static PyObject * _chop_invert(ImagingObject *self) { @@ -2646,11 +2597,8 @@ _chop_overlay(ImagingObject *self, PyObject *args) { return PyImagingNew(ImagingOverlay(self->image, imagep->image)); } -#endif -/* -------------------------------------------------------------------- */ - -#ifdef WITH_IMAGEDRAW +/* Fonts (ImageDraw and ImageFont) ------------------------------------ */ static PyObject * _font_new(PyObject *self_, PyObject *args) { @@ -2879,7 +2827,7 @@ static struct PyMethodDef _font_methods[] = { {NULL, NULL} /* sentinel */ }; -/* -------------------------------------------------------------------- */ +/* Graphics (ImageDraw) ----------------------------------------------- */ static PyObject * _draw_new(PyObject *self_, PyObject *args) { @@ -2983,8 +2931,7 @@ _draw_arc(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3021,8 +2968,7 @@ _draw_bitmap(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3078,8 +3024,7 @@ _draw_chord(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3133,8 +3078,7 @@ _draw_ellipse(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3197,8 +3141,7 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) { free(xy); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3229,12 +3172,9 @@ _draw_points(ImagingDrawObject *self, PyObject *args) { free(xy); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } -#ifdef WITH_ARROW - /* from outline.c */ extern ImagingOutline PyOutline_AsOutline(PyObject *outline); @@ -3260,12 +3200,9 @@ _draw_outline(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } -#endif - static PyObject * _draw_pieslice(ImagingDrawObject *self, PyObject *args) { double *xy; @@ -3319,8 +3256,7 @@ _draw_pieslice(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3371,8 +3307,7 @@ _draw_polygon(ImagingDrawObject *self, PyObject *args) { free(ixy); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -3426,17 +3361,13 @@ _draw_rectangle(ImagingDrawObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static struct PyMethodDef _draw_methods[] = { -#ifdef WITH_IMAGEDRAW /* Graphics (ImageDraw) */ {"draw_lines", (PyCFunction)_draw_lines, METH_VARARGS}, -#ifdef WITH_ARROW {"draw_outline", (PyCFunction)_draw_outline, METH_VARARGS}, -#endif {"draw_polygon", (PyCFunction)_draw_polygon, METH_VARARGS}, {"draw_rectangle", (PyCFunction)_draw_rectangle, METH_VARARGS}, {"draw_points", (PyCFunction)_draw_points, METH_VARARGS}, @@ -3446,12 +3377,9 @@ static struct PyMethodDef _draw_methods[] = { {"draw_ellipse", (PyCFunction)_draw_ellipse, METH_VARARGS}, {"draw_pieslice", (PyCFunction)_draw_pieslice, METH_VARARGS}, {"draw_ink", (PyCFunction)_draw_ink, METH_VARARGS}, -#endif {NULL, NULL} /* sentinel */ }; -#endif - static PyObject * pixel_access_new(ImagingObject *imagep, PyObject *args) { PixelAccessObject *self; @@ -3532,11 +3460,9 @@ pixel_access_setitem(PixelAccessObject *self, PyObject *xy, PyObject *color) { } /* -------------------------------------------------------------------- */ -/* EFFECTS (experimental) */ +/* EFFECTS (experimental) */ /* -------------------------------------------------------------------- */ -#ifdef WITH_EFFECTS - static PyObject * _effect_mandelbrot(ImagingObject *self, PyObject *args) { int xsize = 512; @@ -3588,8 +3514,6 @@ _effect_spread(ImagingObject *self, PyObject *args) { return PyImagingNew(ImagingEffectSpread(self->image, dist)); } -#endif - /* -------------------------------------------------------------------- */ /* UTILITIES */ /* -------------------------------------------------------------------- */ @@ -3642,8 +3566,7 @@ _save_ppm(ImagingObject *self, PyObject *args) { return NULL; } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } /* -------------------------------------------------------------------- */ @@ -3670,20 +3593,14 @@ static struct PyMethodDef methods[] = { {"filter", (PyCFunction)_filter, METH_VARARGS}, {"histogram", (PyCFunction)_histogram, METH_VARARGS}, {"entropy", (PyCFunction)_entropy, METH_VARARGS}, -#ifdef WITH_MODEFILTER {"modefilter", (PyCFunction)_modefilter, METH_VARARGS}, -#endif {"offset", (PyCFunction)_offset, METH_VARARGS}, {"paste", (PyCFunction)_paste, METH_VARARGS}, {"point", (PyCFunction)_point, METH_VARARGS}, {"point_transform", (PyCFunction)_point_transform, METH_VARARGS}, {"putdata", (PyCFunction)_putdata, METH_VARARGS}, -#ifdef WITH_QUANTIZE {"quantize", (PyCFunction)_quantize, METH_VARARGS}, -#endif -#ifdef WITH_RANKFILTER {"rankfilter", (PyCFunction)_rankfilter, METH_VARARGS}, -#endif {"resize", (PyCFunction)_resize, METH_VARARGS}, {"reduce", (PyCFunction)_reduce, METH_VARARGS}, {"transpose", (PyCFunction)_transpose, METH_VARARGS}, @@ -3709,7 +3626,6 @@ static struct PyMethodDef methods[] = { {"putpalettealpha", (PyCFunction)_putpalettealpha, METH_VARARGS}, {"putpalettealphas", (PyCFunction)_putpalettealphas, METH_VARARGS}, -#ifdef WITH_IMAGECHOPS /* Channel operations (ImageChops) */ {"chop_invert", (PyCFunction)_chop_invert, METH_NOARGS}, {"chop_lighter", (PyCFunction)_chop_lighter, METH_VARARGS}, @@ -3728,24 +3644,15 @@ static struct PyMethodDef methods[] = { {"chop_hard_light", (PyCFunction)_chop_hard_light, METH_VARARGS}, {"chop_overlay", (PyCFunction)_chop_overlay, METH_VARARGS}, -#endif - -#ifdef WITH_UNSHARPMASK - /* Kevin Cazabon's unsharpmask extension */ + /* Unsharpmask extension */ {"gaussian_blur", (PyCFunction)_gaussian_blur, METH_VARARGS}, {"unsharp_mask", (PyCFunction)_unsharp_mask, METH_VARARGS}, -#endif - {"box_blur", (PyCFunction)_box_blur, METH_VARARGS}, -#ifdef WITH_EFFECTS /* Special effects */ {"effect_spread", (PyCFunction)_effect_spread, METH_VARARGS}, -#endif /* Misc. */ - {"new_block", (PyCFunction)_new_block, METH_VARARGS}, - {"save_ppm", (PyCFunction)_save_ppm, METH_VARARGS}, {NULL, NULL} /* sentinel */ @@ -3770,16 +3677,40 @@ _getattr_bands(ImagingObject *self, void *closure) { static PyObject * _getattr_id(ImagingObject *self, void *closure) { + if (PyErr_WarnEx( + PyExc_DeprecationWarning, + "id property is deprecated and will be removed in Pillow 12 (2025-10-15)", + 1 + ) < 0) { + return NULL; + } return PyLong_FromSsize_t((Py_ssize_t)self->image); } +static void +_ptr_destructor(PyObject *capsule) { + PyObject *self = (PyObject *)PyCapsule_GetContext(capsule); + Py_DECREF(self); +} + static PyObject * _getattr_ptr(ImagingObject *self, void *closure) { - return PyCapsule_New(self->image, IMAGING_MAGIC, NULL); + PyObject *capsule = PyCapsule_New(self->image, IMAGING_MAGIC, _ptr_destructor); + Py_INCREF(self); + PyCapsule_SetContext(capsule, self); + return capsule; } static PyObject * _getattr_unsafe_ptrs(ImagingObject *self, void *closure) { + if (PyErr_WarnEx( + PyExc_DeprecationWarning, + "unsafe_ptrs property is deprecated and will be removed in Pillow 12 " + "(2025-10-15)", + 1 + ) < 0) { + return NULL; + } return Py_BuildValue( "(sn)(sn)(sn)", "image8", @@ -3838,108 +3769,28 @@ 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, }; -#ifdef WITH_IMAGEDRAW - 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, }; -#endif - static PyMappingMethods pixel_access_as_mapping = { (lenfunc)NULL, /*mp_length*/ (binaryfunc)pixel_access_getitem, /*mp_subscript*/ @@ -3949,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, }; /* -------------------------------------------------------------------- */ @@ -3971,7 +3812,6 @@ static PyObject * _get_stats(PyObject *self, PyObject *args) { PyObject *d; PyObject *v; - ImagingMemoryArena arena = &ImagingDefaultArena; if (!PyArg_ParseTuple(args, ":get_stats")) { return NULL; @@ -3981,6 +3821,10 @@ _get_stats(PyObject *self, PyObject *args) { if (!d) { return NULL; } + + MUTEX_LOCK(&ImagingDefaultArena.mutex); + ImagingMemoryArena arena = &ImagingDefaultArena; + v = PyLong_FromLong(arena->stats_new_count); PyDict_SetItemString(d, "new_count", v ? v : Py_None); Py_XDECREF(v); @@ -4004,25 +3848,27 @@ _get_stats(PyObject *self, PyObject *args) { v = PyLong_FromLong(arena->blocks_cached); PyDict_SetItemString(d, "blocks_cached", v ? v : Py_None); Py_XDECREF(v); + + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); return d; } static PyObject * _reset_stats(PyObject *self, PyObject *args) { - ImagingMemoryArena arena = &ImagingDefaultArena; - if (!PyArg_ParseTuple(args, ":reset_stats")) { return NULL; } + MUTEX_LOCK(&ImagingDefaultArena.mutex); + ImagingMemoryArena arena = &ImagingDefaultArena; arena->stats_new_count = 0; arena->stats_allocated_blocks = 0; arena->stats_reused_blocks = 0; arena->stats_reallocated_blocks = 0; arena->stats_freed_blocks = 0; + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -4031,7 +3877,10 @@ _get_alignment(PyObject *self, PyObject *args) { return NULL; } - return PyLong_FromLong(ImagingDefaultArena.alignment); + MUTEX_LOCK(&ImagingDefaultArena.mutex); + int alignment = ImagingDefaultArena.alignment; + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); + return PyLong_FromLong(alignment); } static PyObject * @@ -4040,7 +3889,10 @@ _get_block_size(PyObject *self, PyObject *args) { return NULL; } - return PyLong_FromLong(ImagingDefaultArena.block_size); + MUTEX_LOCK(&ImagingDefaultArena.mutex); + int block_size = ImagingDefaultArena.block_size; + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); + return PyLong_FromLong(block_size); } static PyObject * @@ -4049,7 +3901,10 @@ _get_blocks_max(PyObject *self, PyObject *args) { return NULL; } - return PyLong_FromLong(ImagingDefaultArena.blocks_max); + MUTEX_LOCK(&ImagingDefaultArena.mutex); + int blocks_max = ImagingDefaultArena.blocks_max; + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); + return PyLong_FromLong(blocks_max); } static PyObject * @@ -4069,10 +3924,11 @@ _set_alignment(PyObject *self, PyObject *args) { return NULL; } + MUTEX_LOCK(&ImagingDefaultArena.mutex); ImagingDefaultArena.alignment = alignment; + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -4092,10 +3948,11 @@ _set_block_size(PyObject *self, PyObject *args) { return NULL; } + MUTEX_LOCK(&ImagingDefaultArena.mutex); ImagingDefaultArena.block_size = block_size; + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -4108,18 +3965,22 @@ _set_blocks_max(PyObject *self, PyObject *args) { if (blocks_max < 0) { PyErr_SetString(PyExc_ValueError, "blocks_max should be greater than 0"); return NULL; - } else if ((unsigned long)blocks_max > - SIZE_MAX / sizeof(ImagingDefaultArena.blocks_pool[0])) { + } + + if ((unsigned long)blocks_max > + SIZE_MAX / sizeof(ImagingDefaultArena.blocks_pool[0])) { PyErr_SetString(PyExc_ValueError, "blocks_max is too large"); return NULL; } - if (!ImagingMemorySetBlocksMax(&ImagingDefaultArena, blocks_max)) { + MUTEX_LOCK(&ImagingDefaultArena.mutex); + int status = ImagingMemorySetBlocksMax(&ImagingDefaultArena, blocks_max); + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); + if (!status) { return ImagingError_MemoryError(); } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -4130,10 +3991,11 @@ _clear_cache(PyObject *self, PyObject *args) { return NULL; } + MUTEX_LOCK(&ImagingDefaultArena.mutex); ImagingMemoryClearCache(&ImagingDefaultArena, i); + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } /* -------------------------------------------------------------------- */ @@ -4239,6 +4101,7 @@ static PyMethodDef functions[] = { {"blend", (PyCFunction)_blend, METH_VARARGS}, {"fill", (PyCFunction)_fill, METH_VARARGS}, {"new", (PyCFunction)_new, METH_VARARGS}, + {"new_block", (PyCFunction)_new_block, METH_VARARGS}, {"merge", (PyCFunction)_merge, METH_VARARGS}, /* Functions */ @@ -4282,13 +4145,11 @@ static PyMethodDef functions[] = { {"zip_encoder", (PyCFunction)PyImaging_ZipEncoderNew, METH_VARARGS}, #endif -/* Memory mapping */ -#ifdef WITH_MAPPING + /* Memory mapping */ {"map_buffer", (PyCFunction)PyImaging_MapBuffer, METH_VARARGS}, -#endif -/* Display support */ #ifdef _WIN32 + /* Display support */ {"display", (PyCFunction)PyImaging_DisplayWin32, METH_VARARGS}, {"display_mode", (PyCFunction)PyImaging_DisplayModeWin32, METH_VARARGS}, {"grabscreen_win32", (PyCFunction)PyImaging_GrabScreenWin32, METH_VARARGS}, @@ -4304,30 +4165,21 @@ static PyMethodDef functions[] = { /* Utilities */ {"getcodecstatus", (PyCFunction)_getcodecstatus, METH_VARARGS}, -/* Special effects (experimental) */ -#ifdef WITH_EFFECTS + /* Special effects (experimental) */ {"effect_mandelbrot", (PyCFunction)_effect_mandelbrot, METH_VARARGS}, {"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 */ -#endif -/* Drawing support stuff */ -#ifdef WITH_IMAGEDRAW + /* Drawing support stuff */ {"font", (PyCFunction)_font_new, METH_VARARGS}, {"draw", (PyCFunction)_draw_new, METH_VARARGS}, -#endif -/* Experimental path stuff */ -#ifdef WITH_IMAGEPATH + /* Experimental path stuff */ {"path", (PyCFunction)PyPath_Create, METH_VARARGS}, -#endif -/* Experimental arrow graphics stuff */ -#ifdef WITH_ARROW + /* Experimental arrow graphics stuff */ {"outline", (PyCFunction)PyOutline_Create, METH_VARARGS}, -#endif /* Resource management */ {"get_stats", (PyCFunction)_get_stats, METH_VARARGS}, @@ -4352,16 +4204,12 @@ setup_module(PyObject *m) { if (PyType_Ready(&Imaging_Type) < 0) { return -1; } - -#ifdef WITH_IMAGEDRAW if (PyType_Ready(&ImagingFont_Type) < 0) { return -1; } - if (PyType_Ready(&ImagingDraw_Type) < 0) { return -1; } -#endif if (PyType_Ready(&PixelAccess_Type) < 0) { return -1; } @@ -4404,6 +4252,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; @@ -4434,6 +4291,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); @@ -4477,10 +4348,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 bafe787a7..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; @@ -531,23 +531,24 @@ buildProofTransform(PyObject *self, PyObject *args) { static PyObject * cms_transform_apply(CmsTransformObject *self, PyObject *args) { - Py_ssize_t idIn; - Py_ssize_t idOut; + PyObject *i0, *i1; Imaging im; Imaging imOut; - int result; - - if (!PyArg_ParseTuple(args, "nn:apply", &idIn, &idOut)) { + if (!PyArg_ParseTuple(args, "OO:apply", &i0, &i1)) { return NULL; } - im = (Imaging)idIn; - imOut = (Imaging)idOut; + if (!PyCapsule_IsValid(i0, IMAGING_MAGIC) || + !PyCapsule_IsValid(i1, IMAGING_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC); + return NULL; + } - result = pyCMSdoTransform(im, imOut, self->transform); + im = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC); + imOut = (Imaging)PyCapsule_GetPointer(i1, IMAGING_MAGIC); - return Py_BuildValue("i", result); + return Py_BuildValue("i", pyCMSdoTransform(im, imOut, self->transform)); } /* -------------------------------------------------------------------- */ @@ -653,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 @@ -671,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); @@ -722,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); @@ -779,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); @@ -800,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 @@ -834,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++) { @@ -857,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); } @@ -925,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); @@ -956,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); @@ -1041,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( @@ -1140,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); } @@ -1149,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); } @@ -1158,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); } @@ -1175,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); } @@ -1228,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); @@ -1244,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); @@ -1260,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); @@ -1320,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) { @@ -1361,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( @@ -1439,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[] = { @@ -1476,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 @@ -1549,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 da03e3ba9..62dab73e5 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -82,6 +82,9 @@ struct { /* font objects */ static FT_Library library; +#ifdef Py_GIL_DISABLED +static PyMutex ft_library_mutex; +#endif typedef struct { PyObject_HEAD FT_Face face; @@ -187,7 +190,9 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { if (filename && font_bytes_size <= 0) { self->font_bytes = NULL; + MUTEX_LOCK(&ft_library_mutex); error = FT_New_Face(library, filename, index, &self->face); + MUTEX_UNLOCK(&ft_library_mutex); } else { /* need to have allocated storage for font_bytes for the life of the object.*/ /* Don't free this before FT_Done_Face */ @@ -197,6 +202,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { } if (!error) { memcpy(self->font_bytes, font_bytes, (size_t)font_bytes_size); + MUTEX_LOCK(&ft_library_mutex); error = FT_New_Memory_Face( library, (FT_Byte *)self->font_bytes, @@ -204,6 +210,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { index, &self->face ); + MUTEX_UNLOCK(&ft_library_mutex); } } @@ -272,7 +279,7 @@ text_layout_raqm( } set_text = raqm_set_text(rq, text, size); PyMem_Free(text); - } else { + } else if (PyBytes_Check(string)) { char *buffer; PyBytes_AsStringAndSize(string, &buffer, &size); if (!buffer || !size) { @@ -281,6 +288,9 @@ text_layout_raqm( goto failed; } set_text = raqm_set_text_utf8(rq, buffer, size); + } else { + PyErr_SetString(PyExc_TypeError, "expected string or bytes"); + goto failed; } if (!set_text) { PyErr_SetString(PyExc_ValueError, "raqm_set_text() failed"); @@ -329,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); } @@ -425,8 +429,11 @@ text_layout_fallback( if (PyUnicode_Check(string)) { count = PyUnicode_GET_LENGTH(string); - } else { + } else if (PyBytes_Check(string)) { PyBytes_AsStringAndSize(string, &buffer, &count); + } else { + PyErr_SetString(PyExc_TypeError, "expected string or bytes"); + return 0; } if (count == 0) { return 0; @@ -824,10 +831,10 @@ font_render(FontObject *self, PyObject *args) { unsigned char convert_scale; /* scale factor for non-8bpp bitmaps */ PyObject *image; Imaging im; - Py_ssize_t id; int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ int color = 0; /* is FT_LOAD_COLOR enabled? */ - int stroke_width = 0; + float stroke_width = 0; + int stroke_filled = 0; PY_LONG_LONG foreground_ink_long = 0; unsigned int foreground_ink; const char *mode = NULL; @@ -847,7 +854,7 @@ font_render(FontObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "OO|zzOzizLffO:render", + "OO|zzOzfpzL(ff):render", &string, &fill, &mode, @@ -855,6 +862,7 @@ font_render(FontObject *self, PyObject *args) { &features, &lang, &stroke_width, + &stroke_filled, &anchor, &foreground_ink_long, &x_start, @@ -913,23 +921,19 @@ font_render(FontObject *self, PyObject *args) { return NULL; } - width += stroke_width * 2 + ceil(x_start); - height += stroke_width * 2 + ceil(y_start); + width += ceil(stroke_width * 2 + x_start); + height += ceil(stroke_width * 2 + y_start); image = PyObject_CallFunction(fill, "ii", width, height); - if (image == Py_None) { - PyMem_Del(glyph_info); - return Py_BuildValue("N(ii)", image, 0, 0); - } else if (image == NULL) { + if (image == NULL) { PyMem_Del(glyph_info); return NULL; } - PyObject *imageId = PyObject_GetAttrString(image, "id"); - id = PyLong_AsSsize_t(imageId); - Py_XDECREF(imageId); - im = (Imaging)id; + PyObject *imagePtr = PyObject_GetAttrString(image, "ptr"); + im = (Imaging)PyCapsule_GetPointer(imagePtr, IMAGING_MAGIC); + Py_XDECREF(imagePtr); - x_offset -= stroke_width; - y_offset -= stroke_width; + x_offset = round(x_offset - stroke_width); + y_offset = round(y_offset - stroke_width); if (count == 0 || width == 0 || height == 0) { PyMem_Del(glyph_info); return Py_BuildValue("N(ii)", image, x_offset, y_offset); @@ -944,7 +948,7 @@ font_render(FontObject *self, PyObject *args) { FT_Stroker_Set( stroker, - (FT_Fixed)stroke_width * 64, + (FT_Fixed)round(stroke_width * 64), FT_STROKER_LINECAP_ROUND, FT_STROKER_LINEJOIN_ROUND, 0 @@ -982,8 +986,8 @@ font_render(FontObject *self, PyObject *args) { } /* set pen position to text origin */ - x = (-x_min + stroke_width + x_start) * 64; - y = (-y_max + (-stroke_width) - y_start) * 64; + x = round((-x_min + stroke_width + x_start) * 64); + y = round((-y_max + (-stroke_width) - y_start) * 64); if (stroker == NULL) { load_flags |= FT_LOAD_RENDER; @@ -1003,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}; @@ -1243,7 +1248,7 @@ font_getvarnames(FontObject *self) { return PyErr_NoMemory(); } - for (int i = 0; i < num_namedstyles; i++) { + for (unsigned int i = 0; i < num_namedstyles; i++) { list_names_filled[i] = 0; } @@ -1369,8 +1374,7 @@ font_setvarname(FontObject *self, PyObject *args) { return geterror(error); } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -1424,15 +1428,16 @@ font_setvaraxes(FontObject *self, PyObject *args) { return geterror(error); } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } #endif static void font_dealloc(FontObject *self) { if (self->face) { + MUTEX_LOCK(&ft_library_mutex); FT_Done_Face(self->face); + MUTEX_UNLOCK(&ft_library_mutex); } if (self->font_bytes) { PyMem_Free(self->font_bytes); @@ -1513,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[] = { @@ -1625,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 550a10903..4b9bf08ba 100644 --- a/src/_imagingmath.c +++ b/src/_imagingmath.c @@ -23,6 +23,9 @@ #define MAX_INT32 2147483647.0 #define MIN_INT32 -2147483648.0 +#define MATH_FUNC_UNOP_MAGIC "Pillow Math unary func" +#define MATH_FUNC_BINOP_MAGIC "Pillow Math binary func" + #define UNOP(name, op, type) \ void name(Imaging out, Imaging im1) { \ int x, y; \ @@ -168,20 +171,28 @@ _unop(PyObject *self, PyObject *args) { Imaging im1; void (*unop)(Imaging, Imaging); - Py_ssize_t op, i0, i1; - if (!PyArg_ParseTuple(args, "nnn", &op, &i0, &i1)) { + PyObject *op, *i0, *i1; + if (!PyArg_ParseTuple(args, "OOO", &op, &i0, &i1)) { return NULL; } - out = (Imaging)i0; - im1 = (Imaging)i1; + if (!PyCapsule_IsValid(op, MATH_FUNC_UNOP_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", MATH_FUNC_UNOP_MAGIC); + return NULL; + } + if (!PyCapsule_IsValid(i0, IMAGING_MAGIC) || + !PyCapsule_IsValid(i1, IMAGING_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC); + return NULL; + } - unop = (void *)op; + unop = (void *)PyCapsule_GetPointer(op, MATH_FUNC_UNOP_MAGIC); + out = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC); + im1 = (Imaging)PyCapsule_GetPointer(i1, IMAGING_MAGIC); unop(out, im1); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyObject * @@ -191,21 +202,30 @@ _binop(PyObject *self, PyObject *args) { Imaging im2; void (*binop)(Imaging, Imaging, Imaging); - Py_ssize_t op, i0, i1, i2; - if (!PyArg_ParseTuple(args, "nnnn", &op, &i0, &i1, &i2)) { + PyObject *op, *i0, *i1, *i2; + if (!PyArg_ParseTuple(args, "OOOO", &op, &i0, &i1, &i2)) { return NULL; } - out = (Imaging)i0; - im1 = (Imaging)i1; - im2 = (Imaging)i2; + if (!PyCapsule_IsValid(op, MATH_FUNC_BINOP_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", MATH_FUNC_BINOP_MAGIC); + return NULL; + } + if (!PyCapsule_IsValid(i0, IMAGING_MAGIC) || + !PyCapsule_IsValid(i1, IMAGING_MAGIC) || + !PyCapsule_IsValid(i2, IMAGING_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC); + return NULL; + } - binop = (void *)op; + binop = (void *)PyCapsule_GetPointer(op, MATH_FUNC_BINOP_MAGIC); + out = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC); + im1 = (Imaging)PyCapsule_GetPointer(i1, IMAGING_MAGIC); + im2 = (Imaging)PyCapsule_GetPointer(i2, IMAGING_MAGIC); binop(out, im1, im2); - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static PyMethodDef _functions[] = { @@ -213,8 +233,17 @@ static PyMethodDef _functions[] = { }; static void -install(PyObject *d, char *name, void *value) { - PyObject *v = PyLong_FromSsize_t((Py_ssize_t)value); +install_unary(PyObject *d, char *name, void *func) { + PyObject *v = PyCapsule_New(func, MATH_FUNC_UNOP_MAGIC, NULL); + if (!v || PyDict_SetItemString(d, name, v)) { + PyErr_Clear(); + } + Py_XDECREF(v); +} + +static void +install_binary(PyObject *d, char *name, void *func) { + PyObject *v = PyCapsule_New(func, MATH_FUNC_BINOP_MAGIC, NULL); if (!v || PyDict_SetItemString(d, name, v)) { PyErr_Clear(); } @@ -225,50 +254,50 @@ static int setup_module(PyObject *m) { PyObject *d = PyModule_GetDict(m); - install(d, "abs_I", abs_I); - install(d, "neg_I", neg_I); - install(d, "add_I", add_I); - install(d, "sub_I", sub_I); - install(d, "diff_I", diff_I); - install(d, "mul_I", mul_I); - install(d, "div_I", div_I); - install(d, "mod_I", mod_I); - install(d, "min_I", min_I); - install(d, "max_I", max_I); - install(d, "pow_I", pow_I); + install_unary(d, "abs_I", abs_I); + install_unary(d, "neg_I", neg_I); + install_binary(d, "add_I", add_I); + install_binary(d, "sub_I", sub_I); + install_binary(d, "diff_I", diff_I); + install_binary(d, "mul_I", mul_I); + install_binary(d, "div_I", div_I); + install_binary(d, "mod_I", mod_I); + install_binary(d, "min_I", min_I); + install_binary(d, "max_I", max_I); + install_binary(d, "pow_I", pow_I); - install(d, "invert_I", invert_I); - install(d, "and_I", and_I); - install(d, "or_I", or_I); - install(d, "xor_I", xor_I); - install(d, "lshift_I", lshift_I); - install(d, "rshift_I", rshift_I); + install_unary(d, "invert_I", invert_I); + install_binary(d, "and_I", and_I); + install_binary(d, "or_I", or_I); + install_binary(d, "xor_I", xor_I); + install_binary(d, "lshift_I", lshift_I); + install_binary(d, "rshift_I", rshift_I); - install(d, "eq_I", eq_I); - install(d, "ne_I", ne_I); - install(d, "lt_I", lt_I); - install(d, "le_I", le_I); - install(d, "gt_I", gt_I); - install(d, "ge_I", ge_I); + install_binary(d, "eq_I", eq_I); + install_binary(d, "ne_I", ne_I); + install_binary(d, "lt_I", lt_I); + install_binary(d, "le_I", le_I); + install_binary(d, "gt_I", gt_I); + install_binary(d, "ge_I", ge_I); - install(d, "abs_F", abs_F); - install(d, "neg_F", neg_F); - install(d, "add_F", add_F); - install(d, "sub_F", sub_F); - install(d, "diff_F", diff_F); - install(d, "mul_F", mul_F); - install(d, "div_F", div_F); - install(d, "mod_F", mod_F); - install(d, "min_F", min_F); - install(d, "max_F", max_F); - install(d, "pow_F", pow_F); + install_unary(d, "abs_F", abs_F); + install_unary(d, "neg_F", neg_F); + install_binary(d, "add_F", add_F); + install_binary(d, "sub_F", sub_F); + install_binary(d, "diff_F", diff_F); + install_binary(d, "mul_F", mul_F); + install_binary(d, "div_F", div_F); + install_binary(d, "mod_F", mod_F); + install_binary(d, "min_F", min_F); + install_binary(d, "max_F", max_F); + install_binary(d, "pow_F", pow_F); - install(d, "eq_F", eq_F); - install(d, "ne_F", ne_F); - install(d, "lt_F", lt_F); - install(d, "le_F", le_F); - install(d, "gt_F", gt_F); - install(d, "ge_F", ge_F); + install_binary(d, "eq_F", eq_F); + install_binary(d, "ne_F", ne_F); + install_binary(d, "lt_F", lt_F); + install_binary(d, "le_F", le_F); + install_binary(d, "gt_F", gt_F); + install_binary(d, "ge_F", ge_F); return 0; } @@ -279,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 614dfbe7f..a20888294 100644 --- a/src/_imagingmorph.c +++ b/src/_imagingmorph.c @@ -11,7 +11,6 @@ * See the README file for information on usage and redistribution. */ -#include "Python.h" #include "libImaging/Imaging.h" #define LUT_SIZE (1 << 9) @@ -30,43 +29,36 @@ static PyObject * apply(PyObject *self, PyObject *args) { const char *lut; - PyObject *py_lut; - Py_ssize_t lut_len, i0, i1; + Py_ssize_t lut_len; + PyObject *i0, *i1; Imaging imgin, imgout; int width, height; int row_idx, col_idx; UINT8 **inrows, **outrows; int num_changed_pixels = 0; - if (!PyArg_ParseTuple(args, "Onn", &py_lut, &i0, &i1)) { - PyErr_SetString(PyExc_RuntimeError, "Argument parsing problem"); + if (!PyArg_ParseTuple(args, "s#OO", &lut, &lut_len, &i0, &i1)) { return NULL; } - if (!PyBytes_Check(py_lut)) { - PyErr_SetString(PyExc_RuntimeError, "The morphology LUT is not a bytes object"); - return NULL; - } - - lut_len = PyBytes_Size(py_lut); - if (lut_len < LUT_SIZE) { PyErr_SetString(PyExc_RuntimeError, "The morphology LUT has the wrong size"); return NULL; } - lut = PyBytes_AsString(py_lut); + if (!PyCapsule_IsValid(i0, IMAGING_MAGIC) || + !PyCapsule_IsValid(i1, IMAGING_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC); + return NULL; + } - imgin = (Imaging)i0; - imgout = (Imaging)i1; + imgin = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC); + imgout = (Imaging)PyCapsule_GetPointer(i1, IMAGING_MAGIC); width = imgin->xsize; height = imgin->ysize; - if (imgin->type != IMAGING_TYPE_UINT8 || imgin->bands != 1) { - PyErr_SetString(PyExc_RuntimeError, "Unsupported image type"); - return NULL; - } - if (imgout->type != IMAGING_TYPE_UINT8 || imgout->bands != 1) { + if (imgin->type != IMAGING_TYPE_UINT8 || imgin->bands != 1 || + imgout->type != IMAGING_TYPE_UINT8 || imgout->bands != 1) { PyErr_SetString(PyExc_RuntimeError, "Unsupported image type"); return NULL; } @@ -129,46 +121,39 @@ apply(PyObject *self, PyObject *args) { static PyObject * match(PyObject *self, PyObject *args) { const char *lut; - PyObject *py_lut; - Py_ssize_t lut_len, i0; + Py_ssize_t lut_len; + PyObject *i0; Imaging imgin; int width, height; int row_idx, col_idx; UINT8 **inrows; - PyObject *ret = PyList_New(0); - if (ret == NULL) { + + if (!PyArg_ParseTuple(args, "s#O", &lut, &lut_len, &i0)) { return NULL; } - if (!PyArg_ParseTuple(args, "On", &py_lut, &i0)) { - Py_DECREF(ret); - PyErr_SetString(PyExc_RuntimeError, "Argument parsing problem"); - return NULL; - } - - if (!PyBytes_Check(py_lut)) { - Py_DECREF(ret); - PyErr_SetString(PyExc_RuntimeError, "The morphology LUT is not a bytes object"); - return NULL; - } - - lut_len = PyBytes_Size(py_lut); - if (lut_len < LUT_SIZE) { - Py_DECREF(ret); PyErr_SetString(PyExc_RuntimeError, "The morphology LUT has the wrong size"); return NULL; } - lut = PyBytes_AsString(py_lut); - imgin = (Imaging)i0; + if (!PyCapsule_IsValid(i0, IMAGING_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC); + return NULL; + } + + imgin = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC); if (imgin->type != IMAGING_TYPE_UINT8 || imgin->bands != 1) { - Py_DECREF(ret); PyErr_SetString(PyExc_RuntimeError, "Unsupported image type"); return NULL; } + PyObject *ret = PyList_New(0); + if (ret == NULL) { + return NULL; + } + inrows = imgin->image8; width = imgin->xsize; height = imgin->ysize; @@ -215,26 +200,31 @@ match(PyObject *self, PyObject *args) { */ static PyObject * get_on_pixels(PyObject *self, PyObject *args) { - Py_ssize_t i0; + PyObject *i0; Imaging img; UINT8 **rows; int row_idx, col_idx; int width, height; + + if (!PyArg_ParseTuple(args, "O", &i0)) { + return NULL; + } + + if (!PyCapsule_IsValid(i0, IMAGING_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC); + return NULL; + } + + img = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC); + rows = img->image8; + width = img->xsize; + height = img->ysize; + PyObject *ret = PyList_New(0); if (ret == NULL) { return NULL; } - if (!PyArg_ParseTuple(args, "n", &i0)) { - Py_DECREF(ret); - PyErr_SetString(PyExc_RuntimeError, "Argument parsing problem"); - return NULL; - } - img = (Imaging)i0; - rows = img->image8; - width = img->xsize; - height = img->ysize; - for (row_idx = 0; row_idx < height; row_idx++) { UINT8 *row = rows[row_idx]; for (col_idx = 0; col_idx < width; col_idx++) { @@ -262,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 e686ec820..3aa4c408b 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -4,8 +4,6 @@ #include #include #include - -#ifdef HAVE_WEBPMUX #include #include @@ -13,12 +11,10 @@ * Check the versions from mux.h and demux.h, to ensure the WebPAnimEncoder and * WebPAnimDecoder APIs are present (initial support was added in 0.5.0). The * very early versions had some significant differences, so we require later - * versions, before enabling animation support. + * versions. */ -#if WEBP_MUX_ABI_VERSION >= 0x0104 && WEBP_DEMUX_ABI_VERSION >= 0x0105 -#define HAVE_WEBPANIM -#endif - +#if WEBP_MUX_ABI_VERSION < 0x0106 || WEBP_DEMUX_ABI_VERSION < 0x0107 +#error libwebp 0.5.0 and above is required. Upgrade libwebp or build Pillow with --disable-webp flag #endif void @@ -35,8 +31,6 @@ ImagingSectionLeave(ImagingSectionCookie *cookie) { /* WebP Muxer Error Handling */ /* -------------------------------------------------------------------- */ -#ifdef HAVE_WEBPMUX - static const char *const kErrorMessages[-WEBP_MUX_NOT_ENOUGH_DATA + 1] = { "WEBP_MUX_NOT_FOUND", "WEBP_MUX_INVALID_ARGUMENT", @@ -89,14 +83,53 @@ HandleMuxError(WebPMuxError err, char *chunk) { return NULL; } -#endif +/* -------------------------------------------------------------------- */ +/* Frame import */ +/* -------------------------------------------------------------------- */ + +static int +import_frame_libwebp(WebPPicture *frame, Imaging im) { + if (strcmp(im->mode, "RGBA") && strcmp(im->mode, "RGB") && + strcmp(im->mode, "RGBX")) { + PyErr_SetString(PyExc_ValueError, "unsupported image mode"); + return -1; + } + + frame->width = im->xsize; + frame->height = im->ysize; + frame->use_argb = 1; // Don't convert RGB pixels to YUV + + if (!WebPPictureAlloc(frame)) { + PyErr_SetString(PyExc_MemoryError, "can't allocate picture frame"); + return -2; + } + + int ignore_fourth_channel = strcmp(im->mode, "RGBA"); + for (int y = 0; y < im->ysize; ++y) { + UINT8 *src = (UINT8 *)im->image32[y]; + UINT32 *dst = frame->argb + frame->argb_stride * y; + if (ignore_fourth_channel) { + for (int x = 0; x < im->xsize; ++x) { + dst[x] = + ((UINT32)(src[x * 4 + 2]) | ((UINT32)(src[x * 4 + 1]) << 8) | + ((UINT32)(src[x * 4]) << 16) | (0xff << 24)); + } + } else { + for (int x = 0; x < im->xsize; ++x) { + dst[x] = + ((UINT32)(src[x * 4 + 2]) | ((UINT32)(src[x * 4 + 1]) << 8) | + ((UINT32)(src[x * 4]) << 16) | ((UINT32)(src[x * 4 + 3]) << 24)); + } + } + } + + return 0; +} /* -------------------------------------------------------------------- */ /* WebP Animation Support */ /* -------------------------------------------------------------------- */ -#ifdef HAVE_WEBPANIM - // Encoder type typedef struct { PyObject_HEAD WebPAnimEncoder *enc; @@ -131,7 +164,7 @@ _anim_encoder_new(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "iiIiiiiii", + "(ii)Iiiiiii", &width, &height, &bgcolor, @@ -190,16 +223,14 @@ _anim_encoder_dealloc(PyObject *self) { PyObject * _anim_encoder_add(PyObject *self, PyObject *args) { - uint8_t *rgb; - Py_ssize_t size; + PyObject *i0; + Imaging im; int timestamp; - int width; - int height; - char *mode; int lossless; float quality_factor; float alpha_quality_factor; int method; + ImagingSectionCookie cookie; WebPConfig config; WebPAnimEncoderObject *encp = (WebPAnimEncoderObject *)self; WebPAnimEncoder *enc = encp->enc; @@ -207,13 +238,9 @@ _anim_encoder_add(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "z#iiisiffi", - (char **)&rgb, - &size, + "Oiiffi", + &i0, ×tamp, - &width, - &height, - &mode, &lossless, &quality_factor, &alpha_quality_factor, @@ -223,11 +250,18 @@ _anim_encoder_add(PyObject *self, PyObject *args) { } // Check for NULL frame, which sets duration of final frame - if (!rgb) { + if (i0 == Py_None) { WebPAnimEncoderAdd(enc, NULL, timestamp, NULL); Py_RETURN_NONE; } + if (!PyCapsule_IsValid(i0, IMAGING_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC); + return NULL; + } + + im = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC); + // Setup config for this frame if (!WebPConfigInit(&config)) { PyErr_SetString(PyExc_RuntimeError, "failed to initialize config!"); @@ -244,20 +278,15 @@ _anim_encoder_add(PyObject *self, PyObject *args) { return NULL; } - // Populate the frame with raw bytes passed to us - frame->width = width; - frame->height = height; - frame->use_argb = 1; // Don't convert RGB pixels to YUV - if (strcmp(mode, "RGBA") == 0) { - WebPPictureImportRGBA(frame, rgb, 4 * width); - } else if (strcmp(mode, "RGBX") == 0) { - WebPPictureImportRGBX(frame, rgb, 4 * width); - } else { - WebPPictureImportRGB(frame, rgb, 3 * width); + if (import_frame_libwebp(frame, im)) { + return NULL; } - // Add the frame to the encoder - if (!WebPAnimEncoderAdd(enc, frame, timestamp, &config)) { + ImagingSectionEnter(&cookie); + int ok = WebPAnimEncoderAdd(enc, frame, timestamp, &config); + ImagingSectionLeave(&cookie); + + if (!ok) { PyErr_SetString(PyExc_RuntimeError, WebPAnimEncoderGetError(enc)); return NULL; } @@ -420,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, @@ -501,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 @@ -544,66 +547,33 @@ 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, }; -#endif - /* -------------------------------------------------------------------- */ /* Legacy WebP Support */ /* -------------------------------------------------------------------- */ PyObject * WebPEncode_wrapper(PyObject *self, PyObject *args) { - int width; - int height; int lossless; float quality_factor; float alpha_quality_factor; int method; int exact; - uint8_t *rgb; + Imaging im; + PyObject *i0; uint8_t *icc_bytes; uint8_t *exif_bytes; uint8_t *xmp_bytes; uint8_t *output; - char *mode; - Py_ssize_t size; Py_ssize_t icc_size; Py_ssize_t exif_size; Py_ssize_t xmp_size; size_t ret_size; - int rgba_mode; - int channels; int ok; ImagingSectionCookie cookie; WebPConfig config; @@ -612,15 +582,11 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "y#iiiffss#iis#s#", - (char **)&rgb, - &size, - &width, - &height, + "Oiffs#iis#s#", + &i0, &lossless, &quality_factor, &alpha_quality_factor, - &mode, &icc_bytes, &icc_size, &method, @@ -633,15 +599,12 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { return NULL; } - rgba_mode = strcmp(mode, "RGBA") == 0; - if (!rgba_mode && strcmp(mode, "RGB") != 0) { - Py_RETURN_NONE; + if (!PyCapsule_IsValid(i0, IMAGING_MAGIC)) { + PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC); + return NULL; } - channels = rgba_mode ? 4 : 3; - if (size < width * height * channels) { - Py_RETURN_NONE; - } + im = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC); // Setup config for this frame if (!WebPConfigInit(&config)) { @@ -652,10 +615,7 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { config.quality = quality_factor; config.alpha_quality = alpha_quality_factor; config.method = method; -#if WEBP_ENCODER_ABI_VERSION >= 0x0209 - // the "exact" flag is only available in libwebp 0.5.0 and later config.exact = exact; -#endif // Validate the config if (!WebPValidateConfig(&config)) { @@ -667,14 +627,9 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { PyErr_SetString(PyExc_ValueError, "could not initialise picture"); return NULL; } - pic.width = width; - pic.height = height; - pic.use_argb = 1; // Don't convert RGB pixels to YUV - if (rgba_mode) { - WebPPictureImportRGBA(&pic, rgb, channels * width); - } else { - WebPPictureImportRGB(&pic, rgb, channels * width); + if (import_frame_libwebp(&pic, im)) { + return NULL; } WebPMemoryWriterInit(&writer); @@ -687,19 +642,21 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { WebPPictureFree(&pic); if (!ok) { - PyErr_Format(PyExc_ValueError, "encoding error %d", (&pic)->error_code); + int error_code = (&pic)->error_code; + char message[50] = ""; + if (error_code == VP8_ENC_ERROR_BAD_DIMENSION) { + sprintf( + message, + ": Image size exceeds WebP limit of %d pixels", + WEBP_MAX_DIMENSION + ); + } + PyErr_Format(PyExc_ValueError, "encoding error %d%s", error_code, message); return NULL; } output = writer.mem; ret_size = writer.size; -#ifndef HAVE_WEBPMUX - if (ret_size > 0) { - PyObject *ret = PyBytes_FromStringAndSize((char *)output, ret_size); - free(output); - return ret; - } -#else { /* I want to truncate the *_size items that get passed into WebP data. Pypy2.1.0 had some issues where the Py_ssize_t items had @@ -775,132 +732,9 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) { return ret; } } -#endif Py_RETURN_NONE; } -PyObject * -WebPDecode_wrapper(PyObject *self, PyObject *args) { - PyBytesObject *webp_string; - const uint8_t *webp; - Py_ssize_t size; - PyObject *ret = Py_None, *bytes = NULL, *pymode = NULL, *icc_profile = NULL, - *exif = NULL; - WebPDecoderConfig config; - VP8StatusCode vp8_status_code = VP8_STATUS_OK; - char *mode = "RGB"; - - if (!PyArg_ParseTuple(args, "S", &webp_string)) { - return NULL; - } - - if (!WebPInitDecoderConfig(&config)) { - Py_RETURN_NONE; - } - - PyBytes_AsStringAndSize((PyObject *)webp_string, (char **)&webp, &size); - - vp8_status_code = WebPGetFeatures(webp, size, &config.input); - if (vp8_status_code == VP8_STATUS_OK) { - // If we don't set it, we don't get alpha. - // Initialized to MODE_RGB - if (config.input.has_alpha) { - config.output.colorspace = MODE_RGBA; - mode = "RGBA"; - } - -#ifndef HAVE_WEBPMUX - vp8_status_code = WebPDecode(webp, size, &config); -#else - { - int copy_data = 0; - WebPData data = {webp, size}; - WebPMuxFrameInfo image; - WebPData icc_profile_data = {0}; - WebPData exif_data = {0}; - - WebPMux *mux = WebPMuxCreate(&data, copy_data); - if (NULL == mux) { - goto end; - } - - if (WEBP_MUX_OK != WebPMuxGetFrame(mux, 1, &image)) { - WebPMuxDelete(mux); - goto end; - } - - webp = image.bitstream.bytes; - size = image.bitstream.size; - - vp8_status_code = WebPDecode(webp, size, &config); - - if (WEBP_MUX_OK == WebPMuxGetChunk(mux, "ICCP", &icc_profile_data)) { - icc_profile = PyBytes_FromStringAndSize( - (const char *)icc_profile_data.bytes, icc_profile_data.size - ); - } - - if (WEBP_MUX_OK == WebPMuxGetChunk(mux, "EXIF", &exif_data)) { - exif = PyBytes_FromStringAndSize( - (const char *)exif_data.bytes, exif_data.size - ); - } - - WebPDataClear(&image.bitstream); - WebPMuxDelete(mux); - } -#endif - } - - if (vp8_status_code != VP8_STATUS_OK) { - goto end; - } - - if (config.output.colorspace < MODE_YUV) { - bytes = PyBytes_FromStringAndSize( - (char *)config.output.u.RGBA.rgba, config.output.u.RGBA.size - ); - } else { - // Skipping YUV for now. Need Test Images. - // UNDONE -- unclear if we'll ever get here if we set mode_rgb* - bytes = PyBytes_FromStringAndSize( - (char *)config.output.u.YUVA.y, config.output.u.YUVA.y_size - ); - } - - pymode = PyUnicode_FromString(mode); - ret = Py_BuildValue( - "SiiSSS", - bytes, - config.output.width, - config.output.height, - pymode, - NULL == icc_profile ? Py_None : icc_profile, - NULL == exif ? Py_None : exif - ); - -end: - WebPFreeDecBuffer(&config.output); - - Py_XDECREF(bytes); - Py_XDECREF(pymode); - Py_XDECREF(icc_profile); - Py_XDECREF(exif); - - if (Py_None == ret) { - Py_RETURN_NONE; - } - - return ret; -} - -// Return the decoder's version number, packed in hexadecimal using 8bits for -// each of major/minor/revision. E.g: v2.5.7 is 0x020507. -PyObject * -WebPDecoderVersion_wrapper() { - return Py_BuildValue("i", WebPGetDecoderVersion()); -} - // Version as string const char * WebPDecoderVersion_str(void) { @@ -916,85 +750,26 @@ WebPDecoderVersion_str(void) { return version; } -/* - * The version of webp that ships with (0.1.3) Ubuntu 12.04 doesn't handle alpha well. - * Files that are valid with 0.3 are reported as being invalid. - */ -int -WebPDecoderBuggyAlpha(void) { - return WebPGetDecoderVersion() == 0x0103; -} - -PyObject * -WebPDecoderBuggyAlpha_wrapper() { - return Py_BuildValue("i", WebPDecoderBuggyAlpha()); -} - /* -------------------------------------------------------------------- */ /* Module Setup */ /* -------------------------------------------------------------------- */ static PyMethodDef webpMethods[] = { -#ifdef HAVE_WEBPANIM {"WebPAnimDecoder", _anim_decoder_new, METH_VARARGS, "WebPAnimDecoder"}, {"WebPAnimEncoder", _anim_encoder_new, METH_VARARGS, "WebPAnimEncoder"}, -#endif {"WebPEncode", WebPEncode_wrapper, METH_VARARGS, "WebPEncode"}, - {"WebPDecode", WebPDecode_wrapper, METH_VARARGS, "WebPDecode"}, - {"WebPDecoderVersion", WebPDecoderVersion_wrapper, METH_NOARGS, "WebPVersion"}, - {"WebPDecoderBuggyAlpha", - WebPDecoderBuggyAlpha_wrapper, - METH_NOARGS, - "WebPDecoderBuggyAlpha"}, {NULL, NULL} }; -void -addMuxFlagToModule(PyObject *m) { - PyObject *have_webpmux; -#ifdef HAVE_WEBPMUX - have_webpmux = Py_True; -#else - have_webpmux = Py_False; -#endif - Py_INCREF(have_webpmux); - PyModule_AddObject(m, "HAVE_WEBPMUX", have_webpmux); -} - -void -addAnimFlagToModule(PyObject *m) { - PyObject *have_webpanim; -#ifdef HAVE_WEBPANIM - have_webpanim = Py_True; -#else - have_webpanim = Py_False; -#endif - Py_INCREF(have_webpanim); - PyModule_AddObject(m, "HAVE_WEBPANIM", have_webpanim); -} - -void -addTransparencyFlagToModule(PyObject *m) { - PyObject *have_transparency = PyBool_FromLong(!WebPDecoderBuggyAlpha()); - if (PyModule_AddObject(m, "HAVE_TRANSPARENCY", have_transparency)) { - Py_DECREF(have_transparency); - } -} - static int setup_module(PyObject *m) { -#ifdef HAVE_WEBPANIM /* Ready object types */ if (PyType_Ready(&WebPAnimDecoder_Type) < 0 || PyType_Ready(&WebPAnimEncoder_Type) < 0) { return -1; } -#endif - PyObject *d = PyModule_GetDict(m); - addMuxFlagToModule(m); - addAnimFlagToModule(m); - addTransparencyFlagToModule(m); + PyObject *d = PyModule_GetDict(m); PyObject *v = PyUnicode_FromString(WebPDecoderVersion_str()); PyDict_SetItemString(d, "webpdecoder_version", v ? v : Py_None); Py_XDECREF(v); @@ -1008,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 b4e2e3899..a05387504 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 * @@ -690,25 +660,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; } /* -------------------------------------------------------------------- */ diff --git a/src/encode.c b/src/encode.c index 063528373..80a7acf0e 100644 --- a/src/encode.c +++ b/src/encode.c @@ -278,8 +278,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 +295,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 +323,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, }; /* -------------------------------------------------------------------- */ @@ -736,7 +709,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 +902,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 +932,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", @@ -1096,7 +1073,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "ss|nnnnppnnnnnnOz#y#y#", + "ss|nnnnppn(nn)nnnOz#y#y#", &mode, &rawmode, &quality, @@ -1254,7 +1231,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"; @@ -1268,7 +1245,7 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "ss|OOOsOnOOOssbbnz#p", + "ss|OOOsOnOOpssbbnz#p", &mode, &format, &offset, @@ -1403,7 +1380,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; @@ -1414,10 +1391,3 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { } #endif - -/* - * Local Variables: - * c-basic-offset: 4 - * End: - * - */ diff --git a/src/libImaging/Access.c b/src/libImaging/Access.c index bf7db281e..1c1937105 100644 --- a/src/libImaging/Access.c +++ b/src/libImaging/Access.c @@ -185,7 +185,7 @@ put_pixel_32(Imaging im, int x, int y, const void *color) { } void -ImagingAccessInit() { +ImagingAccessInit(void) { #define ADD(mode_, get_pixel_, put_pixel_) \ { \ ImagingAccess access = add_item(mode_); \ diff --git a/src/libImaging/BoxBlur.c b/src/libImaging/BoxBlur.c index 51cb7e102..ed91541fe 100644 --- a/src/libImaging/BoxBlur.c +++ b/src/libImaging/BoxBlur.c @@ -238,6 +238,9 @@ ImagingBoxBlur(Imaging imOut, Imaging imIn, float xradius, float yradius, int n) int i; Imaging imTransposed; + if (imOut->xsize == 0 || imOut->ysize == 0) { + return imOut; + } if (n < 1) { return ImagingError_ValueError("number of passes must be greater than zero"); } diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index f1c8ffcff..ea6f8805e 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -501,7 +501,8 @@ 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 (current->dx != 0 && j % 2 == 1 && + roundf(xx[j - 1]) == xx[j - 1]) { // Connect discontiguous corners for (k = 0; k < i; k++) { Edge *other_edge = edge_table[k]; @@ -510,10 +511,8 @@ polygon_generic( 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 (xx[j - 1] == + (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; 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/Filter.c b/src/libImaging/Filter.c index fbd6b425f..7b7b2e429 100644 --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -26,6 +26,8 @@ #include "Imaging.h" +#define ROUND_UP(f) ((int)((f) >= 0.0 ? (f) + 0.5F : (f) - 0.5F)) + static inline UINT8 clip8(float in) { if (in <= 0.0) { @@ -105,6 +107,22 @@ ImagingExpand(Imaging imIn, int xmargin, int ymargin) { return imOut; } +float +kernel_i16(int size, UINT8 *in0, int x, const float *kernel, int bigendian) { + int i; + float result = 0; + int half_size = (size - 1) / 2; + for (i = 0; i < size; i++) { + int x1 = x + i - half_size; + result += _i2f( + in0[x1 * 2 + (bigendian ? 1 : 0)] + + (in0[x1 * 2 + (bigendian ? 0 : 1)] >> 8) + ) * + kernel[i]; + } + return result; +} + void ImagingFilter3x3(Imaging imOut, Imaging im, const float *kernel, float offset) { #define KERNEL1x3(in0, x, kernel, d) \ @@ -135,6 +153,16 @@ ImagingFilter3x3(Imaging imOut, Imaging im, const float *kernel, float offset) { out[x] = in0[x]; } } else { + int bigendian = 0; + if (im->type == IMAGING_TYPE_SPECIAL) { + if (strcmp(im->mode, "I;16B") == 0 +#ifdef WORDS_BIGENDIAN + || strcmp(im->mode, "I;16N") == 0 +#endif + ) { + bigendian = 1; + } + } for (y = 1; y < im->ysize - 1; y++) { UINT8 *in_1 = (UINT8 *)im->image[y - 1]; UINT8 *in0 = (UINT8 *)im->image[y]; @@ -142,14 +170,31 @@ ImagingFilter3x3(Imaging imOut, Imaging im, const float *kernel, float offset) { UINT8 *out = (UINT8 *)imOut->image[y]; out[0] = in0[0]; + if (im->type == IMAGING_TYPE_SPECIAL) { + out[1] = in0[1]; + } for (x = 1; x < im->xsize - 1; x++) { float ss = offset; - ss += KERNEL1x3(in1, x, &kernel[0], 1); - ss += KERNEL1x3(in0, x, &kernel[3], 1); - ss += KERNEL1x3(in_1, x, &kernel[6], 1); - out[x] = clip8(ss); + if (im->type == IMAGING_TYPE_SPECIAL) { + ss += kernel_i16(3, in1, x, &kernel[0], bigendian); + ss += kernel_i16(3, in0, x, &kernel[3], bigendian); + ss += kernel_i16(3, in_1, x, &kernel[6], bigendian); + int ss_int = ROUND_UP(ss); + out[x * 2 + (bigendian ? 1 : 0)] = clip8(ss_int % 256); + out[x * 2 + (bigendian ? 0 : 1)] = clip8(ss_int >> 8); + } else { + ss += KERNEL1x3(in1, x, &kernel[0], 1); + ss += KERNEL1x3(in0, x, &kernel[3], 1); + ss += KERNEL1x3(in_1, x, &kernel[6], 1); + out[x] = clip8(ss); + } + } + if (im->type == IMAGING_TYPE_SPECIAL) { + out[x * 2] = in0[x * 2]; + out[x * 2 + 1] = in0[x * 2 + 1]; + } else { + out[x] = in0[x]; } - out[x] = in0[x]; } } } else { @@ -261,6 +306,16 @@ ImagingFilter5x5(Imaging imOut, Imaging im, const float *kernel, float offset) { out[x + 1] = in0[x + 1]; } } else { + int bigendian = 0; + if (im->type == IMAGING_TYPE_SPECIAL) { + if (strcmp(im->mode, "I;16B") == 0 +#ifdef WORDS_BIGENDIAN + || strcmp(im->mode, "I;16N") == 0 +#endif + ) { + bigendian = 1; + } + } for (y = 2; y < im->ysize - 2; y++) { UINT8 *in_2 = (UINT8 *)im->image[y - 2]; UINT8 *in_1 = (UINT8 *)im->image[y - 1]; @@ -271,17 +326,39 @@ ImagingFilter5x5(Imaging imOut, Imaging im, const float *kernel, float offset) { out[0] = in0[0]; out[1] = in0[1]; + if (im->type == IMAGING_TYPE_SPECIAL) { + out[2] = in0[2]; + out[3] = in0[3]; + } for (x = 2; x < im->xsize - 2; x++) { float ss = offset; - ss += KERNEL1x5(in2, x, &kernel[0], 1); - ss += KERNEL1x5(in1, x, &kernel[5], 1); - ss += KERNEL1x5(in0, x, &kernel[10], 1); - ss += KERNEL1x5(in_1, x, &kernel[15], 1); - ss += KERNEL1x5(in_2, x, &kernel[20], 1); - out[x] = clip8(ss); + if (im->type == IMAGING_TYPE_SPECIAL) { + ss += kernel_i16(5, in2, x, &kernel[0], bigendian); + ss += kernel_i16(5, in1, x, &kernel[5], bigendian); + ss += kernel_i16(5, in0, x, &kernel[10], bigendian); + ss += kernel_i16(5, in_1, x, &kernel[15], bigendian); + ss += kernel_i16(5, in_2, x, &kernel[20], bigendian); + int ss_int = ROUND_UP(ss); + out[x * 2 + (bigendian ? 1 : 0)] = clip8(ss_int % 256); + out[x * 2 + (bigendian ? 0 : 1)] = clip8(ss_int >> 8); + } else { + ss += KERNEL1x5(in2, x, &kernel[0], 1); + ss += KERNEL1x5(in1, x, &kernel[5], 1); + ss += KERNEL1x5(in0, x, &kernel[10], 1); + ss += KERNEL1x5(in_1, x, &kernel[15], 1); + ss += KERNEL1x5(in_2, x, &kernel[20], 1); + out[x] = clip8(ss); + } + } + if (im->type == IMAGING_TYPE_SPECIAL) { + out[x * 2 + 0] = in0[x * 2 + 0]; + out[x * 2 + 1] = in0[x * 2 + 1]; + out[x * 2 + 2] = in0[x * 2 + 2]; + out[x * 2 + 3] = in0[x * 2 + 3]; + } else { + out[x + 0] = in0[x + 0]; + out[x + 1] = in0[x + 1]; } - out[x + 0] = in0[x + 0]; - out[x + 1] = in0[x + 1]; } } } else { @@ -383,7 +460,8 @@ ImagingFilter(Imaging im, int xsize, int ysize, const FLOAT32 *kernel, FLOAT32 o Imaging imOut; ImagingSectionCookie cookie; - if (im->type != IMAGING_TYPE_UINT8 && im->type != IMAGING_TYPE_INT32) { + if (im->type == IMAGING_TYPE_FLOAT32 || + (im->type == IMAGING_TYPE_SPECIAL && im->bands != 1)) { return (Imaging)ImagingError_ModeError(); } diff --git a/src/libImaging/FliDecode.c b/src/libImaging/FliDecode.c index 6b2518d35..130ecb7f7 100644 --- a/src/libImaging/FliDecode.c +++ b/src/libImaging/FliDecode.c @@ -224,7 +224,7 @@ ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt break; case 16: /* COPY chunk */ - if (INT32_MAX / state->xsize < state->ysize) { + if (INT32_MAX < (uint64_t)state->xsize * state->ysize) { /* Integer overflow, bail */ state->errcode = IMAGING_CODEC_OVERRUN; return -1; diff --git a/src/libImaging/Geometry.c b/src/libImaging/Geometry.c index 2bfeed7b6..1e2abd7e7 100644 --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -791,15 +791,15 @@ ImagingGenericTransform( char *out; double xx, yy; + if (!imOut || !imIn || strcmp(imIn->mode, imOut->mode) != 0) { + return (Imaging)ImagingError_ModeError(); + } + ImagingTransformFilter filter = getfilter(imIn, filterid); if (!filter) { return (Imaging)ImagingError_ValueError("bad filter number"); } - if (!imOut || !imIn || strcmp(imIn->mode, imOut->mode) != 0) { - return (Imaging)ImagingError_ModeError(); - } - ImagingCopyPalette(imOut, imIn); ImagingSectionEnter(&cookie); @@ -1035,6 +1035,10 @@ ImagingTransformAffine( double xx, yy; double xo, yo; + if (!imOut || !imIn || strcmp(imIn->mode, imOut->mode) != 0) { + return (Imaging)ImagingError_ModeError(); + } + if (filterid || imIn->type == IMAGING_TYPE_SPECIAL) { return ImagingGenericTransform( imOut, imIn, x0, y0, x1, y1, affine_transform, a, filterid, fill @@ -1046,10 +1050,6 @@ ImagingTransformAffine( return ImagingScaleAffine(imOut, imIn, x0, y0, x1, y1, a, fill); } - if (!imOut || !imIn || strcmp(imIn->mode, imOut->mode) != 0) { - return (Imaging)ImagingError_ModeError(); - } - if (x0 < 0) { x0 = 0; } diff --git a/src/libImaging/GetBBox.c b/src/libImaging/GetBBox.c index c61a27d3b..d430893dd 100644 --- a/src/libImaging/GetBBox.c +++ b/src/libImaging/GetBBox.c @@ -30,26 +30,57 @@ ImagingGetBBox(Imaging im, int bbox[4], int alpha_only) { bbox[1] = -1; bbox[2] = bbox[3] = 0; -#define GETBBOX(image, mask) \ - for (y = 0; y < im->ysize; y++) { \ - has_data = 0; \ - for (x = 0; x < im->xsize; x++) { \ - if (im->image[y][x] & mask) { \ - has_data = 1; \ - if (x < bbox[0]) { \ - bbox[0] = x; \ - } \ - if (x >= bbox[2]) { \ - bbox[2] = x + 1; \ - } \ - } \ - } \ - if (has_data) { \ - if (bbox[1] < 0) { \ - bbox[1] = y; \ - } \ - bbox[3] = y + 1; \ - } \ +#define GETBBOX(image, mask) \ + /* first stage: looking for any pixels from top */ \ + for (y = 0; y < im->ysize; y++) { \ + has_data = 0; \ + for (x = 0; x < im->xsize; x++) { \ + if (im->image[y][x] & mask) { \ + has_data = 1; \ + bbox[0] = x; \ + bbox[1] = y; \ + break; \ + } \ + } \ + if (has_data) { \ + break; \ + } \ + } \ + /* Check that we have a box */ \ + if (bbox[1] < 0) { \ + return 0; /* no data */ \ + } \ + /* second stage: looking for any pixels from bottom */ \ + for (y = im->ysize - 1; y >= bbox[1]; y--) { \ + has_data = 0; \ + for (x = 0; x < im->xsize; x++) { \ + if (im->image[y][x] & mask) { \ + has_data = 1; \ + if (x < bbox[0]) { \ + bbox[0] = x; \ + } \ + bbox[3] = y + 1; \ + break; \ + } \ + } \ + if (has_data) { \ + break; \ + } \ + } \ + /* third stage: looking for left and right boundaries */ \ + for (y = bbox[1]; y < bbox[3]; y++) { \ + for (x = 0; x < bbox[0]; x++) { \ + if (im->image[y][x] & mask) { \ + bbox[0] = x; \ + break; \ + } \ + } \ + for (x = im->xsize - 1; x >= bbox[2]; x--) { \ + if (im->image[y][x] & mask) { \ + bbox[2] = x + 1; \ + break; \ + } \ + } \ } if (im->image8) { @@ -71,11 +102,6 @@ ImagingGetBBox(Imaging im, int bbox[4], int alpha_only) { GETBBOX(image32, mask); } - /* Check that we got a box */ - if (bbox[1] < 0) { - return 0; /* no data */ - } - return 1; /* ok */ } @@ -144,6 +170,9 @@ ImagingGetExtrema(Imaging im, void *extrema) { imax = in[x]; } } + if (imin == 0 && imax == 255) { + break; + } } ((UINT8 *)extrema)[0] = (UINT8)imin; ((UINT8 *)extrema)[1] = (UINT8)imax; diff --git a/src/libImaging/ImPlatform.h b/src/libImaging/ImPlatform.h index f6e7fb6b9..2ce282241 100644 --- a/src/libImaging/ImPlatform.h +++ b/src/libImaging/ImPlatform.h @@ -7,6 +7,7 @@ * Copyright (c) Fredrik Lundh 1995-2003. */ +#define PY_SSIZE_T_CLEAN #include "Python.h" /* Check that we have an ANSI compliant compiler */ @@ -43,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 @@ -54,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 b1c3aed41..0c2d3fc2e 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -60,8 +60,8 @@ typedef struct ImagingHistogramInstance *ImagingHistogram; typedef struct ImagingOutlineInstance *ImagingOutline; typedef struct ImagingPaletteInstance *ImagingPalette; -/* handle magics (used with PyCObject). */ -#define IMAGING_MAGIC "PIL Imaging" +/* handle magics (used with PyCapsule). */ +#define IMAGING_MAGIC "Pillow Imaging" /* pixel types */ #define IMAGING_TYPE_UINT8 0 @@ -161,6 +161,9 @@ typedef struct ImagingMemoryArena { int stats_reallocated_blocks; /* Number of blocks which were actually reallocated after retrieving */ int stats_freed_blocks; /* Number of freed blocks */ +#ifdef Py_GIL_DISABLED + PyMutex mutex; +#endif } *ImagingMemoryArena; /* Objects */ @@ -606,10 +609,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 @@ -710,6 +709,15 @@ _imaging_tell_pyFd(PyObject *fd); #include "ImagingUtils.h" extern UINT8 *clip8_lookups; +/* Mutex lock/unlock helpers */ +#ifdef Py_GIL_DISABLED +#define MUTEX_LOCK(m) PyMutex_Lock(m) +#define MUTEX_UNLOCK(m) PyMutex_Unlock(m) +#else +#define MUTEX_LOCK(m) +#define MUTEX_UNLOCK(m) +#endif + #if defined(__cplusplus) } #endif diff --git a/src/libImaging/Jpeg2K.h b/src/libImaging/Jpeg2K.h index e8d92f7b6..6fbbf7311 100644 --- a/src/libImaging/Jpeg2K.h +++ b/src/libImaging/Jpeg2K.h @@ -104,10 +104,3 @@ typedef struct { int plt; } JPEG2KENCODESTATE; - -/* - * Local Variables: - * c-basic-offset: 4 - * End: - * - */ diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index 5b3d7ffc4..4f185b529 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -640,7 +640,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; @@ -698,8 +698,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { } /* Check that this image is something we can handle */ - if (image->numcomps < 1 || image->numcomps > 4 || - image->color_space == OPJ_CLRSPC_UNKNOWN) { + if (image->numcomps < 1 || image->numcomps > 4) { state->errcode = IMAGING_CODEC_BROKEN; state->state = J2K_STATE_FAILED; goto quick_exit; @@ -744,7 +743,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { /* Find the correct unpacker */ color_space = image->color_space; - if (color_space == OPJ_CLRSPC_UNSPECIFIED) { + if (color_space == OPJ_CLRSPC_UNKNOWN || color_space == OPJ_CLRSPC_UNSPECIFIED) { switch (image->numcomps) { case 1: case 2: @@ -871,7 +870,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) { @@ -884,7 +883,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( @@ -980,10 +978,3 @@ ImagingJpeg2KVersion(void) { } #endif /* HAVE_OPENJPEG */ - -/* - * Local Variables: - * c-basic-offset: 4 - * End: - * - */ diff --git a/src/libImaging/Jpeg2KEncode.c b/src/libImaging/Jpeg2KEncode.c index cb21a186c..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; @@ -652,10 +659,3 @@ ImagingJpeg2KEncodeCleanup(ImagingCodecState state) { } #endif /* HAVE_OPENJPEG */ - -/* - * Local Variables: - * c-basic-offset: 4 - * End: - * - */ diff --git a/src/libImaging/JpegDecode.c b/src/libImaging/JpegDecode.c index 30c64f235..2970f56d1 100644 --- a/src/libImaging/JpegDecode.c +++ b/src/libImaging/JpegDecode.c @@ -48,7 +48,7 @@ char *libjpeg_turbo_version = NULL; #endif int -ImagingJpegUseJCSExtensions() { +ImagingJpegUseJCSExtensions(void) { int use_jcs_extensions = 0; #ifdef JCS_EXTENSIONS #if defined(LIBJPEG_TURBO_VERSION_NUMBER) diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 195730b7c..304daf0af 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/Pack.c b/src/libImaging/Pack.c index f3b714215..c29473d90 100644 --- a/src/libImaging/Pack.c +++ b/src/libImaging/Pack.c @@ -258,16 +258,6 @@ void ImagingPackRGB(UINT8 *out, const UINT8 *in, int pixels) { int i = 0; /* RGB triplets */ -#ifdef __sparc - /* SPARC CPUs cannot read integers from nonaligned addresses. */ - for (; i < pixels; i++) { - out[0] = in[R]; - out[1] = in[G]; - out[2] = in[B]; - out += 3; - in += 4; - } -#else for (; i < pixels - 1; i++) { memcpy(out, in + i * 4, 4); out += 3; @@ -278,7 +268,6 @@ ImagingPackRGB(UINT8 *out, const UINT8 *in, int pixels) { out[2] = in[i * 4 + B]; out += 3; } -#endif } void diff --git a/src/libImaging/Quant.c b/src/libImaging/Quant.c index 197f9f3ee..a489a882d 100644 --- a/src/libImaging/Quant.c +++ b/src/libImaging/Quant.c @@ -36,7 +36,8 @@ #define UINT32_MAX 0xffffffff #endif -#define NO_OUTPUT +// #define DEBUG +// #define TEST_NEAREST_NEIGHBOUR typedef struct { uint32_t scale; @@ -144,7 +145,7 @@ create_pixel_hash(Pixel *pixelData, uint32_t nPixels) { PixelHashData *d; HashTable *hash; uint32_t i; -#ifndef NO_OUTPUT +#ifdef DEBUG uint32_t timer, timer2, timer3; #endif @@ -156,7 +157,7 @@ create_pixel_hash(Pixel *pixelData, uint32_t nPixels) { hash = hashtable_new(pixel_hash, pixel_cmp); hashtable_set_user_data(hash, d); d->scale = 0; -#ifndef NO_OUTPUT +#ifdef DEBUG timer = timer3 = clock(); #endif for (i = 0; i < nPixels; i++) { @@ -167,22 +168,22 @@ create_pixel_hash(Pixel *pixelData, uint32_t nPixels) { } while (hashtable_get_count(hash) > MAX_HASH_ENTRIES) { d->scale++; -#ifndef NO_OUTPUT +#ifdef DEBUG printf("rehashing - new scale: %d\n", (int)d->scale); timer2 = clock(); #endif hashtable_rehash_compute(hash, rehash_collide); -#ifndef NO_OUTPUT +#ifdef DEBUG timer2 = clock() - timer2; printf("rehash took %f sec\n", timer2 / (double)CLOCKS_PER_SEC); timer += timer2; #endif } } -#ifndef NO_OUTPUT +#ifdef DEBUG printf("inserts took %f sec\n", (clock() - timer) / (double)CLOCKS_PER_SEC); #endif -#ifndef NO_OUTPUT +#ifdef DEBUG printf("total %f sec\n", (clock() - timer3) / (double)CLOCKS_PER_SEC); #endif return hash; @@ -304,18 +305,18 @@ mergesort_pixels(PixelList *head, int i) { return head; } -#if defined(TEST_MERGESORT) || defined(TEST_SORTED) +#ifdef DEBUG static int test_sorted(PixelList *pl[3]) { - int i, n, l; + int i, l; PixelList *t; for (i = 0; i < 3; i++) { - n = 0; l = 256; for (t = pl[i]; t; t = t->next[i]) { - if (l < t->p.a.v[i]) + if (l < t->p.a.v[i]) { return 0; + } l = t->p.a.v[i]; } } @@ -347,12 +348,12 @@ splitlists( PixelList *l, *r, *c, *n; int i; int nRight; -#ifndef NO_OUTPUT +#ifdef DEBUG int nLeft; #endif int splitColourVal; -#ifdef TEST_SPLIT +#ifdef DEBUG { PixelList *_prevTest, *_nextTest; int _i, _nextCount[3], _prevCount[3]; @@ -402,14 +403,14 @@ splitlists( #endif nCount[0] = nCount[1] = 0; nRight = 0; -#ifndef NO_OUTPUT +#ifdef DEBUG nLeft = 0; #endif for (left = 0, c = h[axis]; c;) { left = left + c->count; nCount[0] += c->count; c->flag = 0; -#ifndef NO_OUTPUT +#ifdef DEBUG nLeft++; #endif c = c->next[axis]; @@ -424,7 +425,7 @@ splitlists( break; } c->flag = 0; -#ifndef NO_OUTPUT +#ifdef DEBUG nLeft++; #endif nCount[0] += c->count; @@ -442,14 +443,14 @@ splitlists( } c->flag = 1; nRight++; -#ifndef NO_OUTPUT +#ifdef DEBUG nLeft--; #endif nCount[0] -= c->count; nCount[1] += c->count; } } -#ifndef NO_OUTPUT +#ifdef DEBUG if (!nLeft) { for (c = h[axis]; c; c = c->next[axis]) { printf("[%d %d %d]\n", c->p.c.r, c->p.c.g, c->p.c.b); @@ -511,7 +512,7 @@ split(BoxNode *node) { gl = node->tail[1]->p.c.g; bh = node->head[2]->p.c.b; bl = node->tail[2]->p.c.b; -#ifdef TEST_SPLIT +#ifdef DEBUG printf("splitting node [%d %d %d] [%d %d %d] ", rl, gl, bl, rh, gh, bh); #endif f[0] = (rh - rl) * 77; @@ -526,11 +527,8 @@ split(BoxNode *node) { axis = i; } } -#ifdef TEST_SPLIT +#ifdef DEBUG printf("along axis %d\n", axis + 1); -#endif - -#ifdef TEST_SPLIT { PixelList *_prevTest, *_nextTest; int _i, _nextCount[3], _prevCount[3]; @@ -592,12 +590,12 @@ split(BoxNode *node) { if (!splitlists( node->head, node->tail, heads, tails, newCounts, axis, node->pixelCount )) { -#ifndef NO_OUTPUT +#ifdef DEBUG printf("list split failed.\n"); #endif return 0; } -#ifdef TEST_SPLIT +#ifdef DEBUG if (!test_sorted(heads[0])) { printf("bug in split"); exit(1); @@ -623,7 +621,7 @@ split(BoxNode *node) { node->head[i] = NULL; node->tail[i] = NULL; } -#ifdef TEST_SPLIT +#ifdef DEBUG if (left->head[0]) { rh = left->head[0]->p.c.r; rl = left->tail[0]->p.c.r; @@ -687,7 +685,7 @@ median_cut(PixelList *hl[3], uint32_t imPixelCount, int nPixels) { } } while (compute_box_volume(thisNode) == 1); if (!split(thisNode)) { -#ifndef NO_OUTPUT +#ifdef DEBUG printf("Oops, split failed...\n"); #endif exit(1); @@ -716,16 +714,14 @@ free_box_tree(BoxNode *n) { free(n); } -#ifdef TEST_SPLIT_INTEGRITY +#ifdef DEBUG static int checkContained(BoxNode *n, Pixel *pp) { if (n->l && n->r) { return checkContained(n->l, pp) + checkContained(n->r, pp); } if (n->l || n->r) { -#ifndef NO_OUTPUT printf("box tree is dead\n"); -#endif return 0; } if (pp->c.r <= n->head[0]->p.c.r && pp->c.r >= n->tail[0]->p.c.r && @@ -746,7 +742,7 @@ annotate_hash_table(BoxNode *n, HashTable *h, uint32_t *box) { return annotate_hash_table(n->l, h, box) && annotate_hash_table(n->r, h, box); } if (n->l || n->r) { -#ifndef NO_OUTPUT +#ifdef DEBUG printf("box tree is dead\n"); #endif return 0; @@ -754,7 +750,7 @@ annotate_hash_table(BoxNode *n, HashTable *h, uint32_t *box) { for (p = n->head[0]; p; p = p->next[0]) { PIXEL_UNSCALE(&(p->p), &q, d->scale); if (!hashtable_insert(h, q, *box)) { -#ifndef NO_OUTPUT +#ifdef DEBUG printf("hashtable insert failed\n"); #endif return 0; @@ -978,7 +974,7 @@ map_image_pixels_from_median_box( continue; } if (!hashtable_lookup(medianBoxHash, pixelData[i], &pixelVal)) { -#ifndef NO_OUTPUT +#ifdef DEBUG printf("pixel lookup failed\n"); #endif return 0; @@ -1014,7 +1010,8 @@ compute_palette_from_median_cut( uint32_t nPixels, HashTable *medianBoxHash, Pixel **palette, - uint32_t nPaletteEntries + uint32_t nPaletteEntries, + BoxNode *root ) { uint32_t i; uint32_t paletteEntry; @@ -1043,7 +1040,7 @@ compute_palette_from_median_cut( } } for (i = 0; i < nPixels; i++) { -#ifdef TEST_SPLIT_INTEGRITY +#ifdef DEBUG if (!(i % 100)) { printf("%05d\r", i); fflush(stdout); @@ -1058,7 +1055,7 @@ compute_palette_from_median_cut( } #endif if (!hashtable_lookup(medianBoxHash, pixelData[i], &paletteEntry)) { -#ifndef NO_OUTPUT +#ifdef DEBUG printf("pixel lookup failed\n"); #endif for (i = 0; i < 3; i++) { @@ -1068,7 +1065,7 @@ compute_palette_from_median_cut( return 0; } if (paletteEntry >= nPaletteEntries) { -#ifndef NO_OUTPUT +#ifdef DEBUG printf( "panic - paletteEntry>=nPaletteEntries (%d>=%d)\n", (int)paletteEntry, @@ -1140,7 +1137,7 @@ compute_palette_from_quantized_pixels( } for (i = 0; i < nPixels; i++) { if (qp[i] >= nPaletteEntries) { -#ifndef NO_OUTPUT +#ifdef DEBUG printf("scream\n"); #endif return 0; @@ -1208,7 +1205,7 @@ k_means( goto error_2; } -#ifndef NO_OUTPUT +#ifdef DEBUG printf("["); fflush(stdout); #endif @@ -1243,7 +1240,7 @@ k_means( if (changes < 0) { goto error_3; } -#ifndef NO_OUTPUT +#ifdef DEBUG printf(".(%d)", changes); fflush(stdout); #endif @@ -1251,7 +1248,7 @@ k_means( break; } } -#ifndef NO_OUTPUT +#ifdef DEBUG printf("]\n"); #endif if (avgDistSortKey) { @@ -1311,32 +1308,32 @@ quantize( uint32_t **avgDistSortKey; Pixel *p; -#ifndef NO_OUTPUT +#ifdef DEBUG uint32_t timer, timer2; #endif -#ifndef NO_OUTPUT +#ifdef DEBUG timer2 = clock(); printf("create hash table..."); fflush(stdout); timer = clock(); #endif h = create_pixel_hash(pixelData, nPixels); -#ifndef NO_OUTPUT +#ifdef DEBUG printf("done (%f)\n", (clock() - timer) / (double)CLOCKS_PER_SEC); #endif if (!h) { goto error_0; } -#ifndef NO_OUTPUT +#ifdef DEBUG printf("create lists from hash table..."); fflush(stdout); timer = clock(); #endif hl[0] = hl[1] = hl[2] = NULL; hashtable_foreach(h, hash_to_list, hl); -#ifndef NO_OUTPUT +#ifdef DEBUG printf("done (%f)\n", (clock() - timer) / (double)CLOCKS_PER_SEC); #endif @@ -1344,7 +1341,7 @@ quantize( goto error_1; } -#ifndef NO_OUTPUT +#ifdef DEBUG printf("mergesort lists..."); fflush(stdout); timer = clock(); @@ -1352,47 +1349,47 @@ quantize( for (i = 0; i < 3; i++) { hl[i] = mergesort_pixels(hl[i], i); } -#ifdef TEST_MERGESORT +#ifdef DEBUG if (!test_sorted(hl)) { printf("bug in mergesort\n"); goto error_1; } -#endif -#ifndef NO_OUTPUT printf("done (%f)\n", (clock() - timer) / (double)CLOCKS_PER_SEC); #endif -#ifndef NO_OUTPUT +#ifdef DEBUG printf("median cut..."); fflush(stdout); timer = clock(); #endif root = median_cut(hl, nPixels, nQuantPixels); -#ifndef NO_OUTPUT +#ifdef DEBUG printf("done (%f)\n", (clock() - timer) / (double)CLOCKS_PER_SEC); #endif if (!root) { goto error_1; } nPaletteEntries = 0; -#ifndef NO_OUTPUT +#ifdef DEBUG printf("median cut tree to hash table..."); fflush(stdout); timer = clock(); #endif annotate_hash_table(root, h, &nPaletteEntries); -#ifndef NO_OUTPUT +#ifdef DEBUG printf("done (%f)\n", (clock() - timer) / (double)CLOCKS_PER_SEC); #endif -#ifndef NO_OUTPUT +#ifdef DEBUG printf("compute palette...\n"); fflush(stdout); timer = clock(); #endif - if (!compute_palette_from_median_cut(pixelData, nPixels, h, &p, nPaletteEntries)) { + if (!compute_palette_from_median_cut( + pixelData, nPixels, h, &p, nPaletteEntries, root + )) { goto error_3; } -#ifndef NO_OUTPUT +#ifdef DEBUG printf("done (%f)\n", (clock() - timer) / (double)CLOCKS_PER_SEC); #endif @@ -1479,7 +1476,7 @@ quantize( hashtable_free(h2); } #endif -#ifndef NO_OUTPUT +#ifdef DEBUG printf("k means...\n"); fflush(stdout); timer = clock(); @@ -1487,7 +1484,7 @@ quantize( if (kmeans > 0) { k_means(pixelData, nPixels, p, nPaletteEntries, qp, kmeans - 1); } -#ifndef NO_OUTPUT +#ifdef DEBUG printf("done (%f)\n", (clock() - timer) / (double)CLOCKS_PER_SEC); #endif @@ -1495,7 +1492,7 @@ quantize( *palette = p; *paletteLength = nPaletteEntries; -#ifndef NO_OUTPUT +#ifdef DEBUG printf("cleanup..."); fflush(stdout); timer = clock(); @@ -1507,7 +1504,7 @@ quantize( free(avgDistSortKey); } destroy_pixel_hash(h); -#ifndef NO_OUTPUT +#ifdef DEBUG printf("done (%f)\n", (clock() - timer) / (double)CLOCKS_PER_SEC); printf("-----\ntotal time %f\n", (clock() - timer2) / (double)CLOCKS_PER_SEC); #endif diff --git a/src/libImaging/QuantOctree.c b/src/libImaging/QuantOctree.c index 7e02ebf65..ec60b214e 100644 --- a/src/libImaging/QuantOctree.c +++ b/src/libImaging/QuantOctree.c @@ -188,7 +188,7 @@ create_sorted_color_palette(const ColorCube cube) { buckets, cube->size, sizeof(struct _ColorBucket), - (int (*)(void const *, void const *)) & compare_bucket_count + (int (*)(void const *, void const *))&compare_bucket_count ); return buckets; diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index 222d6bca4..f5e386dc2 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -460,6 +460,83 @@ ImagingResampleVertical_8bpc( ImagingSectionLeave(&cookie); } +void +ImagingResampleHorizontal_16bpc( + Imaging imOut, Imaging imIn, int offset, int ksize, int *bounds, double *kk +) { + ImagingSectionCookie cookie; + double ss; + int xx, yy, x, xmin, xmax, ss_int; + double *k; + + int bigendian = 0; + if (strcmp(imIn->mode, "I;16N") == 0 +#ifdef WORDS_BIGENDIAN + || strcmp(imIn->mode, "I;16B") == 0 +#endif + ) { + bigendian = 1; + } + + ImagingSectionEnter(&cookie); + for (yy = 0; yy < imOut->ysize; yy++) { + for (xx = 0; xx < imOut->xsize; xx++) { + xmin = bounds[xx * 2 + 0]; + xmax = bounds[xx * 2 + 1]; + k = &kk[xx * ksize]; + ss = 0.0; + for (x = 0; x < xmax; x++) { + ss += (imIn->image8[yy + offset][(x + xmin) * 2 + (bigendian ? 1 : 0)] + + (imIn->image8[yy + offset][(x + xmin) * 2 + (bigendian ? 0 : 1)] + << 8)) * + k[x]; + } + ss_int = ROUND_UP(ss); + imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256); + imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8); + } + } + ImagingSectionLeave(&cookie); +} + +void +ImagingResampleVertical_16bpc( + Imaging imOut, Imaging imIn, int offset, int ksize, int *bounds, double *kk +) { + ImagingSectionCookie cookie; + double ss; + int xx, yy, y, ymin, ymax, ss_int; + double *k; + + int bigendian = 0; + if (strcmp(imIn->mode, "I;16N") == 0 +#ifdef WORDS_BIGENDIAN + || strcmp(imIn->mode, "I;16B") == 0 +#endif + ) { + bigendian = 1; + } + + ImagingSectionEnter(&cookie); + for (yy = 0; yy < imOut->ysize; yy++) { + ymin = bounds[yy * 2 + 0]; + ymax = bounds[yy * 2 + 1]; + k = &kk[yy * ksize]; + for (xx = 0; xx < imOut->xsize; xx++) { + ss = 0.0; + for (y = 0; y < ymax; y++) { + ss += (imIn->image8[y + ymin][xx * 2 + (bigendian ? 1 : 0)] + + (imIn->image8[y + ymin][xx * 2 + (bigendian ? 0 : 1)] << 8)) * + k[y]; + } + ss_int = ROUND_UP(ss); + imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256); + imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8); + } + } + ImagingSectionLeave(&cookie); +} + void ImagingResampleHorizontal_32bpc( Imaging imOut, Imaging imIn, int offset, int ksize, int *bounds, double *kk @@ -574,7 +651,12 @@ ImagingResample(Imaging imIn, int xsize, int ysize, int filter, float box[4]) { } if (imIn->type == IMAGING_TYPE_SPECIAL) { - return (Imaging)ImagingError_ModeError(); + if (strncmp(imIn->mode, "I;16", 4) == 0) { + ResampleHorizontal = ImagingResampleHorizontal_16bpc; + ResampleVertical = ImagingResampleVertical_16bpc; + } else { + return (Imaging)ImagingError_ModeError(); + } } else if (imIn->image8) { ResampleHorizontal = ImagingResampleHorizontal_8bpc; ResampleVertical = ImagingResampleVertical_8bpc; diff --git a/src/libImaging/SgiRleDecode.c b/src/libImaging/SgiRleDecode.c index a8db11740..e60468990 100644 --- a/src/libImaging/SgiRleDecode.c +++ b/src/libImaging/SgiRleDecode.c @@ -183,7 +183,7 @@ ImagingSgiRleDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t each with 4 bytes per element of tablen Check here before we allocate any memory */ - if (c->bufsize < 8 * c->tablen) { + if (c->bufsize < 8 * (int64_t)c->tablen) { state->errcode = IMAGING_CODEC_OVERRUN; return -1; } @@ -195,6 +195,7 @@ ImagingSgiRleDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t } _imaging_seek_pyFd(state->fd, SGI_HEADER_SIZE, SEEK_SET); if (_imaging_read_pyFd(state->fd, (char *)ptr, c->bufsize) != c->bufsize) { + free(ptr); state->errcode = IMAGING_CODEC_UNKNOWN; return -1; } diff --git a/src/libImaging/Storage.c b/src/libImaging/Storage.c index 9dc133b0f..522e9f375 100644 --- a/src/libImaging/Storage.c +++ b/src/libImaging/Storage.c @@ -218,7 +218,9 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { break; } + MUTEX_LOCK(&ImagingDefaultArena.mutex); ImagingDefaultArena.stats_new_count += 1; + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); return im; } @@ -267,7 +269,10 @@ struct ImagingMemoryArena ImagingDefaultArena = { 0, 0, 0, - 0 // Stats + 0, // Stats +#ifdef Py_GIL_DISABLED + {0}, +#endif }; int @@ -364,18 +369,19 @@ ImagingDestroyArray(Imaging im) { int y = 0; if (im->blocks) { + MUTEX_LOCK(&ImagingDefaultArena.mutex); while (im->blocks[y].ptr) { memory_return_block(&ImagingDefaultArena, im->blocks[y]); y += 1; } + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); free(im->blocks); } } Imaging -ImagingAllocateArray(Imaging im, int dirty, int block_size) { +ImagingAllocateArray(Imaging im, ImagingMemoryArena arena, int dirty, int block_size) { int y, line_in_block, current_block; - ImagingMemoryArena arena = &ImagingDefaultArena; ImagingMemoryBlock block = {NULL, 0}; int aligned_linesize, lines_per_block, blocks_count; char *aligned_ptr = NULL; @@ -498,14 +504,22 @@ ImagingNewInternal(const char *mode, int xsize, int ysize, int dirty) { return NULL; } - if (ImagingAllocateArray(im, dirty, ImagingDefaultArena.block_size)) { + MUTEX_LOCK(&ImagingDefaultArena.mutex); + Imaging tmp = ImagingAllocateArray( + im, &ImagingDefaultArena, dirty, ImagingDefaultArena.block_size + ); + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); + if (tmp) { return im; } ImagingError_Clear(); // Try to allocate the image once more with smallest possible block size - if (ImagingAllocateArray(im, dirty, IMAGING_PAGE_SIZE)) { + MUTEX_LOCK(&ImagingDefaultArena.mutex); + tmp = ImagingAllocateArray(im, &ImagingDefaultArena, dirty, IMAGING_PAGE_SIZE); + MUTEX_UNLOCK(&ImagingDefaultArena.mutex); + if (tmp) { return im; } diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 18a54f633..e4da9162d 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -780,7 +780,7 @@ ImagingLibTiffDecode( decode_err: // TIFFClose in libtiff calls tif_closeproc and TIFFCleanup if (clientstate->fp) { - // Pillow will manage the closing of the file rather than libtiff + // Python will manage the closing of the file rather than libtiff // So only call TIFFCleanup TIFFCleanup(tiff); } else { @@ -1008,7 +1008,17 @@ ImagingLibTiffEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int byt ) == -1) { TRACE(("Encode Error, row %d\n", state->y)); state->errcode = IMAGING_CODEC_BROKEN; - TIFFClose(tiff); + + // TIFFClose in libtiff calls tif_closeproc and TIFFCleanup + if (clientstate->fp) { + // Python will manage the closing of the file rather than libtiff + // So only call TIFFCleanup + TIFFCleanup(tiff); + } else { + // When tif_closeproc refers to our custom _tiffCloseProc though, + // that is fine, as it does not close the file + TIFFClose(tiff); + } if (!clientstate->fp) { free(clientstate->data); } @@ -1025,14 +1035,22 @@ ImagingLibTiffEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int byt TRACE(("Error flushing the tiff")); // likely reason is memory. state->errcode = IMAGING_CODEC_MEMORY; - TIFFClose(tiff); + if (clientstate->fp) { + TIFFCleanup(tiff); + } else { + TIFFClose(tiff); + } if (!clientstate->fp) { free(clientstate->data); } return -1; } TRACE(("Closing \n")); - TIFFClose(tiff); + if (clientstate->fp) { + TIFFCleanup(tiff); + } else { + TIFFClose(tiff); + } // reset the clientstate metadata to use it to read out the buffer. clientstate->loc = 0; clientstate->size = clientstate->eof; // redundant? 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 b96e8b78a..5affe3a1f 100644 --- a/src/path.c +++ b/src/path.c @@ -44,7 +44,7 @@ PyImaging_GetBuffer(PyObject *buffer, Py_buffer *view); typedef struct { PyObject_HEAD Py_ssize_t count; double *xy; - int index; /* temporary use, e.g. in decimate */ + int mapping; } PyPathObject; static PyTypeObject PyPathType; @@ -92,6 +92,7 @@ path_new(Py_ssize_t count, double *xy, int duplicate) { path->count = count; path->xy = xy; + path->mapping = 0; return path; } @@ -277,6 +278,10 @@ path_compact(PyPathObject *self, PyObject *args) { double cityblock = 2.0; + if (self->mapping) { + PyErr_SetString(PyExc_ValueError, "Path compacted during mapping"); + return NULL; + } if (!PyArg_ParseTuple(args, "|d:compact", &cityblock)) { return NULL; } @@ -394,11 +399,13 @@ path_map(PyPathObject *self, PyObject *args) { xy = self->xy; /* apply function to coordinate set */ + self->mapping = 1; for (i = 0; i < self->count; i++) { double x = xy[i + i]; double y = xy[i + i + 1]; PyObject *item = PyObject_CallFunction(function, "dd", x, y); if (!item || !PyArg_ParseTuple(item, "dd", &x, &y)) { + self->mapping = 0; Py_XDECREF(item); return NULL; } @@ -406,9 +413,9 @@ path_map(PyPathObject *self, PyObject *args) { xy[i + i + 1] = y; Py_DECREF(item); } + self->mapping = 0; - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static int @@ -520,8 +527,7 @@ path_transform(PyPathObject *self, PyObject *args) { } } - Py_INCREF(Py_None); - return Py_None; + Py_RETURN_NONE; } static struct PyMethodDef methods[] = { @@ -592,34 +598,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 51e8c0de7..04fcf61e0 100644 --- a/src/thirdparty/pythoncapi_compat.h +++ b/src/thirdparty/pythoncapi_compat.h @@ -7,7 +7,10 @@ // https://github.com/python/pythoncapi_compat // // Latest version: -// https://raw.githubusercontent.com/python/pythoncapi_compat/master/pythoncapi_compat.h +// 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/c84545f0e1e21757d4901f75c47333d25a3fcff0 // // SPDX-License-Identifier: 0BSD @@ -19,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 @@ -45,6 +52,13 @@ extern "C" { # define _PyObject_CAST(op) _Py_CAST(PyObject*, op) #endif +#ifndef Py_BUILD_ASSERT +# define Py_BUILD_ASSERT(cond) \ + do { \ + (void)sizeof(char [1 - 2 * !(cond)]); \ + } while(0) +#endif + // bpo-42262 added Py_NewRef() to Python 3.10.0a3 #if PY_VERSION_HEX < 0x030A00A3 && !defined(Py_NewRef) @@ -280,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) { @@ -573,7 +587,7 @@ static inline int PyWeakref_GetRef(PyObject *ref, PyObject **pobj) return 0; } *pobj = Py_NewRef(obj); - return (*pobj != NULL); + return 1; } #endif @@ -911,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); @@ -922,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); @@ -1197,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 @@ -1338,6 +1352,166 @@ PyDict_SetDefaultRef(PyObject *d, PyObject *key, PyObject *default_value, } #endif +#if PY_VERSION_HEX < 0x030D00B3 +# define Py_BEGIN_CRITICAL_SECTION(op) { +# define Py_END_CRITICAL_SECTION() } +# define Py_BEGIN_CRITICAL_SECTION2(a, b) { +# define Py_END_CRITICAL_SECTION2() } +#endif + +#if PY_VERSION_HEX < 0x030E0000 && PY_VERSION_HEX >= 0x03060000 && !defined(PYPY_VERSION) +typedef struct PyUnicodeWriter PyUnicodeWriter; + +static inline void PyUnicodeWriter_Discard(PyUnicodeWriter *writer) +{ + _PyUnicodeWriter_Dealloc((_PyUnicodeWriter*)writer); + PyMem_Free(writer); +} + +static inline PyUnicodeWriter* PyUnicodeWriter_Create(Py_ssize_t length) +{ + if (length < 0) { + PyErr_SetString(PyExc_ValueError, + "length must be positive"); + return NULL; + } + + const size_t size = sizeof(_PyUnicodeWriter); + PyUnicodeWriter *pub_writer = (PyUnicodeWriter *)PyMem_Malloc(size); + if (pub_writer == _Py_NULL) { + PyErr_NoMemory(); + return _Py_NULL; + } + _PyUnicodeWriter *writer = (_PyUnicodeWriter *)pub_writer; + + _PyUnicodeWriter_Init(writer); + if (_PyUnicodeWriter_Prepare(writer, length, 127) < 0) { + PyUnicodeWriter_Discard(pub_writer); + return NULL; + } + writer->overallocate = 1; + return pub_writer; +} + +static inline PyObject* PyUnicodeWriter_Finish(PyUnicodeWriter *writer) +{ + PyObject *str = _PyUnicodeWriter_Finish((_PyUnicodeWriter*)writer); + assert(((_PyUnicodeWriter*)writer)->buffer == NULL); + PyMem_Free(writer); + return str; +} + +static inline int +PyUnicodeWriter_WriteChar(PyUnicodeWriter *writer, Py_UCS4 ch) +{ + if (ch > 0x10ffff) { + PyErr_SetString(PyExc_ValueError, + "character must be in range(0x110000)"); + return -1; + } + + return _PyUnicodeWriter_WriteChar((_PyUnicodeWriter*)writer, ch); +} + +static inline int +PyUnicodeWriter_WriteStr(PyUnicodeWriter *writer, PyObject *obj) +{ + PyObject *str = PyObject_Str(obj); + if (str == NULL) { + return -1; + } + + int res = _PyUnicodeWriter_WriteStr((_PyUnicodeWriter*)writer, str); + Py_DECREF(str); + return res; +} + +static inline int +PyUnicodeWriter_WriteRepr(PyUnicodeWriter *writer, PyObject *obj) +{ + PyObject *str = PyObject_Repr(obj); + if (str == NULL) { + return -1; + } + + int res = _PyUnicodeWriter_WriteStr((_PyUnicodeWriter*)writer, str); + Py_DECREF(str); + return res; +} + +static inline int +PyUnicodeWriter_WriteUTF8(PyUnicodeWriter *writer, + const char *str, Py_ssize_t size) +{ + if (size < 0) { + size = (Py_ssize_t)strlen(str); + } + + PyObject *str_obj = PyUnicode_FromStringAndSize(str, size); + if (str_obj == _Py_NULL) { + return -1; + } + + int res = _PyUnicodeWriter_WriteStr((_PyUnicodeWriter*)writer, str_obj); + Py_DECREF(str_obj); + return res; +} + +static inline int +PyUnicodeWriter_WriteWideChar(PyUnicodeWriter *writer, + const wchar_t *str, Py_ssize_t size) +{ + if (size < 0) { + size = (Py_ssize_t)wcslen(str); + } + + PyObject *str_obj = PyUnicode_FromWideChar(str, size); + if (str_obj == _Py_NULL) { + return -1; + } + + int res = _PyUnicodeWriter_WriteStr((_PyUnicodeWriter*)writer, str_obj); + Py_DECREF(str_obj); + return res; +} + +static inline int +PyUnicodeWriter_WriteSubstring(PyUnicodeWriter *writer, PyObject *str, + Py_ssize_t start, Py_ssize_t end) +{ + if (!PyUnicode_Check(str)) { + PyErr_Format(PyExc_TypeError, "expect str, not %T", str); + return -1; + } + if (start < 0 || start > end) { + PyErr_Format(PyExc_ValueError, "invalid start argument"); + return -1; + } + if (end > PyUnicode_GET_LENGTH(str)) { + PyErr_Format(PyExc_ValueError, "invalid end argument"); + return -1; + } + + return _PyUnicodeWriter_WriteSubstring((_PyUnicodeWriter*)writer, str, + start, end); +} + +static inline int +PyUnicodeWriter_Format(PyUnicodeWriter *writer, const char *format, ...) +{ + va_list vargs; + va_start(vargs, format); + PyObject *str = PyUnicode_FromFormatV(format, vargs); + va_end(vargs); + if (str == _Py_NULL) { + return -1; + } + + int res = _PyUnicodeWriter_WriteStr((_PyUnicodeWriter*)writer, str); + Py_DECREF(str); + return res; +} +#endif // PY_VERSION_HEX < 0x030E0000 // gh-116560 added PyLong_GetSign() to Python 3.14.0a0 #if PY_VERSION_HEX < 0x030E00A0 @@ -1353,6 +1527,678 @@ 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 +static inline int PyUnicode_Equal(PyObject *str1, PyObject *str2) +{ + if (!PyUnicode_Check(str1)) { + PyErr_Format(PyExc_TypeError, "first argument must be str, not %s", + Py_TYPE(str1)->tp_name); + return -1; + } + if (!PyUnicode_Check(str2)) { + PyErr_Format(PyExc_TypeError, "second argument must be str, not %s", + Py_TYPE(str2)->tp_name); + return -1; + } + +#if PY_VERSION_HEX >= 0x030d0000 && !defined(PYPY_VERSION) + PyAPI_FUNC(int) _PyUnicode_Equal(PyObject *str1, PyObject *str2); + + return _PyUnicode_Equal(str1, str2); +#elif PY_VERSION_HEX >= 0x03060000 && !defined(PYPY_VERSION) + return _PyUnicode_EQ(str1, str2); +#elif PY_VERSION_HEX >= 0x03090000 && defined(PYPY_VERSION) + return _PyUnicode_EQ(str1, str2); +#else + return (PyUnicode_Compare(str1, str2) == 0); +#endif +} +#endif + + +// gh-121645 added PyBytes_Join() to Python 3.14.0a0 +#if PY_VERSION_HEX < 0x030E00A0 +static inline PyObject* PyBytes_Join(PyObject *sep, PyObject *iterable) +{ + return _PyBytes_Join(sep, iterable); +} +#endif + + +#if PY_VERSION_HEX < 0x030E00A0 +static inline Py_hash_t Py_HashBuffer(const void *ptr, Py_ssize_t len) +{ +#if PY_VERSION_HEX >= 0x03000000 && !defined(PYPY_VERSION) + PyAPI_FUNC(Py_hash_t) _Py_HashBytes(const void *src, Py_ssize_t len); + + return _Py_HashBytes(ptr, len); +#else + Py_hash_t hash; + PyObject *bytes = PyBytes_FromStringAndSize((const char*)ptr, len); + if (bytes == NULL) { + return -1; + } + hash = PyObject_Hash(bytes); + Py_DECREF(bytes); + return hash; +#endif +} +#endif + + +#if PY_VERSION_HEX < 0x030E00A0 +static inline int PyIter_NextItem(PyObject *iter, PyObject **item) +{ + iternextfunc tp_iternext; + + assert(iter != NULL); + assert(item != NULL); + + tp_iternext = Py_TYPE(iter)->tp_iternext; + if (tp_iternext == NULL) { + *item = NULL; + PyErr_Format(PyExc_TypeError, "expected an iterator, got '%s'", + Py_TYPE(iter)->tp_name); + return -1; + } + + if ((*item = tp_iternext(iter))) { + return 1; + } + if (!PyErr_Occurred()) { + return 0; + } + if (PyErr_ExceptionMatches(PyExc_StopIteration)) { + PyErr_Clear(); + return 0; + } + return -1; +} +#endif + + +#if PY_VERSION_HEX < 0x030E00A0 +static inline PyObject* PyLong_FromInt32(int32_t value) +{ + Py_BUILD_ASSERT(sizeof(long) >= 4); + return PyLong_FromLong(value); +} + +static inline PyObject* PyLong_FromInt64(int64_t value) +{ + Py_BUILD_ASSERT(sizeof(long long) >= 8); + return PyLong_FromLongLong(value); +} + +static inline PyObject* PyLong_FromUInt32(uint32_t value) +{ + Py_BUILD_ASSERT(sizeof(unsigned long) >= 4); + return PyLong_FromUnsignedLong(value); +} + +static inline PyObject* PyLong_FromUInt64(uint64_t value) +{ + Py_BUILD_ASSERT(sizeof(unsigned long long) >= 8); + return PyLong_FromUnsignedLongLong(value); +} + +static inline int PyLong_AsInt32(PyObject *obj, int32_t *pvalue) +{ + Py_BUILD_ASSERT(sizeof(int) == 4); + int value = PyLong_AsInt(obj); + if (value == -1 && PyErr_Occurred()) { + return -1; + } + *pvalue = (int32_t)value; + return 0; +} + +static inline int PyLong_AsInt64(PyObject *obj, int64_t *pvalue) +{ + Py_BUILD_ASSERT(sizeof(long long) == 8); + long long value = PyLong_AsLongLong(obj); + if (value == -1 && PyErr_Occurred()) { + return -1; + } + *pvalue = (int64_t)value; + return 0; +} + +static inline int PyLong_AsUInt32(PyObject *obj, uint32_t *pvalue) +{ + Py_BUILD_ASSERT(sizeof(long) >= 4); + unsigned long value = PyLong_AsUnsignedLong(obj); + if (value == (unsigned long)-1 && PyErr_Occurred()) { + return -1; + } +#if SIZEOF_LONG > 4 + if ((unsigned long)UINT32_MAX < value) { + PyErr_SetString(PyExc_OverflowError, + "Python int too large to convert to C uint32_t"); + return -1; + } +#endif + *pvalue = (uint32_t)value; + return 0; +} + +static inline int PyLong_AsUInt64(PyObject *obj, uint64_t *pvalue) +{ + Py_BUILD_ASSERT(sizeof(long long) == 8); + unsigned long long value = PyLong_AsUnsignedLongLong(obj); + if (value == (unsigned long long)-1 && PyErr_Occurred()) { + return -1; + } + *pvalue = (uint64_t)value; + return 0; +} +#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 } diff --git a/tox.ini b/tox.ini index c1bc3b17d..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 @@ -33,16 +29,7 @@ commands = skip_install = true deps = -r .ci/requirements-mypy.txt - IceSpringPySideStubs-PyQt6 - IceSpringPySideStubs-PySide6 - ipython - numpy - packaging - pytest - types-defusedxml - types-olefile - types-setuptools extras = typing commands = - mypy src Tests {posargs} + mypy conftest.py selftest.py setup.py docs src winbuild Tests {posargs} diff --git a/wheels/README.md b/wheels/README.md index 8b412b7fe..1175616e1 100644 --- a/wheels/README.md +++ b/wheels/README.md @@ -1,11 +1,11 @@ README ------ -[cibuildwheel](https://github.com/pypa/cibuildwheel) is used to build macOS and Linux -wheels for tagged versions of Pillow. +[cibuildwheel](https://github.com/pypa/cibuildwheel) is used to build wheels for tagged +versions of Pillow. This directory contains [multibuild](https://github.com/multi-build/multibuild) to -build dependencies for the wheels, and dependency licenses to be included. +build dependencies for macOS and Linux wheels, and dependency licenses to be included. Archives -------- @@ -30,6 +30,3 @@ Wheels Wheels are [GitHub Actions artifacts created for tags, relevant changes or manual builds](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml). - -Windows wheels are created separately. They are -[GitHub Actions artifacts created on each run of the Windows workflow](https://github.com/python-pillow/Pillow/actions/workflows/test-windows.yml?query=branch%3Amain). diff --git a/wheels/multibuild b/wheels/multibuild index 452dd2d17..42d761728 160000 --- a/wheels/multibuild +++ b/wheels/multibuild @@ -1 +1 @@ -Subproject commit 452dd2d1705f6b2375369a6570c415beb3163f70 +Subproject commit 42d761728d141d8462cd9943f4329f12fe62b155 diff --git a/winbuild/README.md b/winbuild/README.md index c8048bcc9..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 2016 with Visual Studio 2017 Community, and Windows Server 2019 with 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 9837589b2..a645722d8 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -7,6 +7,8 @@ import re import shutil import struct import subprocess +import sys +from typing import Any def cmd_cd(path: str) -> str: @@ -43,21 +45,19 @@ def cmd_nmake( target: str = "", params: list[str] | None = None, ) -> str: - params = "" if params is None else " ".join(params) - return " ".join( [ "{nmake}", "-nologo", f'-f "{makefile}"' if makefile is not None else "", - f"{params}", + f'{" ".join(params)}' if params is not None else "", f'"{target}"', ] ) def cmds_cmake( - target: str | tuple[str, ...] | list[str], *params, build_dir: str = "." + target: str | tuple[str, ...] | list[str], *params: str, build_dir: str = "." ) -> list[str]: if not isinstance(target, str): target = " ".join(target) @@ -111,30 +111,27 @@ ARCHITECTURES = { V = { "BROTLI": "1.1.0", - "FREETYPE": "2.13.2", - "FRIBIDI": "1.0.15", - "HARFBUZZ": "8.5.0", - "JPEGTURBO": "3.0.3", - "LCMS2": "2.16", - "LIBPNG": "1.6.43", - "LIBWEBP": "1.4.0", - "OPENJPEG": "2.5.2", + "FREETYPE": "2.13.3", + "FRIBIDI": "1.0.16", + "HARFBUZZ": "10.4.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.6.0", - "XZ": "5.4.5", - "ZLIB": "1.3.1", + "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 = { +DEPS: dict[str, dict[str, Any]] = { "libjpeg": { - "url": f"{SF_PROJECTS}/libjpeg-turbo/files/{V['JPEGTURBO']}/" - f"libjpeg-turbo-{V['JPEGTURBO']}.tar.gz/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==========" @@ -157,28 +154,30 @@ DEPS = { 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": f"https://zlib.net/zlib{V['ZLIB_DOTLESS']}.zip", - "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"{SF_PROJECTS}/lzmautils/files/xz-{V['XZ']}.tar.gz/download", + "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"), @@ -186,12 +185,11 @@ DEPS = { cmd_copy(r"src\liblzma\api\lzma\*.h", r"{inc_dir}\lzma"), ], "headers": [r"src\liblzma\api\lzma.h"], - "libs": [r"liblzma.lib"], + "libs": [r"lzma.lib"], }, "libwebp": { - "url": f"http://downloads.webmproject.org/releases/webp/libwebp-{V['LIBWEBP']}.tar.gz", + "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": { @@ -201,7 +199,7 @@ DEPS = { }, "build": [ *cmds_cmake( - "webp webpdemux webpmux", + "webp webpmux webpdemux", "-DBUILD_SHARED_LIBS:BOOL=OFF", "-DWEBP_LINK_STATIC:BOOL=OFF", ), @@ -211,14 +209,13 @@ DEPS = { "libs": [r"libsharpyuv.lib", r"libwebp*.lib"], }, "libtiff": { - "url": f"https://download.osgeo.org/libtiff/tiff-{V['TIFF']}.tar.gz", + "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": { - # link against liblzma.lib - "#ifdef LZMA_SUPPORT": '#ifdef LZMA_SUPPORT\n#pragma comment(lib, "liblzma.lib")', # noqa: E501 + # link against lzma.lib + "#ifdef LZMA_SUPPORT": '#ifdef LZMA_SUPPORT\n#pragma comment(lib, "lzma.lib")', # noqa: E501 }, r"libtiff\tif_webp.c": { # link against libwebp.lib @@ -244,9 +241,8 @@ DEPS = { }, "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"), @@ -260,7 +256,6 @@ DEPS = { "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,9 +264,8 @@ DEPS = { "libs": ["*.lib"], }, "freetype": { - "url": f"https://download.savannah.gnu.org/releases/freetype/freetype-{V['FREETYPE']}.tar.gz", + "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": { @@ -294,19 +288,18 @@ DEPS = { "build": [ cmd_rmdir("objs"), cmd_msbuild( - r"builds\windows\vc2010\freetype.sln", "Release Static", "Clean" + r"builds\windows\vc2010\freetype.vcxproj", "Release Static", "Clean" ), cmd_msbuild( - r"builds\windows\vc2010\freetype.sln", "Release Static", "Build" + r"builds\windows\vc2010\freetype.vcxproj", "Release Static", "Build" ), cmd_xcopy("include", "{inc_dir}"), ], "libs": [r"objs\{msbuild_arch}\Release Static\freetype.lib"], }, "lcms2": { - "url": f"{SF_PROJECTS}/lcms/files/lcms/{V['LCMS2']}/lcms2-{V['LCMS2']}.tar.gz/download", # noqa: E501 + "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": { @@ -332,7 +325,6 @@ DEPS = { "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( @@ -344,30 +336,19 @@ DEPS = { "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( @@ -382,7 +363,6 @@ DEPS = { "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"), @@ -498,7 +478,7 @@ def extract_dep(url: str, filename: str, prefs: dict[str, str]) -> None: except RuntimeError as exc: # Otherwise try upstream print(exc) - download_dep(url, file) + download_dep(url.replace("FILENAME", filename), file) print("Extracting " + filename) sources_dir_abs = os.path.abspath(sources_dir) @@ -519,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) @@ -538,7 +521,7 @@ def write_script( print(" " + line) -def get_footer(dep: dict) -> list[str]: +def get_footer(dep: dict[str, Any]) -> list[str]: lines = [] for out in dep.get("headers", []): lines.append(cmd_copy(out, "{inc_dir}")) @@ -583,6 +566,7 @@ def build_dep(name: str, prefs: dict[str, str], verbose: bool) -> str: license_text += f.read() if "license_pattern" in dep: match = re.search(dep["license_pattern"], license_text, re.DOTALL) + assert match is not None license_text = "\n".join(match.groups()) assert len(license_text) > 50 with open(os.path.join(license_dir, f"{directory}.txt"), "w") as f: @@ -761,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()