diff --git a/.appveyor.yml b/.appveyor.yml index b817cd9d8..0f5dea9c5 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -10,48 +10,48 @@ environment: TEST_OPTIONS: DEPLOY: YES matrix: - - PYTHON: C:/Python311 + - PYTHON: C:/Python312 ARCHITECTURE: x86 APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 - - PYTHON: C:/Python37-x64 + - PYTHON: C:/Python38-x64 ARCHITECTURE: x64 APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 install: - '%PYTHON%\%EXECUTABLE% --version' -- curl -fsSL -o pillow-depends.zip https://github.com/python-pillow/pillow-depends/archive/main.zip -- 7z x pillow-depends.zip -oc:\ -- mv c:\pillow-depends-main c:\pillow-depends -- xcopy /S /Y c:\pillow-depends\test_images\* c:\pillow\tests\images -- 7z x ..\pillow-depends\nasm-2.15.05-win64.zip -oc:\ -- ..\pillow-depends\gs1000w32.exe /S -- path c:\nasm-2.15.05;C:\Program Files (x86)\gs\gs10.0.0\bin;%PATH% +- '%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.01-win64.zip +- 7z x nasm-win64.zip -oc:\ +- choco install ghostscript --version=10.0.0.20230317 +- path c:\nasm-2.16.01;C:\Program Files\gs\gs10.00.0\bin;%PATH% - cd c:\pillow\winbuild\ - ps: | - c:\python37\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ + c:\python38\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: -- ps: | - c:\pillow\winbuild\build\build_pillow.cmd install - $host.SetShouldExit(0) - 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' +- '%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: -- python -m pip install codecov -- codecov --file coverage.xml --name %PYTHON% --flags AppVeyor +- curl -Os https://uploader.codecov.io/latest/windows/codecov.exe +- .\codecov.exe --file coverage.xml --name %PYTHON% --flags AppVeyor matrix: fast_finish: true @@ -60,18 +60,15 @@ cache: - '%LOCALAPPDATA%\pip\Cache' artifacts: -- path: pillow\dist\*.egg +- path: pillow\*.egg name: egg -- path: pillow\dist\*.wheel +- path: pillow\*.whl name: wheel before_deploy: - cd c:\pillow - - '%PYTHON%\%EXECUTABLE% -m pip install wheel' - - cd c:\pillow\winbuild\ - - c:\pillow\winbuild\build\build_pillow.cmd bdist_wheel - - cd c:\pillow - - ps: Get-ChildItem .\dist\*.* | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } + - '%PYTHON%\%EXECUTABLE% -m pip wheel -v -C raqm=vendor -C fribidi=vendor .' + - ps: Get-ChildItem .\*.whl | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } deploy: provider: S3 diff --git a/.ci/after_success.sh b/.ci/after_success.sh index 23a6fcd4d..c71546f00 100755 --- a/.ci/after_success.sh +++ b/.ci/after_success.sh @@ -1,7 +1,7 @@ #!/bin/bash # gather the coverage data -python3 -m pip install codecov +python3 -m pip install coverage if [[ $MATRIX_DOCKER ]]; then python3 -m coverage xml --ignore-errors else diff --git a/.ci/install.sh b/.ci/install.sh index 518b66acc..30b64349d 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -22,12 +22,14 @@ 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\ - cmake meson imagemagick libharfbuzz-dev libfribidi-dev + cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ + sway wl-clipboard libopenblas-dev fi python3 -m pip install --upgrade pip python3 -m pip install --upgrade wheel -PYTHONOPTIMIZE=0 python3 -m pip install cffi +# TODO Update condition when cffi supports 3.13 +if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then PYTHONOPTIMIZE=0 python3 -m pip install cffi ; fi python3 -m pip install coverage python3 -m pip install defusedxml python3 -m pip install olefile @@ -37,14 +39,25 @@ python3 -m pip install -U pytest-timeout python3 -m pip install pyroma if [[ $(uname) != CYGWIN* ]]; then - python3 -m pip install numpy + # TODO Update condition when NumPy supports 3.13 + if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then python3 -m pip install numpy ; fi # PyQt6 doesn't support PyPy3 if [[ $GHA_PYTHON_VERSION == 3.* ]]; then - sudo apt-get -qq install libegl1 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 + sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 python3 -m pip install pyqt6 fi + # Pyroma uses non-isolated build and fails with old setuptools + if [[ + $GHA_PYTHON_VERSION == pypy3.9 + || $GHA_PYTHON_VERSION == 3.8 + || $GHA_PYTHON_VERSION == 3.9 + ]]; then + # To match pyproject.toml + python3 -m pip install "setuptools>=67.8" + fi + # webp pushd depends && ./install_webp.sh && popd diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt new file mode 100644 index 000000000..dd61634cd --- /dev/null +++ b/.ci/requirements-cibw.txt @@ -0,0 +1 @@ +cibuildwheel==2.16.2 diff --git a/.editorconfig b/.editorconfig index 449530717..c3627ae4f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,11 +13,7 @@ indent_style = space trim_trailing_whitespace = true -[*.rst] -# Four-space indentation -indent_size = 4 - -[*.yml] +[*.{toml,yml}] # Two-space indentation indent_size = 2 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..a2be59c52 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Flake8 +8de95676e0fd89f2326b3953488ab66ff29cd2d0 +# Format with Black +53a7e3500437a9fd5826bc04758f7116bd7e52dc +# Format the C code with ClangFormat +46b7e86bab79450ec0a2866c6c0c679afb659d17 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ba2b7d8ed..d03fcf0d9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -19,6 +19,7 @@ Please send a pull request to the `main` branch. Please include [documentation]( - Follow PEP 8. - When committing only documentation changes please include `[ci skip]` in the commit message to avoid running tests on AppVeyor. - 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 8dfa07f4e..3c2066137 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -7,7 +7,7 @@ pull_request_rules: - status-success=Test Successful - status-success=Docker Test Successful - status-success=Windows Test Successful - - status-success=MinGW Test Successful + - status-success=MinGW - status-success=Cygwin Test Successful - status-success=continuous-integration/appveyor/pr actions: diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index db0307046..eb73fc6a7 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -2,11 +2,15 @@ name: CIFuzz on: push: + branches: + - "**" paths: + - ".github/workflows/cifuzz.yml" - "**.c" - "**.h" pull_request: paths: + - ".github/workflows/cifuzz.yml" - "**.c" - "**.h" workflow_dispatch: @@ -14,7 +18,7 @@ on: permissions: contents: read -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -38,13 +42,13 @@ jobs: language: python dry-run: false - name: Upload New Crash - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() && steps.build.outcome == 'success' with: name: artifacts path: ./out/artifacts - name: Upload Legacy Crash - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: steps.run.outcome == 'success' with: name: crash diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..9fe345c8a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: Docs + +on: + push: + branches: + - "**" + paths: + - ".github/workflows/docs.yml" + - "docs/**" + pull_request: + paths: + - ".github/workflows/docs.yml" + - "docs/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_COLOR: 1 + +jobs: + build: + + runs-on: ubuntu-latest + name: Docs + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + cache: pip + cache-dependency-path: ".ci/*.sh" + + - name: Build system information + run: python3 .github/workflows/system-info.py + + - name: Install Linux dependencies + run: | + .ci/install.sh + env: + GHA_PYTHON_VERSION: "3.x" + + - name: Build + run: | + .ci/build.sh + + - name: Docs + run: | + make doccheck diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 49611e287..9069fc615 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,9 @@ name: Lint on: [push, pull_request, workflow_dispatch] +env: + FORCE_COLOR: 1 + permissions: contents: read @@ -17,7 +20,7 @@ jobs: name: Lint steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: pre-commit cache uses: actions/cache@v3 @@ -28,7 +31,7 @@ jobs: lint-pre-commit- - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" cache: pip @@ -46,3 +49,6 @@ jobs: run: tox -e lint env: PRE_COMMIT_COLOR: always + + - name: Mypy + run: tox -e mypy diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index dfd7d0553..f41324c4b 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -3,8 +3,11 @@ set -e brew install libtiff libjpeg openjpeg libimagequant webp little-cms2 freetype libraqm +export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" + +# TODO Update condition when cffi supports 3.13 +if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then PYTHONOPTIMIZE=0 python3 -m pip install cffi ; fi -PYTHONOPTIMIZE=0 python3 -m pip install cffi python3 -m pip install coverage python3 -m pip install defusedxml python3 -m pip install olefile @@ -13,7 +16,8 @@ python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-timeout python3 -m pip install pyroma -python3 -m pip install numpy +# TODO Update condition when NumPy supports 3.13 +if ! [[ "$GHA_PYTHON_VERSION" == "3.13" ]]; then python3 -m pip install numpy ; fi # extra test images pushd depends && ./install_extra_test_images.sh && popd diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 9e2fdc096..8fc7bd379 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -10,7 +10,7 @@ on: permissions: contents: read -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 8c210bc90..545c2e364 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,7 +8,7 @@ on: permissions: issues: write -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -20,7 +20,7 @@ jobs: steps: - name: "Check issues" - uses: actions/stale@v7 + uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: "Awaiting OP Action" diff --git a/.github/workflows/system-info.py b/.github/workflows/system-info.py index 8e840319a..57f28c620 100644 --- a/.github/workflows/system-info.py +++ b/.github/workflows/system-info.py @@ -6,6 +6,8 @@ This sort of info is missing from GitHub Actions. Requested here: https://github.com/actions/virtual-environments/issues/79 """ +from __future__ import annotations + import os import platform import sys diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 7b8070d34..32ac6f65e 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -1,6 +1,25 @@ name: Test Cygwin -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: + - "**" + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read @@ -27,38 +46,63 @@ jobs: git config --global core.autocrlf input - name: Checkout Pillow - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Cygwin - uses: cygwin/cygwin-install-action@v3 + uses: cygwin/cygwin-install-action@v4 with: platform: x86_64 packages: > - ImageMagick gcc-g++ ghostscript jpeg libfreetype-devel - libimagequant-devel libjpeg-devel liblapack-devel - liblcms2-devel libopenjp2-devel libraqm-devel - libtiff-devel libwebp-devel libxcb-devel libxcb-xinerama0 - make netpbm perl + gcc-g++ + ghostscript + ImageMagick + jpeg + libfreetype-devel + libimagequant-devel + libjpeg-devel + liblapack-devel + liblcms2-devel + libopenjp2-devel + libraqm-devel + libtiff-devel + libwebp-devel + libxcb-devel + libxcb-xinerama0 + make + netpbm + perl python3${{ matrix.python-minor-version }}-cffi python3${{ matrix.python-minor-version }}-cython python3${{ matrix.python-minor-version }}-devel python3${{ matrix.python-minor-version }}-numpy python3${{ matrix.python-minor-version }}-sip python3${{ matrix.python-minor-version }}-tkinter - qt5-devel-tools subversion xorg-server-extra zlib-devel + wget + xorg-server-extra + zlib-devel - name: Add Lapack to PATH uses: egor-tensin/cleanup-path@v3 with: dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack' + - name: Select Python version + run: | + ln -sf c:/cygwin/bin/python3.${{ matrix.python-minor-version }} c:/cygwin/bin/python3 + + - name: Get latest NumPy version + id: latest-numpy + shell: bash.exe -eo pipefail -o igncr "{0}" + run: | + python3 -m pip list --outdated | grep numpy | sed -r 's/ +/ /g' | cut -d ' ' -f 3 | sed 's/^/version=/' >> $GITHUB_OUTPUT + - name: pip cache uses: actions/cache@v3 with: path: 'C:\cygwin\home\runneradmin\.cache\pip' - key: ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-${{ hashFiles('.ci/install.sh') }} + key: ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-numpy${{ steps.latest-numpy.outputs.version }}-${{ hashFiles('.ci/install.sh') }} restore-keys: | - ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}- + ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-numpy${{ steps.latest-numpy.outputs.version }}- - name: Build system information run: | @@ -68,15 +112,15 @@ jobs: run: | bash.exe .ci/install.sh - - name: Install a different NumPy + - name: Upgrade NumPy shell: dash.exe -l "{0}" run: | - python3 -m pip install -U 'numpy!=1.21.*' + python3 -m pip install -U "numpy<1.26" - name: Build shell: bash.exe -eo pipefail -o igncr "{0}" run: | - SETUPTOOLS_USE_DISTUTILS=stdlib .ci/build.sh + .ci/build.sh - name: Test run: | @@ -88,7 +132,7 @@ jobs: dash.exe -c "mkdir -p Tests/errors" - name: Upload errors - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: errors diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 7331cf8ee..eb27b4bf7 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -1,6 +1,25 @@ name: Test Docker -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: + - "**" + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read @@ -24,16 +43,17 @@ jobs: # Then run the remainder alpine, amazon-2-amd64, + amazon-2023-amd64, arch, centos-7-amd64, centos-stream-8-amd64, centos-stream-9-amd64, - debian-10-buster-x86, - debian-11-bullseye-x86, - fedora-36-amd64, - fedora-37-amd64, + debian-11-bullseye-amd64, + debian-12-bookworm-x86, + debian-12-bookworm-amd64, + fedora-38-amd64, + fedora-39-amd64, gentoo, - ubuntu-18.04-bionic-amd64, ubuntu-20.04-focal-amd64, ubuntu-22.04-jammy-amd64, ] @@ -49,7 +69,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Build system information run: python3 .github/workflows/system-info.py @@ -87,6 +107,7 @@ jobs: with: flags: GHA_Docker name: ${{ matrix.docker }} + gcov: true success: permissions: diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index ccf6e193a..115c2e9be 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -1,42 +1,50 @@ name: Test MinGW -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: + - "**" + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: runs-on: windows-latest - strategy: - fail-fast: false - matrix: - mingw: ["MINGW32", "MINGW64"] - include: - - mingw: "MINGW32" - name: "MSYS2 MinGW 32-bit" - package: "mingw-w64-i686" - - mingw: "MINGW64" - name: "MSYS2 MinGW 64-bit" - package: "mingw-w64-x86_64" defaults: run: shell: bash.exe --login -eo pipefail "{0}" env: - MSYSTEM: ${{ matrix.mingw }} + MSYSTEM: MINGW64 CHERE_INVOKING: 1 timeout-minutes: 30 - name: ${{ matrix.name }} + name: "MinGW" steps: - name: Checkout Pillow - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up shell run: echo "C:\msys64\usr\bin\" >> $env:GITHUB_PATH @@ -45,30 +53,29 @@ jobs: - name: Install dependencies run: | pacman -S --noconfirm \ - ${{ matrix.package }}-python3-cffi \ - ${{ matrix.package }}-python3-numpy \ - ${{ matrix.package }}-python3-olefile \ - ${{ matrix.package }}-python3-pip \ - ${{ matrix.package }}-python-pyqt6 \ - ${{ matrix.package }}-python3-setuptools \ - ${{ matrix.package }}-freetype \ - ${{ matrix.package }}-gcc \ - ${{ matrix.package }}-ghostscript \ - ${{ matrix.package }}-lcms2 \ - ${{ matrix.package }}-libimagequant \ - ${{ matrix.package }}-libjpeg-turbo \ - ${{ matrix.package }}-libraqm \ - ${{ matrix.package }}-libtiff \ - ${{ matrix.package }}-libwebp \ - ${{ matrix.package }}-openjpeg2 \ - subversion + mingw-w64-x86_64-freetype \ + mingw-w64-x86_64-gcc \ + mingw-w64-x86_64-ghostscript \ + mingw-w64-x86_64-lcms2 \ + mingw-w64-x86_64-libimagequant \ + mingw-w64-x86_64-libjpeg-turbo \ + mingw-w64-x86_64-libraqm \ + mingw-w64-x86_64-libtiff \ + mingw-w64-x86_64-libwebp \ + mingw-w64-x86_64-openjpeg2 \ + mingw-w64-x86_64-python3-cffi \ + mingw-w64-x86_64-python3-numpy \ + mingw-w64-x86_64-python3-olefile \ + mingw-w64-x86_64-python3-pip \ + mingw-w64-x86_64-python3-setuptools \ + mingw-w64-x86_64-python-pyqt6 python3 -m pip install pyroma pytest pytest-cov pytest-timeout pushd depends && ./install_extra_test_images.sh && popd - name: Build Pillow - run: CFLAGS="-coverage" python3 -m pip install --global-option="build_ext" . + run: SETUPTOOLS_USE_DISTUTILS="stdlib" CFLAGS="-coverage" python3 -m pip install . - name: Test Pillow run: | @@ -81,14 +88,4 @@ jobs: with: file: ./coverage.xml flags: GHA_Windows - name: ${{ matrix.name }} - - success: - permissions: - contents: none - needs: build - runs-on: ubuntu-latest - name: MinGW Test Successful - steps: - - name: Success - run: echo MinGW Test Successful + name: "MSYS2 MinGW" diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml index 219189cf2..59bb958ec 100644 --- a/.github/workflows/test-valgrind.yml +++ b/.github/workflows/test-valgrind.yml @@ -1,14 +1,18 @@ name: Test Valgrind -# like the docker tests, but running valgrind only on *.c/*.h changes. +# like the Docker tests, but running valgrind only on *.c/*.h changes. on: push: + branches: + - "**" paths: + - ".github/workflows/test-valgrind.yml" - "**.c" - "**.h" pull_request: paths: + - ".github/workflows/test-valgrind.yml" - "**.c" - "**.h" workflow_dispatch: @@ -16,7 +20,7 @@ on: permissions: contents: read -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -35,7 +39,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Build system information run: python3 .github/workflows/system-info.py @@ -48,5 +52,5 @@ jobs: run: | # The Pillow user in the docker container is UID 1000 sudo chown -R 1000 $GITHUB_WORKSPACE - docker run --name pillow_container -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} + docker run --name pillow_container -e "PILLOW_VALGRIND_TEST=true" -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} sudo chown -R runner $GITHUB_WORKSPACE diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 487c3586f..86cd5b5fa 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -1,6 +1,23 @@ name: Test Windows -on: [push, pull_request, workflow_dispatch] +on: + push: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read @@ -15,54 +32,54 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] - architecture: ["x86", "x64"] - include: - # PyPy 7.3.4+ only ships 64-bit binaries for Windows - - python-version: "pypy3.8" - architecture: "x64" - - python-version: "pypy3.9" - architecture: "x64" + python-version: ["pypy3.10", "pypy3.9", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] timeout-minutes: 30 - name: Python ${{ matrix.python-version }} ${{ matrix.architecture }} + name: Python ${{ matrix.python-version }} steps: - name: Checkout Pillow - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Checkout cached dependencies - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: python-pillow/pillow-depends path: winbuild\depends + - name: Checkout extra test images + uses: actions/checkout@v4 + with: + repository: python-pillow/test-images + path: Tests\test-images + # sets env: pythonLocation - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.architecture }} + allow-prereleases: true cache: pip cache-dependency-path: ".github/workflows/test-windows.yml" - name: Print build system information run: python3 .github/workflows/system-info.py - - name: python3 -m pip install wheel pytest pytest-cov pytest-timeout defusedxml - run: python3 -m pip install wheel pytest pytest-cov pytest-timeout defusedxml + - name: python3 -m pip install pytest pytest-cov pytest-timeout defusedxml olefile pyroma + run: python3 -m pip install pytest pytest-cov pytest-timeout defusedxml olefile pyroma - name: Install dependencies id: install run: | - 7z x winbuild\depends\nasm-2.15.05-win64.zip "-o$env:RUNNER_WORKSPACE\" - echo "$env:RUNNER_WORKSPACE\nasm-2.15.05" >> $env:GITHUB_PATH + choco install nasm --no-progress + echo "C:\Program Files\NASM" >> $env:GITHUB_PATH - winbuild\depends\gs1000w32.exe /S - echo "C:\Program Files (x86)\gs\gs10.0.0\bin" >> $env:GITHUB_PATH + choco install ghostscript --version=10.0.0.20230317 --no-progress + echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH - xcopy /S /Y winbuild\depends\test_images\* Tests\images\ + # Install extra test images + xcopy /S /Y Tests\test-images\* Tests\images # make cache key depend on VS version & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" ` @@ -81,7 +98,7 @@ jobs: - name: Prepare build if: steps.build-cache.outputs.cache-hit != 'true' run: | - & python.exe winbuild\build_prepare.py -v --python=$env:pythonLocation --srcdir + & python.exe winbuild\build_prepare.py -v shell: pwsh - name: Build dependencies / libjpeg-turbo @@ -149,9 +166,8 @@ jobs: - name: Build Pillow run: | - $FLAGS="" - if ('${{ github.event_name }}' -ne 'pull_request') { $FLAGS="--disable-imagequant" } - & winbuild\build\build_pillow.cmd $FLAGS install + $FLAGS="-C raqm=vendor -C fribidi=vendor" + cmd /c "winbuild\build\build_env.cmd && $env:pythonLocation\python.exe -m pip install -v $FLAGS ." & $env:pythonLocation\python.exe selftest.py --installed shell: pwsh @@ -174,7 +190,7 @@ jobs: shell: bash - name: Upload errors - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: errors @@ -190,47 +206,7 @@ jobs: with: file: ./coverage.xml flags: GHA_Windows - name: ${{ runner.os }} Python ${{ matrix.python-version }} ${{ matrix.architecture }} - - - name: Build wheel - id: wheel - if: "github.event_name != 'pull_request'" - run: | - mkdir fribidi\${{ matrix.architecture }} - copy winbuild\build\bin\fribidi* fribidi\${{ matrix.architecture }} - setlocal EnableDelayedExpansion - for %%f in (winbuild\build\license\*) do ( - set x=%%~nf - rem Skip FriBiDi license, it is not included in the wheel. - set fribidi=!x:~0,7! - if NOT !fribidi!==fribidi ( - rem Skip imagequant license, it is not included in the wheel. - set libimagequant=!x:~0,13! - if NOT !libimagequant!==libimagequant ( - echo. >> LICENSE - echo ===== %%~nf ===== >> LICENSE - echo. >> LICENSE - type %%f >> LICENSE - ) - ) - ) - for /f "tokens=3 delims=/" %%a in ("${{ github.ref }}") do echo dist=dist-%%a >> %GITHUB_OUTPUT% - winbuild\\build\\build_pillow.cmd --disable-imagequant bdist_wheel - shell: cmd - - - name: Upload wheel - uses: actions/upload-artifact@v3 - if: "github.event_name != 'pull_request'" - with: - name: ${{ steps.wheel.outputs.dist }} - path: dist\*.whl - - - name: Upload fribidi.dll - if: "github.event_name != 'pull_request' && matrix.python-version == 3.11" - uses: actions/upload-artifact@v3 - with: - name: fribidi - path: fribidi\* + name: ${{ runner.os }} Python ${{ matrix.python-version }} success: permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 11c7b77be..aa0e25138 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,25 @@ name: Test -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: + - "**" + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + pull_request: + paths-ignore: + - ".github/workflows/docs.yml" + - ".github/workflows/wheels*" + - ".gitmodules" + - ".travis.yml" + - "docs/**" + - "wheels/**" + workflow_dispatch: permissions: contents: read @@ -20,16 +39,17 @@ jobs: "ubuntu-latest", ] python-version: [ + "pypy3.10", "pypy3.9", - "pypy3.8", + "3.13", + "3.12", "3.11", "3.10", "3.9", "3.8", - "3.7", ] include: - - python-version: "3.7" + - python-version: "3.9" PYTHONOPTIMIZE: 1 REVERSE: "--reverse" - python-version: "3.8" @@ -39,12 +59,13 @@ jobs: name: ${{ matrix.os }} Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true cache: pip cache-dependency-path: ".ci/*.sh" @@ -75,7 +96,9 @@ jobs: python3 -m pip install pytest-reverse fi if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then - xvfb-run -s '-screen 0 1024x768x24' .ci/test.sh + xvfb-run -s '-screen 0 1024x768x24' sway& + export WAYLAND_DISPLAY=wayland-1 + .ci/test.sh else .ci/test.sh fi @@ -89,17 +112,12 @@ jobs: mkdir -p Tests/errors - name: Upload errors - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: errors path: Tests/errors - - name: Docs - if: startsWith(matrix.os, 'ubuntu') && matrix.python-version == 3.11 - run: | - make doccheck - - name: After success run: | .ci/after_success.sh @@ -107,9 +125,9 @@ jobs: - name: Upload coverage uses: codecov/codecov-action@v3 with: - file: ./coverage.xml flags: ${{ matrix.os == 'macos-latest' && 'GHA_macOS' || 'GHA_Ubuntu' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} + gcov: true success: permissions: diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh new file mode 100755 index 000000000..3ec314873 --- /dev/null +++ b/.github/workflows/wheels-dependencies.sh @@ -0,0 +1,151 @@ +#!/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} +fi +export PLAT=$CIBW_ARCHS +source wheels/multibuild/common_utils.sh +source wheels/multibuild/library_builders.sh +if [ -z "$IS_MACOS" ]; then + source wheels/multibuild/manylinux_utils.sh +fi + +ARCHIVE_SDIR=pillow-depends-main + +# Package versions for fresh source builds +FREETYPE_VERSION=2.13.2 +HARFBUZZ_VERSION=8.3.0 +LIBPNG_VERSION=1.6.40 +JPEGTURBO_VERSION=3.0.1 +OPENJPEG_VERSION=2.5.0 +XZ_VERSION=5.4.5 +TIFF_VERSION=4.6.0 +LCMS2_VERSION=2.16 +if [[ -n "$IS_MACOS" ]]; then + GIFLIB_VERSION=5.1.4 +else + GIFLIB_VERSION=5.2.1 +fi +if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then + ZLIB_VERSION=1.3 +else + ZLIB_VERSION=1.2.8 +fi +LIBWEBP_VERSION=1.3.2 +BZIP2_VERSION=1.0.8 +LIBXCB_VERSION=1.16 +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-2.5.0.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_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) + (cd $out_dir \ + && $cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -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 +} + +function build { + if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then + export BUILD_PREFIX="/usr/local" + fi + build_xz + if [ -z "$IS_ALPINE" ] && [ -z "$IS_MACOS" ]; then + yum remove -y zlib-devel + fi + build_new_zlib + + build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto + if [ -n "$IS_MACOS" ]; then + if [[ "$CIBW_ARCHS" == "arm64" ]]; then + build_simple xorgproto 2023.2 https://www.x.org/pub/individual/proto + build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib + build_simple libpthread-stubs 0.5 https://xcb.freedesktop.org/dist + if [ -f /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc ]; then + cp /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc /Library/Frameworks/Python.framework/Versions/Current/lib/pkgconfig/xcb-proto.pc + fi + fi + else + sed s/\${pc_sysrootdir\}// /usr/local/share/pkgconfig/xcb-proto.pc > /usr/local/lib/pkgconfig/xcb-proto.pc + fi + build_simple libxcb $LIBXCB_VERSION https://www.x.org/releases/individual/lib + + build_libjpeg_turbo + build_tiff + build_libpng + build_lcms2 + if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then + for dylib in libjpeg.dylib libtiff.dylib liblcms2.dylib; do + cp $BUILD_PREFIX/lib/$dylib /opt/arm64-builds/lib + done + fi + build_openjpeg + + ORIGINAL_CFLAGS=$CFLAGS + CFLAGS="$CFLAGS -O3 -DNDEBUG" + if [[ -n "$IS_MACOS" ]]; then + CFLAGS="$CFLAGS -Wl,-headerpad_max_install_names" + fi + build_libwebp + CFLAGS=$ORIGINAL_CFLAGS + + build_brotli + + if [ -n "$IS_MACOS" ]; then + # Custom freetype build + build_simple freetype $FREETYPE_VERSION https://download.savannah.gnu.org/releases/freetype tar.gz --with-harfbuzz=no + else + 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 +} + +# 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 [[ -n "$IS_MACOS" ]]; then + # webp, libtiff, libxcb cause a conflict with building webp, libtiff, libxcb + # libxdmcp causes an issue on macOS < 11 + # if php is installed, brew tries to reinstall these after installing openblas + # remove cairo to fix building harfbuzz on arm64 + # remove lcms2 and libpng to fix building openjpeg on arm64 + # remove zstd to avoid inclusion on x86_64 + # curl from brew requires zstd, use system curl + brew remove --ignore-dependencies webp libpng libtiff libxcb libxdmcp curl php cairo lcms2 ghostscript zstd + + brew install pkg-config +fi + +wrap_wheel_builder build + +# Append licenses +for filename in wheels/dependency_licenses/*; do + echo -e "\n\n----\n\n$(basename $filename | cut -f 1 -d '.')\n" | cat >> LICENSE + cat $filename >> LICENSE +done diff --git a/.github/workflows/wheels-test.ps1 b/.github/workflows/wheels-test.ps1 new file mode 100644 index 000000000..f593c7228 --- /dev/null +++ b/.github/workflows/wheels-test.ps1 @@ -0,0 +1,22 @@ +param ([string]$venv, [string]$pillow="C:\pillow") +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +Set-PSDebug -Trace 1 +if ("$venv" -like "*\cibw-run-*\pp*-win_amd64\*") { + # unlike CPython, PyPy requires Visual C++ Redistributable to be installed + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri 'https://aka.ms/vs/15/release/vc_redist.x64.exe' -OutFile 'vc_redist.x64.exe' + C:\vc_redist.x64.exe /install /quiet /norestart | Out-Null +} +$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 +cd $pillow +& python -VV +if (!$?) { exit $LASTEXITCODE } +& python selftest.py +if (!$?) { exit $LASTEXITCODE } +& python -m pytest -vx Tests\check_wheel.py +if (!$?) { exit $LASTEXITCODE } +& python -m pytest -vx Tests +if (!$?) { exit $LASTEXITCODE } diff --git a/.github/workflows/wheels-test.sh b/.github/workflows/wheels-test.sh new file mode 100755 index 000000000..207ec1567 --- /dev/null +++ b/.github/workflows/wheels-test.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +if [[ "$OSTYPE" == "darwin"* ]]; then + brew install fribidi + export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" +elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then + apk add curl fribidi +else + yum install -y fribidi +fi +if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then + python3 -m pip install numpy +fi + +if [ ! -d "test-images-main" ]; then + curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip + unzip pillow-test-images.zip + mv test-images-main/* Tests/images +fi + +# Runs tests +python3 selftest.py +python3 -m pytest Tests/check_wheel.py +python3 -m pytest diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 000000000..76d42b470 --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,206 @@ +name: Wheels + +on: + push: + paths: + - ".ci/requirements-cibw.txt" + - ".github/workflows/wheel*" + - "wheels/*" + - "winbuild/build_prepare.py" + - "winbuild/fribidi.cmake" + tags: + - "*" + pull_request: + paths: + - ".ci/requirements-cibw.txt" + - ".github/workflows/wheel*" + - "wheels/*" + - "winbuild/build_prepare.py" + - "winbuild/fribidi.cmake" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_COLOR: 1 + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: "macOS x86_64" + os: macos-latest + archs: x86_64 + macosx_deployment_target: "10.10" + - name: "macOS arm64" + os: macos-latest + archs: arm64 + macosx_deployment_target: "11.0" + - name: "manylinux2014 and musllinux x86_64" + os: ubuntu-latest + archs: x86_64 + - name: "manylinux_2_28 x86_64" + os: ubuntu-latest + archs: x86_64 + build: "*manylinux*" + manylinux: "manylinux_2_28" + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Build wheels + run: | + python3 -m pip install -r .ci/requirements-cibw.txt + python3 -m cibuildwheel --output-dir wheelhouse + env: + CIBW_ARCHS: ${{ matrix.archs }} + CIBW_BUILD: ${{ matrix.build }} + CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} + CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} + CIBW_SKIP: pp38-* + CIBW_TEST_SKIP: "*-macosx_arm64" + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} + + - uses: actions/upload-artifact@v3 + with: + name: dist + path: ./wheelhouse/*.whl + + windows: + name: Windows ${{ matrix.arch }} + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - arch: x86 + cibw_arch: x86 + - arch: x64 + cibw_arch: AMD64 + - arch: ARM64 + cibw_arch: ARM64 + steps: + - uses: actions/checkout@v4 + + - name: Checkout extra test images + uses: actions/checkout@v4 + with: + repository: python-pillow/test-images + path: Tests\test-images + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Prepare for build + run: | + choco install nasm --no-progress + echo "C:\Program Files\NASM" >> $env:GITHUB_PATH + + # Install extra test images + xcopy /S /Y Tests\test-images\* Tests\images + + & python.exe -m pip install -r .ci/requirements-cibw.txt + + # Cannot cross-compile FriBiDi (only used for tests) + $FLAGS = ("--no-imagequant", "--architecture=${{ matrix.arch }}") + if ('${{ matrix.arch }}' -eq 'ARM64') { $FLAGS += "--no-fribidi" } + & python.exe winbuild\build_prepare.py -v @FLAGS + shell: pwsh + + - name: Build wheels + run: | + setlocal EnableDelayedExpansion + for %%f in (winbuild\build\license\*) do ( + set x=%%~nf + rem Skip FriBiDi license, it is not included in the wheel. + set fribidi=!x:~0,7! + if NOT !fribidi!==fribidi ( + rem Skip imagequant license, it is not included in the wheel. + set libimagequant=!x:~0,13! + if NOT !libimagequant!==libimagequant ( + echo. >> LICENSE + echo ===== %%~nf ===== >> LICENSE + echo. >> LICENSE + type %%f >> LICENSE + ) + ) + ) + call winbuild\\build\\build_env.cmd + %pythonLocation%\python.exe -m cibuildwheel . --output-dir wheelhouse + env: + CIBW_ARCHS: ${{ matrix.cibw_arch }} + CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" + CIBW_CACHE_PATH: "C:\\cibw" + CIBW_TEST_SKIP: "*-win_arm64" + CIBW_TEST_COMMAND: 'docker run --rm + -v {project}:C:\pillow + -v C:\cibw:C:\cibw + -v %CD%\..\venv-test:%CD%\..\venv-test + -e CI -e GITHUB_ACTIONS + mcr.microsoft.com/windows/servercore:ltsc2022 + powershell C:\pillow\.github\workflows\wheels-test.ps1 %CD%\..\venv-test' + shell: cmd + + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: dist + path: ./wheelhouse/*.whl + + - name: Prepare to upload FriBiDi + if: "matrix.arch != 'ARM64'" + run: | + mkdir fribidi\${{ matrix.arch }} + copy winbuild\build\bin\fribidi* fribidi\${{ matrix.arch }} + shell: cmd + + - name: Upload fribidi.dll + if: "matrix.arch != 'ARM64'" + uses: actions/upload-artifact@v3 + with: + name: fribidi + path: fribidi\* + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + cache: pip + cache-dependency-path: "Makefile" + + - run: make sdist + + - uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/*.tar.gz + + success: + permissions: + contents: none + needs: [build, windows, sdist] + runs-on: ubuntu-latest + name: Wheels Successful + steps: + - name: Success + run: echo Wheels Successful diff --git a/.gitignore b/.gitignore index 790404535..1dd6c9175 100644 --- a/.gitignore +++ b/.gitignore @@ -79,7 +79,7 @@ docs/_build/ # JetBrains .idea -# Extra test images installed from pillow-depends/test_images +# Extra test images installed from python-pillow/test-images Tests/images/README.md Tests/images/crash_1.tif Tests/images/crash_2.tif diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..80d5ab16c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "multibuild"] + path = wheels/multibuild + url = https://github.com/multi-build/multibuild.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 609352f22..d1c4b8015 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,62 +1,63 @@ repos: - - repo: https://github.com/psf/black - rev: 22.12.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.7 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.12.0 hooks: - id: black - args: [--target-version=py37] - # Only .py files, until https://github.com/psf/black/issues/402 resolved - files: \.py$ - types: [] - - - repo: https://github.com/PyCQA/isort - rev: 5.11.1 - hooks: - - id: isort - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 + rev: 1.7.6 hooks: - id: bandit args: [--severity-level=high] files: ^src/ - - repo: https://github.com/asottile/yesqa - rev: v1.4.0 - hooks: - - id: yesqa - - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.3.1 + rev: v1.5.4 hooks: - id: remove-tabs - exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.opt$) - - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: [flake8-2020, flake8-implicit-str-concat] + exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.9.0 + rev: v1.10.0 hooks: - - id: python-check-blanket-noqa - id: rst-backticks - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: + - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-json + - id: check-toml - id: check-yaml + - id: end-of-file-fixer + exclude: ^Tests/images/ + - id: trailing-whitespace + exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6.7 + rev: v0.9.1 hooks: - id: sphinx-lint + - repo: https://github.com/tox-dev/pyproject-fmt + rev: 1.5.3 + hooks: + - id: pyproject-fmt + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.15 + hooks: + - id: validate-pyproject + - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 0.5.2 + rev: 1.3.1 hooks: - id: tox-ini-fmt diff --git a/.readthedocs.yml b/.readthedocs.yml index 0f581ebba..0c8f935d5 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,5 +1,12 @@ version: 2 +formats: [pdf] + +build: + os: ubuntu-22.04 + tools: + python: "3" + python: install: - method: pip diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..8f8250809 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,52 @@ +if: tag IS present OR type = api + +env: + global: + - CIBW_ARCHS=aarch64 + - CIBW_SKIP=pp38-* + +language: python +# Default Python version is usually 3.6 +python: "3.12" +dist: jammy +services: docker + +jobs: + include: + - name: "manylinux2014 aarch64" + os: linux + arch: arm64 + env: + - CIBW_BUILD="*manylinux*" + - CIBW_MANYLINUX_AARCH64_IMAGE=manylinux2014 + - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux2014 + - name: "manylinux_2_28 aarch64" + os: linux + arch: arm64 + env: + - CIBW_BUILD="*manylinux*" + - CIBW_MANYLINUX_AARCH64_IMAGE=manylinux_2_28 + - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux_2_28 + - name: "musllinux aarch64" + os: linux + arch: arm64 + env: + - CIBW_BUILD="*musllinux*" + +install: + - python3 -m pip install -r .ci/requirements-cibw.txt + +script: + - python3 -m cibuildwheel --output-dir wheelhouse + - ls -l "${TRAVIS_BUILD_DIR}/wheelhouse/" + +# Upload wheels to GitHub Releases +deploy: + provider: releases + api_key: $GITHUB_RELEASE_TOKEN + file_glob: true + file: "${TRAVIS_BUILD_DIR}/wheelhouse/*.whl" + on: + repo: python-pillow/Pillow + tags: true + skip_cleanup: true diff --git a/CHANGES.rst b/CHANGES.rst index 76fc230a8..df4e11e0e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,9 +2,450 @@ Changelog (Pillow) ================== -9.4.0 (unreleased) +10.2.0 (unreleased) +------------------- + +- Fix incorrect color blending for overlapping glyphs #7497 + [ZachNagengast, nulano, radarhere] + +- Attempt memory mapping when tile args is a string #7565 + [radarhere] + +- Fill identical pixels with transparency in subsequent frames when saving GIF #7568 + [radarhere] + +- Corrected duration when combining multiple GIF frames into single frame #7521 + [radarhere] + +- Handle disposing GIF background from outside palette #7515 + [radarhere] + +- Seek past the data when skipping a PSD layer #7483 + [radarhere] + +- Import plugins relative to the module #7576 + [deliangyang, jaxx0n] + +- Translate encoder error codes to strings; deprecate ``ImageFile.raise_oserror()`` #7609 + [bgilbert, radarhere] + +- Support reading BC4U and DX10 BC1 images #6486 + [REDxEYE, radarhere, hugovk] + +- Optimize ImageStat.Stat.extrema #7593 + [florath, radarhere] + +- Handle pathlib.Path in FreeTypeFont #7578 + [radarhere, hugovk, nulano] + +- Added support for reading DX10 BC4 DDS images #7603 + [sambvfx, radarhere] + +- Optimized ImageStat.Stat.count #7599 + [florath] + +- Correct PDF palette size when saving #7555 + [radarhere] + +- Fixed closing file pointer with olefile 0.47 #7594 + [radarhere] + +- Raise ValueError when TrueType font size is not greater than zero #7584, #7587 + [akx, radarhere] + +- If absent, do not try to close fp when closing image #7557 + [RaphaelVRossi, radarhere] + +- Allow configuring JPEG restart marker interval on save #7488 + [bgilbert, radarhere] + +- Decrement reference count for PyObject #7549 + [radarhere] + +- Implement ``streamtype=1`` option for tables-only JPEG encoding #7491 + [bgilbert, radarhere] + +- If save_all PNG only has one frame, do not create animated image #7522 + [radarhere] + +- Fixed frombytes() for images with a zero dimension #7493 + [radarhere] + +10.1.0 (2023-10-15) +------------------- + +- Added TrueType default font to allow for different sizes #7354 + [radarhere] + +- Fixed invalid argument warning #7442 + [radarhere] + +- Added ImageOps cover method #7412 + [radarhere, hugovk] + +- Catch struct.error from truncated EXIF when reading JPEG DPI #7458 + [radarhere] + +- Consider default image when selecting mode for PNG save_all #7437 + [radarhere] + +- Support BGR;15, BGR;16 and BGR;24 access, unpacking and putdata #7303 + [radarhere] + +- Added CMYK to RGB unpacker #7310 + [radarhere] + +- Improved flexibility of XMP parsing #7274 + [radarhere] + +- Support reading 8-bit YCbCr TIFF images #7415 + [radarhere] + +- Allow saving I;16B images as PNG #7302 + [radarhere] + +- Corrected drawing I;16 points and writing I;16 text #7257 + [radarhere] + +- Set blue channel to 128 for BC5S #7413 + [radarhere] + +- Increase flexibility when reading IPTC fields #7319 + [radarhere] + +- Set C palette to be empty by default #7289 + [radarhere] + +- Added gs_binary to control Ghostscript use on all platforms #7392 + [radarhere] + +- Read bounding box information from the trailer of EPS files if specified #7382 + [nopperl, radarhere] + +- Added reading 8-bit color DDS images #7426 + [radarhere] + +- Added has_transparency_data #7420 + [radarhere, hugovk] + +- Fixed bug when reading BC5S DDS images #7401 + [radarhere] + +- Prevent TIFF orientation from being applied more than once #7383 + [radarhere] + +- Use previous pixel alpha for QOI_OP_RGB #7357 + [radarhere] + +- Added BC5U reading #7358 + [radarhere] + +- Allow getpixel() to accept a list #7355 + [radarhere, homm] + +- Allow GaussianBlur and BoxBlur to accept a sequence of x and y radii #7336 + [radarhere] + +- Expand JPEG buffer size when saving optimized or progressive #7345 + [radarhere] + +- Added session type check for Linux in ImageGrab.grabclipboard() #7332 + [TheNooB2706, radarhere, hugovk] + +- Allow "loop=None" when saving GIF images #7329 + [radarhere] + +- Fixed transparency when saving P mode images to PDF #7323 + [radarhere] + +- Added saving LA images as PDFs #7299 + [radarhere] + +- Set SMaskInData to 1 for PDFs with alpha #7316, #7317 + [radarhere] + +- Changed Image mode property to be read-only by default #7307 + [radarhere] + +- Silence exceptions in _repr_jpeg_ and _repr_png_ #7266 + [mtreinish, radarhere] + +- Do not use transparency when saving GIF if it has been removed when normalizing mode #7284 + [radarhere] + +- Fix missing symbols when libtiff depends on libjpeg #7270 + [heitbaum] + +10.0.1 (2023-09-15) +------------------- + +- Updated libwebp to 1.3.2 #7395 + [radarhere] + +- Updated zlib to 1.3 #7344 + [radarhere] + +10.0.0 (2023-07-01) +------------------- + +- Fixed deallocating mask images #7246 + [radarhere] + +- Added ImageFont.MAX_STRING_LENGTH #7244 + [radarhere, hugovk] + +- Fix Windows build with pyproject.toml #7230 + [hugovk, nulano, radarhere] + +- Do not close provided file handles with libtiff #7199 + [radarhere] + +- Convert to HSV if mode is HSV in getcolor() #7226 + [radarhere] + +- Added alpha_only argument to getbbox() #7123 + [radarhere. hugovk] + +- Prioritise speed in _repr_png_ #7242 + [radarhere] + +- Do not use CFFI access by default on PyPy #7236 + [radarhere] + +- Limit size even if one dimension is zero in decompression bomb check #7235 + [radarhere] + +- Use --config-settings instead of deprecated --global-option #7171 + [radarhere] + +- Better C integer definitions #6645 + [Yay295, hugovk] + +- Fixed finding dependencies on Cygwin #7175 + [radarhere] + +- Changed grabclipboard() to use PNG instead of JPG compression on macOS #7219 + [abey79, radarhere] + +- Added in_place argument to ImageOps.exif_transpose() #7092 + [radarhere] + +- Fixed calling putpalette() on L and LA images before load() #7187 + [radarhere] + +- Fixed saving TIFF multiframe images with LONG8 tag types #7078 + [radarhere] + +- Fixed combining single duration across duplicate APNG frames #7146 + [radarhere] + +- Remove temporary file when error is raised #7148 + [radarhere] + +- Do not use temporary file when grabbing clipboard on Linux #7200 + [radarhere] + +- If the clipboard fails to open on Windows, wait and try again #7141 + [radarhere] + +- Fixed saving multiple 1 mode frames to GIF #7181 + [radarhere] + +- Replaced absolute PIL import with relative import #7173 + [radarhere] + +- Replaced deprecated Py_FileSystemDefaultEncoding for Python >= 3.12 #7192 + [radarhere] + +- Improved wl-paste mimetype handling in ImageGrab #7094 + [rrcgat, radarhere] + +- Added _repr_jpeg_() for IPython display_jpeg #7135 + [n3011, radarhere, nulano] + +- Use "/sbin/ldconfig" if ldconfig is not found #7068 + [radarhere] + +- Prefer screenshots using XCB over gnome-screenshot #7143 + [nulano, radarhere] + +- Fixed joined corners for ImageDraw rounded_rectangle() odd dimensions #7151 + [radarhere] + +- Support reading signed 8-bit TIFF images #7111 + [radarhere] + +- Added width argument to ImageDraw regular_polygon #7132 + [radarhere] + +- Support I mode for ImageFilter.BuiltinFilter #7108 + [radarhere] + +- Raise error from stderr of Linux ImageGrab.grabclipboard() command #7112 + [radarhere] + +- Added unpacker from I;16B to I;16 #7125 + [radarhere] + +- Support float font sizes #7107 + [radarhere] + +- Use later value for duplicate xref entries in PdfParser #7102 + [radarhere] + +- Load before getting size in __getstate__ #7105 + [bigcat88, radarhere] + +- Fixed type handling for include and lib directories #7069 + [adisbladis, radarhere] + +- Remove deprecations for Pillow 10.0.0 #7059, #7080 + [hugovk, radarhere] + +- Drop support for soon-EOL Python 3.7 #7058 + [hugovk, radarhere] + +9.5.0 (2023-04-01) ------------------ +- Added ImageSourceData to TAGS_V2 #7053 + [radarhere] + +- Clear PPM half token after use #7052 + [radarhere] + +- Removed absolute path to ldconfig #7044 + [radarhere] + +- Support custom comments and PLT markers when saving JPEG2000 images #6903 + [joshware, radarhere, hugovk] + +- Load before getting size in __array_interface__ #7034 + [radarhere] + +- Support creating BGR;15, BGR;16 and BGR;24 images, but drop support for BGR;32 #7010 + [radarhere] + +- Consider transparency when applying APNG blend mask #7018 + [radarhere] + +- Round duration when saving animated WebP images #6996 + [radarhere] + +- Added reading of JPEG2000 comments #6909 + [radarhere] + +- Decrement reference count #7003 + [radarhere, nulano] + +- Allow libtiff_support_custom_tags to be missing #7020 + [radarhere] + +- Improved I;16N support #6834 + [radarhere] + +- Added QOI reading #6852 + [radarhere, hugovk] + +- Added saving RGBA images as PDFs #6925 + [radarhere] + +- Do not raise an error if os.environ does not contain PATH #6935 + [radarhere, hugovk] + +- Close OleFileIO instance when closing or exiting FPX or MIC #7005 + [radarhere] + +- Added __int__ to IFDRational for Python >= 3.11 #6998 + [radarhere] + +- Added memoryview support to Dib.frombytes() #6988 + [radarhere, nulano] + +- Close file pointer copy in the libtiff encoder if still open #6986 + [fcarron, radarhere] + +- Raise an error if ImageDraw co-ordinates are incorrectly ordered #6978 + [radarhere] + +- Added "corners" argument to ImageDraw rounded_rectangle() #6954 + [radarhere] + +- Added memoryview support to frombytes() #6974 + [radarhere] + +- Allow comments in FITS images #6973 + [radarhere] + +- Support saving PDF with different X and Y resolutions #6961 + [jvanderneutstulen, radarhere, hugovk] + +- Fixed writing int as UNDEFINED tag #6950 + [radarhere] + +- Raise an error if EXIF data is too long when saving JPEG #6939 + [radarhere] + +- Handle more than one directory returned by pkg-config #6896 + [sebastic, radarhere] + +- Do not retry past formats when loading all formats for the first time #6902 + [radarhere] + +- Do not retry specified formats if they failed when opening #6893 + [radarhere] + +- Do not unintentionally load TIFF format at first #6892 + [radarhere] + +- Stop reading when EPS line becomes too long #6897 + [radarhere] + +- Allow writing IFDRational to BYTE tag #6890 + [radarhere] + +- Raise ValueError for BoxBlur filter with negative radius #6874 + [hugovk, radarhere] + +- Support arbitrary number of loaded modules on Windows #6761 + [javidcf, radarhere, nulano] + +9.4.0 (2023-01-02) +------------------ + +- Fixed null pointer dereference crash with malformed font #6846 + [wiredfool, radarhere] + +- Return from ImagingFill early if image has a zero dimension #6842 + [radarhere] + +- Reversed deprecations for Image constants, except for duplicate Resampling attributes #6830 + [radarhere] + +- Improve exception traceback readability #6836 + [hugovk, radarhere] + +- Do not attempt to read IFD1 if absent #6840 + [radarhere] + +- Fixed writing int as ASCII tag #6800 + [radarhere] + +- If available, use wl-paste or xclip for grabclipboard() on Linux #6783 + [radarhere] + +- Added signed option when saving JPEG2000 images #6709 + [radarhere] + +- Patch OpenJPEG to include ARM64 fix #6718 + [radarhere] + +- Added support for I;16 modes in putdata() #6825 + [radarhere] + +- Added conversion from RGBa to RGB #6708 + [radarhere] + - Added DDS support for uncompressed L and LA images #6820 [radarhere, REDxEYE] @@ -1819,7 +2260,7 @@ Changelog (Pillow) - Cache EXIF information #3498 [Glandos] -- Added transparency for all PNG greyscale modes #3744 +- Added transparency for all PNG grayscale modes #3744 [radarhere] - Fix deprecation warnings in Python 3.8 #3749 @@ -4321,7 +4762,7 @@ Changelog (Pillow) - Fix Bicubic interpolation #970 [homm] -- Support for 4-bit greyscale TIFF images #980 +- Support for 4-bit grayscale TIFF images #980 [hugovk] - Updated manifest #957 @@ -5471,8 +5912,8 @@ http://svn.effbot.org/public/pil/ a polyline, independent of line angle. - Fixed bearing calculation and clipping in the ImageFont truetype - renderer; this could lead to clipped text, or crashes in the low- - level _imagingft module. (based on input from Adam Twardoch and + renderer; this could lead to clipped text, or crashes in the low-level + _imagingft module. (based on input from Adam Twardoch and others). - Added ImageQt wrapper module, for converting PIL Image objects to @@ -5553,8 +5994,7 @@ http://svn.effbot.org/public/pil/ 1.1.5c2 and 1.1.5 final ----------------------- -- Added experimental PERSPECTIVE transform method (from Jeff Breiden- - bach). +- Added experimental PERSPECTIVE transform method (from Jeff Breidenbach). 1.1.5c1 ------- @@ -5626,8 +6066,8 @@ http://svn.effbot.org/public/pil/ - Fixed BILINEAR/BICUBIC/ANTIALIAS filtering for mode "LA". -- Added "getcolors()" method. This is similar to the existing histo- - gram method, but looks at color values instead of individual layers, +- Added "getcolors()" method. This is similar to the existing histogram + method, but looks at color values instead of individual layers, and returns an unsorted list of (count, color) tuples. By default, the method returns None if finds more than 256 colors. @@ -5843,8 +6283,8 @@ http://svn.effbot.org/public/pil/ - Added limited support for "bitfield compression" in BMP files and DIB buffers, for 15-bit, 16-bit, and 32-bit images. This - also fixes a problem with ImageGrab module when copying screen- - dumps from the clipboard on 15/16/32-bit displays. + also fixes a problem with ImageGrab module when copying screendumps + from the clipboard on 15/16/32-bit displays. - Added experimental WAL (Quake 2 textures) loader. To use this loader, import WalImageFile and call the "open" method in that @@ -5955,8 +6395,8 @@ http://svn.effbot.org/public/pil/ 1.1.3 final ----------- -- Made setup.py look for old versions of zlib. For some back- - ground, see: https://zlib.net/advisory-2002-03-11.txt +- Made setup.py look for old versions of zlib. For some background, + see: https://zlib.net/advisory-2002-03-11.txt 1.1.3c2 ------- @@ -6147,8 +6587,8 @@ http://svn.effbot.org/public/pil/ supports all major PIL image modes (including F and I). - The ImageFile module now includes a Parser class, which can - be used to incrementally decode an image file (while down- - loading it from the net, for example). See the handbook for + be used to incrementally decode an image file (while downloading + it from the net, for example). See the handbook for details. - "show" now converts non-standard modes to "L" or "RGB" (as @@ -6286,8 +6726,8 @@ http://svn.effbot.org/public/pil/ - The Image "transform" method now supports Image.QUAD transforms. The data argument is an 8-tuple giving the upper left, lower - left, lower right, and upper right corner of the source quadri- - lateral. Also added Image.MESH transform which takes a list + left, lower right, and upper right corner of the source quadrilateral. + Also added Image.MESH transform which takes a list of quadrilaterals. - The Image "resize", "rotate", and "transform" methods now support @@ -6397,7 +6837,7 @@ The test suite includes 750 individual tests. - You can now convert directly between all modes supported by PIL. When converting colour images to "P", PIL defaults to - a "web" palette and dithering. When converting greyscale + a "web" palette and dithering. When converting grayscale images to "1", PIL uses a thresholding and dithering. - Added a "dither" option to "convert". By default, "convert" @@ -6475,13 +6915,13 @@ The test suite includes 530 individual tests. - Fixed "paste" to allow a mask also for mode "F" images. - The BMP driver now saves mode "1" images. When loading images, the mode - is set to "L" for 8-bit files with greyscale palettes, and to "P" for + is set to "L" for 8-bit files with grayscale palettes, and to "P" for other 8-bit files. - The IM driver now reads and saves "1" images (file modes "0 1" or "L 1"). - The JPEG and GIF drivers now saves "1" images. For JPEG, the image - is saved as 8-bit greyscale (it will load as mode "L"). For GIF, the + is saved as 8-bit grayscale (it will load as mode "L"). For GIF, the image will be loaded as a "P" image. - Fixed a potential buffer overrun in the GIF encoder. @@ -6512,8 +6952,8 @@ The test suite includes 400 individual tests. neither "short", "int" nor "long" are 32-bit wide. - Added file= and data= keyword arguments to PhotoImage and BitmapImage. - This allows you to use them as drop-in replacements for the corre- - sponding Tkinter classes. + This allows you to use them as drop-in replacements for the corresponding + Tkinter classes. - Removed bogus references to the crack coder (ImagingCrack). @@ -6785,7 +7225,7 @@ The test suite includes 400 individual tests. drawing capabilities can be used to render vector and metafile formats. -- Added restricted drivers for images from Image Tools (greyscale +- Added restricted drivers for images from Image Tools (grayscale only) and LabEye/IFUNC (common interchange modes only). - Some minor improvements to the sample scripts provided in the diff --git a/LICENSE b/LICENSE index 40aabc323..cf65e86d7 100644 --- a/LICENSE +++ b/LICENSE @@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is Pillow is the friendly PIL fork. It is - Copyright © 2010-2022 by Alex Clark and contributors + Copyright © 2010-2023 by Jeffrey A. Clark (Alex) and contributors. Like PIL, Pillow is licensed under the open source HPND License: @@ -13,8 +13,8 @@ By obtaining, using, and/or copying this software and/or its associated documentation, you agree that you have read, understood, and will comply with the following terms and conditions: -Permission to use, copy, modify, and distribute this software and its -associated documentation for any purpose and without fee is hereby granted, +Permission to use, copy, modify and distribute this software and its +documentation for any purpose and without fee is hereby granted, provided that the above copyright notice appears in all copies, and that both that copyright notice and this permission notice appear in supporting documentation, and that the name of Secret Labs AB or the author not be diff --git a/MANIFEST.in b/MANIFEST.in index f51551303..af25dfd2d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,8 +5,10 @@ include *.md include *.py include *.rst include *.sh +include *.toml include *.txt include *.yaml +include .flake8 include LICENSE include Makefile include tox.ini @@ -15,6 +17,7 @@ graft src graft depends graft winbuild graft docs +graft _custom_build # build/src control detritus exclude .appveyor.yml @@ -28,3 +31,4 @@ global-exclude .git* global-exclude *.pyc global-exclude *.so prune .ci +prune wheels diff --git a/Makefile b/Makefile index a2545b54e..ad0a1adab 100644 --- a/Makefile +++ b/Makefile @@ -16,10 +16,16 @@ coverage: python3 -m coverage report .PHONY: doc -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 doccheck: $(MAKE) doc @@ -38,19 +44,15 @@ help: @echo " coverage run coverage test (in progress)" @echo " doc make HTML docs" @echo " docserve run an HTTP server on the docs directory" - @echo " html to make standalone HTML files" - @echo " inplace make inplace extension" + @echo " html make HTML docs" + @echo " htmlview open the index page built by the html target in your browser" @echo " install make and install" @echo " install-coverage make and install with C coverage" @echo " lint run the lint checks" - @echo " lint-fix run Black and isort to (mostly) fix lint issues" + @echo " lint-fix run Ruff to (mostly) fix lint issues" @echo " release-test run code and package tests before release" @echo " test run tests on installed Pillow" -.PHONY: inplace -inplace: clean - python3 -m pip install -e --global-option="build_ext" --global-option="--inplace" . - .PHONY: install install: python3 -m pip -v install . @@ -58,7 +60,7 @@ install: .PHONY: install-coverage install-coverage: - CFLAGS="-coverage -Werror=implicit-function-declaration" python3 -m pip -v install --global-option="build_ext" . + CFLAGS="-coverage -Werror=implicit-function-declaration" python3 -m pip -v install . python3 selftest.py .PHONY: debug @@ -67,10 +69,11 @@ debug: # for our stuff, kills optimization, and redirects to dev null so we # see any build failures. make clean > /dev/null - CFLAGS='-g -O0' python3 -m pip -v install --global-option="build_ext" . > /dev/null + CFLAGS='-g -O0' python3 -m pip -v install . > /dev/null .PHONY: release-test release-test: + python3 Tests/check_release_notes.py python3 -m pip install -e .[tests] python3 selftest.py python3 -m pytest Tests @@ -115,6 +118,6 @@ lint: .PHONY: lint-fix lint-fix: python3 -c "import black" > /dev/null 2>&1 || python3 -m pip install black - python3 -c "import isort" > /dev/null 2>&1 || python3 -m pip install isort - python3 -m black --target-version py37 . - python3 -m isort . + python3 -m black . + python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff + python3 -m ruff --fix . diff --git a/README.md b/README.md index 8ee68f9b8..e11bd2faa 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ ## Python Imaging Library (Fork) -Pillow is the friendly PIL fork by [Alex Clark and -Contributors](https://github.com/python-pillow/Pillow/graphs/contributors). +Pillow is the friendly PIL fork by [Jeffrey A. Clark (Alex) and +contributors](https://github.com/python-pillow/Pillow/graphs/contributors). PIL is the Python Imaging Library by Fredrik Lundh and Contributors. As of 2019, Pillow development is [supported by Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise). @@ -45,12 +45,12 @@ As of 2019, Pillow development is AppVeyor CI build status (Windows) - GitHub Actions wheels build status (Wheels) - GitHub Actions build status (Wheels) + Travis CI wheels build status (aarch64) + src="https://img.shields.io/travis/com/python-pillow/Pillow/main.svg?label=aarch64%20wheels"> Code coverage @@ -74,9 +74,9 @@ As of 2019, Pillow development is Number of PyPI downloads - OpenSSF Best Practices + src="https://www.bestpractices.dev/projects/6331/badge"> @@ -88,6 +88,10 @@ As of 2019, Pillow development is Follow on https://twitter.com/PythonPillow + Follow on https://fosstodon.org/@pillow diff --git a/RELEASING.md b/RELEASING.md index b05067484..74f427f03 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,30 +10,28 @@ 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 that all of the wheel builds [Pillow Wheel Builder](https://github.com/python-pillow/pillow-wheels) pass the tests in Travis CI and GitHub Actions. -* [ ] In compliance with [PEP 440](https://www.python.org/dev/peps/pep-0440/), update version identifier in `src/PIL/_version.py` +* [ ] Check that all of the wheel builds pass the tests in the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) and [Travis CI](https://app.travis-ci.com/github/python-pillow/pillow) 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 git branch 5.2.x git tag 5.2.0 - git push --all git push --tags ``` -* [ ] Create and check source distribution: - ```bash - make sdist - ``` -* [ ] Create [binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#binary-distributions) -* [ ] Check and upload all binaries and source distributions e.g.: +* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) +* [ ] Check and upload all source and binary distributions e.g.: ```bash python3 -m twine check --strict dist/* python3 -m twine upload dist/Pillow-5.2.0* ``` * [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) -* [ ] In compliance with [PEP 440](https://www.python.org/dev/peps/pep-0440/), increment and append `.dev0` to version identifier in `src/PIL/_version.py` - +* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), + increment and append `.dev0` to version identifier in `src/PIL/_version.py` and then: + ```bash + git push --all + ``` ## Point Release Released as needed for security, installation or critical bug fixes. @@ -45,29 +43,28 @@ Released as needed for security, installation or critical bug fixes. 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`. -* [ ] In compliance with [PEP 440](https://www.python.org/dev/peps/pep-0440/), update version identifier in `src/PIL/_version.py` +* [ ] 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.: ```bash git tag 5.2.1 - git push git push --tags ``` * [ ] Create and check source distribution: ```bash make sdist ``` -* [ ] Create [binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#binary-distributions) -* [ ] Check and upload all binaries and source distributions e.g.: +* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) +* [ ] Check and upload all source and binary distributions e.g.: ```bash python3 -m twine check --strict dist/* python3 -m twine upload dist/Pillow-5.2.1* ``` -* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) +* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then: + ```bash + git push + ``` ## Embargoed Release @@ -83,35 +80,32 @@ Released as needed privately to individual vendors for critical security-related ```bash git checkout 2.5.x git tag 2.5.3 - git push origin 2.5.x git push origin --tags ``` * [ ] Create and check source distribution: ```bash make sdist ``` -* [ ] Create [binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#binary-distributions) -* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) - -## Binary Distributions - -### Windows -* [ ] Download the artifacts from the [GitHub Actions "Test Windows" workflow](https://github.com/python-pillow/Pillow/actions/workflows/test-windows.yml) - and copy into `dist/` - -### Mac and Linux -* [ ] Use the [Pillow Wheel Builder](https://github.com/python-pillow/pillow-wheels): +* [ ] Create [source and binary distributions](https://github.com/python-pillow/Pillow/blob/main/RELEASING.md#source-and-binary-distributions) +* [ ] Publish the [release on GitHub](https://github.com/python-pillow/Pillow/releases) and then: ```bash - git clone https://github.com/python-pillow/pillow-wheels - cd pillow-wheels - ./update-pillow-tag.sh [[release tag]] + git push origin 2.5.x ``` -* [ ] Download wheels from the [Pillow Wheel Builder release](https://github.com/python-pillow/pillow-wheels/releases) - and copy into `dist/` + +## Source and Binary Distributions + +* [ ] Download sdist and wheels from the [GitHub Actions "Wheels" workflow](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml) + and copy into `dist/`. For example using [GitHub CLI](https://github.com/cli/cli): + ```bash + gh run download --dir dist + # select dist + ``` +* [ ] Download the Linux aarch64 wheels created by Travis CI from [GitHub releases](https://github.com/python-pillow/Pillow/releases) + and copy into `dist`. ## Publicize Release -* [ ] Announce release availability via [Twitter](https://twitter.com/pythonpillow) e.g. https://twitter.com/PythonPillow/status/1013789184354603010 +* [ ] Announce release availability via [Twitter](https://twitter.com/pythonpillow) and [Mastodon](https://fosstodon.org/@pillow) e.g. https://twitter.com/PythonPillow/status/1013789184354603010 ## Documentation diff --git a/Tests/32bit_segfault_check.py b/Tests/32bit_segfault_check.py index 2ff7f908f..06ed2ed2f 100755 --- a/Tests/32bit_segfault_check.py +++ b/Tests/32bit_segfault_check.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from __future__ import annotations import sys diff --git a/Tests/bench_cffi_access.py b/Tests/bench_cffi_access.py index 87cad699d..8a37c7d51 100644 --- a/Tests/bench_cffi_access.py +++ b/Tests/bench_cffi_access.py @@ -1,3 +1,4 @@ +from __future__ import annotations import time from PIL import PyAccess @@ -27,31 +28,25 @@ def timer(func, label, *args): for x in range(iterations): func(*args) if time.time() - starttime > 10: - print( - "{}: breaking at {} iterations, {:.6f} per iteration".format( - label, x + 1, (time.time() - starttime) / (x + 1.0) - ) - ) break - if x == iterations - 1: - endtime = time.time() - print( - "{}: {:.4f} s {:.6f} per iteration".format( - label, endtime - starttime, (endtime - starttime) / (x + 1.0) - ) + endtime = time.time() + print( + "{}: completed {} iterations in {:.4f}s, {:.6f}s per iteration".format( + label, x + 1, endtime - starttime, (endtime - starttime) / (x + 1.0) ) + ) def test_direct(): im = hopper() im.load() - # im = Image.new( "RGB", (2000, 2000), (1, 3, 2)) + # im = Image.new("RGB", (2000, 2000), (1, 3, 2)) caccess = im.im.pixel_access(False) access = PyAccess.new(im, False) assert caccess[(0, 0)] == access[(0, 0)] - print("Size: %sx%s" % im.size) + print(f"Size: {im.width}x{im.height}") timer(iterate_get, "PyAccess - get", im.size, access) timer(iterate_set, "PyAccess - set", im.size, access) timer(iterate_get, "C-api - get", im.size, caccess) diff --git a/Tests/check_fli_oob.py b/Tests/check_fli_oob.py index 7b3d4d7ee..ac46ff1eb 100644 --- a/Tests/check_fli_oob.py +++ b/Tests/check_fli_oob.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from __future__ import annotations from PIL import Image diff --git a/Tests/check_fli_overflow.py b/Tests/check_fli_overflow.py index 08a55d349..0fabcb5d3 100644 --- a/Tests/check_fli_overflow.py +++ b/Tests/check_fli_overflow.py @@ -1,10 +1,10 @@ +from __future__ import annotations from PIL import Image TEST_FILE = "Tests/images/fli_overflow.fli" def test_fli_overflow(): - # this should not crash with a malloc error or access violation with Image.open(TEST_FILE) as im: im.load() diff --git a/Tests/check_icns_dos.py b/Tests/check_icns_dos.py index a34bee45c..ac6be4869 100644 --- a/Tests/check_icns_dos.py +++ b/Tests/check_icns_dos.py @@ -1,5 +1,6 @@ # Tests potential DOS of IcnsImagePlugin with 0 length block. # Run from anywhere that PIL is importable. +from __future__ import annotations from io import BytesIO diff --git a/Tests/check_imaging_leaks.py b/Tests/check_imaging_leaks.py index d07082aba..8c17c051d 100755 --- a/Tests/check_imaging_leaks.py +++ b/Tests/check_imaging_leaks.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from __future__ import annotations import pytest from PIL import Image diff --git a/Tests/check_j2k_dos.py b/Tests/check_j2k_dos.py index 71dcea4f3..2c63c3402 100644 --- a/Tests/check_j2k_dos.py +++ b/Tests/check_j2k_dos.py @@ -1,5 +1,6 @@ # Tests potential DOS of Jpeg2kImagePlugin with 0 length block. # Run from anywhere that PIL is importable. +from __future__ import annotations from io import BytesIO diff --git a/Tests/check_j2k_leaks.py b/Tests/check_j2k_leaks.py old mode 100755 new mode 100644 index afe5836f3..83a12e2c2 --- a/Tests/check_j2k_leaks.py +++ b/Tests/check_j2k_leaks.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO import pytest diff --git a/Tests/check_j2k_overflow.py b/Tests/check_j2k_overflow.py index b16412898..982f6ea74 100644 --- a/Tests/check_j2k_overflow.py +++ b/Tests/check_j2k_overflow.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image diff --git a/Tests/check_jp2_overflow.py b/Tests/check_jp2_overflow.py index 0210505f5..9afbff112 100755 --- a/Tests/check_jp2_overflow.py +++ b/Tests/check_jp2_overflow.py @@ -12,6 +12,7 @@ # the output should be empty. There may be python issues # in the valgrind especially if run in a debug python # version. +from __future__ import annotations from PIL import Image diff --git a/Tests/check_jpeg_leaks.py b/Tests/check_jpeg_leaks.py index ab8d77719..3cd37c7af 100644 --- a/Tests/check_jpeg_leaks.py +++ b/Tests/check_jpeg_leaks.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO import pytest @@ -75,43 +76,42 @@ post-patch: """ -def test_qtables_leak(): +standard_l_qtable = ( + # fmt: off + 16, 11, 10, 16, 24, 40, 51, 61, + 12, 12, 14, 19, 26, 58, 60, 55, + 14, 13, 16, 24, 40, 57, 69, 56, + 14, 17, 22, 29, 51, 87, 80, 62, + 18, 22, 37, 56, 68, 109, 103, 77, + 24, 35, 55, 64, 81, 104, 113, 92, + 49, 64, 78, 87, 103, 121, 120, 101, + 72, 92, 95, 98, 112, 100, 103, 99, + # fmt: on +) + +standard_chrominance_qtable = ( + # fmt: off + 17, 18, 24, 47, 99, 99, 99, 99, + 18, 21, 26, 66, 99, 99, 99, 99, + 24, 26, 56, 99, 99, 99, 99, 99, + 47, 66, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + # fmt: on +) + + +@pytest.mark.parametrize( + "qtables", + ( + (standard_l_qtable, standard_chrominance_qtable), + [standard_l_qtable, standard_chrominance_qtable], + ), +) +def test_qtables_leak(qtables): im = hopper("RGB") - - standard_l_qtable = [ - int(s) - for s in """ - 16 11 10 16 24 40 51 61 - 12 12 14 19 26 58 60 55 - 14 13 16 24 40 57 69 56 - 14 17 22 29 51 87 80 62 - 18 22 37 56 68 109 103 77 - 24 35 55 64 81 104 113 92 - 49 64 78 87 103 121 120 101 - 72 92 95 98 112 100 103 99 - """.split( - None - ) - ] - - standard_chrominance_qtable = [ - int(s) - for s in """ - 17 18 24 47 99 99 99 99 - 18 21 26 66 99 99 99 99 - 24 26 56 99 99 99 99 99 - 47 66 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - """.split( - None - ) - ] - - qtables = [standard_l_qtable, standard_chrominance_qtable] - for _ in range(iterations): test_output = BytesIO() im.save(test_output, "JPEG", qtables=qtables) diff --git a/Tests/check_large_memory.py b/Tests/check_large_memory.py index d98f4a694..9b83798d5 100644 --- a/Tests/check_large_memory.py +++ b/Tests/check_large_memory.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys import pytest diff --git a/Tests/check_large_memory_numpy.py b/Tests/check_large_memory_numpy.py index 24cb1f722..0ff3de8dc 100644 --- a/Tests/check_large_memory_numpy.py +++ b/Tests/check_large_memory_numpy.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys import pytest diff --git a/Tests/check_libtiff_segfault.py b/Tests/check_libtiff_segfault.py index bd7f407e4..ee1d7d11f 100644 --- a/Tests/check_libtiff_segfault.py +++ b/Tests/check_libtiff_segfault.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image diff --git a/Tests/check_png_dos.py b/Tests/check_png_dos.py index d8d645189..292fe4b7f 100644 --- a/Tests/check_png_dos.py +++ b/Tests/check_png_dos.py @@ -1,3 +1,4 @@ +from __future__ import annotations import zlib from io import BytesIO @@ -23,7 +24,6 @@ def test_ignore_dos_text(): def test_dos_text(): - try: im = Image.open(TEST_FILE) im.load() diff --git a/Tests/check_release_notes.py b/Tests/check_release_notes.py new file mode 100644 index 000000000..ebfaffa47 --- /dev/null +++ b/Tests/check_release_notes.py @@ -0,0 +1,7 @@ +from __future__ import annotations +import sys +from pathlib import Path + +for rst in Path("docs/releasenotes").glob("[1-9]*.rst"): + if "TODO" in open(rst).read(): + sys.exit(f"Error: remove TODO from {rst}") diff --git a/Tests/check_wheel.py b/Tests/check_wheel.py new file mode 100644 index 000000000..afe4cc3ee --- /dev/null +++ b/Tests/check_wheel.py @@ -0,0 +1,42 @@ +from __future__ import annotations +import sys + +from PIL import features + + +def test_wheel_modules(): + expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"} + + # tkinter is not available in cibuildwheel installed CPython on Windows + try: + import tkinter + + assert tkinter + except ImportError: + expected_modules.remove("tkinter") + + assert set(features.get_supported_modules()) == expected_modules + + +def test_wheel_codecs(): + expected_codecs = {"jpg", "jpg_2000", "zlib", "libtiff"} + + assert set(features.get_supported_codecs()) == expected_codecs + + +def test_wheel_features(): + expected_features = { + "webp_anim", + "webp_mux", + "transp_webp", + "raqm", + "fribidi", + "harfbuzz", + "libjpeg_turbo", + "xcb", + } + + if sys.platform == "win32": + expected_features.remove("xcb") + + assert set(features.get_supported_features()) == expected_features diff --git a/Tests/conftest.py b/Tests/conftest.py index 66da7593c..cd64bd755 100644 --- a/Tests/conftest.py +++ b/Tests/conftest.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io diff --git a/Tests/createfontdatachunk.py b/Tests/createfontdatachunk.py index e318eb732..2e990b709 100755 --- a/Tests/createfontdatachunk.py +++ b/Tests/createfontdatachunk.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from __future__ import annotations import base64 import os diff --git a/Tests/fonts/CBDTTestFont.ttf b/Tests/fonts/CBDTTestFont.ttf new file mode 100644 index 000000000..73444e8dc Binary files /dev/null and b/Tests/fonts/CBDTTestFont.ttf differ diff --git a/Tests/fonts/DejaVuSans/LICENSE.txt b/Tests/fonts/DejaVuSans/LICENSE.txt index 30516578f..be6a4d84c 100644 --- a/Tests/fonts/DejaVuSans/LICENSE.txt +++ b/Tests/fonts/DejaVuSans/LICENSE.txt @@ -37,4 +37,4 @@ The Font Software may be sold as part of a larger software package but no copy o THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. -Except as contained in this notice, the name of Tavmjong Bah shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from Tavmjong Bah. For further information, contact: tavmjong @ free . fr. \ No newline at end of file +Except as contained in this notice, the name of Tavmjong Bah shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from Tavmjong Bah. For further information, contact: tavmjong @ free . fr. diff --git a/Tests/fonts/EBDTTestFont.ttf b/Tests/fonts/EBDTTestFont.ttf new file mode 100644 index 000000000..046e9e45c Binary files /dev/null and b/Tests/fonts/EBDTTestFont.ttf differ diff --git a/Tests/fonts/LICENSE.txt b/Tests/fonts/LICENSE.txt index da559b3d3..3c8a23197 100644 --- a/Tests/fonts/LICENSE.txt +++ b/Tests/fonts/LICENSE.txt @@ -2,7 +2,6 @@ NotoNastaliqUrdu-Regular.ttf and NotoSansSymbols-Regular.ttf, from https://github.com/googlei18n/noto-fonts NotoSans-Regular.ttf, from https://www.google.com/get/noto/ NotoSansJP-Thin.otf, from https://www.google.com/get/noto/help/cjk/ -NotoColorEmoji.ttf, from https://github.com/googlefonts/noto-emoji AdobeVFPrototype.ttf, from https://github.com/adobe-fonts/adobe-variable-font-prototype TINY5x3GX.ttf, from http://velvetyne.fr/fonts/tiny ArefRuqaa-Regular.ttf, from https://github.com/google/fonts/tree/master/ofl/arefruqaa @@ -25,3 +24,5 @@ FreeMono.ttf is licensed under GPLv3. 10x20-ISO8859-1.pcf, from https://packages.ubuntu.com/xenial/xfonts-base "Public domain font. Share and enjoy." + +CBDTTestFont.ttf and EBDTTestFont.ttf from https://github.com/nulano/font-tests are public domain. diff --git a/Tests/fonts/NotoColorEmoji.ttf b/Tests/fonts/NotoColorEmoji.ttf deleted file mode 100644 index ef7b72575..000000000 Binary files a/Tests/fonts/NotoColorEmoji.ttf and /dev/null differ diff --git a/Tests/fonts/fuzz_font-5203009437302784 b/Tests/fonts/fuzz_font-5203009437302784 new file mode 100644 index 000000000..0465e48c2 --- /dev/null +++ b/Tests/fonts/fuzz_font-5203009437302784 @@ -0,0 +1,10 @@ +STARTFONT +FONT +SIZE 10 +FONTBOUNDINGBOX +CHARS +STARTCHAR +ENCODING +BBX 2 5 +ENDCHAR +ENDFONT diff --git a/Tests/fonts/oom-4da0210eb7081b0bf15bf16cc4c52ce02c1e1bbc.ttf b/Tests/fonts/oom-4da0210eb7081b0bf15bf16cc4c52ce02c1e1bbc.ttf new file mode 100644 index 000000000..fe200842e Binary files /dev/null and b/Tests/fonts/oom-4da0210eb7081b0bf15bf16cc4c52ce02c1e1bbc.ttf differ diff --git a/Tests/helper.py b/Tests/helper.py index 0d1d03ac8..b333c2fd4 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -1,10 +1,12 @@ """ Helper functions. """ +from __future__ import annotations import logging import os import shutil +import subprocess import sys import sysconfig import tempfile @@ -20,7 +22,7 @@ logger = logging.getLogger(__name__) HAS_UPLOADER = False -if os.environ.get("SHOW_ERRORS", None): +if os.environ.get("SHOW_ERRORS"): # local img.show for errors. HAS_UPLOADER = True @@ -91,11 +93,11 @@ def assert_image_equal(a, b, msg=None): if HAS_UPLOADER: try: url = test_image_results.upload(a, b) - logger.error(f"Url for test images: {url}") + logger.error("URL for test images: %s", url) except Exception: pass - assert False, msg or "got different content" + pytest.fail(msg or "got different content") def assert_image_equal_tofile(a, filename, msg=None, mode=None): @@ -126,7 +128,7 @@ def assert_image_similar(a, b, epsilon, msg=None): if HAS_UPLOADER: try: url = test_image_results.upload(a, b) - logger.error(f"Url for test images: {url}") + logger.exception("URL for test images: %s", url) except Exception: pass raise e @@ -258,11 +260,21 @@ def hopper(mode=None, cache={}): def djpeg_available(): - return bool(shutil.which("djpeg")) + if shutil.which("djpeg"): + try: + subprocess.check_call(["djpeg", "-version"]) + return True + except subprocess.CalledProcessError: # pragma: no cover + return False def cjpeg_available(): - return bool(shutil.which("cjpeg")) + if shutil.which("cjpeg"): + try: + subprocess.check_call(["cjpeg", "-version"]) + return True + except subprocess.CalledProcessError: # pragma: no cover + return False def netpbm_available(): @@ -271,7 +283,7 @@ def netpbm_available(): def magick_command(): if sys.platform == "win32": - magickhome = os.environ.get("MAGICK_HOME", "") + magickhome = os.environ.get("MAGICK_HOME") if magickhome: imagemagick = [os.path.join(magickhome, "convert.exe")] graphicsmagick = [os.path.join(magickhome, "gm.exe"), "convert"] diff --git a/Tests/icc/LICENSE.txt b/Tests/icc/LICENSE.txt index 7d289c331..7119461ed 100644 --- a/Tests/icc/LICENSE.txt +++ b/Tests/icc/LICENSE.txt @@ -22,4 +22,3 @@ and that the name of ICC shall not be used in advertising or publicity pertaining to distribution of the software without specific, written prior permission. ICC makes no representations about the suitability of this software for any purpose. - diff --git a/Tests/images/8bit.s.tif b/Tests/images/8bit.s.tif new file mode 100644 index 000000000..043cba6af Binary files /dev/null and b/Tests/images/8bit.s.tif differ diff --git a/Tests/images/apng/mode_greyscale.png b/Tests/images/apng/mode_grayscale.png similarity index 100% rename from Tests/images/apng/mode_greyscale.png rename to Tests/images/apng/mode_grayscale.png diff --git a/Tests/images/apng/mode_greyscale_alpha.png b/Tests/images/apng/mode_grayscale_alpha.png similarity index 100% rename from Tests/images/apng/mode_greyscale_alpha.png rename to Tests/images/apng/mode_grayscale_alpha.png diff --git a/Tests/images/background_outside_palette.gif b/Tests/images/background_outside_palette.gif new file mode 100644 index 000000000..63e767463 Binary files /dev/null and b/Tests/images/background_outside_palette.gif differ diff --git a/Tests/images/bc1.dds b/Tests/images/bc1.dds new file mode 100755 index 000000000..faec63a00 Binary files /dev/null and b/Tests/images/bc1.dds differ diff --git a/Tests/images/bc1_typeless.dds b/Tests/images/bc1_typeless.dds new file mode 100755 index 000000000..47a85e2d0 Binary files /dev/null and b/Tests/images/bc1_typeless.dds differ diff --git a/Tests/images/bc4_typeless.dds b/Tests/images/bc4_typeless.dds new file mode 100644 index 000000000..27f87889f Binary files /dev/null and b/Tests/images/bc4_typeless.dds differ diff --git a/Tests/images/bc4_unorm.dds b/Tests/images/bc4_unorm.dds new file mode 100644 index 000000000..13da711bd Binary files /dev/null and b/Tests/images/bc4_unorm.dds differ diff --git a/Tests/images/bc4_unorm.png b/Tests/images/bc4_unorm.png new file mode 100644 index 000000000..71d536c84 Binary files /dev/null and b/Tests/images/bc4_unorm.png differ diff --git a/Tests/images/bc4u.dds b/Tests/images/bc4u.dds new file mode 100644 index 000000000..7f9f050b6 Binary files /dev/null and b/Tests/images/bc4u.dds differ diff --git a/Tests/images/bc5s.png b/Tests/images/bc5s.png index 39d7811bf..5e7a1b95e 100644 Binary files a/Tests/images/bc5s.png and b/Tests/images/bc5s.png differ diff --git a/Tests/images/bc5u.dds b/Tests/images/bc5u.dds new file mode 100644 index 000000000..4e4773cd7 Binary files /dev/null and b/Tests/images/bc5u.dds differ diff --git a/Tests/images/bitmap_font_blend.png b/Tests/images/bitmap_font_blend.png new file mode 100644 index 000000000..a5acf3667 Binary files /dev/null and b/Tests/images/bitmap_font_blend.png differ diff --git a/Tests/images/bitmap_font_stroke_basic.png b/Tests/images/bitmap_font_stroke_basic.png index 86b2d09f6..26aa3ab8e 100644 Binary files a/Tests/images/bitmap_font_stroke_basic.png and b/Tests/images/bitmap_font_stroke_basic.png differ diff --git a/Tests/images/bitmap_font_stroke_raqm.png b/Tests/images/bitmap_font_stroke_raqm.png index 08029ce34..be273d7cb 100644 Binary files a/Tests/images/bitmap_font_stroke_raqm.png and b/Tests/images/bitmap_font_stroke_raqm.png differ diff --git a/Tests/images/blend_transparency.png b/Tests/images/blend_transparency.png new file mode 100644 index 000000000..cef0a16de Binary files /dev/null and b/Tests/images/blend_transparency.png differ diff --git a/Tests/images/cbdt.png b/Tests/images/cbdt.png new file mode 100644 index 000000000..542bb812e Binary files /dev/null and b/Tests/images/cbdt.png differ diff --git a/Tests/images/cbdt_mask.png b/Tests/images/cbdt_mask.png new file mode 100644 index 000000000..b0854a605 Binary files /dev/null and b/Tests/images/cbdt_mask.png differ diff --git a/Tests/images/cbdt_notocoloremoji.png b/Tests/images/cbdt_notocoloremoji.png deleted file mode 100644 index 1da12fba1..000000000 Binary files a/Tests/images/cbdt_notocoloremoji.png and /dev/null differ diff --git a/Tests/images/cbdt_notocoloremoji_mask.png b/Tests/images/cbdt_notocoloremoji_mask.png deleted file mode 100644 index 6d036a0b6..000000000 Binary files a/Tests/images/cbdt_notocoloremoji_mask.png and /dev/null differ diff --git a/Tests/images/comment.jp2 b/Tests/images/comment.jp2 new file mode 100644 index 000000000..4bdf91760 Binary files /dev/null and b/Tests/images/comment.jp2 differ diff --git a/Tests/images/default_font_freetype.png b/Tests/images/default_font_freetype.png new file mode 100644 index 000000000..bc1654a25 Binary files /dev/null and b/Tests/images/default_font_freetype.png differ diff --git a/Tests/images/duplicate_xref_entry.pdf b/Tests/images/duplicate_xref_entry.pdf new file mode 100644 index 000000000..f57a57d61 Binary files /dev/null and b/Tests/images/duplicate_xref_entry.pdf differ diff --git a/Tests/images/five_channels.psd b/Tests/images/five_channels.psd new file mode 100644 index 000000000..021a5fa63 Binary files /dev/null and b/Tests/images/five_channels.psd differ diff --git a/Tests/images/hopper.qoi b/Tests/images/hopper.qoi new file mode 100644 index 000000000..6b255aba1 Binary files /dev/null and b/Tests/images/hopper.qoi differ diff --git a/Tests/images/hopper_emboss_I.png b/Tests/images/hopper_emboss_I.png new file mode 100644 index 000000000..f4dab388f Binary files /dev/null and b/Tests/images/hopper_emboss_I.png differ diff --git a/Tests/images/hopper_emboss_more_I.png b/Tests/images/hopper_emboss_more_I.png new file mode 100644 index 000000000..c417c915f Binary files /dev/null and b/Tests/images/hopper_emboss_more_I.png differ diff --git a/Tests/images/hopper_rle8_greyscale.bmp b/Tests/images/hopper_rle8_grayscale.bmp similarity index 100% rename from Tests/images/hopper_rle8_greyscale.bmp rename to Tests/images/hopper_rle8_grayscale.bmp diff --git a/Tests/images/imagedraw_default_font_size.png b/Tests/images/imagedraw_default_font_size.png new file mode 100644 index 000000000..f695b5cd6 Binary files /dev/null and b/Tests/images/imagedraw_default_font_size.png differ diff --git a/Tests/images/imagedraw_ellipse_various_sizes.png b/Tests/images/imagedraw_ellipse_various_sizes.png index 11a1be6fa..5e3cf22b4 100644 Binary files a/Tests/images/imagedraw_ellipse_various_sizes.png and b/Tests/images/imagedraw_ellipse_various_sizes.png differ diff --git a/Tests/images/imagedraw_ellipse_various_sizes_filled.png b/Tests/images/imagedraw_ellipse_various_sizes_filled.png index d71e175b8..dd2f641f1 100644 Binary files a/Tests/images/imagedraw_ellipse_various_sizes_filled.png and b/Tests/images/imagedraw_ellipse_various_sizes_filled.png differ diff --git a/Tests/images/imagedraw_rectangle_I.png b/Tests/images/imagedraw_rectangle_I.png index 4e94f6943..a75f12c2e 100644 Binary files a/Tests/images/imagedraw_rectangle_I.png and b/Tests/images/imagedraw_rectangle_I.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nnnn.png b/Tests/images/imagedraw_rounded_rectangle_corners_nnnn.png new file mode 100644 index 000000000..3e79e21ae Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nnnn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nnny.png b/Tests/images/imagedraw_rounded_rectangle_corners_nnny.png new file mode 100644 index 000000000..7fa09a3c0 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nnny.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nnyn.png b/Tests/images/imagedraw_rounded_rectangle_corners_nnyn.png new file mode 100644 index 000000000..d825ad263 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nnyn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nnyy.png b/Tests/images/imagedraw_rounded_rectangle_corners_nnyy.png new file mode 100644 index 000000000..c19da698e Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nnyy.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nynn.png b/Tests/images/imagedraw_rounded_rectangle_corners_nynn.png new file mode 100644 index 000000000..f3e95d487 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nynn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nyny.png b/Tests/images/imagedraw_rounded_rectangle_corners_nyny.png new file mode 100644 index 000000000..274d27984 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nyny.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nyyn.png b/Tests/images/imagedraw_rounded_rectangle_corners_nyyn.png new file mode 100644 index 000000000..c5f40bfdb Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nyyn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_nyyy.png b/Tests/images/imagedraw_rounded_rectangle_corners_nyyy.png new file mode 100644 index 000000000..01bfd1750 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_nyyy.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_ynnn.png b/Tests/images/imagedraw_rounded_rectangle_corners_ynnn.png new file mode 100644 index 000000000..efd27be4f Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_ynnn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_ynny.png b/Tests/images/imagedraw_rounded_rectangle_corners_ynny.png new file mode 100644 index 000000000..d3acd01ab Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_ynny.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_ynyn.png b/Tests/images/imagedraw_rounded_rectangle_corners_ynyn.png new file mode 100644 index 000000000..55ddbc033 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_ynyn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_ynyy.png b/Tests/images/imagedraw_rounded_rectangle_corners_ynyy.png new file mode 100644 index 000000000..c000b26e9 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_ynyy.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_yynn.png b/Tests/images/imagedraw_rounded_rectangle_corners_yynn.png new file mode 100644 index 000000000..7056b4fd9 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_yynn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_yyny.png b/Tests/images/imagedraw_rounded_rectangle_corners_yyny.png new file mode 100644 index 000000000..5eca030b9 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_yyny.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_yyyn.png b/Tests/images/imagedraw_rounded_rectangle_corners_yyyn.png new file mode 100644 index 000000000..7f1f00344 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_yyyn.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_corners_yyyy.png b/Tests/images/imagedraw_rounded_rectangle_corners_yyyy.png new file mode 100644 index 000000000..2e815f4ad Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_corners_yyyy.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_x_odd.png b/Tests/images/imagedraw_rounded_rectangle_x_odd.png new file mode 100644 index 000000000..f23f1945e Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_x_odd.png differ diff --git a/Tests/images/imagedraw_rounded_rectangle_y_odd.png b/Tests/images/imagedraw_rounded_rectangle_y_odd.png new file mode 100644 index 000000000..96441bc72 Binary files /dev/null and b/Tests/images/imagedraw_rounded_rectangle_y_odd.png differ diff --git a/Tests/images/imagedraw_triangle_width.png b/Tests/images/imagedraw_triangle_width.png new file mode 100644 index 000000000..3d35326e7 Binary files /dev/null and b/Tests/images/imagedraw_triangle_width.png differ diff --git a/Tests/images/negative_size.ppm b/Tests/images/negative_size.ppm old mode 100755 new mode 100644 diff --git a/Tests/images/orientation_rectangle.jpg b/Tests/images/orientation_rectangle.jpg new file mode 100644 index 000000000..85cfbd0a8 Binary files /dev/null and b/Tests/images/orientation_rectangle.jpg differ diff --git a/Tests/images/palette.dds b/Tests/images/palette.dds new file mode 100644 index 000000000..7171cb1f2 Binary files /dev/null and b/Tests/images/palette.dds differ diff --git a/Tests/images/pil123rgba.qoi b/Tests/images/pil123rgba.qoi new file mode 100644 index 000000000..1e46036c7 Binary files /dev/null and b/Tests/images/pil123rgba.qoi differ diff --git a/Tests/images/test_combine_caron_below_ttb.png b/Tests/images/test_combine_caron_below_ttb.png index 5c7576de0..2b7cc89ea 100644 Binary files a/Tests/images/test_combine_caron_below_ttb.png and b/Tests/images/test_combine_caron_below_ttb.png differ diff --git a/Tests/images/test_combine_caron_below_ttb_lb.png b/Tests/images/test_combine_caron_below_ttb_lb.png index bacd6a141..3ced2dbfc 100644 Binary files a/Tests/images/test_combine_caron_below_ttb_lb.png and b/Tests/images/test_combine_caron_below_ttb_lb.png differ diff --git a/Tests/images/test_combine_caron_ttb.png b/Tests/images/test_combine_caron_ttb.png index a94be2f0a..569cc1ec3 100644 Binary files a/Tests/images/test_combine_caron_ttb.png and b/Tests/images/test_combine_caron_ttb.png differ diff --git a/Tests/images/test_combine_caron_ttb_lt.png b/Tests/images/test_combine_caron_ttb_lt.png index a94be2f0a..569cc1ec3 100644 Binary files a/Tests/images/test_combine_caron_ttb_lt.png and b/Tests/images/test_combine_caron_ttb_lt.png differ diff --git a/Tests/images/truncated_exif_dpi.jpg b/Tests/images/truncated_exif_dpi.jpg new file mode 100644 index 000000000..b41ab4004 Binary files /dev/null and b/Tests/images/truncated_exif_dpi.jpg differ diff --git a/Tests/images/unimplemented_pixel_format.dds b/Tests/images/unimplemented_pfflags.dds similarity index 99% rename from Tests/images/unimplemented_pixel_format.dds rename to Tests/images/unimplemented_pfflags.dds index 41a343886..e3fc8344d 100755 Binary files a/Tests/images/unimplemented_pixel_format.dds and b/Tests/images/unimplemented_pfflags.dds differ diff --git a/Tests/images/unsupported_bitcount_luminance.dds b/Tests/images/unsupported_bitcount_luminance.dds new file mode 100644 index 000000000..f9bb82254 Binary files /dev/null and b/Tests/images/unsupported_bitcount_luminance.dds differ diff --git a/Tests/images/unsupported_bitcount_rgb.dds b/Tests/images/unsupported_bitcount_rgb.dds new file mode 100644 index 000000000..77d527507 Binary files /dev/null and b/Tests/images/unsupported_bitcount_rgb.dds differ diff --git a/Tests/images/xmp_no_prefix.jpg b/Tests/images/xmp_no_prefix.jpg new file mode 100644 index 000000000..bcd78c7ed Binary files /dev/null and b/Tests/images/xmp_no_prefix.jpg differ diff --git a/Tests/images/xmp_padded.jpg b/Tests/images/xmp_padded.jpg new file mode 100644 index 000000000..9ecfb3efe Binary files /dev/null and b/Tests/images/xmp_padded.jpg differ diff --git a/Tests/images/zero_bb_eof_before_boundingbox.eps b/Tests/images/zero_bb_eof_before_boundingbox.eps new file mode 100644 index 000000000..c268bffd1 Binary files /dev/null and b/Tests/images/zero_bb_eof_before_boundingbox.eps differ diff --git a/Tests/images/zero_bb_trailer.eps b/Tests/images/zero_bb_trailer.eps new file mode 100644 index 000000000..964889965 Binary files /dev/null and b/Tests/images/zero_bb_trailer.eps differ diff --git a/Tests/images/zero_width.gif b/Tests/images/zero_width.gif new file mode 100644 index 000000000..da6823b60 Binary files /dev/null and b/Tests/images/zero_width.gif differ diff --git a/Tests/oss-fuzz/build.sh b/Tests/oss-fuzz/build.sh index 7e9098f53..3aa6c7f6a 100755 --- a/Tests/oss-fuzz/build.sh +++ b/Tests/oss-fuzz/build.sh @@ -15,12 +15,12 @@ # ################################################################################ -python3 setup.py build --build-base=/tmp/build install +python3 -m pip install . # Build fuzzers in $OUT. for fuzzer in $(find $SRC -name 'fuzz_*.py'); do compile_python_fuzzer $fuzzer \ - --add-binary /usr/local/lib/libjpeg.so.62.3.0:. \ + --add-binary /usr/local/lib/libjpeg.so.62.4.0:. \ --add-binary /usr/local/lib/libfreetype.so.6:. \ --add-binary /usr/local/lib/liblcms2.so.2:. \ --add-binary /usr/local/lib/libopenjp2.so.7:. \ diff --git a/Tests/oss-fuzz/fuzz_font.py b/Tests/oss-fuzz/fuzz_font.py index bc2ba9a7e..4e7c7deec 100755 --- a/Tests/oss-fuzz/fuzz_font.py +++ b/Tests/oss-fuzz/fuzz_font.py @@ -13,6 +13,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations import atheris diff --git a/Tests/oss-fuzz/fuzz_pillow.py b/Tests/oss-fuzz/fuzz_pillow.py index 545daccb6..e7cd0474a 100644 --- a/Tests/oss-fuzz/fuzz_pillow.py +++ b/Tests/oss-fuzz/fuzz_pillow.py @@ -13,6 +13,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations import atheris diff --git a/Tests/oss-fuzz/fuzzers.py b/Tests/oss-fuzz/fuzzers.py index 10a172b46..3f3c1e388 100644 --- a/Tests/oss-fuzz/fuzzers.py +++ b/Tests/oss-fuzz/fuzzers.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import warnings diff --git a/Tests/oss-fuzz/test_fuzzers.py b/Tests/oss-fuzz/test_fuzzers.py index 629e9ac00..68834045a 100644 --- a/Tests/oss-fuzz/test_fuzzers.py +++ b/Tests/oss-fuzz/test_fuzzers.py @@ -1,3 +1,4 @@ +from __future__ import annotations import subprocess import sys @@ -6,6 +7,7 @@ import packaging import pytest from PIL import Image, features +from Tests.helper import skip_unless_feature if sys.platform.startswith("win32"): pytest.skip("Fuzzer is linux only", allow_module_level=True) @@ -48,6 +50,7 @@ def test_fuzz_images(path): fuzzers.disable_decompressionbomb_error() +@skip_unless_feature("freetype2") @pytest.mark.parametrize( "path", subprocess.check_output("find Tests/fonts -type f", shell=True).split(b"\n") ) @@ -57,6 +60,6 @@ def test_fuzz_fonts(path): with open(path, "rb") as f: try: fuzzers.fuzz_font(f.read()) - except (Image.DecompressionBombError, Image.DecompressionBombWarning): + except (Image.DecompressionBombError, Image.DecompressionBombWarning, OSError): pass assert True diff --git a/Tests/test_000_sanity.py b/Tests/test_000_sanity.py index 3fd982474..c582dfad3 100644 --- a/Tests/test_000_sanity.py +++ b/Tests/test_000_sanity.py @@ -1,3 +1,4 @@ +from __future__ import annotations from PIL import Image diff --git a/Tests/test_binary.py b/Tests/test_binary.py index 4882e65e6..62da26636 100644 --- a/Tests/test_binary.py +++ b/Tests/test_binary.py @@ -1,3 +1,4 @@ +from __future__ import annotations from PIL import _binary diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index ed9aff9cc..bed8dc3a8 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import warnings @@ -18,7 +19,6 @@ def test_bad(): """These shouldn't crash/dos, but they shouldn't return anything either""" for f in get_files("b"): - # Assert that there is no unclosed file warning with warnings.catch_warnings(): try: diff --git a/Tests/test_box_blur.py b/Tests/test_box_blur.py index 3bdd5177d..e798cba3d 100644 --- a/Tests/test_box_blur.py +++ b/Tests/test_box_blur.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageFilter @@ -22,7 +23,7 @@ def test_imageops_box_blur(): def box_blur(image, radius=1, n=1): - return image._new(image.im.box_blur(radius, n)) + return image._new(image.im.box_blur((radius, radius), n)) def assert_image(im, data, delta=0): diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index 6d9a60570..448ba2fac 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -1,3 +1,4 @@ +from __future__ import annotations from array import array import pytest diff --git a/Tests/test_core_resources.py b/Tests/test_core_resources.py index 385192a3c..5275652f6 100644 --- a/Tests/test_core_resources.py +++ b/Tests/test_core_resources.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys import pytest @@ -177,13 +178,14 @@ class TestEnvVars: Image._apply_env_variables({"PILLOW_BLOCK_SIZE": "2m"}) assert Image.core.get_block_size() == 2 * 1024 * 1024 - def test_warnings(self): - pytest.warns( - UserWarning, Image._apply_env_variables, {"PILLOW_ALIGNMENT": "15"} - ) - pytest.warns( - UserWarning, Image._apply_env_variables, {"PILLOW_BLOCK_SIZE": "1024"} - ) - pytest.warns( - UserWarning, Image._apply_env_variables, {"PILLOW_BLOCKS_MAX": "wat"} - ) + @pytest.mark.parametrize( + "var", + ( + {"PILLOW_ALIGNMENT": "15"}, + {"PILLOW_BLOCK_SIZE": "1024"}, + {"PILLOW_BLOCKS_MAX": "wat"}, + ), + ) + def test_warnings(self, var): + with pytest.warns(UserWarning): + Image._apply_env_variables(var) diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index 63071b78c..391948d40 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image @@ -36,12 +37,10 @@ class TestDecompressionBomb: Image.MAX_IMAGE_PIXELS = 128 * 128 - 1 assert Image.MAX_IMAGE_PIXELS == 128 * 128 - 1 - def open(): + with pytest.warns(Image.DecompressionBombWarning): with Image.open(TEST_FILE): pass - pytest.warns(Image.DecompressionBombWarning, open) - def test_exception(self): # Set limit to trigger exception on the test file Image.MAX_IMAGE_PIXELS = 64 * 128 - 1 @@ -66,6 +65,15 @@ class TestDecompressionBomb: with pytest.raises(Image.DecompressionBombError): im.seek(1) + def test_exception_gif_zero_width(self): + # Set limit to trigger exception on the test file + Image.MAX_IMAGE_PIXELS = 4 * 64 * 128 + assert Image.MAX_IMAGE_PIXELS == 4 * 64 * 128 + + with pytest.raises(Image.DecompressionBombError): + with Image.open("Tests/images/zero_width.gif"): + pass + def test_exception_bmp(self): with pytest.raises(Image.DecompressionBombError): with Image.open("Tests/images/bmp/b/reallybig.bmp"): @@ -87,7 +95,8 @@ class TestDecompressionCrop: # same decompression bomb warnings on them. with hopper() as src: box = (0, 0, src.width * 2, src.height * 2) - pytest.warns(Image.DecompressionBombWarning, src.crop, box) + with pytest.warns(Image.DecompressionBombWarning): + src.crop(box) def test_crop_decompression_checks(self): im = Image.new("RGB", (100, 100)) @@ -95,7 +104,8 @@ class TestDecompressionCrop: for value in ((-9999, -9999, -9990, -9990), (-999, -999, -990, -990)): assert im.crop(value).size == (9, 9) - pytest.warns(Image.DecompressionBombWarning, im.crop, (-160, -160, 99, 99)) + with pytest.warns(Image.DecompressionBombWarning): + im.crop((-160, -160, 99, 99)) with pytest.raises(Image.DecompressionBombError): im.crop((-99909, -99990, 99999, 99999)) diff --git a/Tests/test_deprecate.py b/Tests/test_deprecate.py index 30ed4a808..d45a6603c 100644 --- a/Tests/test_deprecate.py +++ b/Tests/test_deprecate.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import _deprecate @@ -7,9 +8,9 @@ from PIL import _deprecate "version, expected", [ ( - 10, - "Old thing is deprecated and will be removed in Pillow 10 " - r"\(2023-07-01\)\. Use new thing instead\.", + 11, + "Old thing is deprecated and will be removed in Pillow 11 " + r"\(2024-10-15\)\. Use new thing instead\.", ), ( None, @@ -24,7 +25,7 @@ def test_version(version, expected): def test_unknown_version(): - expected = r"Unknown removal version, update PIL\._deprecate\?" + expected = r"Unknown removal version: 12345. Update PIL\._deprecate\?" with pytest.raises(ValueError, match=expected): _deprecate.deprecate("Old thing", 12345, "new thing") @@ -52,18 +53,18 @@ def test_old_version(deprecated, plural, expected): def test_plural(): expected = ( - r"Old things are deprecated and will be removed in Pillow 10 \(2023-07-01\)\. " + r"Old things are deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " r"Use new thing instead\." ) with pytest.warns(DeprecationWarning, match=expected): - _deprecate.deprecate("Old things", 10, "new thing", plural=True) + _deprecate.deprecate("Old things", 11, "new thing", plural=True) def test_replacement_and_action(): expected = "Use only one of 'replacement' and 'action'" with pytest.raises(ValueError, match=expected): _deprecate.deprecate( - "Old thing", 10, replacement="new thing", action="Upgrade to new thing" + "Old thing", 11, replacement="new thing", action="Upgrade to new thing" ) @@ -76,16 +77,16 @@ def test_replacement_and_action(): ) def test_action(action): expected = ( - r"Old thing is deprecated and will be removed in Pillow 10 \(2023-07-01\)\. " + r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " r"Upgrade to new thing\." ) with pytest.warns(DeprecationWarning, match=expected): - _deprecate.deprecate("Old thing", 10, action=action) + _deprecate.deprecate("Old thing", 11, action=action) def test_no_replacement_or_action(): expected = ( - r"Old thing is deprecated and will be removed in Pillow 10 \(2023-07-01\)" + r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)" ) with pytest.warns(DeprecationWarning, match=expected): - _deprecate.deprecate("Old thing", 10) + _deprecate.deprecate("Old thing", 11) diff --git a/Tests/test_deprecated_imageqt.py b/Tests/test_deprecated_imageqt.py deleted file mode 100644 index 2528ff3f7..000000000 --- a/Tests/test_deprecated_imageqt.py +++ /dev/null @@ -1,18 +0,0 @@ -import warnings - -with warnings.catch_warnings(record=True) as w: - # Arrange: cause all warnings to always be triggered - warnings.simplefilter("always") - - # Act: trigger a warning with Qt5 - from PIL import ImageQt - - -def test_deprecated(): - # Assert - if ImageQt.qt_version in ("5", "side2"): - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "deprecated" in str(w[0].message) - else: - assert len(w) == 0 diff --git a/Tests/test_features.py b/Tests/test_features.py index c4e9cd368..8f0e4b418 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import re diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 51637c786..60d951636 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageSequence, PngImagePlugin @@ -163,6 +164,12 @@ def test_apng_blend(): assert im.getpixel((64, 32)) == (0, 255, 0, 255) +def test_apng_blend_transparency(): + with Image.open("Tests/images/blend_transparency.png") as im: + im.seek(1) + assert im.getpixel((0, 0)) == (255, 0, 0) + + def test_apng_chunk_order(): with Image.open("Tests/images/apng/fctl_actl.png") as im: im.seek(im.n_frames - 1) @@ -225,13 +232,13 @@ def test_apng_mode(): assert im.getpixel((0, 0)) == (0, 0, 128, 191) assert im.getpixel((64, 32)) == (0, 0, 128, 191) - with Image.open("Tests/images/apng/mode_greyscale.png") as im: + with Image.open("Tests/images/apng/mode_grayscale.png") as im: assert im.mode == "L" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == 128 assert im.getpixel((64, 32)) == 255 - with Image.open("Tests/images/apng/mode_greyscale_alpha.png") as im: + with Image.open("Tests/images/apng/mode_grayscale_alpha.png") as im: assert im.mode == "LA" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (128, 191) @@ -263,13 +270,11 @@ def test_apng_chunk_errors(): with Image.open("Tests/images/apng/chunk_no_actl.png") as im: assert not im.is_animated - def open(): + with pytest.warns(UserWarning): with Image.open("Tests/images/apng/chunk_multi_actl.png") as im: im.load() assert not im.is_animated - pytest.warns(UserWarning, open) - with Image.open("Tests/images/apng/chunk_actl_after_idat.png") as im: assert not im.is_animated @@ -287,21 +292,17 @@ def test_apng_chunk_errors(): def test_apng_syntax_errors(): - def open_frames_zero(): + with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im: assert not im.is_animated with pytest.raises(OSError): im.load() - pytest.warns(UserWarning, open_frames_zero) - - def open_frames_zero_default(): + with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") as im: assert not im.is_animated im.load() - pytest.warns(UserWarning, open_frames_zero_default) - # we can handle this case gracefully exception = None with Image.open("Tests/images/apng/syntax_num_frames_low.png") as im: @@ -316,13 +317,11 @@ def test_apng_syntax_errors(): im.seek(im.n_frames - 1) im.load() - def open(): + with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_invalid.png") as im: assert not im.is_animated im.load() - pytest.warns(UserWarning, open) - @pytest.mark.parametrize( "test_file", @@ -352,15 +351,13 @@ def test_apng_save(tmp_path): im.load() assert not im.is_animated assert im.n_frames == 1 - assert im.get_format_mimetype() == "image/apng" + assert im.get_format_mimetype() == "image/png" assert im.info.get("default_image") is None assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/single_frame_default.png") as im: - frames = [] - for frame_im in ImageSequence.Iterator(im): - frames.append(frame_im.copy()) + frames = [frame_im.copy() for frame_im in ImageSequence.Iterator(im)] frames[0].save( test_file, save_all=True, default_image=True, append_images=frames[1:] ) @@ -376,6 +373,20 @@ def test_apng_save(tmp_path): assert im.getpixel((64, 32)) == (0, 255, 0, 255) +def test_apng_save_alpha(tmp_path): + test_file = str(tmp_path / "temp.png") + + im = Image.new("RGBA", (1, 1), (255, 0, 0, 255)) + im2 = Image.new("RGBA", (1, 1), (255, 0, 0, 127)) + im.save(test_file, save_all=True, append_images=[im2]) + + with Image.open(test_file) as reloaded: + assert reloaded.getpixel((0, 0)) == (255, 0, 0, 255) + + reloaded.seek(1) + assert reloaded.getpixel((0, 0)) == (255, 0, 0, 127) + + def test_apng_save_split_fdat(tmp_path): # test to make sure we do not generate sequence errors when writing # frames with image data spanning multiple fdAT chunks (in this case @@ -438,15 +449,29 @@ def test_apng_save_duration_loop(tmp_path): test_file, save_all=True, append_images=[frame, frame], duration=[500, 100, 150] ) with Image.open(test_file) as im: - im.load() assert im.n_frames == 1 - assert im.info.get("duration") == 750 + assert "duration" not in im.info + + different_frame = Image.new("RGBA", (128, 64)) + frame.save( + test_file, + save_all=True, + append_images=[frame, different_frame], + duration=[500, 100, 150], + ) + with Image.open(test_file) as im: + assert im.n_frames == 2 + assert im.info["duration"] == 600 + + im.seek(1) + assert im.info["duration"] == 150 # test info duration - frame.info["duration"] = 750 - frame.save(test_file, save_all=True) + frame.info["duration"] = 300 + frame.save(test_file, save_all=True, append_images=[frame, different_frame]) with Image.open(test_file) as im: - assert im.info.get("duration") == 750 + assert im.n_frames == 2 + assert im.info["duration"] == 600 def test_apng_save_disposal(tmp_path): @@ -650,20 +675,17 @@ def test_seek_after_close(): @pytest.mark.parametrize("mode", ("RGBA", "RGB", "P")) -def test_different_modes_in_later_frames(mode, tmp_path): +@pytest.mark.parametrize("default_image", (True, False)) +@pytest.mark.parametrize("duplicate", (True, False)) +def test_different_modes_in_later_frames(mode, default_image, duplicate, tmp_path): test_file = str(tmp_path / "temp.png") im = Image.new("L", (1, 1)) - im.save(test_file, save_all=True, append_images=[Image.new(mode, (1, 1))]) + im.save( + test_file, + save_all=True, + default_image=default_image, + append_images=[im.convert(mode) if duplicate else Image.new(mode, (1, 1), 1)], + ) with Image.open(test_file) as reloaded: assert reloaded.mode == mode - - -def test_constants_deprecation(): - for enum, prefix in { - PngImagePlugin.Disposal: "APNG_DISPOSE_", - PngImagePlugin.Blend: "APNG_BLEND_", - }.items(): - for name in enum.__members__: - with pytest.warns(DeprecationWarning): - assert getattr(PngImagePlugin, prefix + name) == enum[name] diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index ba2781820..4c1e38d1d 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -1,6 +1,7 @@ +from __future__ import annotations import pytest -from PIL import BlpImagePlugin, Image +from PIL import Image from .helper import ( assert_image_equal, @@ -72,14 +73,3 @@ def test_crashes(test_file): with Image.open(f) as im: with pytest.raises(OSError): im.load() - - -def test_constants_deprecation(): - for enum, prefix in { - BlpImagePlugin.Format: "BLP_FORMAT_", - BlpImagePlugin.Encoding: "BLP_ENCODING_", - BlpImagePlugin.AlphaEncoding: "BLP_ALPHA_ENCODING_", - }.items(): - for name in enum.__members__: - with pytest.warns(DeprecationWarning): - assert getattr(BlpImagePlugin, prefix + name) == enum[name] diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 5f6d52355..4cc92c5f6 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import pytest @@ -141,7 +142,6 @@ def test_rgba_bitfields(): # This test image has been manually hexedited # to change the bitfield compression in the header from XBGR to RGBA with Image.open("Tests/images/rgb32bf-rgba.bmp") as im: - # So before the comparing the image, swap the channels b, g, r = im.split()[1:] im = Image.merge("RGB", (r, g, b)) @@ -160,7 +160,7 @@ def test_rle8(): with Image.open("Tests/images/hopper_rle8.bmp") as im: assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.bmp", 12) - with Image.open("Tests/images/hopper_rle8_greyscale.bmp") as im: + with Image.open("Tests/images/hopper_rle8_grayscale.bmp") as im: assert_image_equal_tofile(im, "Tests/images/bw_gradient.png") # This test image has been manually hexedited diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index e330404d6..5780232a2 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import BufrStubImagePlugin, Image @@ -10,7 +11,6 @@ TEST_FILE = "Tests/images/gfs.t06z.rassda.tm00.bufr_d" def test_open(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.format == "BUFR" @@ -31,7 +31,6 @@ def test_invalid_file(): def test_load(): # Arrange with Image.open(TEST_FILE) as im: - # Act / Assert: stub cannot load without an implemented handler with pytest.raises(OSError): im.load() @@ -58,6 +57,7 @@ def test_handler(tmp_path): def load(self, im): self.loaded = True + im.fp.close() return Image.new("RGB", (1, 1)) def save(self, im, fp, filename): diff --git a/Tests/test_file_container.py b/Tests/test_file_container.py index 65cf6a75e..0da5d3824 100644 --- a/Tests/test_file_container.py +++ b/Tests/test_file_container.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import ContainerIO, Image diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py index f04a20a22..08c3257f9 100644 --- a/Tests/test_file_cur.py +++ b/Tests/test_file_cur.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import CurImagePlugin, Image diff --git a/Tests/test_file_dcx.py b/Tests/test_file_dcx.py index 0f09c4b99..25e4badbc 100644 --- a/Tests/test_file_dcx.py +++ b/Tests/test_file_dcx.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings import pytest @@ -15,7 +16,6 @@ def test_sanity(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.size == (128, 128) assert isinstance(im, DcxImagePlugin.DcxImageFile) @@ -29,7 +29,8 @@ def test_unclosed_file(): im = Image.open(TEST_FILE) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -54,7 +55,6 @@ def test_invalid_file(): def test_tell(): # Arrange with Image.open(TEST_FILE) as im: - # Act frame = im.tell() diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index cac4108a8..2d60fbb64 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -1,4 +1,5 @@ """Test DdsImagePlugin""" +from __future__ import annotations from io import BytesIO import pytest @@ -12,10 +13,16 @@ TEST_FILE_DXT3 = "Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds" TEST_FILE_DXT5 = "Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.dds" TEST_FILE_ATI1 = "Tests/images/ati1.dds" TEST_FILE_ATI2 = "Tests/images/ati2.dds" +TEST_FILE_DX10_BC4_TYPELESS = "Tests/images/bc4_typeless.dds" +TEST_FILE_DX10_BC4_UNORM = "Tests/images/bc4_unorm.dds" TEST_FILE_DX10_BC5_TYPELESS = "Tests/images/bc5_typeless.dds" TEST_FILE_DX10_BC5_UNORM = "Tests/images/bc5_unorm.dds" TEST_FILE_DX10_BC5_SNORM = "Tests/images/bc5_snorm.dds" +TEST_FILE_DX10_BC1 = "Tests/images/bc1.dds" +TEST_FILE_DX10_BC1_TYPELESS = "Tests/images/bc1_typeless.dds" +TEST_FILE_BC4U = "Tests/images/bc4u.dds" TEST_FILE_BC5S = "Tests/images/bc5s.dds" +TEST_FILE_BC5U = "Tests/images/bc5u.dds" TEST_FILE_BC6H = "Tests/images/bc6h.dds" TEST_FILE_BC6HS = "Tests/images/bc6h_sf.dds" TEST_FILE_DX10_BC7 = "Tests/images/bc7-argb-8bpp_MipMaps-1.dds" @@ -28,11 +35,20 @@ TEST_FILE_UNCOMPRESSED_RGB = "Tests/images/hopper.dds" TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds" -def test_sanity_dxt1(): - """Check DXT1 images can be opened""" +@pytest.mark.parametrize( + "image_path", + ( + TEST_FILE_DXT1, + # hexeditted to use DX10 FourCC + TEST_FILE_DX10_BC1, + TEST_FILE_DX10_BC1_TYPELESS, + ), +) +def test_sanity_dxt1_bc1(image_path): + """Check DXT1 and BC1 images can be opened""" with Image.open(TEST_FILE_DXT1.replace(".dds", ".png")) as target: target = target.convert("RGBA") - with Image.open(TEST_FILE_DXT1) as im: + with Image.open(image_path) as im: im.load() assert im.format == "DDS" @@ -68,10 +84,18 @@ def test_sanity_dxt5(): assert_image_equal_tofile(im, TEST_FILE_DXT5.replace(".dds", ".png")) -def test_sanity_ati1(): - """Check ATI1 images can be opened""" +@pytest.mark.parametrize( + "image_path", + ( + TEST_FILE_ATI1, + # hexeditted to use BC4U FourCC + TEST_FILE_BC4U, + ), +) +def test_sanity_ati1_bc4u(image_path): + """Check ATI1 and BC4U images can be opened""" - with Image.open(TEST_FILE_ATI1) as im: + with Image.open(image_path) as im: im.load() assert im.format == "DDS" @@ -81,10 +105,39 @@ def test_sanity_ati1(): assert_image_equal_tofile(im, TEST_FILE_ATI1.replace(".dds", ".png")) -def test_sanity_ati2(): - """Check ATI2 images can be opened""" +@pytest.mark.parametrize( + "image_path", + ( + TEST_FILE_DX10_BC4_UNORM, + # hexeditted to be typeless + TEST_FILE_DX10_BC4_TYPELESS, + ), +) +def test_dx10_bc4(image_path): + """Check DX10 BC4 images can be opened""" - with Image.open(TEST_FILE_ATI2) as im: + with Image.open(image_path) as im: + im.load() + + assert im.format == "DDS" + assert im.mode == "L" + assert im.size == (64, 64) + + assert_image_equal_tofile(im, TEST_FILE_DX10_BC4_UNORM.replace(".dds", ".png")) + + +@pytest.mark.parametrize( + "image_path", + ( + TEST_FILE_ATI2, + # hexeditted to use BC5U FourCC + TEST_FILE_BC5U, + ), +) +def test_sanity_ati2_bc5u(image_path): + """Check ATI2 and BC5U images can be opened""" + + with Image.open(image_path) as im: im.load() assert im.format == "DDS" @@ -190,12 +243,6 @@ def test_dx10_r8g8b8a8_unorm_srgb(): ) -def test_unimplemented_dxgi_format(): - with pytest.raises(NotImplementedError): - with Image.open("Tests/images/unimplemented_dxgi_format.dds"): - pass - - @pytest.mark.parametrize( ("mode", "size", "test_file"), [ @@ -289,9 +336,34 @@ def test_dxt5_colorblock_alpha_issue_4142(): assert px[2] != 0 -def test_unimplemented_pixel_format(): +def test_palette(): + with Image.open("Tests/images/palette.dds") as im: + assert_image_equal_tofile(im, "Tests/images/transparent.gif") + + +@pytest.mark.parametrize( + "test_file", + ( + "Tests/images/unsupported_bitcount_rgb.dds", + "Tests/images/unsupported_bitcount_luminance.dds", + ), +) +def test_unsupported_bitcount(test_file): + with pytest.raises(OSError): + with Image.open(test_file): + pass + + +@pytest.mark.parametrize( + "test_file", + ( + "Tests/images/unimplemented_dxgi_format.dds", + "Tests/images/unimplemented_pfflags.dds", + ), +) +def test_not_implemented(test_file): with pytest.raises(NotImplementedError): - with Image.open("Tests/images/unimplemented_pixel_format.dds"): + with Image.open(test_file): pass diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 015dda992..c479c384a 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import pytest @@ -8,6 +9,7 @@ from .helper import ( assert_image_similar, assert_image_similar_tofile, hopper, + is_win32, mark_if_feature_version, skip_unless_feature, ) @@ -28,34 +30,65 @@ FILE2_COMPARE_SCALE2 = "Tests/images/non_zero_bb_scale2.png" # EPS test files with binary preview FILE3 = "Tests/images/binary_preview_map.eps" +# Three unsigned 32bit little-endian values: +# 0xC6D3D0C5 magic number +# byte position of start of postscript section (12) +# byte length of postscript section (0) +# this byte length isn't valid, but we don't read it +simple_binary_header = b"\xc5\xd0\xd3\xc6\x0c\x00\x00\x00\x00\x00\x00\x00" + +# taken from page 8 of the specification +# https://web.archive.org/web/20220120164601/https://www.adobe.com/content/dam/acom/en/devnet/actionscript/articles/5002.EPSF_Spec.pdf +simple_eps_file = ( + b"%!PS-Adobe-3.0 EPSF-3.0", + b"%%BoundingBox: 5 5 105 105", + b"10 setlinewidth", + b"10 10 moveto", + b"0 90 rlineto 90 0 rlineto 0 -90 rlineto closepath", + b"stroke", +) +simple_eps_file_with_comments = ( + simple_eps_file[:1] + + ( + b"%%Comment1: Some Value", + b"%%SecondComment: Another Value", + ) + + simple_eps_file[1:] +) +simple_eps_file_without_version = simple_eps_file[1:] +simple_eps_file_without_boundingbox = simple_eps_file[:1] + simple_eps_file[2:] +simple_eps_file_with_invalid_boundingbox = ( + simple_eps_file[:1] + (b"%%BoundingBox: a b c d",) + simple_eps_file[2:] +) +simple_eps_file_with_invalid_boundingbox_valid_imagedata = ( + simple_eps_file_with_invalid_boundingbox + (b"%ImageData: 100 100 8 3",) +) +simple_eps_file_with_long_ascii_comment = ( + simple_eps_file[:2] + (b"%%Comment: " + b"X" * 300,) + simple_eps_file[2:] +) +simple_eps_file_with_long_binary_data = ( + simple_eps_file[:2] + + ( + b"%%BeginBinary: 300", + b"\0" * 300, + b"%%EndBinary", + ) + + simple_eps_file[2:] +) + @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_sanity(): - # Regular scale - with Image.open(FILE1) as image1: - image1.load() - assert image1.mode == "RGB" - assert image1.size == (460, 352) - assert image1.format == "EPS" - - with Image.open(FILE2) as image2: - image2.load() - assert image2.mode == "RGB" - assert image2.size == (360, 252) - assert image2.format == "EPS" - - # Double scale - with Image.open(FILE1) as image1_scale2: - image1_scale2.load(scale=2) - assert image1_scale2.mode == "RGB" - assert image1_scale2.size == (920, 704) - assert image1_scale2.format == "EPS" - - with Image.open(FILE2) as image2_scale2: - image2_scale2.load(scale=2) - assert image2_scale2.mode == "RGB" - assert image2_scale2.size == (720, 504) - assert image2_scale2.format == "EPS" +@pytest.mark.parametrize( + ("filename", "size"), ((FILE1, (460, 352)), (FILE2, (360, 252))) +) +@pytest.mark.parametrize("scale", (1, 2)) +def test_sanity(filename, size, scale): + expected_size = tuple(s * scale for s in size) + with Image.open(filename) as image: + image.load(scale=scale) + assert image.mode == "RGB" + assert image.size == expected_size + assert image.format == "EPS" @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -67,20 +100,94 @@ def test_load(): assert im.load()[0, 0] == (255, 255, 255) +def test_binary(): + if HAS_GHOSTSCRIPT: + assert EpsImagePlugin.gs_binary is not None + else: + assert EpsImagePlugin.gs_binary is False + + if not is_win32(): + assert EpsImagePlugin.gs_windows_binary is None + elif not HAS_GHOSTSCRIPT: + assert EpsImagePlugin.gs_windows_binary is False + else: + assert EpsImagePlugin.gs_windows_binary is not None + + def test_invalid_file(): invalid_file = "Tests/images/flower.jpg" - with pytest.raises(SyntaxError): EpsImagePlugin.EpsImageFile(invalid_file) +def test_binary_header_only(): + data = io.BytesIO(simple_binary_header) + with pytest.raises(SyntaxError, match='EPS header missing "%!PS-Adobe" comment'): + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_missing_version_comment(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_without_version)) + with pytest.raises(SyntaxError): + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_missing_boundingbox_comment(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_without_boundingbox)) + with pytest.raises(SyntaxError, match='EPS header missing "%%BoundingBox" comment'): + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_invalid_boundingbox_comment(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox)) + 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): + 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): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_ascii_comment)) + with pytest.raises(SyntaxError, match="not an EPS file"): + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_long_binary_data(prefix): + data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_binary_data)) + EpsImagePlugin.EpsImageFile(data) + + +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +def test_load_long_binary_data(prefix): + 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.size == (100, 100) + assert img.format == "EPS" + + @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_cmyk(): with Image.open("Tests/images/pil_sample_cmyk.eps") as cmyk_image: - assert cmyk_image.mode == "CMYK" assert cmyk_image.size == (100, 100) assert cmyk_image.format == "EPS" @@ -101,7 +208,7 @@ def test_showpage(): with Image.open("Tests/images/reqd_showpage.png") as target: # should not crash/hang plot_image.load() - # fonts could be slightly different + # fonts could be slightly different assert_image_similar(plot_image, target, 6) @@ -112,7 +219,7 @@ def test_transparency(): assert plot_image.mode == "RGBA" with Image.open("Tests/images/reqd_showpage_transparency.png") as target: - # fonts could be slightly different + # fonts could be slightly different assert_image_similar(plot_image, target, 6) @@ -207,7 +314,6 @@ def test_resize(filename): @pytest.mark.parametrize("filename", (FILE1, FILE2)) def test_thumbnail(filename): # Issue #619 - # Arrange with Image.open(filename) as im: new_size = (100, 100) im.thumbnail(new_size) @@ -221,7 +327,7 @@ def test_read_binary_preview(): pass -def test_readline(tmp_path): +def test_readline_psfile(tmp_path): # check all the freaking line endings possible from the spec # test_string = u'something\r\nelse\n\rbaz\rbif\n' line_endings = ["\r\n", "\n", "\n\r", "\r"] @@ -238,7 +344,8 @@ def test_readline(tmp_path): def _test_readline_io_psfile(test_string, ending): f = io.BytesIO(test_string.encode("latin-1")) - t = EpsImagePlugin.PSFile(f) + with pytest.warns(DeprecationWarning): + t = EpsImagePlugin.PSFile(f) _test_readline(t, ending) def _test_readline_file_psfile(test_string, ending): @@ -247,7 +354,8 @@ def test_readline(tmp_path): w.write(test_string.encode("latin-1")) with open(f, "rb") as r: - t = EpsImagePlugin.PSFile(r) + with pytest.warns(DeprecationWarning): + t = EpsImagePlugin.PSFile(r) _test_readline(t, ending) for ending in line_endings: @@ -256,6 +364,25 @@ def test_readline(tmp_path): _test_readline_file_psfile(s, ending) +def test_psfile_deprecation(): + with pytest.warns(DeprecationWarning): + EpsImagePlugin.PSFile(None) + + +@pytest.mark.parametrize("prefix", (b"", simple_binary_header)) +@pytest.mark.parametrize( + "line_ending", + (b"\r\n", b"\n", b"\n\r", b"\r"), +) +def test_readline(prefix, line_ending): + simple_file = prefix + line_ending.join(simple_eps_file_with_comments) + data = io.BytesIO(simple_file) + test_file = EpsImagePlugin.EpsImageFile(data) + assert test_file.info["Comment1"] == "Some Value" + assert test_file.info["SecondComment"] == "Another Value" + assert test_file.size == (100, 100) + + @pytest.mark.parametrize( "filename", ( @@ -293,3 +420,18 @@ def test_timeout(test_file): with pytest.raises(Image.UnidentifiedImageError): with Image.open(f): pass + + +def test_bounding_box_in_trailer(): + # 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( + FILE1 + ) as header_image: + assert trailer_image.size == header_image.size + + +def test_eof_before_bounding_box(): + with pytest.raises(OSError): + with Image.open("Tests/images/zero_bb_eof_before_boundingbox.eps"): + pass diff --git a/Tests/test_file_fits.py b/Tests/test_file_fits.py index 447888acd..1383f9c5c 100644 --- a/Tests/test_file_fits.py +++ b/Tests/test_file_fits.py @@ -1,8 +1,9 @@ +from __future__ import annotations from io import BytesIO import pytest -from PIL import FitsImagePlugin, FitsStubImagePlugin, Image +from PIL import FitsImagePlugin, Image from .helper import assert_image_equal, hopper @@ -12,7 +13,6 @@ TEST_FILE = "Tests/images/hopper.fits" def test_open(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.format == "FITS" assert im.size == (128, 128) @@ -45,36 +45,7 @@ def test_naxis_zero(): pass -def test_stub_deprecated(): - class Handler: - opened = False - loaded = False - - def open(self, im): - self.opened = True - - def load(self, im): - self.loaded = True - return Image.new("RGB", (1, 1)) - - handler = Handler() - with pytest.warns(DeprecationWarning): - FitsStubImagePlugin.register_handler(handler) - - with Image.open(TEST_FILE) as im: - assert im.format == "FITS" - assert im.size == (128, 128) - assert im.mode == "L" - - assert handler.opened - assert not handler.loaded - - im.load() - assert handler.loaded - - FitsStubImagePlugin._handler = None - Image.register_open( - FitsImagePlugin.FitsImageFile.format, - FitsImagePlugin.FitsImageFile, - FitsImagePlugin._accept, - ) +def test_comment(): + image_data = b"SIMPLE = T / comment string" + with pytest.raises(OSError): + FitsImagePlugin.FitsImageFile(BytesIO(image_data)) diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index b8b999d70..10bf36cc2 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings import pytest @@ -36,7 +37,8 @@ def test_unclosed_file(): im = Image.open(static_test_file) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -64,7 +66,6 @@ def test_context_manager(): def test_tell(): # Arrange with Image.open(static_test_file) as im: - # Act frame = im.tell() @@ -110,7 +111,6 @@ def test_eoferror(): def test_seek_tell(): with Image.open(animated_test_file) as im: - layer_number = im.tell() assert layer_number == 0 diff --git a/Tests/test_file_fpx.py b/Tests/test_file_fpx.py index fa22e90f6..af3b79815 100644 --- a/Tests/test_file_fpx.py +++ b/Tests/test_file_fpx.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image @@ -18,6 +19,16 @@ def test_sanity(): assert_image_equal_tofile(im, "Tests/images/input_bw_one_band.png") +def test_close(): + with Image.open("Tests/images/input_bw_one_band.fpx") as im: + pass + assert im.ole.fp.closed + + im = Image.open("Tests/images/input_bw_one_band.fpx") + im.close() + assert im.ole.fp.closed + + def test_invalid_file(): # Test an invalid OLE file invalid_file = "Tests/images/flower.jpg" diff --git a/Tests/test_file_ftex.py b/Tests/test_file_ftex.py index cae20fa46..a494c8029 100644 --- a/Tests/test_file_ftex.py +++ b/Tests/test_file_ftex.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import FtexImagePlugin, Image @@ -21,12 +22,3 @@ def test_invalid_file(): with pytest.raises(SyntaxError): FtexImagePlugin.FtexImageFile(invalid_file) - - -def test_constants_deprecation(): - for enum, prefix in { - FtexImagePlugin.Format: "FORMAT_", - }.items(): - for name in enum.__members__: - with pytest.warns(DeprecationWarning): - assert getattr(FtexImagePlugin, prefix + name) == enum[name] diff --git a/Tests/test_file_gbr.py b/Tests/test_file_gbr.py index 1ea8af8ee..7dfe05396 100644 --- a/Tests/test_file_gbr.py +++ b/Tests/test_file_gbr.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import GbrImagePlugin, Image diff --git a/Tests/test_file_gd.py b/Tests/test_file_gd.py index 5594e5bbb..ec80c54a1 100644 --- a/Tests/test_file_gd.py +++ b/Tests/test_file_gd.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import GdImageFile, UnidentifiedImageError diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index d48fc1442..78b77e974 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings from io import BytesIO @@ -36,7 +37,8 @@ def test_unclosed_file(): im = Image.open(TEST_GIF) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -158,39 +160,42 @@ def test_optimize(): assert test_bilevel(1) == 799 -def test_optimize_correctness(): - # 256 color Palette image, posterize to > 128 and < 128 levels - # Size bigger and smaller than 512x512 +@pytest.mark.parametrize( + "colors, size, expected_palette_length", + ( + # These do optimize the palette + (256, 511, 256), + (255, 511, 255), + (129, 511, 129), + (128, 511, 128), + (64, 511, 64), + (4, 511, 4), + # These don't optimize the palette + (128, 513, 256), + (64, 513, 256), + (4, 513, 256), + ), +) +def test_optimize_correctness(colors, size, expected_palette_length): + # 256 color Palette image, posterize to > 128 and < 128 levels. + # Size bigger and smaller than 512x512. # Check the palette for number of colors allocated. - # Check for correctness after conversion back to RGB - def check(colors, size, expected_palette_length): - # make an image with empty colors in the start of the palette range - im = Image.frombytes( - "P", (colors, colors), bytes(range(256 - colors, 256)) * colors - ) - im = im.resize((size, size)) - outfile = BytesIO() - im.save(outfile, "GIF") - outfile.seek(0) - with Image.open(outfile) as reloaded: - # check palette length - palette_length = max(i + 1 for i, v in enumerate(reloaded.histogram()) if v) - assert expected_palette_length == palette_length + # Check for correctness after conversion back to RGB. - assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) + # make an image with empty colors in the start of the palette range + im = Image.frombytes( + "P", (colors, colors), bytes(range(256 - colors, 256)) * colors + ) + im = im.resize((size, size)) + outfile = BytesIO() + im.save(outfile, "GIF") + outfile.seek(0) + with Image.open(outfile) as reloaded: + # check palette length + palette_length = max(i + 1 for i, v in enumerate(reloaded.histogram()) if v) + assert expected_palette_length == palette_length - # These do optimize the palette - check(256, 511, 256) - check(255, 511, 255) - check(129, 511, 129) - check(128, 511, 128) - check(64, 511, 64) - check(4, 511, 4) - - # These don't optimize the palette - check(128, 513, 256) - check(64, 513, 256) - check(4, 513, 256) + assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) def test_optimize_full_l(): @@ -201,24 +206,44 @@ def test_optimize_full_l(): def test_optimize_if_palette_can_be_reduced_by_half(): - with Image.open("Tests/images/test.colors.gif") as im: - # Reduce dimensions because original is too big for _get_optimize() - im = im.resize((591, 443)) - im_rgb = im.convert("RGB") + im = Image.new("P", (8, 1)) + im.palette = ImagePalette.raw("RGB", bytes((0, 0, 0) * 150)) + for i in range(8): + im.putpixel((i, 0), (i + 1, 0, 0)) - for (optimize, colors) in ((False, 256), (True, 8)): + for optimize, colors in ((False, 256), (True, 8)): out = BytesIO() - im_rgb.save(out, "GIF", optimize=optimize) + im.save(out, "GIF", optimize=optimize) with Image.open(out) as reloaded: assert len(reloaded.palette.palette) // 3 == colors +def test_full_palette_second_frame(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("P", (1, 256)) + + full_palette_im = Image.new("P", (1, 256)) + for i in range(256): + full_palette_im.putpixel((0, i), i) + full_palette_im.palette = ImagePalette.ImagePalette( + "RGB", bytearray(i // 3 for i in range(768)) + ) + full_palette_im.palette.dirty = 1 + + im.save(out, save_all=True, append_images=[full_palette_im]) + + with Image.open(out) as reloaded: + reloaded.seek(1) + + for i in range(256): + reloaded.getpixel((0, i)) == i + + def test_roundtrip(tmp_path): out = str(tmp_path / "temp.gif") im = hopper() im.save(out) with Image.open(out) as reread: - assert_image_similar(reread.convert("RGB"), im, 50) @@ -229,7 +254,6 @@ def test_roundtrip2(tmp_path): im2 = im.copy() im2.save(out) with Image.open(out) as reread: - assert_image_similar(reread.convert("RGB"), hopper(), 50) @@ -239,7 +263,6 @@ def test_roundtrip_save_all(tmp_path): im = hopper() im.save(out, save_all=True) with Image.open(out) as reread: - assert_image_similar(reread.convert("RGB"), im, 50) # Multiframe image @@ -251,6 +274,19 @@ def test_roundtrip_save_all(tmp_path): assert reread.n_frames == 5 +def test_roundtrip_save_all_1(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("1", (1, 1)) + im2 = Image.new("1", (1, 1), 1) + im.save(out, save_all=True, append_images=[im2]) + + with Image.open(out) as reloaded: + assert reloaded.getpixel((0, 0)) == 0 + + reloaded.seek(1) + assert reloaded.getpixel((0, 0)) == 255 + + @pytest.mark.parametrize( "path, mode", ( @@ -281,13 +317,11 @@ def test_headers_saving_for_animated_gifs(tmp_path): important_headers = ["background", "version", "duration", "loop"] # Multiframe image with Image.open("Tests/images/dispose_bgnd.gif") as im: - info = im.info.copy() out = str(tmp_path / "temp.gif") im.save(out, save_all=True) with Image.open(out) as reread: - for header in important_headers: assert info[header] == reread.info[header] @@ -305,7 +339,6 @@ def test_palette_handling(tmp_path): im2.save(f, optimize=True) with Image.open(f) as reloaded: - assert_image_similar(im, reloaded.convert("RGB"), 10) @@ -321,7 +354,6 @@ def test_palette_434(tmp_path): orig = "Tests/images/test.colors.gif" with Image.open(orig) as im: - with roundtrip(im) as reloaded: assert_image_similar(im, reloaded, 1) with roundtrip(im, optimize=True) as reloaded: @@ -572,7 +604,6 @@ def test_save_dispose(tmp_path): ) with Image.open(out) as img: - for i in range(2): img.seek(img.tell() + 1) assert img.disposal_method == i + 1 @@ -581,7 +612,7 @@ def test_save_dispose(tmp_path): def test_dispose2_palette(tmp_path): out = str(tmp_path / "temp.gif") - # Four colors: white, grey, black, red + # Four colors: white, gray, black, red circles = [(255, 255, 255), (153, 153, 153), (0, 0, 0), (255, 0, 0)] im_list = [] @@ -770,7 +801,6 @@ def test_multiple_duration(tmp_path): out, save_all=True, append_images=im_list[1:], duration=duration_list ) with Image.open(out) as reread: - for duration in duration_list: assert reread.info["duration"] == duration try: @@ -783,7 +813,6 @@ def test_multiple_duration(tmp_path): out, save_all=True, append_images=im_list[1:], duration=tuple(duration_list) ) with Image.open(out) as reread: - for duration in duration_list: assert reread.info["duration"] == duration try: @@ -841,7 +870,6 @@ def test_identical_frames(tmp_path): out, save_all=True, append_images=im_list[1:], duration=duration_list ) with Image.open(out) as reread: - # Assert that the first three frames were combined assert reread.n_frames == 2 @@ -850,7 +878,14 @@ def test_identical_frames(tmp_path): @pytest.mark.parametrize( - "duration", ([1000, 1500, 2000, 4000], (1000, 1500, 2000, 4000), 8500) + "duration", + ( + [1000, 1500, 2000], + (1000, 1500, 2000), + # One more duration than the number of frames + [1000, 1500, 2000, 4000], + 1500, + ), ) def test_identical_frames_to_single_frame(duration, tmp_path): out = str(tmp_path / "temp.gif") @@ -866,7 +901,15 @@ def test_identical_frames_to_single_frame(duration, tmp_path): assert reread.n_frames == 1 # Assert that the new duration is the total of the identical frames - assert reread.info["duration"] == 8500 + assert reread.info["duration"] == 4500 + + +def test_loop_none(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("L", (100, 100), "#000") + im.save(out, loop=None) + with Image.open(out) as reread: + assert "loop" not in reread.info def test_number_of_loops(tmp_path): @@ -1080,6 +1123,21 @@ def test_transparent_optimize(tmp_path): assert reloaded.info["transparency"] == reloaded.getpixel((252, 0)) +def test_removed_transparency(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("RGB", (256, 1)) + + for x in range(256): + im.putpixel((x, 0), (x, 0, 0)) + + im.info["transparency"] = (255, 255, 255) + with pytest.warns(UserWarning): + im.save(out) + + with Image.open(out) as reloaded: + assert "transparency" not in reloaded.info + + def test_rgb_transparency(tmp_path): out = str(tmp_path / "temp.gif") @@ -1095,7 +1153,8 @@ def test_rgb_transparency(tmp_path): im = Image.new("RGB", (1, 1)) im.info["transparency"] = b"" ims = [Image.new("RGB", (1, 1))] - pytest.warns(UserWarning, im.save, out, save_all=True, append_images=ims) + with pytest.warns(UserWarning): + im.save(out, save_all=True, append_images=ims) with Image.open(out) as reloaded: assert "transparency" not in reloaded.info @@ -1112,6 +1171,12 @@ def test_rgba_transparency(tmp_path): assert_image_equal(hopper("P").convert("RGB"), reloaded) +def test_background_outside_palettte(tmp_path): + with Image.open("Tests/images/background_outside_palette.gif") as im: + im.seek(1) + assert im.info["background"] == 255 + + def test_bbox(tmp_path): out = str(tmp_path / "temp.gif") @@ -1123,6 +1188,18 @@ def test_bbox(tmp_path): assert reread.n_frames == 2 +def test_bbox_alpha(tmp_path): + out = str(tmp_path / "temp.gif") + + im = Image.new("RGBA", (1, 2), (255, 0, 0, 255)) + im.putpixel((0, 1), (255, 0, 0, 0)) + im2 = Image.new("RGBA", (1, 2), (255, 0, 0, 0)) + im.save(out, save_all=True, append_images=[im2]) + + with Image.open(out) as reread: + assert reread.n_frames == 2 + + def test_palette_save_L(tmp_path): # Generate an L mode image with a separate palette @@ -1138,18 +1215,17 @@ def test_palette_save_L(tmp_path): def test_palette_save_P(tmp_path): - # Pass in a different palette, then construct what the image would look like. - # Forcing a non-straight grayscale palette. - - im = hopper("P") - palette = bytes(255 - i // 3 for i in range(768)) + im = Image.new("P", (1, 2)) + im.putpixel((0, 1), 1) out = str(tmp_path / "temp.gif") - im.save(out, palette=palette) + im.save(out, palette=bytes((1, 2, 3, 4, 5, 6))) with Image.open(out) as reloaded: - im.putpalette(palette) - assert_image_equal(reloaded, im) + reloaded_rgb = reloaded.convert("RGB") + + assert reloaded_rgb.getpixel((0, 0)) == (1, 2, 3) + assert reloaded_rgb.getpixel((0, 1)) == (4, 5, 6) def test_palette_save_duplicate_entries(tmp_path): diff --git a/Tests/test_file_gimpgradient.py b/Tests/test_file_gimpgradient.py index 3f056fdae..d5be46dc3 100644 --- a/Tests/test_file_gimpgradient.py +++ b/Tests/test_file_gimpgradient.py @@ -1,3 +1,4 @@ +from __future__ import annotations from PIL import GimpGradientFile, ImagePalette diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index caec9cf21..775d3b7cd 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL.GimpPaletteFile import GimpPaletteFile diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index fd427746e..d962e85a4 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import GribStubImagePlugin, Image @@ -10,7 +11,6 @@ TEST_FILE = "Tests/images/WAlaska.wind.7days.grb" def test_open(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.format == "GRIB" @@ -31,7 +31,6 @@ def test_invalid_file(): def test_load(): # Arrange with Image.open(TEST_FILE) as im: - # Act / Assert: stub cannot load without an implemented handler with pytest.raises(OSError): im.load() @@ -58,6 +57,7 @@ def test_handler(tmp_path): def load(self, im): self.loaded = True + im.fp.close() return Image.new("RGB", (1, 1)) def save(self, im, fp, filename): diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index 20b4b9619..9c776b712 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Hdf5StubImagePlugin, Image @@ -8,7 +9,6 @@ TEST_FILE = "Tests/images/hdf5.h5" def test_open(): # Act with Image.open(TEST_FILE) as im: - # Assert assert im.format == "HDF5" @@ -29,7 +29,6 @@ def test_invalid_file(): def test_load(): # Arrange with Image.open(TEST_FILE) as im: - # Act / Assert: stub cannot load without an implemented handler with pytest.raises(OSError): im.load() @@ -59,6 +58,7 @@ def test_handler(tmp_path): def load(self, im): self.loaded = True + im.fp.close() return Image.new("RGB", (1, 1)) def save(self, im, fp, filename): diff --git a/Tests/test_file_icns.py b/Tests/test_file_icns.py index 55632909c..c62fffc5b 100644 --- a/Tests/test_file_icns.py +++ b/Tests/test_file_icns.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import os import warnings @@ -16,7 +17,6 @@ def test_sanity(): # Loading this icon by default should result in the largest size # (512x512@2x) being loaded with Image.open(TEST_FILE) as im: - # Assert that there is no unclosed file warning with warnings.catch_warnings(): im.load() diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index afb17b1af..de9fa353a 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import os @@ -175,7 +176,6 @@ def test_save_256x256(tmp_path): # Act im.save(outfile) with Image.open(outfile) as im_saved: - # Assert assert im_saved.size == (256, 256) @@ -213,12 +213,10 @@ def test_save_append_images(tmp_path): def test_unexpected_size(): # This image has been manually hexedited to state that it is 16x32 # while the image within is still 16x16 - def open(): + with pytest.warns(UserWarning): with Image.open("Tests/images/hopper_unexpected.ico") as im: assert im.size == (16, 16) - pytest.warns(UserWarning, open) - def test_draw_reloaded(tmp_path): with Image.open(TEST_ICO_FILE) as im: diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index 5cf93713b..0cb26d06a 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -1,3 +1,4 @@ +from __future__ import annotations import filecmp import warnings @@ -32,7 +33,8 @@ def test_unclosed_file(): im = Image.open(TEST_IM) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -51,7 +53,6 @@ def test_context_manager(): def test_tell(): # Arrange with Image.open(TEST_IM) as im: - # Act frame = im.tell() diff --git a/Tests/test_file_imt.py b/Tests/test_file_imt.py index f56acc429..3db488558 100644 --- a/Tests/test_file_imt.py +++ b/Tests/test_file_imt.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import pytest diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index 2d0e6977a..d0ecde393 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -1,5 +1,8 @@ +from __future__ import annotations import sys -from io import StringIO +from io import BytesIO, StringIO + +import pytest from PIL import Image, IptcImagePlugin @@ -11,7 +14,6 @@ TEST_FILE = "Tests/images/iptc.jpg" def test_getiptcinfo_jpg_none(): # Arrange with hopper() as im: - # Act iptc = IptcImagePlugin.getiptcinfo(im) @@ -22,7 +24,6 @@ def test_getiptcinfo_jpg_none(): def test_getiptcinfo_jpg_found(): # Arrange with Image.open(TEST_FILE) as im: - # Act iptc = IptcImagePlugin.getiptcinfo(im) @@ -32,10 +33,39 @@ def test_getiptcinfo_jpg_found(): assert iptc[(2, 101)] == b"Hungary" +def test_getiptcinfo_fotostation(): + # Arrange + with open(TEST_FILE, "rb") as fp: + data = bytearray(fp.read()) + data[86] = 240 + f = BytesIO(data) + with Image.open(f) as im: + # Act + iptc = IptcImagePlugin.getiptcinfo(im) + + # Assert + for tag in iptc.keys(): + if tag[0] == 240: + return + pytest.fail("FotoStation tag not found") + + +def test_getiptcinfo_zero_padding(): + # Arrange + with Image.open(TEST_FILE) as im: + im.info["photoshop"][0x0404] += b"\x00\x00\x00" + + # Act + iptc = IptcImagePlugin.getiptcinfo(im) + + # Assert + assert isinstance(iptc, dict) + assert len(iptc) == 3 + + def test_getiptcinfo_tiff_none(): # Arrange with Image.open("Tests/images/hopper.tif") as im: - # Act iptc = IptcImagePlugin.getiptcinfo(im) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index eabc6bf75..ffaea6296 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import warnings @@ -57,7 +58,6 @@ class TestFileJpeg: return Image.frombytes(mode, size, os.urandom(size[0] * size[1] * len(mode))) def test_sanity(self): - # internal version number assert re.search(r"\d+\.\d+$", features.version_codec("jpg")) @@ -215,13 +215,20 @@ class TestFileJpeg: # Should not raise OSError for image with icc larger than image size. im.save( f, - format="JPEG", progressive=True, quality=95, icc_profile=icc_profile, optimize=True, ) + with Image.open("Tests/images/flower2.jpg") as im: + f = str(tmp_path / "temp2.jpg") + im.save(f, progressive=True, quality=94, icc_profile=b" " * 53955) + + with Image.open("Tests/images/flower2.jpg") as im: + f = str(tmp_path / "temp3.jpg") + im.save(f, progressive=True, quality=94, exif=b" " * 43668) + def test_optimize(self): im1 = self.roundtrip(hopper()) im2 = self.roundtrip(hopper(), optimize=0) @@ -271,7 +278,10 @@ class TestFileJpeg: # https://github.com/python-pillow/Pillow/issues/148 f = str(tmp_path / "temp.jpg") im = hopper() - im.save(f, "JPEG", quality=90, exif=b"1" * 65532) + im.save(f, "JPEG", quality=90, exif=b"1" * 65533) + + with pytest.raises(ValueError): + im.save(f, "JPEG", quality=90, exif=b"1" * 65534) def test_exif_typeerror(self): with Image.open("Tests/images/exif_typeerror.jpg") as im: @@ -368,7 +378,6 @@ class TestFileJpeg: def test_exif_gps_typeerror(self): with Image.open("Tests/images/exif_gps_typeerror.jpg") as im: - # Should not raise a TypeError im._getexif() @@ -447,7 +456,7 @@ class TestFileJpeg: ims = im.get_child_images() assert len(ims) == 1 - assert_image_equal_tofile(ims[0], "Tests/images/flower_thumbnail.png") + assert_image_similar_tofile(ims[0], "Tests/images/flower_thumbnail.png", 2.1) def test_mp(self): with Image.open("Tests/images/pil_sample_rgb.jpg") as im: @@ -635,11 +644,22 @@ class TestFileJpeg: assert max(im2.quantization[0]) <= 255 assert max(im2.quantization[1]) <= 255 - def test_convert_dict_qtables_deprecation(self): - with pytest.warns(DeprecationWarning): - qtable = {0: [1, 2, 3, 4]} - qtable2 = JpegImagePlugin.convert_dict_qtables(qtable) - assert qtable == qtable2 + @pytest.mark.parametrize( + "blocks, rows, markers", + ((0, 0, 0), (1, 0, 15), (3, 0, 5), (8, 0, 1), (0, 1, 3), (0, 2, 1)), + ) + def test_restart_markers(self, blocks, rows, markers): + im = Image.new("RGB", (32, 32)) # 16 MCUs + out = BytesIO() + im.save( + out, + format="JPEG", + restart_marker_blocks=blocks, + restart_marker_rows=rows, + # force 8x8 pixel MCUs + subsampling=0, + ) + assert len(re.findall(b"\xff[\xd0-\xd7]", out.getvalue())) == markers @pytest.mark.skipif(not djpeg_available(), reason="djpeg not available") def test_load_djpeg(self): @@ -682,7 +702,6 @@ class TestFileJpeg: # Shouldn't raise error fn = "Tests/images/sugarshack_bad_mpo_header.jpg" with pytest.warns(UserWarning, Image.open, fn) as im: - # Assert assert im.format == "JPEG" @@ -704,7 +723,6 @@ class TestFileJpeg: # Arrange outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/hopper.tif") as im: - # Act im.save(outfile, "JPEG", dpi=im.info["dpi"]) @@ -731,7 +749,6 @@ class TestFileJpeg: # This Photoshop CC 2017 image has DPI in EXIF not metadata # EXIF XResolution is (2000000, 10000) with Image.open("Tests/images/photoshop-200dpi.jpg") as im: - # Act / Assert assert im.info.get("dpi") == (200, 200) @@ -740,7 +757,6 @@ class TestFileJpeg: # This image has DPI in EXIF not metadata # EXIF XResolution is 72 with Image.open("Tests/images/exif-72dpi-int.jpg") as im: - # Act / Assert assert im.info.get("dpi") == (72, 72) @@ -749,7 +765,6 @@ class TestFileJpeg: # This is photoshop-200dpi.jpg with EXIF resolution unit set to cm: # exiftool -exif:ResolutionUnit=cm photoshop-200dpi.jpg with Image.open("Tests/images/exif-200dpcm.jpg") as im: - # Act / Assert assert im.info.get("dpi") == (508, 508) @@ -758,7 +773,6 @@ class TestFileJpeg: # This is photoshop-200dpi.jpg with EXIF resolution set to 0/0: # exiftool -XResolution=0/0 -YResolution=0/0 photoshop-200dpi.jpg with Image.open("Tests/images/exif-dpi-zerodivision.jpg") as im: - # Act / Assert # This should return the default, and not raise a ZeroDivisionError assert im.info.get("dpi") == (72, 72) @@ -767,7 +781,13 @@ class TestFileJpeg: # Arrange # 0x011A tag in this exif contains string '300300\x02' with Image.open("Tests/images/broken_exif_dpi.jpg") as im: + # Act / Assert + # This should return the default + assert im.info.get("dpi") == (72, 72) + def test_dpi_exif_truncated(self): + # Arrange + with Image.open("Tests/images/truncated_exif_dpi.jpg") as im: # Act / Assert # This should return the default assert im.info.get("dpi") == (72, 72) @@ -777,7 +797,6 @@ class TestFileJpeg: # This is photoshop-200dpi.jpg with resolution removed from EXIF: # exiftool "-*resolution*"= photoshop-200dpi.jpg with Image.open("Tests/images/no-dpi-in-exif.jpg") as im: - # Act / Assert # "When the image resolution is unknown, 72 [dpi] is designated." # https://exiv2.org/tags.html @@ -787,7 +806,6 @@ class TestFileJpeg: # This is no-dpi-in-exif with the tiff header of the exif block # hexedited from MM * to FF FF FF FF with Image.open("Tests/images/invalid-exif.jpg") as im: - # This should return the default, and not a SyntaxError or # OSError for unidentified image. assert im.info.get("dpi") == (72, 72) @@ -810,7 +828,6 @@ class TestFileJpeg: def test_invalid_exif_x_resolution(self): # When no x or y resolution is defined in EXIF with Image.open("Tests/images/invalid-exif-without-x-resolution.jpg") as im: - # This should return the default, and not a ValueError or # OSError for an unidentified image. assert im.info.get("dpi") == (72, 72) @@ -820,7 +837,6 @@ class TestFileJpeg: # This image has been manually hexedited to have an IFD offset of 10, # in contrast to normal 8 with Image.open("Tests/images/exif-ifd-offset.jpg") as im: - # Act / Assert assert im._getexif()[306] == "2017:03:13 23:03:09" @@ -891,7 +907,10 @@ class TestFileJpeg: def test_getxmp(self): with Image.open("Tests/images/xmp_test.jpg") as im: if ElementTree is None: - with pytest.warns(UserWarning): + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): assert im.getxmp() == {} else: xmp = im.getxmp() @@ -914,6 +933,28 @@ class TestFileJpeg: with Image.open("Tests/images/hopper.jpg") as im: assert im.getxmp() == {} + def test_getxmp_no_prefix(self): + with Image.open("Tests/images/xmp_no_prefix.jpg") as im: + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + assert im.getxmp() == {} + else: + assert im.getxmp() == {"xmpmeta": {"key": "value"}} + + def test_getxmp_padded(self): + with Image.open("Tests/images/xmp_padded.jpg") as im: + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + assert im.getxmp() == {} + else: + assert im.getxmp() == {"xmpmeta": None} + @pytest.mark.timeout(timeout=1) def test_eof(self): # Even though this decoder never says that it is finished @@ -938,6 +979,40 @@ class TestFileJpeg: im.load() ImageFile.LOAD_TRUNCATED_IMAGES = False + def test_separate_tables(self): + im = hopper() + data = [] # [interchange, tables-only, image-only] + for streamtype in range(3): + out = BytesIO() + im.save(out, format="JPEG", streamtype=streamtype) + data.append(out.getvalue()) + + # SOI, EOI + for marker in b"\xff\xd8", b"\xff\xd9": + assert marker in data[1] and 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] + # 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] + + with Image.open(BytesIO(data[0])) as interchange_im: + with Image.open(BytesIO(data[1] + data[2])) as combined_im: + assert_image_equal(interchange_im, combined_im) + + def test_repr_jpeg(self): + im = hopper() + + with Image.open(BytesIO(im._repr_jpeg_())) as repr_jpeg: + assert repr_jpeg.format == "JPEG" + assert_image_similar(im, repr_jpeg, 17) + + def test_repr_jpeg_error_returns_none(self): + im = hopper("F") + + assert im._repr_jpeg_() is None + @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 cd142e67f..aaa4104e5 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -1,16 +1,25 @@ +from __future__ import annotations import os import re from io import BytesIO import pytest -from PIL import Image, ImageFile, Jpeg2KImagePlugin, UnidentifiedImageError, features +from PIL import ( + Image, + ImageFile, + Jpeg2KImagePlugin, + UnidentifiedImageError, + _binary, + features, +) from .helper import ( assert_image_equal, assert_image_similar, assert_image_similar_tofile, skip_unless_feature, + skip_unless_feature_version, ) EXTRA_DIR = "Tests/images/jpeg2000" @@ -252,18 +261,29 @@ def test_mct(): assert_image_similar(im, jp2, 1.0e-3) -def test_rgba(): +def test_sgnd(tmp_path): + outfile = str(tmp_path / "temp.jp2") + + im = Image.new("L", (1, 1)) + im.save(outfile) + with Image.open(outfile) as reloaded: + assert reloaded.getpixel((0, 0)) == 0 + + im = Image.new("L", (1, 1)) + im.save(outfile, signed=True) + with Image.open(outfile) as reloaded_signed: + assert reloaded_signed.getpixel((0, 0)) == 128 + + +@pytest.mark.parametrize("ext", (".j2k", ".jp2")) +def test_rgba(ext): # Arrange - with Image.open("Tests/images/rgb_trns_ycbc.j2k") as j2k: - with Image.open("Tests/images/rgb_trns_ycbc.jp2") as jp2: + with Image.open("Tests/images/rgb_trns_ycbc" + ext) as im: + # Act + im.load() - # Act - j2k.load() - jp2.load() - - # Assert - assert j2k.mode == "RGBA" - assert jp2.mode == "RGBA" + # Assert + assert im.mode == "RGBA" @pytest.mark.parametrize("ext", (".j2k", ".jp2")) @@ -340,6 +360,35 @@ def test_subsampling_decode(name): assert_image_similar(im, expected, epsilon) +def test_comment(): + with Image.open("Tests/images/comment.jp2") 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: + b = BytesIO(fp.read(130)) + with Image.open(b) as im: + pass + + +def test_save_comment(): + for comment in ("Created by Pillow", b"Created by Pillow"): + out = BytesIO() + test_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) + 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" ") + + @pytest.mark.parametrize( "test_file", [ @@ -357,3 +406,29 @@ def test_crashes(test_file): im.load() except OSError: pass + + +@skip_unless_feature_version("jpg_2000", "2.4.0") +def test_plt_marker(): + # Search the start of the codesteam for PLT + out = BytesIO() + test_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") + + jp2_boxid = _binary.i16be(marker) + if jp2_boxid == 0xFF4F: + # SOC has no length + continue + elif jp2_boxid == 0xFF58: + # PLT + return + elif jp2_boxid == 0xFF93: + pytest.fail("SOD without finding PLT first") + + hdr = out.read(2) + length = _binary.i16be(hdr) + out.seek(length - 2, os.SEEK_CUR) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 1109cd15e..65adf449d 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1,3 +1,4 @@ +from __future__ import annotations import base64 import io import itertools @@ -8,7 +9,7 @@ from collections import namedtuple import pytest -from PIL import Image, ImageFilter, TiffImagePlugin, TiffTags, features +from PIL import Image, ImageFilter, ImageOps, TiffImagePlugin, TiffTags, features from PIL.TiffImagePlugin import SAMPLEFORMAT, STRIPOFFSETS, SUBIFD from .helper import ( @@ -645,7 +646,6 @@ class TestFileLibTiff(LibTiffTestCase): pilim = hopper() def save_bytesio(compression=None): - buffer_io = io.BytesIO() pilim.save(buffer_io, format="tiff", compression=compression) buffer_io.seek(0) @@ -669,6 +669,16 @@ class TestFileLibTiff(LibTiffTestCase): assert reloaded.tag_v2[530] == (1, 1) assert reloaded.tag_v2[532] == (0, 255, 128, 255, 128, 255) + def test_exif_ifd(self, tmp_path): + outfile = str(tmp_path / "temp.tif") + with Image.open("Tests/images/tiff_adobe_deflate.tif") as im: + assert im.tag_v2[34665] == 125456 + im.save(outfile) + + with Image.open(outfile) as reloaded: + if Image.core.libtiff_support_custom_tags: + assert reloaded.tag_v2[34665] == 125456 + def test_crashing_metadata(self, tmp_path): # issue 1597 with Image.open("Tests/images/rdf.tif") as im: @@ -740,7 +750,6 @@ class TestFileLibTiff(LibTiffTestCase): def test_multipage_compression(self): with Image.open("Tests/images/compression.tif") as im: - im.seek(0) assert im._compression == "tiff_ccitt" assert im.size == (10, 10) @@ -986,6 +995,36 @@ class TestFileLibTiff(LibTiffTestCase): ) as im: assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + @pytest.mark.parametrize( + "file_name, mode, size, tile", + [ + ( + "tiff_wrong_bits_per_sample.tiff", + "RGBA", + (52, 53), + [("raw", (0, 0, 52, 53), 160, ("RGBA", 0, 1))], + ), + ( + "tiff_wrong_bits_per_sample_2.tiff", + "RGB", + (16, 16), + [("raw", (0, 0, 16, 16), 8, ("RGB", 0, 1))], + ), + ( + "tiff_wrong_bits_per_sample_3.tiff", + "RGBA", + (512, 256), + [("libtiff", (0, 0, 512, 256), 0, ("RGBA", "tiff_lzw", False, 48782))], + ), + ], + ) + def test_wrong_bits_per_sample(self, file_name, mode, size, tile): + with Image.open("Tests/images/" + file_name) as im: + assert im.mode == mode + assert im.size == size + assert im.tile == tile + im.load() + def test_no_rows_per_strip(self): # This image does not have a RowsPerStrip TIFF tag infile = "Tests/images/no_rows_per_strip.tif" @@ -997,7 +1036,18 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/g4_orientation_1.tif") as base_im: for i in range(2, 9): with Image.open("Tests/images/g4_orientation_" + str(i) + ".tif") as im: + assert 274 in im.tag_v2 + im.load() + assert 274 not in im.tag_v2 + + assert_image_similar(base_im, im, 0.7) + + def test_exif_transpose(self): + with Image.open("Tests/images/g4_orientation_1.tif") as base_im: + for i in range(2, 9): + with Image.open("Tests/images/g4_orientation_" + str(i) + ".tif") as im: + im = ImageOps.exif_transpose(im) assert_image_similar(base_im, im, 0.7) @@ -1067,3 +1117,27 @@ class TestFileLibTiff(LibTiffTestCase): out = str(tmp_path / "temp.tif") with pytest.raises(SystemError): im.save(out, compression=compression) + + def test_save_many_compressed(self, tmp_path): + im = hopper() + out = str(tmp_path / "temp.tif") + for _ in range(10000): + im.save(out, compression="jpeg") + + @pytest.mark.parametrize( + "path, sizes", + ( + ("Tests/images/hopper.tif", ()), + ("Tests/images/child_ifd.tiff", (16, 8)), + ("Tests/images/child_ifd_jpeg.tiff", (20,)), + ), + ) + def test_get_child_images(self, path, sizes): + with Image.open(path) as im: + ims = im.get_child_images() + + assert len(ims) == len(sizes) + for i, im in enumerate(ims): + w = sizes[i] + expected = Image.new("RGB", (w, w), "#f00") + assert_image_similar(im, expected, 1) diff --git a/Tests/test_file_libtiff_small.py b/Tests/test_file_libtiff_small.py index 03137c8b6..9501c55a6 100644 --- a/Tests/test_file_libtiff_small.py +++ b/Tests/test_file_libtiff_small.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO from PIL import Image diff --git a/Tests/test_file_mcidas.py b/Tests/test_file_mcidas.py index 41f22cf0c..4b31aaa78 100644 --- a/Tests/test_file_mcidas.py +++ b/Tests/test_file_mcidas.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, McIdasImagePlugin diff --git a/Tests/test_file_mic.py b/Tests/test_file_mic.py index 464d138e2..e7ea39ea9 100644 --- a/Tests/test_file_mic.py +++ b/Tests/test_file_mic.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImagePalette @@ -51,6 +52,16 @@ def test_seek(): assert im.tell() == 0 +def test_close(): + with Image.open(TEST_FILE) as im: + pass + assert im.ole.fp.closed + + im = Image.open(TEST_FILE) + im.close() + assert im.ole.fp.closed + + def test_invalid_file(): # Test an invalid OLE file invalid_file = "Tests/images/flower.jpg" diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 3e5476222..da62bc6d4 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings from io import BytesIO @@ -42,7 +43,8 @@ def test_unclosed_file(): im = Image.open(test_files[0]) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -168,8 +170,7 @@ def test_mp_no_data(): def test_mp_attribute(test_file): with Image.open(test_file) as im: mpinfo = im._getmp() - frame_number = 0 - for mpentry in mpinfo[0xB002]: + for frame_number, mpentry in enumerate(mpinfo[0xB002]): mpattr = mpentry["Attribute"] if frame_number: assert not mpattr["RepresentativeImageFlag"] @@ -180,7 +181,6 @@ def test_mp_attribute(test_file): assert mpattr["ImageDataFormat"] == "JPEG" assert mpattr["MPType"] == "Multi-Frame Image: (Disparity)" assert mpattr["Reserved"] == 0 - frame_number += 1 @pytest.mark.parametrize("test_file", test_files) diff --git a/Tests/test_file_msp.py b/Tests/test_file_msp.py index 50d7c590b..f4e357ae0 100644 --- a/Tests/test_file_msp.py +++ b/Tests/test_file_msp.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import pytest @@ -44,7 +45,6 @@ def test_open_windows_v1(): # Arrange # Act with Image.open(TEST_FILE) as im: - # Assert assert_image_equal(im, hopper("1")) assert isinstance(im, MspImagePlugin.MspImageFile) @@ -59,7 +59,6 @@ def _assert_file_image_equal(source_path, target_path): not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) def test_open_windows_v2(): - files = ( os.path.join(EXTRA_DIR, f) for f in os.listdir(EXTRA_DIR) diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index be7c8d0c8..735840de4 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os.path import subprocess @@ -26,8 +27,7 @@ def open_with_magick(magick, tmp_path, f): rc = subprocess.call( magick + [f, outfile], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT ) - if rc: - raise OSError + assert not rc return Image.open(outfile) diff --git a/Tests/test_file_pcd.py b/Tests/test_file_pcd.py index dc45a48c1..596a3414f 100644 --- a/Tests/test_file_pcd.py +++ b/Tests/test_file_pcd.py @@ -1,3 +1,4 @@ +from __future__ import annotations from PIL import Image diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index 485adf785..f42ec4a68 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageFile, PcxImagePlugin diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 9667b6a4a..9e07d9ed0 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import os import os.path @@ -8,7 +9,7 @@ import pytest from PIL import Image, PdfParser, features -from .helper import hopper, mark_if_feature_version +from .helper import hopper, mark_if_feature_version, skip_unless_feature def helper_save_as_pdf(tmp_path, mode, **kwargs): @@ -42,6 +43,28 @@ def test_save(tmp_path, mode): helper_save_as_pdf(tmp_path, mode) +@skip_unless_feature("jpg_2000") +@pytest.mark.parametrize("mode", ("LA", "RGBA")) +def test_save_alpha(tmp_path, mode): + helper_save_as_pdf(tmp_path, mode) + + +def test_p_alpha(tmp_path): + # Arrange + outfile = str(tmp_path / "temp.pdf") + with Image.open("Tests/images/pil123p.png") as im: + assert im.mode == "P" + assert isinstance(im.info["transparency"], bytes) + + # Act + im.save(outfile) + + # Assert + with open(outfile, "rb") as fp: + contents = fp.read() + assert b"\n/SMask " in contents + + def test_monochrome(tmp_path): # Arrange mode = "1" @@ -52,8 +75,8 @@ def test_monochrome(tmp_path): def test_unsupported_mode(tmp_path): - im = hopper("LA") - outfile = str(tmp_path / "temp_LA.pdf") + im = hopper("PA") + outfile = str(tmp_path / "temp_PA.pdf") with pytest.raises(ValueError): im.save(outfile) @@ -80,6 +103,34 @@ def test_resolution(tmp_path): assert size == (61.44, 61.44) +@pytest.mark.parametrize( + "params", + ( + {"dpi": (75, 150)}, + {"dpi": (75, 150), "resolution": 200}, + ), +) +def test_dpi(params, tmp_path): + im = hopper() + + outfile = str(tmp_path / "temp.pdf") + im.save(outfile, **params) + + with open(outfile, "rb") as fp: + contents = fp.read() + + size = tuple( + float(d) + for d in contents.split(b"stream\nq ")[1].split(b" 0 0 cm")[0].split(b" 0 0 ") + ) + assert size == (122.88, 61.44) + + size = tuple( + float(d) for d in contents.split(b"/MediaBox [ 0 0 ")[1].split(b"]")[0].split() + ) + assert size == (122.88, 61.44) + + @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) @@ -89,7 +140,6 @@ def test_save_all(tmp_path): # Multiframe image with Image.open("Tests/images/dispose_bgnd.gif") as im: - outfile = str(tmp_path / "temp.pdf") im.save(outfile, save_all=True) @@ -123,7 +173,6 @@ def test_save_all(tmp_path): def test_multiframe_normal_save(tmp_path): # Test saving a multiframe image without save_all with Image.open("Tests/images/dispose_bgnd.gif") as im: - outfile = str(tmp_path / "temp.pdf") im.save(outfile) @@ -286,6 +335,7 @@ def test_pdf_append_to_bytesio(): @pytest.mark.timeout(1) +@pytest.mark.skipif("PILLOW_VALGRIND_TEST" in os.environ, reason="Valgrind is slower") @pytest.mark.parametrize("newline", (b"\r", b"\n")) def test_redos(newline): malicious = b" trailer<<>>" + newline * 3456 diff --git a/Tests/test_file_pixar.py b/Tests/test_file_pixar.py index 315ea4676..63779f202 100644 --- a/Tests/test_file_pixar.py +++ b/Tests/test_file_pixar.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, PixarImagePlugin diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 9481cd5dd..ff3862110 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -1,3 +1,4 @@ +from __future__ import annotations import re import sys import warnings @@ -78,9 +79,8 @@ class TestFilePng: return chunks def test_sanity(self, tmp_path): - # internal version number - assert re.search(r"\d+\.\d+\.\d+(\.\d+)?$", features.version_codec("zlib")) + assert re.search(r"\d+(\.\d+){1,3}$", features.version_codec("zlib")) test_file = str(tmp_path / "temp.png") @@ -93,11 +93,11 @@ class TestFilePng: assert im.format == "PNG" assert im.get_format_mimetype() == "image/png" - for mode in ["1", "L", "P", "RGB", "I", "I;16"]: + for mode in ["1", "L", "P", "RGB", "I", "I;16", "I;16B"]: im = hopper(mode) im.save(test_file) with Image.open(test_file) as reloaded: - if mode == "I;16": + if mode in ("I;16", "I;16B"): reloaded = reloaded.convert(mode) assert_image_equal(reloaded, im) @@ -156,7 +156,6 @@ class TestFilePng: assert im.info == {"spam": "egg"} def test_bad_itxt(self): - im = load(HEAD + chunk(b"iTXt") + TAIL) assert im.info == {} @@ -201,7 +200,6 @@ class TestFilePng: assert im.info["spam"].tkey == "Spam" def test_interlace(self): - test_file = "Tests/images/pil123p.png" with Image.open(test_file) as im: assert_image(im, "P", (162, 150)) @@ -300,7 +298,7 @@ class TestFilePng: assert_image(im, "RGBA", (10, 10)) assert im.getcolors() == [(100, (0, 0, 0, 0))] - def test_save_greyscale_transparency(self, tmp_path): + def test_save_grayscale_transparency(self, tmp_path): for mode, num_transparent in {"1": 1994, "L": 559, "I": 559}.items(): in_file = "Tests/images/" + mode.lower() + "_trns.png" with Image.open(in_file) as im: @@ -495,7 +493,6 @@ class TestFilePng: # Check reading images with null tRNS value, issue #1239 test_file = "Tests/images/tRNS_null_1x1.png" with Image.open(test_file) as im: - assert im.info["transparency"] == 0 def test_save_icc_profile(self): @@ -536,11 +533,10 @@ class TestFilePng: assert repr_png.format == "PNG" assert_image_equal(im, repr_png) - def test_repr_png_error(self): + def test_repr_png_error_returns_none(self): im = hopper("F") - with pytest.raises(ValueError): - im._repr_png_() + assert im._repr_png_() is None def test_chunk_order(self, tmp_path): with Image.open("Tests/images/icc_profile.png") as im: @@ -593,7 +589,7 @@ class TestFilePng: def test_textual_chunks_after_idat(self): with Image.open("Tests/images/hopper.png") as im: - assert "comment" in im.text.keys() + assert "comment" in im.text for k, v in { "date:create": "2014-09-04T09:37:08+03:00", "date:modify": "2014-09-04T09:37:08+03:00", @@ -670,7 +666,10 @@ class TestFilePng: def test_getxmp(self): with Image.open("Tests/images/color_snakes.png") as im: if ElementTree is None: - with pytest.warns(UserWarning): + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): assert im.getxmp() == {} else: xmp = im.getxmp() diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index fbcbea6c6..bb49a46d3 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys from io import BytesIO @@ -256,6 +257,16 @@ def test_truncated_file(tmp_path): im.load() +def test_not_enough_image_data(tmp_path): + path = str(tmp_path / "temp.ppm") + with open(path, "wb") as f: + f.write(b"P2 1 2 255 255") + + with Image.open(path) as im: + with pytest.raises(ValueError): + im.load() + + @pytest.mark.parametrize("maxval", (b"0", b"65536")) def test_invalid_maxval(maxval, tmp_path): path = str(tmp_path / "temp.ppm") diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 4f934375c..8b06ce2b1 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings import pytest @@ -27,7 +28,8 @@ def test_unclosed_file(): im = Image.open(test_file) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -77,7 +79,6 @@ def test_eoferror(): def test_seek_tell(): with Image.open(test_file) as im: - layer_number = im.tell() assert layer_number == 1 @@ -95,7 +96,6 @@ def test_seek_tell(): def test_seek_eoferror(): with Image.open(test_file) as im: - with pytest.raises(EOFError): im.seek(-1) @@ -112,6 +112,11 @@ def test_rgba(): assert_image_equal_tofile(im, "Tests/images/imagedraw_square.png") +def test_layer_skip(): + with Image.open("Tests/images/five_channels.psd") as im: + assert im.n_frames == 1 + + def test_icc_profile(): with Image.open(test_file) as im: assert "icc_profile" in im.info diff --git a/Tests/test_file_qoi.py b/Tests/test_file_qoi.py new file mode 100644 index 000000000..b7c945729 --- /dev/null +++ b/Tests/test_file_qoi.py @@ -0,0 +1,29 @@ +from __future__ import annotations +import pytest + +from PIL import Image, QoiImagePlugin + +from .helper import assert_image_equal_tofile + + +def test_sanity(): + with Image.open("Tests/images/hopper.qoi") as im: + assert im.mode == "RGB" + assert im.size == (128, 128) + assert im.format == "QOI" + + assert_image_equal_tofile(im, "Tests/images/hopper.png") + + with Image.open("Tests/images/pil123rgba.qoi") as im: + assert im.mode == "RGBA" + assert im.size == (162, 150) + assert im.format == "QOI" + + assert_image_equal_tofile(im, "Tests/images/pil123rgba.png") + + +def test_invalid_file(): + invalid_file = "Tests/images/flower.jpg" + + with pytest.raises(SyntaxError): + QoiImagePlugin.QoiImageFile(invalid_file) diff --git a/Tests/test_file_sgi.py b/Tests/test_file_sgi.py index 6a5d8887d..13698276b 100644 --- a/Tests/test_file_sgi.py +++ b/Tests/test_file_sgi.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, SgiImagePlugin diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 0e3b705a2..f21098754 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -1,3 +1,4 @@ +from __future__ import annotations import tempfile import warnings from io import BytesIO @@ -25,7 +26,8 @@ def test_unclosed_file(): im = Image.open(TEST_FILE) im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(): @@ -79,7 +81,6 @@ def test_is_spider_image(): def test_tell(): # Arrange with Image.open(TEST_FILE) as im: - # Act index = im.tell() diff --git a/Tests/test_file_sun.py b/Tests/test_file_sun.py index 05c78c316..874b37b52 100644 --- a/Tests/test_file_sun.py +++ b/Tests/test_file_sun.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import pytest @@ -16,7 +17,6 @@ def test_sanity(): # Act with Image.open(test_file) as im: - # Assert assert im.size == (128, 128) diff --git a/Tests/test_file_tar.py b/Tests/test_file_tar.py index 5daab47fc..4470823cd 100644 --- a/Tests/test_file_tar.py +++ b/Tests/test_file_tar.py @@ -1,3 +1,4 @@ +from __future__ import annotations import warnings import pytest @@ -10,27 +11,28 @@ from .helper import is_pypy TEST_TAR_FILE = "Tests/images/hopper.tar" -def test_sanity(): - for codec, test_path, format in [ - ["zlib", "hopper.png", "PNG"], - ["jpg", "hopper.jpg", "JPEG"], - ]: - if features.check(codec): - with TarIO.TarIO(TEST_TAR_FILE, test_path) as tar: - with Image.open(tar) as im: - im.load() - assert im.mode == "RGB" - assert im.size == (128, 128) - assert im.format == format +@pytest.mark.parametrize( + "codec, test_path, format", + ( + ("zlib", "hopper.png", "PNG"), + ("jpg", "hopper.jpg", "JPEG"), + ), +) +def test_sanity(codec, test_path, format): + if features.check(codec): + with TarIO.TarIO(TEST_TAR_FILE, test_path) as tar: + with Image.open(tar) as im: + im.load() + assert im.mode == "RGB" + assert im.size == (128, 128) + assert im.format == format @pytest.mark.skipif(is_pypy(), reason="Requires CPython") def test_unclosed_file(): - def open(): + with pytest.warns(ResourceWarning): TarIO.TarIO(TEST_TAR_FILE, "hopper.jpg") - pytest.warns(ResourceWarning, open) - def test_close(): with warnings.catch_warnings(): diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index 7d8b5139a..d0f228573 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os from glob import glob from itertools import product @@ -78,7 +79,6 @@ def test_id_field(): # Act with Image.open(test_file) as im: - # Assert assert im.size == (100, 100) @@ -89,7 +89,6 @@ def test_id_field_rle(): # Act with Image.open(test_file) as im: - # Assert assert im.size == (199, 199) @@ -165,13 +164,14 @@ def test_save_id_section(tmp_path): # Save with custom id section greater than 255 characters id_section = b"Test content" * 25 - pytest.warns(UserWarning, lambda: im.save(out, id_section=id_section)) + with pytest.warns(UserWarning): + im.save(out, id_section=id_section) + with Image.open(out) as test_im: assert test_im.info["id_section"] == id_section[:255] test_file = "Tests/images/tga_id_field.tga" with Image.open(test_file) as im: - # Save with no id section im.save(out, id_section="") with Image.open(out) as test_im: diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 4f3c8e390..0851796d0 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import warnings from io import BytesIO @@ -25,7 +26,6 @@ except ImportError: class TestFileTiff: def test_sanity(self, tmp_path): - filename = str(tmp_path / "temp.tif") hopper("RGB").save(filename) @@ -62,7 +62,8 @@ class TestFileTiff: im = Image.open("Tests/images/multipage.tiff") im.load() - pytest.warns(ResourceWarning, open) + with pytest.warns(ResourceWarning): + open() def test_closed_file(self): with warnings.catch_warnings(): @@ -84,24 +85,6 @@ class TestFileTiff: with Image.open("Tests/images/multipage.tiff") as im: im.load() - @pytest.mark.parametrize( - "path, sizes", - ( - ("Tests/images/hopper.tif", ()), - ("Tests/images/child_ifd.tiff", (16, 8)), - ("Tests/images/child_ifd_jpeg.tiff", (20,)), - ), - ) - def test_get_child_images(self, path, sizes): - with Image.open(path) as im: - ims = im.get_child_images() - - assert len(ims) == len(sizes) - for i, im in enumerate(ims): - w = sizes[i] - expected = Image.new("RGB", (w, w), "#f00") - assert_image_similar(im, expected, 1) - def test_mac_tiff(self): # Read RGBa images from macOS [@PIL136] @@ -114,39 +97,16 @@ class TestFileTiff: assert_image_similar_tofile(im, "Tests/images/pil136.png", 1) - def test_bigtiff(self): + def test_bigtiff(self, tmp_path): with Image.open("Tests/images/hopper_bigtiff.tif") as im: assert_image_equal_tofile(im, "Tests/images/hopper.tif") - @pytest.mark.parametrize( - "file_name,mode,size,tile", - [ - ( - "tiff_wrong_bits_per_sample.tiff", - "RGBA", - (52, 53), - [("raw", (0, 0, 52, 53), 160, ("RGBA", 0, 1))], - ), - ( - "tiff_wrong_bits_per_sample_2.tiff", - "RGB", - (16, 16), - [("raw", (0, 0, 16, 16), 8, ("RGB", 0, 1))], - ), - ( - "tiff_wrong_bits_per_sample_3.tiff", - "RGBA", - (512, 256), - [("libtiff", (0, 0, 512, 256), 0, ("RGBA", "tiff_lzw", False, 48782))], - ), - ], - ) - def test_wrong_bits_per_sample(self, file_name, mode, size, tile): - with Image.open("Tests/images/" + file_name) as im: - assert im.mode == mode - assert im.size == size - assert im.tile == tile - im.load() + 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_set_legacy_api(self): ifd = TiffImagePlugin.ImageFileDirectory_v2() @@ -157,7 +117,6 @@ class TestFileTiff: def test_xyres_tiff(self): filename = "Tests/images/pil168.tif" with Image.open(filename) as im: - # legacy api assert isinstance(im.tag[X_RESOLUTION][0], tuple) assert isinstance(im.tag[Y_RESOLUTION][0], tuple) @@ -171,7 +130,6 @@ class TestFileTiff: def test_xyres_fallback_tiff(self): filename = "Tests/images/compression.tif" with Image.open(filename) as im: - # v2 api assert isinstance(im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational) assert isinstance(im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational) @@ -186,7 +144,6 @@ class TestFileTiff: def test_int_resolution(self): filename = "Tests/images/pil168.tif" with Image.open(filename) as im: - # Try to read a file where X,Y_RESOLUTION are ints im.tag_v2[X_RESOLUTION] = 71 im.tag_v2[Y_RESOLUTION] = 71 @@ -235,7 +192,8 @@ class TestFileTiff: def test_bad_exif(self): with Image.open("Tests/images/hopper_bad_exif.jpg") as i: # Should not raise struct.error. - pytest.warns(UserWarning, i._getexif) + with pytest.warns(UserWarning): + i._getexif() def test_save_rgba(self, tmp_path): im = hopper("RGBA") @@ -248,6 +206,12 @@ class TestFileTiff: with pytest.raises(OSError): im.save(outfile) + def test_8bit_s(self): + with Image.open("Tests/images/8bit.s.tif") as im: + im.load() + assert im.mode == "L" + assert im.getpixel((50, 50)) == 184 + def test_little_endian(self): with Image.open("Tests/images/16bit.cropped.tif") as im: assert im.getpixel((0, 0)) == 480 @@ -381,7 +345,6 @@ class TestFileTiff: def test___str__(self): filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: - # Act ret = str(im.ifd) @@ -392,7 +355,6 @@ class TestFileTiff: # Arrange filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: - # v2 interface v2_tags = { 256: 55, @@ -630,7 +592,6 @@ class TestFileTiff: filename = str(tmp_path / "temp.tif") hopper("RGB").save(filename, **kwargs) with Image.open(filename) as im: - # legacy interface assert im.tag[X_RESOLUTION][0][0] == 72 assert im.tag[Y_RESOLUTION][0][0] == 36 @@ -774,7 +735,10 @@ class TestFileTiff: def test_getxmp(self): with Image.open("Tests/images/lab.tif") as im: if ElementTree is None: - with pytest.warns(UserWarning): + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): assert im.getxmp() == {} else: xmp = im.getxmp() diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index b90dde3d9..edd57e6b5 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -1,3 +1,4 @@ +from __future__ import annotations import io import struct @@ -54,7 +55,6 @@ def test_rt_metadata(tmp_path): img.save(f, tiffinfo=info) with Image.open(f) as loaded: - assert loaded.tag[ImageJMetaDataByteCounts] == (len(bin_data),) assert loaded.tag_v2[ImageJMetaDataByteCounts] == (len(bin_data),) @@ -74,14 +74,12 @@ def test_rt_metadata(tmp_path): info[ImageJMetaDataByteCounts] = (8, len(bin_data) - 8) img.save(f, tiffinfo=info) with Image.open(f) as loaded: - assert loaded.tag[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8) assert loaded.tag_v2[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8) def test_read_metadata(): with Image.open("Tests/images/hopper_g4.tif") as img: - assert { "YResolution": IFDRational(4294967295, 113653537), "PlanarConfiguration": 1, @@ -185,30 +183,32 @@ def test_iptc(tmp_path): im.save(out) -def test_writing_bytes_to_ascii(tmp_path): - im = hopper() +@pytest.mark.parametrize("value, expected", ((b"test", "test"), (1, "1"))) +def test_writing_other_types_to_ascii(value, expected, tmp_path): info = TiffImagePlugin.ImageFileDirectory_v2() tag = TiffTags.TAGS_V2[271] assert tag.type == TiffTags.ASCII - info[271] = b"test" + info[271] = value + im = hopper() out = str(tmp_path / "temp.tiff") im.save(out, tiffinfo=info) with Image.open(out) as reloaded: - assert reloaded.tag_v2[271] == "test" + assert reloaded.tag_v2[271] == expected -def test_writing_int_to_bytes(tmp_path): +@pytest.mark.parametrize("value", (1, IFDRational(1))) +def test_writing_other_types_to_bytes(value, tmp_path): im = hopper() info = TiffImagePlugin.ImageFileDirectory_v2() tag = TiffTags.TAGS_V2[700] assert tag.type == TiffTags.BYTE - info[700] = 1 + info[700] = value out = str(tmp_path / "temp.tiff") im.save(out, tiffinfo=info) @@ -217,6 +217,22 @@ def test_writing_int_to_bytes(tmp_path): assert reloaded.tag_v2[700] == b"\x01" +def test_writing_other_types_to_undefined(tmp_path): + im = hopper() + info = TiffImagePlugin.ImageFileDirectory_v2() + + tag = TiffTags.TAGS_V2[33723] + assert tag.type == TiffTags.UNDEFINED + + info[33723] = 1 + + out = str(tmp_path / "temp.tiff") + im.save(out, tiffinfo=info) + + with Image.open(out) as reloaded: + assert reloaded.tag_v2[33723] == b"1" + + def test_undefined_zero(tmp_path): # Check that the tag has not been changed since this test was created tag = TiffTags.TAGS_V2[45059] @@ -237,7 +253,8 @@ def test_empty_metadata(): head = f.read(8) info = TiffImagePlugin.ImageFileDirectory(head) # Should not raise struct.error. - pytest.warns(UserWarning, info.load, f) + with pytest.warns(UserWarning): + info.load(f) def test_iccprofile(tmp_path): @@ -403,11 +420,12 @@ def test_too_many_entries(): ifd = TiffImagePlugin.ImageFileDirectory_v2() # 277: ("SamplesPerPixel", SHORT, 1), - ifd._tagdata[277] = struct.pack("hh", 4, 4) + ifd._tagdata[277] = struct.pack(">> im.save('Tests/images/hopper_45.png') with Image.open("Tests/images/hopper_45.png") as target: - for (resample, epsilon) in ( + for resample, epsilon in ( (Image.Resampling.NEAREST, 10), (Image.Resampling.BILINEAR, 5), (Image.Resampling.BICUBIC, 0), diff --git a/Tests/test_image_split.py b/Tests/test_image_split.py index 5cb7c9a8b..707508250 100644 --- a/Tests/test_image_split.py +++ b/Tests/test_image_split.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, features diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 4fd07a2b4..9e6796ca2 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image @@ -147,7 +148,7 @@ def test_reducing_gap_values(): ref = hopper() ref.thumbnail((18, 18), Image.Resampling.BICUBIC, reducing_gap=None) - with pytest.raises(AssertionError): + with pytest.raises(pytest.fail.Exception): assert_image_equal(ref, im) assert_image_similar(ref, im, 3.5) diff --git a/Tests/test_image_tobitmap.py b/Tests/test_image_tobitmap.py index 178cfcef3..156b9919d 100644 --- a/Tests/test_image_tobitmap.py +++ b/Tests/test_image_tobitmap.py @@ -1,10 +1,10 @@ +from __future__ import annotations import pytest from .helper import assert_image_equal, fromstring, hopper def test_sanity(): - with pytest.raises(ValueError): hopper().tobitmap() diff --git a/Tests/test_image_tobytes.py b/Tests/test_image_tobytes.py index 31e1c0080..f6042bca5 100644 --- a/Tests/test_image_tobytes.py +++ b/Tests/test_image_tobytes.py @@ -1,3 +1,4 @@ +from __future__ import annotations from .helper import hopper diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index a78349801..15939ef64 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -1,3 +1,4 @@ +from __future__ import annotations import math import pytest @@ -42,12 +43,12 @@ class TestImageTransform: def test_extent(self): im = hopper("RGB") (w, h) = im.size - # fmt: off - transformed = im.transform(im.size, Image.Transform.EXTENT, - (0, 0, - w//2, h//2), # ul -> lr - Image.Resampling.BILINEAR) - # fmt: on + transformed = im.transform( + im.size, + Image.Transform.EXTENT, + (0, 0, w // 2, h // 2), # ul -> lr + Image.Resampling.BILINEAR, + ) scaled = im.resize((w * 2, h * 2), Image.Resampling.BILINEAR).crop((0, 0, w, h)) @@ -58,13 +59,12 @@ class TestImageTransform: # one simple quad transform, equivalent to scale & crop upper left quad im = hopper("RGB") (w, h) = im.size - # fmt: off - transformed = im.transform(im.size, Image.Transform.QUAD, - (0, 0, 0, h//2, - # ul -> ccw around quad: - w//2, h//2, w//2, 0), - Image.Resampling.BILINEAR) - # fmt: on + transformed = im.transform( + im.size, + Image.Transform.QUAD, + (0, 0, 0, h // 2, w // 2, h // 2, w // 2, 0), # ul -> ccw around quad + Image.Resampling.BILINEAR, + ) scaled = im.transform( (w, h), @@ -99,16 +99,21 @@ class TestImageTransform: # this should be a checkerboard of halfsized hoppers in ul, lr im = hopper("RGBA") (w, h) = im.size - # fmt: off - transformed = im.transform(im.size, Image.Transform.MESH, - [((0, 0, w//2, h//2), # box - (0, 0, 0, h, - w, h, w, 0)), # ul -> ccw around quad - ((w//2, h//2, w, h), # box - (0, 0, 0, h, - w, h, w, 0))], # ul -> ccw around quad - Image.Resampling.BILINEAR) - # fmt: on + transformed = im.transform( + im.size, + Image.Transform.MESH, + ( + ( + (0, 0, w // 2, h // 2), # box + (0, 0, 0, h, w, h, w, 0), # ul -> ccw around quad + ), + ( + (w // 2, h // 2, w, h), # box + (0, 0, 0, h, w, h, w, 0), # ul -> ccw around quad + ), + ), + Image.Resampling.BILINEAR, + ) scaled = im.transform( (w // 2, h // 2), @@ -174,11 +179,13 @@ class TestImageTransform: im = op(im, (40, 10)) - colors = im.getcolors() - assert colors == [ - (20 * 10, opaque), - (20 * 10, transparent), - ] + colors = sorted(im.getcolors()) + assert colors == sorted( + ( + (20 * 10, opaque), + (20 * 10, transparent), + ) + ) @pytest.mark.parametrize("mode", ("RGBA", "LA")) def test_nearest_resize(self, mode): diff --git a/Tests/test_image_transpose.py b/Tests/test_image_transpose.py index 877f439ca..66a2d9e29 100644 --- a/Tests/test_image_transpose.py +++ b/Tests/test_image_transpose.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL.Image import Transpose diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index b839a7b14..8e3a738d7 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -1,3 +1,4 @@ +from __future__ import annotations from PIL import Image, ImageChops from .helper import assert_image_equal, hopper @@ -10,7 +11,7 @@ GREEN = (0, 255, 0) ORANGE = (255, 128, 0) WHITE = (255, 255, 255) -GREY = 128 +GRAY = 128 def test_sanity(): @@ -50,7 +51,6 @@ def test_add(): # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: - # Act new = ImageChops.add(im1, im2) @@ -63,7 +63,6 @@ def test_add_scale_offset(): # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: - # Act new = ImageChops.add(im1, im2, scale=2.5, offset=100) @@ -87,7 +86,6 @@ def test_add_modulo(): # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: - # Act new = ImageChops.add_modulo(im1, im2) @@ -111,7 +109,6 @@ def test_blend(): # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: - # Act new = ImageChops.blend(im1, im2, 0.5) @@ -125,19 +122,18 @@ def test_constant(): im = Image.new("RGB", (20, 10)) # Act - new = ImageChops.constant(im, GREY) + new = ImageChops.constant(im, GRAY) # Assert assert new.size == im.size - assert new.getpixel((0, 0)) == GREY - assert new.getpixel((19, 9)) == GREY + assert new.getpixel((0, 0)) == GRAY + assert new.getpixel((19, 9)) == GRAY def test_darker_image(): # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: - # Act new = ImageChops.darker(im1, im2) @@ -149,7 +145,6 @@ def test_darker_pixel(): # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_chord_RGB.png") as im2: - # Act new = ImageChops.darker(im1, im2) @@ -161,7 +156,6 @@ def test_difference(): # Arrange with Image.open("Tests/images/imagedraw_arc_end_le_start.png") as im1: with Image.open("Tests/images/imagedraw_arc_no_loops.png") as im2: - # Act new = ImageChops.difference(im1, im2) @@ -173,7 +167,6 @@ def test_difference_pixel(): # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_polygon_kite_RGB.png") as im2: - # Act new = ImageChops.difference(im1, im2) @@ -195,7 +188,6 @@ def test_duplicate(): def test_invert(): # Arrange with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im: - # Act new = ImageChops.invert(im) @@ -209,7 +201,6 @@ def test_lighter_image(): # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: - # Act new = ImageChops.lighter(im1, im2) @@ -221,7 +212,6 @@ def test_lighter_pixel(): # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_chord_RGB.png") as im2: - # Act new = ImageChops.lighter(im1, im2) @@ -275,7 +265,6 @@ def test_offset(): xoffset = 45 yoffset = 20 with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im: - # Act new = ImageChops.offset(im, xoffset, yoffset) @@ -292,7 +281,6 @@ def test_screen(): # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: - # Act new = ImageChops.screen(im1, im2) @@ -305,7 +293,6 @@ def test_subtract(): # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: - # Act new = ImageChops.subtract(im1, im2) @@ -319,7 +306,6 @@ def test_subtract_scale_offset(): # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: - # Act new = ImageChops.subtract(im1, im2, scale=2.5, offset=100) @@ -332,7 +318,6 @@ def test_subtract_clip(): # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_chord_RGB.png") as im2: - # Act new = ImageChops.subtract(im1, im2) @@ -344,7 +329,6 @@ def test_subtract_modulo(): # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: - # Act new = ImageChops.subtract_modulo(im1, im2) @@ -358,7 +342,6 @@ def test_subtract_modulo_no_clip(): # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_chord_RGB.png") as im2: - # Act new = ImageChops.subtract_modulo(im1, im2) @@ -370,7 +353,6 @@ def test_soft_light(): # Arrange with Image.open("Tests/images/hopper.png") as im1: with Image.open("Tests/images/hopper-XYZ.png") as im2: - # Act new = ImageChops.soft_light(im1, im2) @@ -383,7 +365,6 @@ def test_hard_light(): # Arrange with Image.open("Tests/images/hopper.png") as im1: with Image.open("Tests/images/hopper-XYZ.png") as im2: - # Act new = ImageChops.hard_light(im1, im2) @@ -396,7 +377,6 @@ def test_overlay(): # Arrange with Image.open("Tests/images/hopper.png") as im1: with Image.open("Tests/images/hopper-XYZ.png") as im2: - # Act new = ImageChops.overlay(im1, im2) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 3d8dbe6bb..0dde82bd7 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -1,3 +1,4 @@ +from __future__ import annotations import datetime import os import re @@ -617,11 +618,12 @@ def test_auxiliary_channels_isolated(): assert_image_equal(test_image.convert(dst_format[2]), reference_image) -def test_constants_deprecation(): - for enum, prefix in { - ImageCms.Intent: "INTENT_", - ImageCms.Direction: "DIRECTION_", - }.items(): - for name in enum.__members__: - with pytest.warns(DeprecationWarning): - assert getattr(ImageCms, prefix + name) == enum[name] +@pytest.mark.parametrize("mode", ("RGB", "RGBA", "RGBX")) +def test_rgb_lab(mode): + im = Image.new(mode, (1, 1)) + converted_im = im.convert("LAB") + assert converted_im.getpixel((0, 0)) == (0, 128, 128) + + im = Image.new("LAB", (1, 1), (255, 0, 0)) + converted_im = im.convert(mode) + assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255) diff --git a/Tests/test_imagecolor.py b/Tests/test_imagecolor.py index dcc44e6e3..c0ffd2ebf 100644 --- a/Tests/test_imagecolor.py +++ b/Tests/test_imagecolor.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageColor @@ -193,6 +194,10 @@ def test_rounding_errors(): Image.new("LA", (1, 1), "white") +def test_color_hsv(): + assert (170, 255, 255) == ImageColor.getcolor("hsv(240, 100%, 100%)", "HSV") + + def test_color_too_long(): # Arrange color_too_long = "hsl(" + "1" * 40 + "," + "1" * 40 + "%," + "1" * 40 + "%)" diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 4c4c41b7b..379fe78cd 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1,8 +1,10 @@ +from __future__ import annotations +import contextlib import os.path import pytest -from PIL import Image, ImageColor, ImageDraw, ImageFont +from PIL import Image, ImageColor, ImageDraw, ImageFont, features from .helper import ( assert_image_equal, @@ -27,15 +29,21 @@ X1 = int(X0 * 3) Y0 = int(H / 4) Y1 = int(X0 * 3) -# Two kinds of bounding box -BBOX1 = [(X0, Y0), (X1, Y1)] -BBOX2 = [X0, Y0, X1, Y1] +# Bounding boxes +BBOX = (((X0, Y0), (X1, Y1)), [(X0, Y0), (X1, Y1)], (X0, Y0, X1, Y1), [X0, Y0, X1, Y1]) -# Two kinds of coordinate sequences -POINTS1 = [(10, 10), (20, 40), (30, 30)] -POINTS2 = [10, 10, 20, 40, 30, 30] +# Coordinate sequences +POINTS = ( + ((10, 10), (20, 40), (30, 30)), + [(10, 10), (20, 40), (30, 30)], + (10, 10, 20, 40, 30, 30), + [10, 10, 20, 40, 30, 30], +) -KITE_POINTS = [(10, 50), (70, 10), (90, 50), (70, 90), (10, 50)] +KITE_POINTS = ( + ((10, 50), (70, 10), (90, 50), (70, 90), (10, 50)), + [(10, 50), (70, 10), (90, 50), (70, 90), (10, 50)], +) def test_sanity(): @@ -52,7 +60,6 @@ def test_sanity(): def test_valueerror(): with Image.open("Tests/images/chi.gif") as im: - draw = ImageDraw.Draw(im) draw.line((0, 0), fill=(0, 0, 0)) @@ -64,7 +71,7 @@ def test_mode_mismatch(): ImageDraw.ImageDraw(im, mode="L") -@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +@pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((0, 180), (0.5, 180.4))) def test_arc(bbox, start, end): # Arrange @@ -78,7 +85,8 @@ def test_arc(bbox, start, end): assert_image_similar_tofile(im, "Tests/images/imagedraw_arc.png", 1) -def test_arc_end_le_start(): +@pytest.mark.parametrize("bbox", BBOX) +def test_arc_end_le_start(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -86,13 +94,14 @@ def test_arc_end_le_start(): end = 0 # Act - draw.arc(BBOX1, start=start, end=end) + draw.arc(bbox, start=start, end=end) # Assert assert_image_equal_tofile(im, "Tests/images/imagedraw_arc_end_le_start.png") -def test_arc_no_loops(): +@pytest.mark.parametrize("bbox", BBOX) +def test_arc_no_loops(bbox): # No need to go in loops # Arrange im = Image.new("RGB", (W, H)) @@ -101,57 +110,61 @@ def test_arc_no_loops(): end = 370 # Act - draw.arc(BBOX1, start=start, end=end) + draw.arc(bbox, start=start, end=end) # Assert assert_image_similar_tofile(im, "Tests/images/imagedraw_arc_no_loops.png", 1) -def test_arc_width(): +@pytest.mark.parametrize("bbox", BBOX) +def test_arc_width(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.arc(BBOX1, 10, 260, width=5) + draw.arc(bbox, 10, 260, width=5) # Assert assert_image_similar_tofile(im, "Tests/images/imagedraw_arc_width.png", 1) -def test_arc_width_pieslice_large(): +@pytest.mark.parametrize("bbox", BBOX) +def test_arc_width_pieslice_large(bbox): # Tests an arc with a large enough width that it is a pieslice # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.arc(BBOX1, 10, 260, fill="yellow", width=100) + draw.arc(bbox, 10, 260, fill="yellow", width=100) # Assert assert_image_similar_tofile(im, "Tests/images/imagedraw_arc_width_pieslice.png", 1) -def test_arc_width_fill(): +@pytest.mark.parametrize("bbox", BBOX) +def test_arc_width_fill(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.arc(BBOX1, 10, 260, fill="yellow", width=5) + draw.arc(bbox, 10, 260, fill="yellow", width=5) # Assert assert_image_similar_tofile(im, "Tests/images/imagedraw_arc_width_fill.png", 1) -def test_arc_width_non_whole_angle(): +@pytest.mark.parametrize("bbox", BBOX) +def test_arc_width_non_whole_angle(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) expected = "Tests/images/imagedraw_arc_width_non_whole_angle.png" # Act - draw.arc(BBOX1, 10, 259.5, width=5) + draw.arc(bbox, 10, 259.5, width=5) # Assert assert_image_similar_tofile(im, expected, 1) @@ -185,7 +198,7 @@ def test_bitmap(): @pytest.mark.parametrize("mode", ("RGB", "L")) -@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +@pytest.mark.parametrize("bbox", BBOX) def test_chord(mode, bbox): # Arrange im = Image.new(mode, (W, H)) @@ -199,37 +212,40 @@ def test_chord(mode, bbox): assert_image_similar_tofile(im, expected, 1) -def test_chord_width(): +@pytest.mark.parametrize("bbox", BBOX) +def test_chord_width(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.chord(BBOX1, 10, 260, outline="yellow", width=5) + draw.chord(bbox, 10, 260, outline="yellow", width=5) # Assert assert_image_similar_tofile(im, "Tests/images/imagedraw_chord_width.png", 1) -def test_chord_width_fill(): +@pytest.mark.parametrize("bbox", BBOX) +def test_chord_width_fill(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.chord(BBOX1, 10, 260, fill="red", outline="yellow", width=5) + draw.chord(bbox, 10, 260, fill="red", outline="yellow", width=5) # Assert assert_image_similar_tofile(im, "Tests/images/imagedraw_chord_width_fill.png", 1) -def test_chord_zero_width(): +@pytest.mark.parametrize("bbox", BBOX) +def test_chord_zero_width(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.chord(BBOX1, 10, 260, fill="red", outline="yellow", width=0) + draw.chord(bbox, 10, 260, fill="red", outline="yellow", width=0) # Assert assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_zero_width.png") @@ -248,7 +264,7 @@ def test_chord_too_fat(): @pytest.mark.parametrize("mode", ("RGB", "L")) -@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +@pytest.mark.parametrize("bbox", BBOX) def test_ellipse(mode, bbox): # Arrange im = Image.new(mode, (W, H)) @@ -262,13 +278,14 @@ def test_ellipse(mode, bbox): assert_image_similar_tofile(im, expected, 1) -def test_ellipse_translucent(): +@pytest.mark.parametrize("bbox", BBOX) +def test_ellipse_translucent(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") # Act - draw.ellipse(BBOX1, fill=(0, 255, 0, 127)) + draw.ellipse(bbox, fill=(0, 255, 0, 127)) # Assert expected = "Tests/images/imagedraw_ellipse_translucent.png" @@ -298,13 +315,14 @@ def test_ellipse_symmetric(): assert_image_equal(im, im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)) -def test_ellipse_width(): +@pytest.mark.parametrize("bbox", BBOX) +def test_ellipse_width(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.ellipse(BBOX1, outline="blue", width=5) + draw.ellipse(bbox, outline="blue", width=5) # Assert assert_image_similar_tofile(im, "Tests/images/imagedraw_ellipse_width.png", 1) @@ -322,25 +340,27 @@ def test_ellipse_width_large(): assert_image_similar_tofile(im, "Tests/images/imagedraw_ellipse_width_large.png", 1) -def test_ellipse_width_fill(): +@pytest.mark.parametrize("bbox", BBOX) +def test_ellipse_width_fill(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.ellipse(BBOX1, fill="green", outline="blue", width=5) + draw.ellipse(bbox, fill="green", outline="blue", width=5) # Assert assert_image_similar_tofile(im, "Tests/images/imagedraw_ellipse_width_fill.png", 1) -def test_ellipse_zero_width(): +@pytest.mark.parametrize("bbox", BBOX) +def test_ellipse_zero_width(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.ellipse(BBOX1, fill="green", outline="blue", width=0) + draw.ellipse(bbox, fill="green", outline="blue", width=0) # Assert assert_image_equal_tofile(im, "Tests/images/imagedraw_ellipse_zero_width.png") @@ -356,7 +376,13 @@ def ellipse_various_sizes_helper(filled): for w in ellipse_sizes: y = 1 for h in ellipse_sizes: - border = [x, y, x + w - 1, y + h - 1] + x1 = x + w + if w: + x1 -= 1 + y1 = y + h + if h: + y1 -= 1 + border = [x, y, x1, y1] if filled: draw.ellipse(border, fill="white") else: @@ -381,7 +407,7 @@ def test_ellipse_various_sizes_filled(): ) -@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +@pytest.mark.parametrize("points", POINTS) def test_line(points): # Arrange im = Image.new("RGB", (W, H)) @@ -453,7 +479,7 @@ def test_transform(): assert_image_equal(im, expected) -@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +@pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((-92, 46), (-92.2, 46.2))) def test_pieslice(bbox, start, end): # Arrange @@ -467,38 +493,41 @@ def test_pieslice(bbox, start, end): assert_image_similar_tofile(im, "Tests/images/imagedraw_pieslice.png", 1) -def test_pieslice_width(): +@pytest.mark.parametrize("bbox", BBOX) +def test_pieslice_width(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.pieslice(BBOX1, 10, 260, outline="blue", width=5) + draw.pieslice(bbox, 10, 260, outline="blue", width=5) # Assert assert_image_similar_tofile(im, "Tests/images/imagedraw_pieslice_width.png", 1) -def test_pieslice_width_fill(): +@pytest.mark.parametrize("bbox", BBOX) +def test_pieslice_width_fill(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) expected = "Tests/images/imagedraw_pieslice_width_fill.png" # Act - draw.pieslice(BBOX1, 10, 260, fill="white", outline="blue", width=5) + draw.pieslice(bbox, 10, 260, fill="white", outline="blue", width=5) # Assert assert_image_similar_tofile(im, expected, 1) -def test_pieslice_zero_width(): +@pytest.mark.parametrize("bbox", BBOX) +def test_pieslice_zero_width(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.pieslice(BBOX1, 10, 260, fill="white", outline="blue", width=0) + draw.pieslice(bbox, 10, 260, fill="white", outline="blue", width=0) # Assert assert_image_equal_tofile(im, "Tests/images/imagedraw_pieslice_zero_width.png") @@ -546,7 +575,7 @@ def test_pieslice_no_spikes(): assert_image_equal(im, im_pre_erase) -@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +@pytest.mark.parametrize("points", POINTS) def test_point(points): # Arrange im = Image.new("RGB", (W, H)) @@ -559,7 +588,19 @@ def test_point(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_point.png") -@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +def test_point_I16(): + # Arrange + im = Image.new("I;16", (1, 1)) + draw = ImageDraw.Draw(im) + + # Act + draw.point((0, 0), fill=0x1234) + + # Assert + assert im.getpixel((0, 0)) == 0x1234 + + +@pytest.mark.parametrize("points", POINTS) def test_polygon(points): # Arrange im = Image.new("RGB", (W, H)) @@ -573,7 +614,8 @@ def test_polygon(points): @pytest.mark.parametrize("mode", ("RGB", "L")) -def test_polygon_kite(mode): +@pytest.mark.parametrize("kite_points", KITE_POINTS) +def test_polygon_kite(mode, kite_points): # Test drawing lines of different gradients (dx>dy, dy>dx) and # vertical (dx==0) and horizontal (dy==0) lines # Arrange @@ -582,7 +624,7 @@ def test_polygon_kite(mode): expected = f"Tests/images/imagedraw_polygon_kite_{mode}.png" # Act - draw.polygon(KITE_POINTS, fill="blue", outline="yellow") + draw.polygon(kite_points, fill="blue", outline="yellow") # Assert assert_image_equal_tofile(im, expected) @@ -629,7 +671,7 @@ def test_polygon_translucent(): assert_image_equal_tofile(im, expected) -@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +@pytest.mark.parametrize("bbox", BBOX) def test_rectangle(bbox): # Arrange im = Image.new("RGB", (W, H)) @@ -656,63 +698,68 @@ def test_big_rectangle(): assert_image_similar_tofile(im, "Tests/images/imagedraw_big_rectangle.png", 1) -def test_rectangle_width(): +@pytest.mark.parametrize("bbox", BBOX) +def test_rectangle_width(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) expected = "Tests/images/imagedraw_rectangle_width.png" # Act - draw.rectangle(BBOX1, outline="green", width=5) + draw.rectangle(bbox, outline="green", width=5) # Assert assert_image_equal_tofile(im, expected) -def test_rectangle_width_fill(): +@pytest.mark.parametrize("bbox", BBOX) +def test_rectangle_width_fill(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) expected = "Tests/images/imagedraw_rectangle_width_fill.png" # Act - draw.rectangle(BBOX1, fill="blue", outline="green", width=5) + draw.rectangle(bbox, fill="blue", outline="green", width=5) # Assert assert_image_equal_tofile(im, expected) -def test_rectangle_zero_width(): +@pytest.mark.parametrize("bbox", BBOX) +def test_rectangle_zero_width(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.rectangle(BBOX1, fill="blue", outline="green", width=0) + draw.rectangle(bbox, fill="blue", outline="green", width=0) # Assert assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle_zero_width.png") -def test_rectangle_I16(): +@pytest.mark.parametrize("bbox", BBOX) +def test_rectangle_I16(bbox): # Arrange im = Image.new("I;16", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.rectangle(BBOX1, fill="black", outline="green") + draw.rectangle(bbox, outline=0xFFFF) # Assert assert_image_equal_tofile(im.convert("I"), "Tests/images/imagedraw_rectangle_I.png") -def test_rectangle_translucent_outline(): +@pytest.mark.parametrize("bbox", BBOX) +def test_rectangle_translucent_outline(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") # Act - draw.rectangle(BBOX1, fill="black", outline=(0, 255, 0, 127), width=5) + draw.rectangle(bbox, fill="black", outline=(0, 255, 0, 127), width=5) # Assert assert_image_equal_tofile( @@ -736,6 +783,36 @@ def test_rounded_rectangle(xy): assert_image_equal_tofile(im, "Tests/images/imagedraw_rounded_rectangle.png") +@pytest.mark.parametrize("top_left", (True, False)) +@pytest.mark.parametrize("top_right", (True, False)) +@pytest.mark.parametrize("bottom_right", (True, False)) +@pytest.mark.parametrize("bottom_left", (True, False)) +def test_rounded_rectangle_corners(top_left, top_right, bottom_right, bottom_left): + corners = (top_left, top_right, bottom_right, bottom_left) + + # Arrange + im = Image.new("RGB", (200, 200)) + draw = ImageDraw.Draw(im) + + # Act + draw.rounded_rectangle( + (10, 20, 190, 180), 30, fill="red", outline="green", width=5, corners=corners + ) + + # Assert + suffix = "".join( + ( + ("y" if top_left else "n"), + ("y" if top_right else "n"), + ("y" if bottom_right else "n"), + ("y" if bottom_left else "n"), + ) + ) + assert_image_equal_tofile( + im, "Tests/images/imagedraw_rounded_rectangle_corners_" + suffix + ".png" + ) + + @pytest.mark.parametrize( "xy, radius, type", [ @@ -759,13 +836,14 @@ def test_rounded_rectangle_non_integer_radius(xy, radius, type): ) -def test_rounded_rectangle_zero_radius(): +@pytest.mark.parametrize("bbox", BBOX) +def test_rounded_rectangle_zero_radius(bbox): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.rounded_rectangle(BBOX1, 0, fill="blue", outline="green", width=5) + draw.rounded_rectangle(bbox, 0, fill="blue", outline="green", width=5) # Assert assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle_width_fill.png") @@ -775,7 +853,9 @@ def test_rounded_rectangle_zero_radius(): "xy, suffix", [ ((20, 10, 80, 90), "x"), + ((20, 10, 81, 90), "x_odd"), ((10, 20, 90, 80), "y"), + ((10, 20, 90, 81), "y_odd"), ((20, 20, 80, 80), "both"), ], ) @@ -795,14 +875,15 @@ def test_rounded_rectangle_translucent(xy, suffix): ) -def test_floodfill(): +@pytest.mark.parametrize("bbox", BBOX) +def test_floodfill(bbox): red = ImageColor.getrgb("red") for mode, value in [("L", 1), ("RGBA", (255, 0, 0, 0)), ("RGB", red)]: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) - draw.rectangle(BBOX2, outline="yellow", fill="green") + draw.rectangle(bbox, outline="yellow", fill="green") centre_point = (int(W / 2), int(H / 2)) # Act @@ -827,13 +908,14 @@ def test_floodfill(): assert_image_equal(im, Image.new("RGB", (1, 1), red)) -def test_floodfill_border(): +@pytest.mark.parametrize("bbox", BBOX) +def test_floodfill_border(bbox): # floodfill() is experimental # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) - draw.rectangle(BBOX2, outline="yellow", fill="green") + draw.rectangle(bbox, outline="yellow", fill="green") centre_point = (int(W / 2), int(H / 2)) # Act @@ -848,13 +930,14 @@ def test_floodfill_border(): assert_image_equal_tofile(im, "Tests/images/imagedraw_floodfill2.png") -def test_floodfill_thresh(): +@pytest.mark.parametrize("bbox", BBOX) +def test_floodfill_thresh(bbox): # floodfill() is experimental # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) - draw.rectangle(BBOX2, outline="darkgreen", fill="green") + draw.rectangle(bbox, outline="darkgreen", fill="green") centre_point = (int(W / 2), int(H / 2)) # Act @@ -903,9 +986,6 @@ def test_square(): img, draw = create_base_image_draw((10, 10)) draw.rectangle((2, 2, 7, 7), BLACK) assert_image_equal_tofile(img, expected, "square as normal rectangle failed") - img, draw = create_base_image_draw((10, 10)) - draw.rectangle((7, 7, 2, 2), BLACK) - assert_image_equal_tofile(img, expected, "square as inverted rectangle failed") def test_triangle_right(): @@ -1192,21 +1272,6 @@ def test_textbbox_stroke(): assert draw.textbbox((2, 2), "ABC\nAaaa", font, stroke_width=4) == (-2, 2, 54, 50) -def test_textsize_deprecation(): - im = Image.new("RGB", (W, H)) - draw = ImageDraw.Draw(im) - - with pytest.warns(DeprecationWarning) as log: - draw.textsize("Hello") - assert len(log) == 1 - with pytest.warns(DeprecationWarning) as log: - draw.textsize("Hello\nWorld") - assert len(log) == 1 - with pytest.warns(DeprecationWarning) as log: - draw.multiline_textsize("Hello\nWorld") - assert len(log) == 1 - - @skip_unless_feature("freetype2") def test_stroke(): for suffix, stroke_fill in {"same": None, "different": "#0f0"}.items(): @@ -1275,6 +1340,7 @@ def test_stroke_multiline(): assert_image_similar_tofile(im, "Tests/images/imagedraw_stroke_multiline.png", 3.3) +@skip_unless_feature("freetype2") def test_setting_default_font(): # Arrange im = Image.new("RGB", (100, 250)) @@ -1289,10 +1355,37 @@ def test_setting_default_font(): assert draw.getfont() == font finally: ImageDraw.ImageDraw.font = None - assert isinstance(draw.getfont(), ImageFont.ImageFont) + assert isinstance(draw.getfont(), ImageFont.load_default().__class__) -def test_same_color_outline(): +def test_default_font_size(): + freetype_support = features.check_module("freetype2") + text = "Default font at a specific size." + + im = Image.new("RGB", (220, 25)) + draw = ImageDraw.Draw(im) + with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + draw.text((0, 0), text, font_size=16) + assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") + + with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + assert draw.textlength(text, font_size=16) == 216 + + with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + assert draw.textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) + + im = Image.new("RGB", (220, 25)) + draw = ImageDraw.Draw(im) + with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + draw.multiline_text((0, 0), text, font_size=16) + assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") + + with contextlib.nullcontext() if freetype_support else pytest.raises(ImportError): + assert draw.multiline_textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) + + +@pytest.mark.parametrize("bbox", BBOX) +def test_same_color_outline(bbox): # Prepare shape x0, y0 = 5, 5 x1, y1 = 5, 50 @@ -1308,12 +1401,12 @@ def test_same_color_outline(): for mode in ["RGB", "L"]: for fill, outline in [["red", None], ["red", "red"], ["red", "#f00"]]: for operation, args in { - "chord": [BBOX1, 0, 180], - "ellipse": [BBOX1], + "chord": [bbox, 0, 180], + "ellipse": [bbox], "shape": [s], - "pieslice": [BBOX1, -90, 45], + "pieslice": [bbox, -90, 45], "polygon": [[(18, 30), (85, 30), (60, 72)]], - "rectangle": [BBOX1], + "rectangle": [bbox], }.items(): # Arrange im = Image.new(mode, (W, H)) @@ -1330,20 +1423,20 @@ def test_same_color_outline(): @pytest.mark.parametrize( - "n_sides, rotation, polygon_name", - [(4, 0, "square"), (8, 0, "regular_octagon"), (4, 45, "square")], + "n_sides, polygon_name, args", + [ + (4, "square", {}), + (8, "regular_octagon", {}), + (4, "square_rotate_45", {"rotation": 45}), + (3, "triangle_width", {"width": 5, "outline": "yellow"}), + ], ) -def test_draw_regular_polygon(n_sides, rotation, polygon_name): +def test_draw_regular_polygon(n_sides, polygon_name, args): im = Image.new("RGBA", size=(W, H), color=(255, 0, 0, 0)) - filename_base = f"Tests/images/imagedraw_{polygon_name}" - filename = ( - f"{filename_base}.png" - if rotation == 0 - else f"{filename_base}_rotate_{rotation}.png" - ) + filename = f"Tests/images/imagedraw_{polygon_name}.png" draw = ImageDraw.Draw(im) bounding_circle = ((W // 2, H // 2), 25) - draw.regular_polygon(bounding_circle, n_sides, rotation=rotation, fill="red") + draw.regular_polygon(bounding_circle, n_sides, fill="red", **args) assert_image_equal_tofile(im, filename) @@ -1470,3 +1563,21 @@ def test_polygon2(): draw.polygon([(18, 30), (19, 31), (18, 30), (85, 30), (60, 72)], "red") expected = "Tests/images/imagedraw_outline_polygon_RGB.png" assert_image_similar_tofile(im, expected, 1) + + +@pytest.mark.parametrize("xy", ((1, 1, 0, 1), (1, 1, 1, 0))) +def test_incorrectly_ordered_coordinates(xy): + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + with pytest.raises(ValueError): + draw.arc(xy, 10, 260) + with pytest.raises(ValueError): + draw.chord(xy, 10, 260) + with pytest.raises(ValueError): + draw.ellipse(xy) + with pytest.raises(ValueError): + draw.pieslice(xy, 10, 260) + with pytest.raises(ValueError): + draw.rectangle(xy) + with pytest.raises(ValueError): + draw.rounded_rectangle(xy) diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index 6fc829f1a..d729af14d 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -1,8 +1,9 @@ +from __future__ import annotations import os.path import pytest -from PIL import Image, ImageDraw, ImageDraw2 +from PIL import Image, ImageDraw, ImageDraw2, features from .helper import ( assert_image_equal, @@ -27,15 +28,16 @@ X1 = int(X0 * 3) Y0 = int(H / 4) Y1 = int(X0 * 3) -# Two kinds of bounding box -BBOX1 = [(X0, Y0), (X1, Y1)] -BBOX2 = [X0, Y0, X1, Y1] +# Bounding boxes +BBOX = (((X0, Y0), (X1, Y1)), [(X0, Y0), (X1, Y1)], (X0, Y0, X1, Y1), [X0, Y0, X1, Y1]) -# Two kinds of coordinate sequences -POINTS1 = [(10, 10), (20, 40), (30, 30)] -POINTS2 = [10, 10, 20, 40, 30, 30] - -KITE_POINTS = [(10, 50), (70, 10), (90, 50), (70, 90), (10, 50)] +# Coordinate sequences +POINTS = ( + ((10, 10), (20, 40), (30, 30)), + [(10, 10), (20, 40), (30, 30)], + (10, 10, 20, 40, 30, 30), + [10, 10, 20, 40, 30, 30], +) FONT_PATH = "Tests/fonts/FreeMono.ttf" @@ -52,7 +54,7 @@ def test_sanity(): draw.line(list(range(10)), pen) -@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +@pytest.mark.parametrize("bbox", BBOX) def test_ellipse(bbox): # Arrange im = Image.new("RGB", (W, H)) @@ -80,7 +82,7 @@ def test_ellipse_edge(): assert_image_similar_tofile(im, "Tests/images/imagedraw_ellipse_edge.png", 1) -@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +@pytest.mark.parametrize("points", POINTS) def test_line(points): # Arrange im = Image.new("RGB", (W, H)) @@ -94,7 +96,8 @@ def test_line(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_line.png") -def test_line_pen_as_brush(): +@pytest.mark.parametrize("points", POINTS) +def test_line_pen_as_brush(points): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -103,13 +106,13 @@ def test_line_pen_as_brush(): # Act # Pass in the pen as the brush parameter - draw.line(POINTS1, pen, brush) + draw.line(points, pen, brush) # Assert assert_image_equal_tofile(im, "Tests/images/imagedraw_line.png") -@pytest.mark.parametrize("points", (POINTS1, POINTS2)) +@pytest.mark.parametrize("points", POINTS) def test_polygon(points): # Arrange im = Image.new("RGB", (W, H)) @@ -124,7 +127,7 @@ def test_polygon(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_polygon.png") -@pytest.mark.parametrize("bbox", (BBOX1, BBOX2)) +@pytest.mark.parametrize("bbox", BBOX) def test_rectangle(bbox): # Arrange im = Image.new("RGB", (W, H)) @@ -171,19 +174,18 @@ def test_text(): @skip_unless_feature("freetype2") -def test_textsize(): +def test_textbbox(): # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) font = ImageDraw2.Font("white", FONT_PATH) # Act - with pytest.warns(DeprecationWarning) as log: - size = draw.textsize("ImageDraw2", font) - assert len(log) == 1 + bbox = draw.textbbox((0, 0), "ImageDraw2", font) # Assert - assert size[1] == 12 + right = 72 if features.check_feature("raqm") else 70 + assert bbox == (0, 2, right, 12) @skip_unless_feature("freetype2") diff --git a/Tests/test_imageenhance.py b/Tests/test_imageenhance.py index 221ef8cdb..f4e4d59be 100644 --- a/Tests/test_imageenhance.py +++ b/Tests/test_imageenhance.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageEnhance diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index fc0fbfb9b..4804a554f 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO import pytest @@ -30,7 +31,6 @@ SAFEBLOCK = ImageFile.SAFEBLOCK class TestImageFile: def test_parser(self): def roundtrip(format): - im = hopper("L").resize((1000, 1000), Image.Resampling.NEAREST) if format in ("MSP", "XBM"): im = im.convert("1") @@ -116,8 +116,9 @@ class TestImageFile: assert_image_equal(im1, im2) def test_raise_oserror(self): - with pytest.raises(OSError): - ImageFile.raise_oserror(1) + with pytest.warns(DeprecationWarning): + with pytest.raises(OSError): + ImageFile.raise_oserror(1) def test_raise_typeerror(self): with pytest.raises(TypeError): @@ -137,7 +138,7 @@ class TestImageFile: class DummyImageFile(ImageFile.ImageFile): def _open(self): - self.mode = "RGB" + self._mode = "RGB" self._size = (1, 1) im = DummyImageFile(buf) @@ -218,7 +219,7 @@ xoff, yoff, xsize, ysize = 10, 20, 100, 100 class MockImageFile(ImageFile.ImageFile): def _open(self): self.rawmode = "RGBA" - self.mode = "RGBA" + self._mode = "RGBA" self._size = (200, 200) self.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None)] diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 306a2f1bf..6e04cddc7 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -1,9 +1,11 @@ +from __future__ import annotations import copy import os import re import shutil import sys from io import BytesIO +from pathlib import Path import pytest from packaging.version import parse as parse_version @@ -76,8 +78,9 @@ def _render(font, layout_engine): return img -def test_font_with_name(layout_engine): - _render(FONT_PATH, layout_engine) +@pytest.mark.parametrize("font", (FONT_PATH, Path(FONT_PATH))) +def test_font_with_name(layout_engine, font): + _render(font, layout_engine) def test_font_with_filelike(layout_engine): @@ -141,7 +144,9 @@ def test_I16(font): draw = ImageDraw.Draw(im) txt = "Hello World!" - draw.text((10, 10), txt, font=font) + draw.text((10, 10), txt, fill=0xFFFE, font=font) + + assert im.getpixel((12, 14)) == 0xFFFE target = "Tests/images/transparent_background_text_L.png" assert_image_similar_tofile(im.convert("L"), target, 0.01) @@ -191,6 +196,16 @@ def test_getlength( assert length == length_raqm +def test_float_size(): + lengths = [] + for size in (48, 48.5, 49): + f = ImageFont.truetype( + "Tests/fonts/NotoSans-Regular.ttf", size, layout_engine=layout_engine + ) + lengths.append(f.getlength("text")) + assert lengths[0] != lengths[1] != lengths[2] + + def test_render_multiline(font): im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -251,27 +266,6 @@ def test_draw_align(font): draw.text((100, 40), line, (0, 0, 0), font=font, align="left") -def test_multiline_size(font): - im = Image.new(mode="RGB", size=(300, 100)) - draw = ImageDraw.Draw(im) - - with pytest.warns(DeprecationWarning) as log: - # Test that textsize() correctly connects to multiline_textsize() - assert draw.textsize(TEST_TEXT, font=font) == draw.multiline_textsize( - TEST_TEXT, font=font - ) - - # Test that multiline_textsize corresponds to ImageFont.textsize() - # for single line text - assert font.getsize("A") == draw.multiline_textsize("A", font=font) - - # Test that textsize() can pass on additional arguments - # to multiline_textsize() - draw.textsize(TEST_TEXT, font=font, spacing=4) - draw.textsize(TEST_TEXT, font, 4) - assert len(log) == 6 - - def test_multiline_bbox(font): im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -298,12 +292,6 @@ def test_multiline_width(font): draw.textbbox((0, 0), "longest line", font=font)[2] == draw.multiline_textbbox((0, 0), "longest line\nline", font=font)[2] ) - with pytest.warns(DeprecationWarning) as log: - assert ( - draw.textsize("longest line", font=font)[0] - == draw.multiline_textsize("longest line\nline", font=font)[0] - ) - assert len(log) == 2 def test_multiline_spacing(font): @@ -318,40 +306,35 @@ def test_multiline_spacing(font): "orientation", (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270) ) def test_rotated_transposed_font(font, orientation): - img_grey = Image.new("L", (100, 100)) - draw = ImageDraw.Draw(img_grey) + img_gray = Image.new("L", (100, 100)) + draw = ImageDraw.Draw(img_gray) word = "testing" transposed_font = ImageFont.TransposedFont(font, orientation=orientation) # Original font draw.font = font - with pytest.warns(DeprecationWarning) as log: - box_size_a = draw.textsize(word) - assert box_size_a == font.getsize(word) - assert len(log) == 2 bbox_a = draw.textbbox((10, 10), word) # Rotated font draw.font = transposed_font - with pytest.warns(DeprecationWarning) as log: - box_size_b = draw.textsize(word) - assert box_size_b == transposed_font.getsize(word) - assert len(log) == 2 bbox_b = draw.textbbox((20, 20), word) - # Check (w,h) of box a is (h,w) of box b - assert box_size_a[0] == box_size_b[1] - assert box_size_a[1] == box_size_b[0] + # Check (w, h) of box a is (h, w) of box b + assert ( + bbox_a[2] - bbox_a[0], + bbox_a[3] - bbox_a[1], + ) == ( + bbox_b[3] - bbox_b[1], + bbox_b[2] - bbox_b[0], + ) - # Check bbox b is (20, 20, 20 + h, 20 + w) - assert bbox_b[0] == 20 - assert bbox_b[1] == 20 - assert bbox_b[2] == 20 + bbox_a[3] - bbox_a[1] - assert bbox_b[3] == 20 + bbox_a[2] - bbox_a[0] + # Check top left co-ordinates are correct + assert bbox_b[:2] == (20, 20) # text length is undefined for vertical text - pytest.raises(ValueError, draw.textlength, word) + with pytest.raises(ValueError): + draw.textlength(word) @pytest.mark.parametrize( @@ -364,36 +347,33 @@ def test_rotated_transposed_font(font, orientation): ), ) def test_unrotated_transposed_font(font, orientation): - img_grey = Image.new("L", (100, 100)) - draw = ImageDraw.Draw(img_grey) + img_gray = Image.new("L", (100, 100)) + draw = ImageDraw.Draw(img_gray) word = "testing" transposed_font = ImageFont.TransposedFont(font, orientation=orientation) # Original font draw.font = font - with pytest.warns(DeprecationWarning) as log: - box_size_a = draw.textsize(word) - assert len(log) == 1 bbox_a = draw.textbbox((10, 10), word) length_a = draw.textlength(word) # Rotated font draw.font = transposed_font - with pytest.warns(DeprecationWarning) as log: - box_size_b = draw.textsize(word) - assert len(log) == 1 bbox_b = draw.textbbox((20, 20), word) length_b = draw.textlength(word) # Check boxes a and b are same size - assert box_size_a == box_size_b + assert ( + bbox_a[2] - bbox_a[0], + bbox_a[3] - bbox_a[1], + ) == ( + bbox_b[2] - bbox_b[0], + bbox_b[3] - bbox_b[1], + ) - # Check bbox b is (20, 20, 20 + w, 20 + h) - assert bbox_b[0] == 20 - assert bbox_b[1] == 20 - assert bbox_b[2] == 20 + bbox_a[2] - bbox_a[0] - assert bbox_b[3] == 20 + bbox_a[3] - bbox_a[1] + # Check top left co-ordinates are correct + assert bbox_b[:2] == (20, 20) assert length_a == length_b @@ -446,19 +426,6 @@ def test_free_type_font_get_metrics(font): assert (ascent, descent) == (16, 4) -def test_free_type_font_get_offset(font): - # Arrange - text = "offset this" - - # Act - with pytest.warns(DeprecationWarning) as log: - offset = font.getoffset(text) - - # Assert - assert len(log) == 1 - assert offset == (0, 3) - - def test_free_type_font_get_mask(font): # Arrange text = "mask this" @@ -489,7 +456,7 @@ def test_load_non_font_bytes(): def test_default_font(): # Arrange - txt = 'This is a "better than nothing" default font.' + txt = "This is a default font using FreeType support." im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -497,8 +464,16 @@ def test_default_font(): default_font = ImageFont.load_default() draw.text((10, 10), txt, font=default_font) + larger_default_font = ImageFont.load_default(size=14) + draw.text((10, 60), txt, font=larger_default_font) + # Assert - assert_image_equal_tofile(im, "Tests/images/default_font.png") + assert_image_equal_tofile(im, "Tests/images/default_font_freetype.png") + + +@pytest.mark.parametrize("mode", (None, "1", "RGBA")) +def test_getbbox(font, mode): + assert (0, 4, 12, 16) == font.getbbox("A", mode) def test_getbbox_empty(font): @@ -516,14 +491,6 @@ def test_render_empty(font): assert_image_equal(im, target) -def test_unicode_pilfont(): - # should not segfault, should return UnicodeDecodeError - # issue #2826 - font = ImageFont.load_default() - with pytest.raises(UnicodeEncodeError): - font.getbbox("’") - - def test_unicode_extended(layout_engine): # issue #3777 text = "A\u278A\U0001F12B" @@ -617,19 +584,6 @@ def test_imagefont_getters(font): assert font.getlength("M") == 12 assert font.getlength("y") == 12 assert font.getlength("a") == 12 - with pytest.warns(DeprecationWarning) as log: - assert font.getsize("A") == (12, 16) - assert font.getsize("AB") == (24, 16) - assert font.getsize("M") == (12, 16) - assert font.getsize("y") == (12, 20) - assert font.getsize("a") == (12, 16) - assert font.getsize_multiline("A") == (12, 16) - assert font.getsize_multiline("AB") == (24, 16) - assert font.getsize_multiline("a") == (12, 16) - assert font.getsize_multiline("ABC\n") == (36, 36) - assert font.getsize_multiline("ABC\nA") == (36, 36) - assert font.getsize_multiline("ABC\nAaaa") == (48, 36) - assert len(log) == 11 @pytest.mark.parametrize("stroke_width", (0, 2)) @@ -640,16 +594,6 @@ def test_getsize_stroke(font, stroke_width): 12 + stroke_width, 16 + stroke_width, ) - with pytest.warns(DeprecationWarning) as log: - assert font.getsize("A", stroke_width=stroke_width) == ( - 12 + stroke_width * 2, - 16 + stroke_width * 2, - ) - assert font.getsize_multiline("ABC\nAaaa", stroke_width=stroke_width) == ( - 48 + stroke_width * 2, - 36 + stroke_width * 4, - ) - assert len(log) == 2 def test_complex_font_settings(): @@ -776,17 +720,6 @@ def test_variation_set_by_axes(font): _check_text(font, "Tests/images/variation_tiny_axes.png", 32.5) -def test_textbbox_non_freetypefont(): - im = Image.new("RGB", (200, 200)) - d = ImageDraw.Draw(im) - default_font = ImageFont.load_default() - with pytest.warns(DeprecationWarning) as log: - width, height = d.textsize("test", font=default_font) - assert len(log) == 1 - assert d.textlength("test", font=default_font) == width - assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, width, height) - - @pytest.mark.parametrize( "anchor, left, top", ( @@ -872,25 +805,23 @@ def test_anchor_invalid(font): d.font = font for anchor in ["", "l", "a", "lax", "sa", "xa", "lx"]: - pytest.raises(ValueError, lambda: font.getmask2("hello", anchor=anchor)) - pytest.raises(ValueError, lambda: font.getbbox("hello", anchor=anchor)) - pytest.raises(ValueError, lambda: d.text((0, 0), "hello", anchor=anchor)) - pytest.raises(ValueError, lambda: d.textbbox((0, 0), "hello", anchor=anchor)) - pytest.raises( - ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor) - ) - pytest.raises( - ValueError, - lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor), - ) + with pytest.raises(ValueError): + font.getmask2("hello", anchor=anchor) + with pytest.raises(ValueError): + font.getbbox("hello", anchor=anchor) + with pytest.raises(ValueError): + d.text((0, 0), "hello", anchor=anchor) + with pytest.raises(ValueError): + d.textbbox((0, 0), "hello", anchor=anchor) + with pytest.raises(ValueError): + d.multiline_text((0, 0), "foo\nbar", anchor=anchor) + with pytest.raises(ValueError): + d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor) for anchor in ["lt", "lb"]: - pytest.raises( - ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor) - ) - pytest.raises( - ValueError, - lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor), - ) + with pytest.raises(ValueError): + d.multiline_text((0, 0), "foo\nbar", anchor=anchor) + with pytest.raises(ValueError): + d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor) @pytest.mark.parametrize("bpp", (1, 2, 4, 8)) @@ -928,6 +859,19 @@ def test_bitmap_font_stroke(layout_engine): assert_image_similar_tofile(im, target, 0.03) +@pytest.mark.parametrize("embedded_color", (False, True)) +def test_bitmap_blend(layout_engine, embedded_color): + font = ImageFont.truetype( + "Tests/fonts/EBDTTestFont.ttf", size=64, layout_engine=layout_engine + ) + + im = Image.new("RGBA", (128, 96), "white") + d = ImageDraw.Draw(im) + d.text((16, 16), "AA", font=font, fill="#8E2F52", embedded_color=embedded_color) + + assert_image_equal_tofile(im, "Tests/images/bitmap_font_blend.png") + + def test_standard_embedded_color(layout_engine): txt = "Hello World!" ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) @@ -966,15 +910,15 @@ def test_float_coord(layout_engine, fontmode): def test_cbdt(layout_engine): try: font = ImageFont.truetype( - "Tests/fonts/NotoColorEmoji.ttf", size=109, layout_engine=layout_engine + "Tests/fonts/CBDTTestFont.ttf", size=64, layout_engine=layout_engine ) - im = Image.new("RGB", (150, 150), "white") + im = Image.new("RGB", (128, 96), "white") d = ImageDraw.Draw(im) - d.text((10, 10), "\U0001f469", font=font, embedded_color=True) + d.text((16, 16), "AB", font=font, embedded_color=True) - assert_image_similar_tofile(im, "Tests/images/cbdt_notocoloremoji.png", 6.2) + assert_image_equal_tofile(im, "Tests/images/cbdt.png") except OSError as e: # pragma: no cover assert str(e) in ("unimplemented feature", "unknown file format") pytest.skip("freetype compiled without libpng or CBDT support") @@ -983,17 +927,15 @@ def test_cbdt(layout_engine): def test_cbdt_mask(layout_engine): try: font = ImageFont.truetype( - "Tests/fonts/NotoColorEmoji.ttf", size=109, layout_engine=layout_engine + "Tests/fonts/CBDTTestFont.ttf", size=64, layout_engine=layout_engine ) - im = Image.new("RGB", (150, 150), "white") + im = Image.new("RGB", (128, 96), "white") d = ImageDraw.Draw(im) - d.text((10, 10), "\U0001f469", "black", font=font) + d.text((16, 16), "AB", "green", font=font) - assert_image_similar_tofile( - im, "Tests/images/cbdt_notocoloremoji_mask.png", 6.2 - ) + assert_image_equal_tofile(im, "Tests/images/cbdt_mask.png") except OSError as e: # pragma: no cover assert str(e) in ("unimplemented feature", "unknown file format") pytest.skip("freetype compiled without libpng or CBDT support") @@ -1084,14 +1026,6 @@ def test_woff2(layout_engine): assert_image_similar_tofile(im, "Tests/images/test_woff2.png", 5) -def test_fill_deprecation(font): - with pytest.warns(DeprecationWarning): - font.getmask2("Hello world", fill=Image.core.fill) - with pytest.warns(DeprecationWarning): - with pytest.raises(TypeError): - font.getmask2("Hello world", fill=None) - - def test_render_mono_size(): # issue 4177 @@ -1107,10 +1041,30 @@ def test_render_mono_size(): assert_image_equal_tofile(im, "Tests/images/text_mono.gif") +def test_too_many_characters(font): + with pytest.raises(ValueError): + font.getlength("A" * 1_000_001) + with pytest.raises(ValueError): + font.getbbox("A" * 1_000_001) + with pytest.raises(ValueError): + font.getmask2("A" * 1_000_001) + + transposed_font = ImageFont.TransposedFont(font) + with pytest.raises(ValueError): + transposed_font.getlength("A" * 1_000_001) + + default_font = ImageFont.load_default() + with pytest.raises(ValueError): + default_font.getlength("A" * 1_000_001) + with pytest.raises(ValueError): + default_font.getbbox("A" * 1_000_001) + + @pytest.mark.parametrize( "test_file", [ "Tests/fonts/oom-e8e927ba6c0d38274a37c1567560eb33baf74627.ttf", + "Tests/fonts/oom-4da0210eb7081b0bf15bf16cc4c52ce02c1e1bbc.ttf", ], ) def test_oom(test_file): @@ -1133,10 +1087,7 @@ def test_raqm_missing_warning(monkeypatch): ) -def test_constants_deprecation(): - for enum, prefix in { - ImageFont.Layout: "LAYOUT_", - }.items(): - for name in enum.__members__: - with pytest.warns(DeprecationWarning): - assert getattr(ImageFont, prefix + name) == enum[name] +@pytest.mark.parametrize("size", [-1, 0]) +def test_invalid_truetype_sizes_raise_valueerror(layout_engine, size): + with pytest.raises(ValueError): + ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine) diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index cf039e86e..bea532b05 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageDraw, ImageFont @@ -360,37 +361,20 @@ def test_anchor_invalid_ttb(): d.font = font for anchor in ["", "l", "a", "lax", "xa", "la", "ls", "ld", "lx"]: - pytest.raises( - ValueError, lambda: font.getmask2("hello", anchor=anchor, direction="ttb") - ) - pytest.raises( - ValueError, lambda: font.getbbox("hello", anchor=anchor, direction="ttb") - ) - pytest.raises( - ValueError, lambda: d.text((0, 0), "hello", anchor=anchor, direction="ttb") - ) - pytest.raises( - ValueError, - lambda: d.textbbox((0, 0), "hello", anchor=anchor, direction="ttb"), - ) - pytest.raises( - ValueError, - lambda: d.multiline_text( - (0, 0), "foo\nbar", anchor=anchor, direction="ttb" - ), - ) - pytest.raises( - ValueError, - lambda: d.multiline_textbbox( - (0, 0), "foo\nbar", anchor=anchor, direction="ttb" - ), - ) + with pytest.raises(ValueError): + font.getmask2("hello", anchor=anchor, direction="ttb") + with pytest.raises(ValueError): + font.getbbox("hello", anchor=anchor, direction="ttb") + with pytest.raises(ValueError): + d.text((0, 0), "hello", anchor=anchor, direction="ttb") + with pytest.raises(ValueError): + d.textbbox((0, 0), "hello", anchor=anchor, direction="ttb") + with pytest.raises(ValueError): + d.multiline_text((0, 0), "foo\nbar", anchor=anchor, direction="ttb") + with pytest.raises(ValueError): + d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor, direction="ttb") # ttb multiline text does not support anchors at all - pytest.raises( - ValueError, - lambda: d.multiline_text((0, 0), "foo\nbar", anchor="mm", direction="ttb"), - ) - pytest.raises( - ValueError, - lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor="mm", direction="ttb"), - ) + with pytest.raises(ValueError): + d.multiline_text((0, 0), "foo\nbar", anchor="mm", direction="ttb") + with pytest.raises(ValueError): + d.multiline_textbbox((0, 0), "foo\nbar", anchor="mm", direction="ttb") diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py new file mode 100644 index 000000000..21b4dee3c --- /dev/null +++ b/Tests/test_imagefontpil.py @@ -0,0 +1,46 @@ +from __future__ import annotations +import pytest + +from PIL import Image, ImageDraw, ImageFont, features + +from .helper import assert_image_equal_tofile + +pytestmark = pytest.mark.skipif( + features.check_module("freetype2"), + reason="PILfont superseded if FreeType is supported", +) + + +def test_default_font(): + # Arrange + txt = 'This is a "better than nothing" default font.' + im = Image.new(mode="RGB", size=(300, 100)) + draw = ImageDraw.Draw(im) + + # Act + default_font = ImageFont.load_default() + draw.text((10, 10), txt, font=default_font) + + # Assert + assert_image_equal_tofile(im, "Tests/images/default_font.png") + + +def test_size_without_freetype(): + with pytest.raises(ImportError): + ImageFont.load_default(size=14) + + +def test_unicode(): + # should not segfault, should return UnicodeDecodeError + # issue #2826 + font = ImageFont.load_default() + with pytest.raises(UnicodeEncodeError): + font.getbbox("’") + + +def test_textbbox(): + im = Image.new("RGB", (200, 200)) + d = ImageDraw.Draw(im) + default_font = ImageFont.load_default() + assert d.textlength("test", font=default_font) == 24 + assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, 24, 11) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 5e0eca28b..b7683ec18 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import shutil import subprocess @@ -11,6 +12,10 @@ from .helper import assert_image_equal_tofile, skip_unless_feature class TestImageGrab: + @pytest.mark.skipif( + os.environ.get("USERNAME") == "ContainerAdministrator", + reason="can't grab screen when running in Docker", + ) @pytest.mark.skipif( sys.platform not in ("win32", "darwin"), reason="requires Windows or macOS" ) @@ -64,9 +69,13 @@ $bmp = New-Object Drawing.Bitmap 200, 200 ) p.communicate() else: - with pytest.raises(NotImplementedError) as e: - ImageGrab.grabclipboard() - assert str(e.value) == "ImageGrab.grabclipboard() is macOS and Windows only" + if not shutil.which("wl-paste") and not shutil.which("xclip"): + with pytest.raises( + NotImplementedError, + match="wl-paste or xclip is required for" + r" ImageGrab.grabclipboard\(\) on Linux", + ): + ImageGrab.grabclipboard() return ImageGrab.grabclipboard() @@ -94,3 +103,18 @@ $ms = new-object System.IO.MemoryStream(, $bytes) im = ImageGrab.grabclipboard() assert_image_equal_tofile(im, "Tests/images/hopper.png") + + @pytest.mark.skipif( + ( + sys.platform != "linux" + or not all(shutil.which(cmd) for cmd in ("wl-paste", "wl-copy")) + ), + reason="Linux with wl-clipboard only", + ) + @pytest.mark.parametrize("ext", ("gif", "png", "ico")) + def test_grabclipboard_wl_clipboard(self, ext): + image_path = "Tests/images/hopper." + ext + with open(image_path, "rb") as fp: + subprocess.call(["wl-copy"], stdin=fp) + im = ImageGrab.grabclipboard() + assert_image_equal_tofile(im, image_path) diff --git a/Tests/test_imagemath.py b/Tests/test_imagemath.py index fe7ac9a7a..22de86c7c 100644 --- a/Tests/test_imagemath.py +++ b/Tests/test_imagemath.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageMath diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index 29c71f917..ec55aadf9 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -1,4 +1,5 @@ # Test the ImageMorphology functionality +from __future__ import annotations import pytest from PIL import Image, ImageMorph, _imagingmorph diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index c9b2fd865..7980bead0 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageDraw, ImageOps, ImageStat, features @@ -21,7 +22,6 @@ deformer = Deformer() def test_sanity(): - ImageOps.autocontrast(hopper("L")) ImageOps.autocontrast(hopper("RGB")) @@ -40,6 +40,9 @@ def test_sanity(): ImageOps.contain(hopper("L"), (128, 128)) ImageOps.contain(hopper("RGB"), (128, 128)) + ImageOps.cover(hopper("L"), (128, 128)) + ImageOps.cover(hopper("RGB"), (128, 128)) + ImageOps.crop(hopper("L"), 1) ImageOps.crop(hopper("RGB"), 1) @@ -120,6 +123,20 @@ def test_contain_round(): assert new_im.height == 5 +@pytest.mark.parametrize( + "image_name, expected_size", + ( + ("colr_bungee.png", (1024, 256)), # landscape + ("imagedraw_stroke_multiline.png", (256, 640)), # portrait + ("hopper.png", (256, 256)), # square + ), +) +def test_cover(image_name, expected_size): + with Image.open("Tests/images/" + image_name) as im: + new_im = ImageOps.cover(im, (256, 256)) + assert new_im.size == expected_size + + def test_pad(): # Same ratio im = hopper() @@ -405,6 +422,24 @@ def test_exif_transpose(): assert 0x0112 not in transposed_im.getexif() +def test_exif_transpose_in_place(): + with Image.open("Tests/images/orientation_rectangle.jpg") as im: + assert im.size == (2, 1) + assert im.getexif()[0x0112] == 8 + expected = im.rotate(90, expand=True) + + ImageOps.exif_transpose(im, in_place=True) + assert im.size == (1, 2) + assert 0x0112 not in im.getexif() + assert_image_equal(im, expected) + + +def test_autocontrast_unsupported_mode(): + im = Image.new("RGBA", (1, 1)) + with pytest.raises(OSError): + ImageOps.autocontrast(im) + + def test_autocontrast_cutoff(): # Test the cutoff argument of autocontrast with Image.open("Tests/images/bw_gradient.png") as img: @@ -419,7 +454,6 @@ def test_autocontrast_cutoff(): def test_autocontrast_mask_toy_input(): # Test the mask argument of autocontrast with Image.open("Tests/images/bw_gradient.png") as img: - rect_mask = Image.new("L", img.size, 0) draw = ImageDraw.Draw(rect_mask) x0 = img.size[0] // 4 @@ -439,7 +473,6 @@ def test_autocontrast_mask_toy_input(): def test_autocontrast_mask_real_input(): # Test the autocontrast with a rectangular mask with Image.open("Tests/images/iptc.jpg") as img: - rect_mask = Image.new("L", img.size, 0) draw = ImageDraw.Draw(rect_mask) x0, y0 = img.size[0] // 2, img.size[1] // 2 diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index 8837ed2a2..84d3a6950 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageFilter diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 5bda28117..e5b59b74a 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImagePalette @@ -6,14 +7,9 @@ from .helper import assert_image_equal, assert_image_equal_tofile def test_sanity(): - palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) assert len(palette.colors) == 256 - with pytest.warns(DeprecationWarning): - with pytest.raises(ValueError): - ImagePalette.ImagePalette("RGB", list(range(256)) * 3, 10) - def test_reload(): with Image.open("Tests/images/hopper.gif") as im: @@ -23,7 +19,6 @@ def test_reload(): def test_getcolor(): - palette = ImagePalette.ImagePalette() assert len(palette.palette) == 0 assert len(palette.colors) == 0 @@ -84,7 +79,6 @@ def test_getcolor_not_special(index, palette): def test_file(tmp_path): - palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) f = str(tmp_path / "temp.lut") diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index de3920cf5..ac3ea3281 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -1,3 +1,4 @@ +from __future__ import annotations import array import math import struct @@ -8,7 +9,6 @@ from PIL import Image, ImagePath def test_path(): - p = ImagePath.Path(list(range(10))) # sequence interface @@ -29,7 +29,7 @@ def test_path(): (6.0, 7.0), (8.0, 9.0), ] - assert p.tolist(1) == [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] + assert p.tolist(True) == [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0] assert p.getbbox() == (0.0, 1.0, 8.0, 9.0) @@ -39,51 +39,65 @@ def test_path(): p.transform((1, 0, 1, 0, 1, 1)) assert list(p) == [(1.0, 2.0), (5.0, 6.0), (9.0, 10.0)] - # alternative constructors - p = ImagePath.Path([0, 1]) - assert list(p) == [(0.0, 1.0)] - p = ImagePath.Path([0.0, 1.0]) - assert list(p) == [(0.0, 1.0)] - p = ImagePath.Path([0, 1]) - assert list(p) == [(0.0, 1.0)] - p = ImagePath.Path([(0, 1)]) - assert list(p) == [(0.0, 1.0)] - p = ImagePath.Path(p) - assert list(p) == [(0.0, 1.0)] - p = ImagePath.Path(p.tolist(0)) - assert list(p) == [(0.0, 1.0)] - p = ImagePath.Path(p.tolist(1)) - assert list(p) == [(0.0, 1.0)] - p = ImagePath.Path(array.array("f", [0, 1])) - assert list(p) == [(0.0, 1.0)] - arr = array.array("f", [0, 1]) - if hasattr(arr, "tobytes"): - p = ImagePath.Path(arr.tobytes()) - else: - p = ImagePath.Path(arr.tostring()) +@pytest.mark.parametrize( + "coords", + ( + (0, 1), + [0, 1], + (0.0, 1.0), + [0.0, 1.0], + ((0, 1),), + [(0, 1)], + ((0.0, 1.0),), + [(0.0, 1.0)], + array.array("f", [0, 1]), + array.array("f", [0, 1]).tobytes(), + ImagePath.Path((0, 1)), + ), +) +def test_path_constructors(coords): + # Arrange / Act + p = ImagePath.Path(coords) + + # Assert assert list(p) == [(0.0, 1.0)] -def test_invalid_coords(): - # Arrange - coords = ["a", "b"] - - # Act / Assert +@pytest.mark.parametrize( + "coords", + ( + ("a", "b"), + ([0, 1],), + [[0, 1]], + ([0.0, 1.0],), + [[0.0, 1.0]], + ), +) +def test_invalid_path_constructors(coords): + # Act with pytest.raises(ValueError) as e: ImagePath.Path(coords) + # Assert assert str(e.value) == "incorrect coordinate type" -def test_path_odd_number_of_coordinates(): - # Arrange - coords = [0] - - # Act / Assert +@pytest.mark.parametrize( + "coords", + ( + (0,), + [0], + (0, 1, 2), + [0, 1, 2], + ), +) +def test_path_odd_number_of_coordinates(coords): + # Act with pytest.raises(ValueError) as e: ImagePath.Path(coords) + # Assert assert str(e.value) == "wrong number of coordinates" diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index 2f2b07918..41d247f42 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -1,14 +1,12 @@ +from __future__ import annotations import warnings import pytest +from PIL import ImageQt + from .helper import assert_image_similar, hopper -with warnings.catch_warnings() as w: - warnings.simplefilter("ignore", category=DeprecationWarning) - from PIL import ImageQt - - pytestmark = pytest.mark.skipif( not ImageQt.qt_is_installed, reason="Qt bindings are not installed" ) @@ -26,10 +24,6 @@ def test_rgb(): from PyQt6.QtGui import qRgb elif ImageQt.qt_version == "side6": from PySide6.QtGui import qRgb - elif ImageQt.qt_version == "5": - from PyQt5.QtGui import qRgb - elif ImageQt.qt_version == "side2": - from PySide2.QtGui import qRgb assert qRgb(0, 0, 0) == qRgba(0, 0, 0, 255) diff --git a/Tests/test_imagesequence.py b/Tests/test_imagesequence.py index 6af7e7602..6d71e4d87 100644 --- a/Tests/test_imagesequence.py +++ b/Tests/test_imagesequence.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageSequence, TiffImagePlugin @@ -6,7 +7,6 @@ from .helper import assert_image_equal, hopper, skip_unless_feature def test_sanity(tmp_path): - test_file = str(tmp_path / "temp.im") im = hopper("RGB") diff --git a/Tests/test_imageshow.py b/Tests/test_imageshow.py index 3e147a9ef..761d28d30 100644 --- a/Tests/test_imageshow.py +++ b/Tests/test_imageshow.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageShow @@ -55,8 +56,8 @@ def test_show_without_viewers(): viewers = ImageShow._viewers ImageShow._viewers = [] - im = hopper() - assert not ImageShow.show(im) + with hopper() as im: + assert not ImageShow.show(im) ImageShow._viewers = viewers @@ -85,24 +86,7 @@ def test_ipythonviewer(): test_viewer = viewer break else: - assert False + pytest.fail() im = hopper() assert test_viewer.show(im) == 1 - - -@pytest.mark.skipif( - not on_ci() or is_win32(), - reason="Only run on CIs; hangs on Windows CIs", -) -@pytest.mark.parametrize("viewer", ImageShow._viewers) -def test_file_deprecated(tmp_path, viewer): - f = str(tmp_path / "temp.jpg") - hopper().save(f) - with pytest.warns(DeprecationWarning): - try: - viewer.show_file(file=f) - except NotImplementedError: - pass - with pytest.raises(TypeError): - viewer.show_file() diff --git a/Tests/test_imagestat.py b/Tests/test_imagestat.py index 5717fe150..7b56b89cc 100644 --- a/Tests/test_imagestat.py +++ b/Tests/test_imagestat.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image, ImageStat @@ -6,7 +7,6 @@ from .helper import hopper def test_sanity(): - im = hopper() st = ImageStat.Stat(im) @@ -31,7 +31,6 @@ def test_sanity(): def test_hopper(): - im = hopper() st = ImageStat.Stat(im) @@ -45,7 +44,6 @@ def test_hopper(): def test_constant(): - im = Image.new("L", (128, 128), 128) st = ImageStat.Stat(im) diff --git a/Tests/test_imagetk.py b/Tests/test_imagetk.py index 995d0ee1f..bb20fbb6f 100644 --- a/Tests/test_imagetk.py +++ b/Tests/test_imagetk.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image @@ -89,13 +90,6 @@ def test_photoimage_blank(mode): assert_image_equal(reloaded.convert(mode), im) -def test_box_deprecation(): - im = hopper() - im_tk = ImageTk.PhotoImage(im) - with pytest.warns(DeprecationWarning): - im_tk.paste(im, (0, 0, 128, 128)) - - def test_bitmapimage(): im = hopper("1") diff --git a/Tests/test_imagewin.py b/Tests/test_imagewin.py index 9d64d17a3..6927eedcf 100644 --- a/Tests/test_imagewin.py +++ b/Tests/test_imagewin.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import ImageWin @@ -100,8 +101,11 @@ class TestImageWinDib: # Act # Make one the same as the using tobytes()/frombytes() test_buffer = dib1.tobytes() - dib2.frombytes(test_buffer) + for datatype in ("bytes", "memoryview"): + if datatype == "memoryview": + test_buffer = memoryview(test_buffer) + dib2.frombytes(test_buffer) - # Assert - # Confirm they're the same - assert dib1.tobytes() == dib2.tobytes() + # Assert + # Confirm they're the same + assert dib1.tobytes() == dib2.tobytes() diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index df1305655..bd154335a 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO from PIL import Image, ImageWin diff --git a/Tests/test_lib_image.py b/Tests/test_lib_image.py index 37ed3659d..92cad4ac1 100644 --- a/Tests/test_lib_image.py +++ b/Tests/test_lib_image.py @@ -1,10 +1,10 @@ +from __future__ import annotations import pytest from PIL import Image def test_setmode(): - im = Image.new("L", (1, 1), 255) im.im.setmode("1") assert im.im.getpixel((0, 0)) == 255 diff --git a/Tests/test_lib_pack.py b/Tests/test_lib_pack.py index 979806cae..1293f7628 100644 --- a/Tests/test_lib_pack.py +++ b/Tests/test_lib_pack.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys import pytest @@ -207,6 +208,9 @@ class TestLibPack: 0x01000083, ) + def test_I16(self): + self.assert_pack("I;16N", "I;16N", 2, 0x0201, 0x0403, 0x0605) + def test_F_float(self): self.assert_pack("F", "F;32F", 4, 1.539989614439558e-36, 4.063216068939723e-34) @@ -337,6 +341,17 @@ class TestLibUnpack: self.assert_unpack("RGB", "G;16N", 2, (0, 1, 0), (0, 3, 0), (0, 5, 0)) self.assert_unpack("RGB", "B;16N", 2, (0, 0, 1), (0, 0, 3), (0, 0, 5)) + self.assert_unpack( + "RGB", "CMYK", 4, (250, 249, 248), (242, 241, 240), (234, 233, 233) + ) + + def test_BGR(self): + self.assert_unpack("BGR;15", "BGR;15", 3, (8, 131, 0), (24, 0, 8), (41, 131, 8)) + self.assert_unpack( + "BGR;16", "BGR;16", 3, (8, 64, 0), (24, 129, 0), (41, 194, 0) + ) + self.assert_unpack("BGR;24", "BGR;24", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9)) + def test_RGBA(self): self.assert_unpack("RGBA", "LA", 2, (1, 1, 1, 2), (3, 3, 3, 4), (5, 5, 5, 6)) self.assert_unpack( @@ -754,6 +769,7 @@ class TestLibUnpack: def test_I16(self): self.assert_unpack("I;16", "I;16", 2, 0x0201, 0x0403, 0x0605) + self.assert_unpack("I;16", "I;16B", 2, 0x0102, 0x0304, 0x0506) self.assert_unpack("I;16B", "I;16B", 2, 0x0102, 0x0304, 0x0506) self.assert_unpack("I;16L", "I;16L", 2, 0x0201, 0x0403, 0x0605) self.assert_unpack("I;16", "I;12", 2, 0x0010, 0x0203, 0x0040) @@ -761,10 +777,12 @@ class TestLibUnpack: self.assert_unpack("I;16", "I;16N", 2, 0x0201, 0x0403, 0x0605) self.assert_unpack("I;16B", "I;16N", 2, 0x0201, 0x0403, 0x0605) self.assert_unpack("I;16L", "I;16N", 2, 0x0201, 0x0403, 0x0605) + self.assert_unpack("I;16N", "I;16N", 2, 0x0201, 0x0403, 0x0605) else: self.assert_unpack("I;16", "I;16N", 2, 0x0102, 0x0304, 0x0506) self.assert_unpack("I;16B", "I;16N", 2, 0x0102, 0x0304, 0x0506) self.assert_unpack("I;16L", "I;16N", 2, 0x0102, 0x0304, 0x0506) + self.assert_unpack("I;16N", "I;16N", 2, 0x0102, 0x0304, 0x0506) def test_CMYK16(self): self.assert_unpack("CMYK", "CMYK;16L", 8, (2, 4, 6, 8), (10, 12, 14, 16)) diff --git a/Tests/test_locale.py b/Tests/test_locale.py index 7a07fbbe0..49b052fa4 100644 --- a/Tests/test_locale.py +++ b/Tests/test_locale.py @@ -1,3 +1,4 @@ +from __future__ import annotations import locale import pytest diff --git a/Tests/test_main.py b/Tests/test_main.py index 46ff63c4e..a84e61a7b 100644 --- a/Tests/test_main.py +++ b/Tests/test_main.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import subprocess import sys diff --git a/Tests/test_map.py b/Tests/test_map.py index d816bddaf..76444f33d 100644 --- a/Tests/test_map.py +++ b/Tests/test_map.py @@ -1,3 +1,4 @@ +from __future__ import annotations import sys import pytest diff --git a/Tests/test_mode_i16.py b/Tests/test_mode_i16.py index efcdab9ec..3e17d8dcc 100644 --- a/Tests/test_mode_i16.py +++ b/Tests/test_mode_i16.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image @@ -42,7 +43,6 @@ def test_basic(tmp_path, mode): im_in.save(filename) with Image.open(filename) as im_out: - verify(im_in) verify(im_out) @@ -87,13 +87,9 @@ def test_tobytes(): def test_convert(): - im = original.copy() - verify(im.convert("I;16")) - verify(im.convert("I;16").convert("L")) - verify(im.convert("I;16").convert("I")) - - verify(im.convert("I;16B")) - verify(im.convert("I;16B").convert("L")) - verify(im.convert("I;16B").convert("I")) + for mode in ("I;16", "I;16B", "I;16N"): + verify(im.convert(mode)) + verify(im.convert(mode).convert("L")) + verify(im.convert(mode).convert("I")) diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 3de7ec30f..6f0e99b3f 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -1,10 +1,11 @@ +from __future__ import annotations import warnings import pytest from PIL import Image -from .helper import assert_deep_equal, assert_image, hopper +from .helper import assert_deep_equal, assert_image, hopper, skip_unless_feature numpy = pytest.importorskip("numpy", reason="NumPy not installed") @@ -219,6 +220,13 @@ def test_zero_size(): assert im.size == (0, 0) +@skip_unless_feature("libtiff") +def test_load_first(): + with Image.open("Tests/images/g4_orientation_5.tif") as im: + a = numpy.array(im) + assert a.shape == (88, 590) + + def test_bool(): # https://github.com/python-pillow/Pillow/issues/2044 a = numpy.zeros((10, 2), dtype=bool) @@ -235,7 +243,6 @@ def test_no_resource_warning_for_numpy_array(): test_file = "Tests/images/hopper.png" with Image.open(test_file) as im: - # Act/Assert with warnings.catch_warnings(): array(im) diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index ea9b33dfc..aeeafb6f1 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -1,3 +1,4 @@ +from __future__ import annotations import time import pytest @@ -88,9 +89,8 @@ def test_parsing(): b"D:20180729214124+08'00'": "20180729134124", b"D:20180729214124-05'00'": "20180730024124", }.items(): - d = PdfParser.get_value(b"<>", 0)[ - 0 - ] + b = b"<>" + d = PdfParser.get_value(b, 0)[0] assert time.strftime("%Y%m%d%H%M%S", getattr(d, name)) == value @@ -118,3 +118,9 @@ def test_pdf_repr(): 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>" + + +def test_duplicate_xref_entry(): + pdf = PdfParser("Tests/images/duplicate_xref_entry.pdf") + assert pdf.xref_table.existing_entries[6][0] == 1197 + pdf.close() diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index 23eb9e39f..eb687b57b 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pickle import pytest @@ -75,13 +76,13 @@ def test_pickle_la_mode_with_palette(tmp_path): # Act / Assert for protocol in range(0, pickle.HIGHEST_PROTOCOL + 1): - im.mode = "LA" + im._mode = "LA" with open(filename, "wb") as f: pickle.dump(im, f, protocol) with open(filename, "rb") as f: loaded_im = pickle.load(f) - im.mode = "PA" + im._mode = "PA" assert im == loaded_im @@ -89,7 +90,6 @@ def test_pickle_la_mode_with_palette(tmp_path): def test_pickle_tell(): # Arrange with Image.open("Tests/images/hopper.webp") as image: - # Act: roundtrip unpickled_image = pickle.loads(pickle.dumps(image)) @@ -113,6 +113,7 @@ def helper_assert_pickled_font_images(font1, font2): assert_image_equal(im1, im2) +@skip_unless_feature("freetype2") @pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) def test_pickle_font_string(protocol): # Arrange @@ -126,6 +127,7 @@ def test_pickle_font_string(protocol): helper_assert_pickled_font_images(font, unpickled_font) +@skip_unless_feature("freetype2") @pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) def test_pickle_font_file(tmp_path, protocol): # Arrange diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index e74d79828..77c7952e9 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import sys from io import BytesIO diff --git a/Tests/test_pyroma.py b/Tests/test_pyroma.py index aa05c2cfd..08133b6c3 100644 --- a/Tests/test_pyroma.py +++ b/Tests/test_pyroma.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import __version__ diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py index 1fc816146..49ca01677 100644 --- a/Tests/test_qt_image_qapplication.py +++ b/Tests/test_qt_image_qapplication.py @@ -1,12 +1,9 @@ -import warnings - +from __future__ import annotations import pytest -with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=DeprecationWarning) - from PIL import ImageQt +from PIL import ImageQt -from .helper import assert_image_equal, assert_image_equal_tofile, hopper +from .helper import assert_image_equal_tofile, assert_image_similar, hopper if ImageQt.qt_is_installed: from PIL.ImageQt import QPixmap @@ -19,14 +16,6 @@ if ImageQt.qt_is_installed: from PySide6.QtCore import QPoint from PySide6.QtGui import QImage, QPainter, QRegion from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget - elif ImageQt.qt_version == "5": - from PyQt5.QtCore import QPoint - from PyQt5.QtGui import QImage, QPainter, QRegion - from PyQt5.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget - elif ImageQt.qt_version == "side2": - from PySide2.QtCore import QPoint - from PySide2.QtGui import QImage, QPainter, QRegion - from PySide2.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget class Example(QWidget): def __init__(self): @@ -48,7 +37,7 @@ if ImageQt.qt_is_installed: def roundtrip(expected): result = ImageQt.fromqpixmap(ImageQt.toqpixmap(expected)) # Qt saves all pixmaps as rgb - assert_image_equal(result, expected.convert("RGB")) + assert_image_similar(result, expected.convert("RGB"), 1) @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py index c1983031a..396bd9080 100644 --- a/Tests/test_qt_image_toqimage.py +++ b/Tests/test_qt_image_toqimage.py @@ -1,10 +1,7 @@ -import warnings - +from __future__ import annotations import pytest -with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=DeprecationWarning) - from PIL import ImageQt +from PIL import ImageQt from .helper import assert_image_equal, assert_image_equal_tofile, hopper @@ -32,7 +29,7 @@ def test_sanity(mode, tmp_path): assert_image_equal(rt, src) if mode == "1": - # BW appears to not save correctly on QT5 + # BW appears to not save correctly on Qt # kicks out errors on console: # libpng warning: Invalid color type/bit depth combination # in IHDR diff --git a/Tests/test_sgi_crash.py b/Tests/test_sgi_crash.py index b5f9d4424..37d72d451 100644 --- a/Tests/test_sgi_crash.py +++ b/Tests/test_sgi_crash.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import Image diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index d25d42dfc..d93b03904 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -1,3 +1,4 @@ +from __future__ import annotations import shutil import pytest diff --git a/Tests/test_tiff_crashes.py b/Tests/test_tiff_crashes.py index 143765b8e..64e781cba 100644 --- a/Tests/test_tiff_crashes.py +++ b/Tests/test_tiff_crashes.py @@ -10,6 +10,7 @@ # the output should be empty. There may be Python issues # in the valgrind especially if run in a debug Python # version. +from __future__ import annotations import pytest diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index 12f475df0..e7b41fb47 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -1,3 +1,4 @@ +from __future__ import annotations from fractions import Fraction from PIL import Image, TiffImagePlugin, features @@ -7,7 +8,6 @@ from .helper import hopper def _test_equal(num, denom, target): - t = IFDRational(num, denom) assert target == t @@ -15,7 +15,6 @@ def _test_equal(num, denom, target): def test_sanity(): - _test_equal(1, 1, 1) _test_equal(1, 1, Fraction(1, 1)) diff --git a/Tests/test_uploader.py b/Tests/test_uploader.py index 720926e53..6b693f7cd 100644 --- a/Tests/test_uploader.py +++ b/Tests/test_uploader.py @@ -1,3 +1,4 @@ +from __future__ import annotations from .helper import assert_image_equal, assert_image_similar, hopper diff --git a/Tests/test_util.py b/Tests/test_util.py index 9efbdd1f3..1457d85f7 100644 --- a/Tests/test_util.py +++ b/Tests/test_util.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest from PIL import _util diff --git a/Tests/test_webp_leaks.py b/Tests/test_webp_leaks.py index 34197c14f..28ebc7d79 100644 --- a/Tests/test_webp_leaks.py +++ b/Tests/test_webp_leaks.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO from PIL import Image @@ -9,7 +10,6 @@ test_file = "Tests/images/hopper.webp" @skip_unless_feature("webp") class TestWebPLeaks(PillowLeakTestCase): - mem_limit = 3 * 1024 # kb iterations = 100 diff --git a/_custom_build/backend.py b/_custom_build/backend.py new file mode 100644 index 000000000..d1537b809 --- /dev/null +++ b/_custom_build/backend.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import sys + +from setuptools.build_meta import * # noqa: F403 +from setuptools.build_meta import build_wheel + +backend_class = build_wheel.__self__.__class__ + + +class _CustomBuildMetaBackend(backend_class): + def run_setup(self, setup_script="setup.py"): + if self.config_settings: + + def config_has(key, value): + settings = self.config_settings.get(key) + if settings: + if not isinstance(settings, list): + settings = [settings] + return value in settings + + flags = [] + for dependency in ( + "zlib", + "jpeg", + "tiff", + "freetype", + "raqm", + "lcms", + "webp", + "webpmux", + "jpeg2000", + "imagequant", + "xcb", + ): + if config_has(dependency, "enable"): + flags.append("--enable-" + dependency) + elif config_has(dependency, "disable"): + flags.append("--disable-" + dependency) + for dependency in ("raqm", "fribidi"): + if config_has(dependency, "vendor"): + flags.append("--vendor-" + dependency) + if self.config_settings.get("platform-guessing") == "disable": + flags.append("--disable-platform-guessing") + if self.config_settings.get("debug") == "true": + flags.append("--debug") + if flags: + sys.argv = sys.argv[:1] + ["build_ext"] + flags + sys.argv[1:] + return super().run_setup(setup_script) + + def build_wheel( + self, wheel_directory, config_settings=None, metadata_directory=None + ): + self.config_settings = config_settings + return super().build_wheel(wheel_directory, config_settings, metadata_directory) + + +build_wheel = _CustomBuildMetaBackend().build_wheel diff --git a/codecov.yml b/codecov.yml index f3afccc1c..1ea7974eb 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,9 +1,9 @@ -# Documentation: https://docs.codecov.io/docs/codecov-yaml +# Documentation: https://docs.codecov.com/docs/codecov-yaml codecov: # Avoid "Missing base report" due to committing CHANGES.rst with "[CI skip]" # https://github.com/codecov/support/issues/363 - # https://docs.codecov.io/docs/comparing-commits + # https://docs.codecov.com/docs/comparing-commits allow_coverage_offsets: true comment: false @@ -12,7 +12,7 @@ coverage: status: project: default: - threshold: 0.01% + threshold: 0.1% # Matches 'omit:' in .coveragerc ignore: diff --git a/conftest.py b/conftest.py index e123cca80..4dcd5e053 100644 --- a/conftest.py +++ b/conftest.py @@ -1 +1,3 @@ +from __future__ import annotations + pytest_plugins = ["Tests.helper"] diff --git a/depends/download-and-extract.sh b/depends/download-and-extract.sh index d9608e782..a318bfafd 100755 --- a/depends/download-and-extract.sh +++ b/depends/download-and-extract.sh @@ -8,5 +8,5 @@ if [ ! -f $archive.tar.gz ]; then wget -O $archive.tar.gz $url fi -rm -r $archive +rmdir $archive tar -xvzf $archive.tar.gz diff --git a/depends/install_extra_test_images.sh b/depends/install_extra_test_images.sh index 02da12d61..1ef6f4e97 100755 --- a/depends/install_extra_test_images.sh +++ b/depends/install_extra_test_images.sh @@ -1,15 +1,12 @@ -#!/bin/bash +#!/usr/bin/env bash # install extra test images -# Use SVN to just fetch a single Git subdirectory -svn_export() -{ - if [ ! -z $1 ]; then - echo "" - echo "Retrying svn export..." - echo "" - fi +archive=test-images-main - svn export --force https://github.com/python-pillow/pillow-depends/trunk/test_images ../Tests/images -} -svn_export || svn_export retry || svn_export retry || svn_export retry +./download-and-extract.sh $archive https://github.com/python-pillow/test-images/archive/main.tar.gz + +mv $archive/* ../Tests/images/ + +# Cleanup old tarball and empty directory +rm $archive.tar.gz +rmdir $archive diff --git a/depends/install_imagequant.sh b/depends/install_imagequant.sh index 64dd024bd..b7cebbdbf 100755 --- a/depends/install_imagequant.sh +++ b/depends/install_imagequant.sh @@ -1,7 +1,7 @@ #!/bin/bash # install libimagequant -archive=libimagequant-4.0.4 +archive=libimagequant-4.2.2 ./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 992503650..070ba23a1 100755 --- a/depends/install_raqm.sh +++ b/depends/install_raqm.sh @@ -2,7 +2,7 @@ # install raqm -archive=libraqm-0.9.0 +archive=libraqm-0.10.1 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz @@ -11,4 +11,3 @@ pushd $archive meson build --prefix=/usr && sudo ninja -C build install popd - diff --git a/depends/install_raqm_cmake.sh b/depends/install_raqm_cmake.sh index 7d2c399df..37d9d1160 100755 --- a/depends/install_raqm_cmake.sh +++ b/depends/install_raqm_cmake.sh @@ -15,4 +15,3 @@ make && sudo make install cd .. popd - diff --git a/depends/install_webp.sh b/depends/install_webp.sh index 05867b7d4..6f867ab37 100755 --- a/depends/install_webp.sh +++ b/depends/install_webp.sh @@ -1,7 +1,7 @@ #!/bin/bash # install webp -archive=libwebp-1.2.4 +archive=libwebp-1.3.2 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz diff --git a/depends/termux.sh b/depends/termux.sh index 1acc09c44..d437029fd 100755 --- a/depends/termux.sh +++ b/depends/termux.sh @@ -2,4 +2,3 @@ pkg install -y python ndk-sysroot clang make \ libjpeg-turbo - diff --git a/docs/COPYING b/docs/COPYING index 25f03b343..bc44ba388 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-2022 by Alex Clark and contributors + Copyright © 2010-2023 by Jeffrey A. Clark (Alex) and contributors Like PIL, Pillow is licensed under the open source PIL Software License: diff --git a/docs/Guardfile b/docs/Guardfile index b689b079a..16a891a73 100755 --- a/docs/Guardfile +++ b/docs/Guardfile @@ -1,8 +1,10 @@ #!/usr/bin/env python3 +from __future__ import annotations + from livereload.compiler import shell from livereload.task import Task -Task.add('*.rst', shell('make html')) -Task.add('*/*.rst', shell('make html')) -Task.add('Makefile', shell('make html')) -Task.add('conf.py', shell('make html')) +Task.add("*.rst", shell("make html")) +Task.add("*/*.rst", shell("make html")) +Task.add("Makefile", shell("make html")) +Task.add("conf.py", shell("make html")) diff --git a/docs/Makefile b/docs/Makefile index d32d25a3c..3b4deb9bf 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -19,6 +19,7 @@ I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" + @echo " htmlview to open the index page built by the html target in your browser" @echo " serve to start a local server for viewing docs" @echo " livehtml to start a local server for viewing docs and auto-reload on change" @echo " dirhtml to make HTML files named index.html in directories" @@ -45,7 +46,7 @@ clean: -rm -rf $(BUILDDIR)/* install-sphinx: - $(PYTHON) -m pip install --quiet furo olefile sphinx sphinx-copybutton sphinx-inline-tabs sphinx-issues sphinx-removed-in sphinxext-opengraph + $(PYTHON) -m pip install --quiet furo olefile sphinx sphinx-copybutton sphinx-inline-tabs sphinx-removed-in sphinxext-opengraph .PHONY: html html: @@ -196,6 +197,10 @@ doctest: @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." +.PHONY: htmlview +htmlview: html + $(PYTHON) -c "import os, webbrowser; webbrowser.open('file://' + os.path.realpath('$(BUILDDIR)/html/index.html'))" + .PHONY: livehtml livehtml: html livereload $(BUILDDIR)/html -p 33233 diff --git a/docs/about.rst b/docs/about.rst index 03829c133..872ac0ea6 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -12,7 +12,7 @@ The fork author's goal is to foster and support active development of PIL throug .. _GitHub Actions: https://github.com/python-pillow/Pillow/actions .. _AppVeyor: https://ci.appveyor.com/project/Python-pillow/pillow -.. _Travis CI: https://app.travis-ci.com/github/python-pillow/pillow-wheels +.. _Travis CI: https://app.travis-ci.com/github/python-pillow/Pillow .. _GitHub: https://github.com/python-pillow/Pillow .. _Python Package Index: https://pypi.org/project/Pillow/ diff --git a/docs/conf.py b/docs/conf.py index 04823e2d7..9974b0f2a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # sys.path.insert(0, os.path.abspath('.')) +from __future__ import annotations import PIL @@ -28,11 +29,11 @@ needs_sphinx = "2.4" # ones. extensions = [ "sphinx.ext.autodoc", + "sphinx.ext.extlinks", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", "sphinx_copybutton", "sphinx_inline_tabs", - "sphinx_issues", "sphinx_removed_in", "sphinxext.opengraph", ] @@ -52,8 +53,10 @@ master_doc = "index" # General information about the project. project = "Pillow (PIL Fork)" -copyright = "1995-2011 Fredrik Lundh, 2010-2022 Alex Clark and Contributors" -author = "Fredrik Lundh, Alex Clark and Contributors" +copyright = ( + "1995-2011 Fredrik Lundh, 2010-2023 Jeffrey A. Clark (Alex) and contributors" +) +author = "Fredrik Lundh, Jeffrey A. Clark (Alex), contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -164,6 +167,12 @@ html_static_path = ["resources"] # directly to the root of the documentation. # html_extra_path = [] +html_css_files = ["css/dark.css"] + +html_js_files = [ + "js/activate_tab.js", +] + # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' @@ -243,7 +252,7 @@ latex_documents = [ master_doc, "PillowPILFork.tex", "Pillow (PIL Fork) Documentation", - "Alex Clark", + "Jeffrey A. Clark (Alex)", "manual", ) ] @@ -293,7 +302,7 @@ texinfo_documents = [ "Pillow (PIL Fork) Documentation", author, "PillowPILFork", - "Pillow is the friendly PIL fork by Alex Clark and Contributors.", + "Pillow is the friendly PIL fork by Jeffrey A. Clark (Alex) and contributors.", "Miscellaneous", ) ] @@ -311,12 +320,29 @@ texinfo_documents = [ # texinfo_no_detailmenu = False -def setup(app): - app.add_css_file("css/dark.css") +linkcheck_allowed_redirects = { + r"https://www.bestpractices.dev/projects/6331": r"https://www.bestpractices.dev/en/.*", + r"https://badges.gitter.im/python-pillow/Pillow.svg": r"https://badges.gitter.im/repo.svg", + r"https://gitter.im/python-pillow/Pillow?.*": r"https://app.gitter.im/#/room/#python-pillow_Pillow:gitter.im?.*", + r"https://pillow.readthedocs.io/?badge=latest": r"https://pillow.readthedocs.io/en/stable/?badge=latest", + r"https://pillow.readthedocs.io": r"https://pillow.readthedocs.io/en/stable/", + r"https://tidelift.com/badges/package/pypi/Pillow?.*": r"https://img.shields.io/badge/.*", + r"https://zenodo.org/badge/17549/python-pillow/Pillow.svg": r"https://zenodo.org/badge/doi/[\.0-9]+/zenodo.[0-9]+.svg", + r"https://zenodo.org/badge/latestdoi/17549/python-pillow/Pillow": r"https://zenodo.org/record/[0-9]+", +} - -# GitHub repo for sphinx-issues -issues_github_path = "python-pillow/Pillow" +# sphinx.ext.extlinks +# This config is a dictionary of external sites, +# mapping unique short aliases to a base URL and a prefix. +# https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html +_repo = "https://github.com/python-pillow/Pillow/" +extlinks = { + "cve": ("https://cve.mitre.org/cgi-bin/cvename.cgi?name=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"), +} # sphinxext.opengraph ogp_image = ( diff --git a/docs/deprecations.rst b/docs/deprecations.rst index dec652df8..75c0b73eb 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -10,24 +10,62 @@ Deprecated features ------------------- Below are features which are considered deprecated. Where appropriate, -a ``DeprecationWarning`` is issued. +a :py:exc:`DeprecationWarning` is issued. + +PSFile +~~~~~~ + +.. deprecated:: 9.5.0 + +The :py:class:`~PIL.EpsImagePlugin.PSFile` class has been deprecated and will +be removed in Pillow 11 (2024-10-15). This class was only made as a helper to +be used internally, so there is no replacement. If you need this functionality +though, it is a very short class that can easily be recreated in your own code. + +PyAccess and Image.USE_CFFI_ACCESS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 10.0.0 + +Since Pillow's C API is now faster than PyAccess on PyPy, +:py:mod:`~PIL.PyAccess` has been deprecated and will be removed in Pillow +11.0.0 (2024-10-15). Pillow's C API will now be used by default on PyPy instead. + +``Image.USE_CFFI_ACCESS``, for switching from the C API to PyAccess, is +similarly deprecated. + +ImageFile.raise_oserror +~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 10.2.0 + +``ImageFile.raise_oserror()`` has been deprecated and will be removed in Pillow +12.0.0 (2025-10-15). The function is undocumented and is only useful for translating +error codes returned by a codec's ``decode()`` method, which ImageFile already does +automatically. + +Removed features +---------------- + +Deprecated features are only removed in major releases after an appropriate +period of deprecation has passed. Tk/Tcl 8.4 ~~~~~~~~~~ .. deprecated:: 8.2.0 +.. versionremoved:: 10.0.0 -Support for Tk/Tcl 8.4 is deprecated and will be removed in Pillow 10.0.0 (2023-07-01), -when Tk/Tcl 8.5 will be the minimum supported. +Support for Tk/Tcl 8.4 was removed in Pillow 10.0.0 (2023-07-01). Categories ~~~~~~~~~~ .. deprecated:: 8.2.0 +.. versionremoved:: 10.0.0 -``im.category`` is deprecated and will be removed in Pillow 10.0.0 (2023-07-01), -along with the related ``Image.NORMAL``, ``Image.SEQUENCE`` and -``Image.CONTAINER`` attributes. +``im.category`` was removed along with the related ``Image.NORMAL``, +``Image.SEQUENCE`` and ``Image.CONTAINER`` attributes. To determine if an image has multiple frames or not, ``getattr(im, "is_animated", False)`` can be used instead. @@ -36,78 +74,53 @@ JpegImagePlugin.convert_dict_qtables ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. deprecated:: 8.3.0 +.. versionremoved:: 10.0.0 -JPEG ``quantization`` is now automatically converted, but still returned as a -dictionary. The :py:attr:`~PIL.JpegImagePlugin.convert_dict_qtables` method no longer -performs any operations on the data given to it, has been deprecated and will be -removed in Pillow 10.0.0 (2023-07-01). +Since deprecation in Pillow 8.3.0, the ``convert_dict_qtables`` method no longer +performed any operations on the data given to it, and has been removed. ImagePalette size parameter ~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. deprecated:: 8.4.0 - -The ``size`` parameter will be removed in Pillow 10.0.0 (2023-07-01). +.. versionremoved:: 10.0.0 Before Pillow 8.3.0, ``ImagePalette`` required palette data of particular lengths by -default, and the size parameter could be used to override that. Pillow 8.3.0 removed -the default required length, also removing the need for the size parameter. +default, and the ``size`` parameter could be used to override that. Pillow 8.3.0 +removed the default required length, also removing the need for the ``size`` parameter. ImageShow.Viewer.show_file file argument ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. deprecated:: 9.1.0 +.. versionremoved:: 10.0.0 The ``file`` argument in :py:meth:`~PIL.ImageShow.Viewer.show_file()` has been -deprecated and will be removed in Pillow 10.0.0 (2023-07-01). It has been replaced by -``path``. +removed and replaced by ``path``. In effect, ``viewer.show_file("test.jpg")`` will continue to work unchanged. -``viewer.show_file(file="test.jpg")`` will raise a deprecation warning, and suggest -``viewer.show_file(path="test.jpg")`` instead. Constants ~~~~~~~~~ .. deprecated:: 9.1.0 +.. versionremoved:: 10.0.0 -A number of constants have been deprecated and will be removed in Pillow 10.0.0 -(2023-07-01). Instead, ``enum.IntEnum`` classes have been added. +A number of constants have been removed. +Instead, ``enum.IntEnum`` classes have been added. + +.. note:: + + Additional ``Image`` constants were deprecated in Pillow 9.1.0, but that + was reversed in Pillow 9.4.0 and those constants will now remain available. + See :ref:`restored-image-constants` ===================================================== ============================================================ -Deprecated Use instead +Removed Use instead ===================================================== ============================================================ -``Image.NONE`` Either ``Image.Dither.NONE`` or ``Image.Resampling.NEAREST`` -``Image.NEAREST`` Either ``Image.Dither.NONE`` or ``Image.Resampling.NEAREST`` -``Image.ORDERED`` ``Image.Dither.ORDERED`` -``Image.RASTERIZE`` ``Image.Dither.RASTERIZE`` -``Image.FLOYDSTEINBERG`` ``Image.Dither.FLOYDSTEINBERG`` -``Image.WEB`` ``Image.Palette.WEB`` -``Image.ADAPTIVE`` ``Image.Palette.ADAPTIVE`` -``Image.AFFINE`` ``Image.Transform.AFFINE`` -``Image.EXTENT`` ``Image.Transform.EXTENT`` -``Image.PERSPECTIVE`` ``Image.Transform.PERSPECTIVE`` -``Image.QUAD`` ``Image.Transform.QUAD`` -``Image.MESH`` ``Image.Transform.MESH`` -``Image.FLIP_LEFT_RIGHT`` ``Image.Transpose.FLIP_LEFT_RIGHT`` -``Image.FLIP_TOP_BOTTOM`` ``Image.Transpose.FLIP_TOP_BOTTOM`` -``Image.ROTATE_90`` ``Image.Transpose.ROTATE_90`` -``Image.ROTATE_180`` ``Image.Transpose.ROTATE_180`` -``Image.ROTATE_270`` ``Image.Transpose.ROTATE_270`` -``Image.TRANSPOSE`` ``Image.Transpose.TRANSPOSE`` -``Image.TRANSVERSE`` ``Image.Transpose.TRANSVERSE`` -``Image.BOX`` ``Image.Resampling.BOX`` -``Image.BILINEAR`` ``Image.Resampling.BILINEAR`` -``Image.LINEAR`` ``Image.Resampling.BILINEAR`` -``Image.HAMMING`` ``Image.Resampling.HAMMING`` -``Image.BICUBIC`` ``Image.Resampling.BICUBIC`` -``Image.CUBIC`` ``Image.Resampling.BICUBIC`` -``Image.LANCZOS`` ``Image.Resampling.LANCZOS`` -``Image.ANTIALIAS`` ``Image.Resampling.LANCZOS`` -``Image.MEDIANCUT`` ``Image.Quantize.MEDIANCUT`` -``Image.MAXCOVERAGE`` ``Image.Quantize.MAXCOVERAGE`` -``Image.FASTOCTREE`` ``Image.Quantize.FASTOCTREE`` -``Image.LIBIMAGEQUANT`` ``Image.Quantize.LIBIMAGEQUANT`` +``Image.LINEAR`` ``Image.BILINEAR`` or ``Image.Resampling.BILINEAR`` +``Image.CUBIC`` ``Image.BICUBIC`` or ``Image.Resampling.BICUBIC`` +``Image.ANTIALIAS`` ``Image.LANCZOS`` or ``Image.Resampling.LANCZOS`` ``ImageCms.INTENT_PERCEPTUAL`` ``ImageCms.Intent.PERCEPTUAL`` ``ImageCms.INTENT_RELATIVE_COLORMETRIC`` ``ImageCms.Intent.RELATIVE_COLORMETRIC`` ``ImageCms.INTENT_SATURATION`` ``ImageCms.Intent.SATURATION`` @@ -137,71 +150,31 @@ FitsStubImagePlugin ~~~~~~~~~~~~~~~~~~~ .. deprecated:: 9.1.0 +.. versionremoved:: 10.0.0 -The stub image plugin ``FitsStubImagePlugin`` has been deprecated and will be removed in -Pillow 10.0.0 (2023-07-01). FITS images can be read without a handler through -:mod:`~PIL.FitsImagePlugin` instead. - -FreeTypeFont.getmask2 fill parameter -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 9.2.0 - -The undocumented ``fill`` parameter of :py:meth:`.FreeTypeFont.getmask2` has been -deprecated and will be removed in Pillow 10 (2023-07-01). - -PhotoImage.paste box parameter -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 9.2.0 - -The ``box`` parameter is unused. It will be removed in Pillow 10.0.0 (2023-07-01). - -PyQt5 and PySide2 -~~~~~~~~~~~~~~~~~ - -.. deprecated:: 9.2.0 - -`Qt 5 reached end-of-life `_ on 2020-12-08 for -open-source users (and will reach EOL on 2023-12-08 for commercial licence holders). - -Support for PyQt5 and PySide2 has been deprecated from ``ImageQt`` and will be removed -in Pillow 10 (2023-07-01). Upgrade to -`PyQt6 `_ or -`PySide6 `_ instead. - -Image.coerce_e -~~~~~~~~~~~~~~ - -.. deprecated:: 9.2.0 - -This undocumented method has been deprecated and will be removed in Pillow 10 -(2023-07-01). - -.. _Font size and offset methods: +The stub image plugin ``FitsStubImagePlugin`` has been removed. +FITS images can be read without a handler through :mod:`~PIL.FitsImagePlugin` instead. Font size and offset methods ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. deprecated:: 9.2.0 +.. versionremoved:: 10.0.0 -Several functions for computing the size and offset of rendered text -have been deprecated and will be removed in Pillow 10 (2023-07-01): +Several functions for computing the size and offset of rendered text have been removed: -=========================================================================== ============================================================================================================= -Deprecated Use instead -=========================================================================== ============================================================================================================= -:py:meth:`.FreeTypeFont.getsize` and :py:meth:`.FreeTypeFont.getoffset` :py:meth:`.FreeTypeFont.getbbox` and :py:meth:`.FreeTypeFont.getlength` -:py:meth:`.FreeTypeFont.getsize_multiline` :py:meth:`.ImageDraw.multiline_textbbox` -:py:meth:`.ImageFont.getsize` :py:meth:`.ImageFont.getbbox` and :py:meth:`.ImageFont.getlength` -:py:meth:`.TransposedFont.getsize` :py:meth:`.TransposedFont.getbbox` and :py:meth:`.TransposedFont.getlength` -:py:meth:`.ImageDraw.textsize` and :py:meth:`.ImageDraw.multiline_textsize` :py:meth:`.ImageDraw.textbbox`, :py:meth:`.ImageDraw.textlength` and :py:meth:`.ImageDraw.multiline_textbbox` -:py:meth:`.ImageDraw2.Draw.textsize` :py:meth:`.ImageDraw2.Draw.textbbox` and :py:meth:`.ImageDraw2.Draw.textlength` -=========================================================================== ============================================================================================================= +=============================================================== ============================================================================================================= +Removed Use instead +=============================================================== ============================================================================================================= +``FreeTypeFont.getsize()`` and ``FreeTypeFont.getoffset()`` :py:meth:`.FreeTypeFont.getbbox` and :py:meth:`.FreeTypeFont.getlength` +``FreeTypeFont.getsize_multiline()`` :py:meth:`.ImageDraw.multiline_textbbox` +``ImageFont.getsize()`` :py:meth:`.ImageFont.getbbox` and :py:meth:`.ImageFont.getlength` +``TransposedFont.getsize()`` :py:meth:`.TransposedFont.getbbox` and :py:meth:`.TransposedFont.getlength` +``ImageDraw.textsize()`` and ``ImageDraw.multiline_textsize()`` :py:meth:`.ImageDraw.textbbox`, :py:meth:`.ImageDraw.textlength` and :py:meth:`.ImageDraw.multiline_textbbox` +``ImageDraw2.Draw.textsize()`` :py:meth:`.ImageDraw2.Draw.textbbox` and :py:meth:`.ImageDraw2.Draw.textlength` +=============================================================== ============================================================================================================= -Previous code: - -.. code-block:: python +Previous code:: from PIL import Image, ImageDraw, ImageFont @@ -216,9 +189,7 @@ Previous code: width, height = font.getsize_multiline("Hello\nworld") width, height = draw.multiline_textsize("Hello\nworld") -Use instead: - -.. code-block:: python +Use instead:: from PIL import Image, ImageDraw, ImageFont @@ -233,11 +204,43 @@ Use instead: left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld") width, height = right - left, bottom - top -Removed features ----------------- +FreeTypeFont.getmask2 fill parameter +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Deprecated features are only removed in major releases after an appropriate -period of deprecation has passed. +.. deprecated:: 9.2.0 +.. versionremoved:: 10.0.0 + +The undocumented ``fill`` parameter of :py:meth:`.FreeTypeFont.getmask2` has been +removed. + +PhotoImage.paste box parameter +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 9.2.0 +.. versionremoved:: 10.0.0 + +The ``box`` parameter was unused and has been removed. + +PyQt5 and PySide2 +~~~~~~~~~~~~~~~~~ + +.. deprecated:: 9.2.0 +.. versionremoved:: 10.0.0 + +`Qt 5 reached end-of-life `_ on 2020-12-08 for +open-source users (and will reach EOL on 2023-12-08 for commercial licence holders). + +Support for PyQt5 and PySide2 has been removed from ``ImageQt``. Upgrade to +`PyQt6 `_ or +`PySide6 `_ instead. + +Image.coerce_e +~~~~~~~~~~~~~~ + +.. deprecated:: 9.2.0 +.. versionremoved:: 10.0.0 + +This undocumented method has been removed. PILLOW_VERSION constant ~~~~~~~~~~~~~~~~~~~~~~~ @@ -274,7 +277,7 @@ ImageFile.raise_ioerror .. deprecated:: 7.2.0 .. versionremoved:: 9.0.0 -``IOError`` was merged into ``OSError`` in Python 3.3. +:py:exc:`IOError` was merged into :py:exc:`OSError` in Python 3.3. So, ``ImageFile.raise_ioerror`` has been removed. Use ``ImageFile.raise_oserror`` instead. @@ -287,7 +290,7 @@ FreeType 2.7 Support for FreeType 2.7 has been removed. We recommend upgrading to at least `FreeType`_ 2.10.4, which fixed a severe -vulnerability introduced in FreeType 2.6 (:cve:`CVE-2020-15999`). +vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`). .. _FreeType: https://freetype.org/ @@ -300,9 +303,9 @@ im.offset ``im.offset()`` has been removed, call :py:func:`.ImageChops.offset()` instead. It was documented as deprecated in PIL 1.1.2, -raised a ``DeprecationWarning`` since 1.1.5, -an ``Exception`` since Pillow 3.0.0 -and ``NotImplementedError`` since 3.3.0. +raised a :py:exc:`DeprecationWarning` since 1.1.5, +an :py:exc:`Exception` since Pillow 3.0.0 +and :py:exc:`NotImplementedError` since 3.3.0. Image.fromstring, im.fromstring and im.tostring ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -314,9 +317,9 @@ Image.fromstring, im.fromstring and im.tostring * ``im.fromstring()`` has been removed, call :py:meth:`~PIL.Image.Image.frombytes()` instead. * ``im.tostring()`` has been removed, call :py:meth:`~PIL.Image.Image.tobytes()` instead. -They issued a ``DeprecationWarning`` since 2.0.0, -an ``Exception`` since 3.0.0 -and ``NotImplementedError`` since 3.3.0. +They issued a :py:exc:`DeprecationWarning` since 2.0.0, +an :py:exc:`Exception` since 3.0.0 +and :py:exc:`NotImplementedError` since 3.3.0. ImageCms.CmsProfile attributes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -325,7 +328,7 @@ ImageCms.CmsProfile attributes .. versionremoved:: 8.0.0 Some attributes in :py:class:`PIL.ImageCms.CmsProfile` have been removed. From 6.0.0, -they issued a ``DeprecationWarning``: +they issued a :py:exc:`DeprecationWarning`: ======================== =================================================== Removed Use instead @@ -358,16 +361,12 @@ Implicitly closing the image's underlying file in ``Image.__del__`` has been rem Use a context manager or call ``Image.close()`` instead to close the file in a deterministic way. -Previous method: - -.. code-block:: python +Previous method:: im = Image.open("hopper.png") im.save("out.jpg") -Use instead: - -.. code-block:: python +Use instead:: with Image.open("hopper.png") as im: im.save("out.jpg") @@ -453,7 +452,7 @@ PIL.OleFileIO .. deprecated:: 4.0.0 .. versionremoved:: 6.0.0 -PIL.OleFileIO was removed as a vendored file in Pillow 4.0.0 (2017-01) in favour of -the upstream olefile Python package, and replaced with an ``ImportError`` in 5.0.0 +``PIL.OleFileIO`` was removed as a vendored file in Pillow 4.0.0 (2017-01) in favour of +the upstream :pypi:`olefile` Python package, and replaced with an :py:exc:`ImportError` in 5.0.0 (2018-01). The deprecated file has now been removed from Pillow. If needed, install from PyPI (eg. ``python3 -m pip install olefile``). diff --git a/docs/example/DdsImagePlugin.py b/docs/example/DdsImagePlugin.py index ec3938b36..e98bb8680 100644 --- a/docs/example/DdsImagePlugin.py +++ b/docs/example/DdsImagePlugin.py @@ -9,6 +9,7 @@ The contents of this file are hereby released in the public domain (CC0) Full text of the CC0 license: https://creativecommons.org/publicdomain/zero/1.0/ """ +from __future__ import annotations import struct from io import BytesIO @@ -211,18 +212,21 @@ class DdsImageFile(ImageFile.ImageFile): def _open(self): if not _accept(self.fp.read(4)): - raise SyntaxError("not a DDS file") + msg = "not a DDS file" + raise SyntaxError(msg) (header_size,) = struct.unpack("`_. PIL is the Python Imaging Library by Fredrik Lundh and Contributors. +Pillow is the friendly PIL fork by `Jeffrey A. Clark (Alex) and contributors `_. PIL is the Python Imaging Library by Fredrik Lundh and contributors. Pillow for enterprise is available via the Tidelift Subscription. `Learn more `_. @@ -37,12 +37,12 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more + Overview ======== diff --git a/docs/installation.rst b/docs/installation.rst index 42fe8c254..fbcfbb907 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,6 +1,14 @@ Installation ============ +.. raw:: html + + + Warnings -------- @@ -42,6 +50,11 @@ Install Pillow with :command:`pip`:: python3 -m pip install --upgrade pip python3 -m pip install --upgrade Pillow +Optionally, install :pypi:`defusedxml` for Pillow to read XMP data, +and :pypi:`olefile` for Pillow to read FPX and MIC images:: + + python3 -m pip install --upgrade defusedxml olefile + .. tab:: Linux @@ -68,13 +81,25 @@ Install Pillow with :command:`pip`:: python3 -m pip install --upgrade pip python3 -m pip install --upgrade Pillow + While we provide binaries for both x86-64 and arm64, we do not provide universal2 + binaries. However, it is simple to combine our current binaries to create one:: + + python3 -m pip download --only-binary=:all: --platform macosx_10_10_x86_64 Pillow + python3 -m pip download --only-binary=:all: --platform macosx_11_0_arm64 Pillow + python3 -m pip install delocate + + Then, with the names of the downloaded wheels, use Python to combine them:: + + from delocate.fuse import fuse_wheels + fuse_wheels('Pillow-9.4.0-2-cp39-cp39-macosx_10_10_x86_64.whl', 'Pillow-9.4.0-cp39-cp39-macosx_11_0_arm64.whl', 'Pillow-9.4.0-cp39-cp39-macosx_11_0_universal2.whl') + .. tab:: Windows - We provide Pillow binaries for Windows compiled for the matrix of - supported Pythons in both 32 and 64-bit versions in the wheel format. - These binaries include support for all optional libraries except - libimagequant and libxcb. Raqm support requires - FriBiDi to be installed separately:: + We provide Pillow binaries for Windows compiled for the matrix of supported + Pythons in the wheel format. These include x86, x86-64 and arm64 versions + (with the exception of Python 3.8 on arm64). These binaries include support + for all optional libraries except libimagequant and libxcb. Raqm support + requires FriBiDi to be installed separately:: python3 -m pip install --upgrade pip python3 -m pip install --upgrade Pillow @@ -133,24 +158,24 @@ Many of Pillow's features require external libraries: * Pillow has been tested with libjpeg versions **6b**, **8**, **9-9d** and libjpeg-turbo version **8**. - * Starting with Pillow 3.0.0, libjpeg is required by default, but - may be disabled with the ``--disable-jpeg`` flag. + * Starting with Pillow 3.0.0, libjpeg is required by default. It can be + disabled with the ``-C jpeg=disable`` flag. * **zlib** provides access to compressed PNGs - * Starting with Pillow 3.0.0, zlib is required by default, but may - be disabled with the ``--disable-zlib`` flag. + * Starting with Pillow 3.0.0, zlib is required by default. It can be + disabled with the ``-C zlib=disable`` flag. * **libtiff** provides compressed TIFF functionality - * Pillow has been tested with libtiff versions **3.x** and **4.0-4.5** + * Pillow has been tested with libtiff versions **3.x** and **4.0-4.6.0** * **libfreetype** provides type related services * **littlecms** provides color management * Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and - above uses liblcms2. Tested with **1.19** and **2.7-2.14**. + above uses liblcms2. Tested with **1.19** and **2.7-2.16**. * **libwebp** provides the WebP format. @@ -158,8 +183,6 @@ Many of Pillow's features require external libraries: transparent WebP files. Versions **0.3.0** and above support transparency. -* **tcl/tk** provides support for tkinter bitmap and photo images. - * **openjpeg** provides JPEG 2000 functionality. * Pillow has been tested with openjpeg **2.0.0**, **2.1.0**, **2.3.1**, @@ -169,7 +192,7 @@ Many of Pillow's features require external libraries: * **libimagequant** provides improved color quantization - * Pillow has been tested with libimagequant **2.6-4.0.4** + * Pillow has been tested with libimagequant **2.6-4.2.2** * Libimagequant is licensed GPLv3, which is more restrictive than the Pillow license, therefore we will not be distributing binaries with libimagequant support enabled. @@ -186,8 +209,8 @@ Many of Pillow's features require external libraries: * Pillow wheels since version 8.2.0 include a modified version of libraqm that loads libfribidi at runtime if it is installed. On Windows this requires compiling FriBiDi and installing ``fribidi.dll`` - into a directory listed in the `Dynamic-Link Library Search Order (Microsoft Docs) - `_ + into a directory listed in the `Dynamic-link library search order (Microsoft Learn) + `_ (``fribidi-0.dll`` or ``libfribidi-0.dll`` are also detected). See `Build Options`_ to see how to build this version. * Previous versions of Pillow (5.0.0 to 8.1.2) linked libraqm dynamically at runtime. @@ -300,6 +323,11 @@ 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 @@ -338,7 +366,7 @@ for your machine (e.g. :file:`/usr` or :file:`/usr/local`), no additional configuration should be required. If they are installed in a non-standard location, you may need to configure setuptools to use those locations by editing :file:`setup.py` or -:file:`setup.cfg`, or by adding environment variables on the command +:file:`pyproject.toml`, or by adding environment variables on the command line:: CFLAGS="-I/usr/pkg/include" python3 -m pip install --upgrade Pillow --no-binary :all: @@ -368,40 +396,40 @@ Build Options using a setting of 1. By default, it uses 4 CPUs, or if 4 are not available, as many as are present. -* Build flags: ``--disable-zlib``, ``--disable-jpeg``, - ``--disable-tiff``, ``--disable-freetype``, ``--disable-lcms``, - ``--disable-webp``, ``--disable-webpmux``, ``--disable-jpeg2000``, - ``--disable-imagequant``, ``--disable-xcb``. +* 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 jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``. Disable building the corresponding feature even if the development libraries are present on the building machine. -* Build flags: ``--enable-zlib``, ``--enable-jpeg``, - ``--enable-tiff``, ``--enable-freetype``, ``--enable-lcms``, - ``--enable-webp``, ``--enable-webpmux``, ``--enable-jpeg2000``, - ``--enable-imagequant``, ``--enable-xcb``. +* 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 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. -* Build flags: ``--vendor-raqm --vendor-fribidi`` +* Config settings: ``-C raqm=vendor``, ``-C fribidi=vendor``. These flags are used to compile a modified version of libraqm and a shim that dynamically loads libfribidi at runtime. These are used to compile the standard Pillow wheels. Compiling libraqm requires a C99-compliant compiler. -* Build flag: ``--disable-platform-guessing``. Skips all of the +* Build flag: ``-C platform-guessing=disable``. Skips all of the platform dependent guessing of include and library directories for automated build systems that configure the proper paths in the environment variables (e.g. Buildroot). -* Build flag: ``--debug``. Adds a debugging flag to the include and +* Build flag: ``-C debug=true``. Adds a debugging flag to the include and library search process to dump all paths searched for and found to stdout. Sample usage:: - python3 -m pip install --upgrade Pillow --global-option="build_ext" --global-option="--enable-[feature]" + python3 -m pip install --upgrade Pillow -C [feature]=enable Platform Support ---------------- @@ -422,7 +450,9 @@ These platforms are built and tested for every change. +==================================+============================+=====================+ | Alpine | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Amazon Linux 2 | 3.7 | x86-64 | +| Amazon Linux 2 | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Amazon Linux 2023 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Arch | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -432,33 +462,35 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | CentOS Stream 9 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Debian 10 Buster | 3.7 | x86 | +| Debian 11 Bullseye | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Debian 11 Bullseye | 3.9 | x86 | +| Debian 12 Bookworm | 3.11 | x86, x86-64 | +----------------------------------+----------------------------+---------------------+ -| Fedora 36 | 3.10 | x86-64 | +| Fedora 38 | 3.11 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Fedora 37 | 3.11 | x86-64 | +| Fedora 39 | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Gentoo | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| macOS 11 Big Sur | 3.7, 3.8, 3.9, 3.10, 3.11, | x86-64 | -| | PyPy3 | | +| macOS 12 Monterey | 3.8, 3.9, 3.10, 3.11, | x86-64 | +| | 3.12, PyPy3 | | +----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 18.04 LTS (Bionic) | 3.9 | x86-64 | +| Ubuntu Linux 20.04 LTS (Focal) | 3.8 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 20.04 LTS (Focal) | 3.7, 3.8, 3.9, 3.10, 3.11, | x86-64 | -| | PyPy3 | | -+----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 22.04 LTS (Jammy) | 3.10 | arm64v8, ppc64le, | -| | | s390x, x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Windows Server 2016 | 3.7 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Windows Server 2022 | 3.7, 3.8, 3.9, 3.10, 3.11, | x86, x86-64 | -| | PyPy3 | | +| Ubuntu Linux 22.04 LTS (Jammy) | 3.8, 3.9, 3.10, 3.11, | x86-64 | +| | 3.12, PyPy3 | | | +----------------------------+---------------------+ -| | 3.9 (MinGW) | x86, x86-64 | +| | 3.10 | arm64v8, ppc64le, | +| | | s390x | ++----------------------------------+----------------------------+---------------------+ +| Windows Server 2016 | 3.8 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Windows Server 2022 | 3.8, 3.9, 3.10, 3.11, | x86-64 | +| | 3.12, PyPy3 | | +| +----------------------------+---------------------+ +| | 3.12 | x86 | +| +----------------------------+---------------------+ +| | 3.9 (MinGW) | x86-64 | | +----------------------------+---------------------+ | | 3.8, 3.9 (Cygwin) | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -474,89 +506,97 @@ These platforms have been reported to work at the versions mentioned. Contributors please test Pillow on your platform then update this document and send a pull request. -+----------------------------------+---------------------------+------------------+--------------+ -| Operating system | | Tested Python | | Latest tested | | Tested | -| | | versions | | Pillow version | | processors | -+==================================+===========================+==================+==============+ -| macOS 13 Ventura | 3.7, 3.8, 3.9, 3.10, 3.11 | 9.3.0 |arm | -+----------------------------------+---------------------------+------------------+--------------+ -| macOS 12 Big Sur | 3.7, 3.8, 3.9, 3.10, 3.11 | 9.3.0 |arm | -+----------------------------------+---------------------------+------------------+--------------+ -| macOS 11 Big Sur | 3.7, 3.8, 3.9, 3.10 | 8.4.0 |arm | -| +---------------------------+------------------+--------------+ -| | 3.7, 3.8, 3.9, 3.10, 3.11 | 9.3.0 |x86-64 | -| +---------------------------+------------------+ | -| | 3.6 | 8.4.0 | | -+----------------------------------+---------------------------+------------------+--------------+ -| macOS 10.15 Catalina | 3.6, 3.7, 3.8, 3.9 | 8.3.2 |x86-64 | -| +---------------------------+------------------+ | -| | 3.5 | 7.2.0 | | -+----------------------------------+---------------------------+------------------+--------------+ -| macOS 10.14 Mojave | 3.5, 3.6, 3.7, 3.8 | 7.2.0 |x86-64 | -| +---------------------------+------------------+ | -| | 2.7 | 6.0.0 | | -| +---------------------------+------------------+ | -| | 3.4 | 5.4.1 | | -+----------------------------------+---------------------------+------------------+--------------+ -| macOS 10.13 High Sierra | 2.7, 3.4, 3.5, 3.6 | 4.2.1 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| macOS 10.12 Sierra | 2.7, 3.4, 3.5, 3.6 | 4.1.1 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Mac OS X 10.11 El Capitan | 2.7, 3.4, 3.5, 3.6, 3.7 | 5.4.1 |x86-64 | -| +---------------------------+------------------+ | -| | 3.3 | 4.1.0 | | -+----------------------------------+---------------------------+------------------+--------------+ -| Mac OS X 10.9 Mavericks | 2.7, 3.2, 3.3, 3.4 | 3.0.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Mac OS X 10.8 Mountain Lion | 2.6, 2.7, 3.2, 3.3 | |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Redhat Linux 6 | 2.6 | |x86 | -+----------------------------------+---------------------------+------------------+--------------+ -| CentOS 6.3 | 2.7, 3.3 | |x86 | -+----------------------------------+---------------------------+------------------+--------------+ -| CentOS 8 | 3.9 | 9.0.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Fedora 23 | 2.7, 3.4 | 3.1.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Ubuntu Linux 12.04 LTS (Precise) | | 2.6, 3.2, 3.3, 3.4, 3.5 | 3.4.1 |x86,x86-64 | -| | | PyPy5.3.1, PyPy3 v2.4.0 | | | -| +---------------------------+------------------+--------------+ -| | 2.7 | 4.3.0 |x86-64 | -| +---------------------------+------------------+--------------+ -| | 2.7, 3.2 | 3.4.1 |ppc | -+----------------------------------+---------------------------+------------------+--------------+ -| Ubuntu Linux 10.04 LTS (Lucid) | 2.6 | 2.3.0 |x86,x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Debian 8.2 Jessie | 2.7, 3.4 | 3.1.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Raspbian Jessie | 2.7, 3.4 | 3.1.0 |arm | -+----------------------------------+---------------------------+------------------+--------------+ -| Raspbian Stretch | 2.7, 3.5 | 4.0.0 |arm | -+----------------------------------+---------------------------+------------------+--------------+ -| Raspberry Pi OS | 3.6, 3.7, 3.8, 3.9 | 8.2.0 |arm | -| +---------------------------+------------------+ | -| | 2.7 | 6.2.2 | | -+----------------------------------+---------------------------+------------------+--------------+ -| Gentoo Linux | 2.7, 3.2 | 2.1.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| FreeBSD 11.1 | 2.7, 3.4, 3.5, 3.6 | 4.3.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| FreeBSD 10.3 | 2.7, 3.4, 3.5 | 4.2.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| FreeBSD 10.2 | 2.7, 3.4 | 3.1.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Windows 10 | 3.7 | 7.1.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Windows 10/Cygwin 3.3 | 3.6, 3.7, 3.8, 3.9 | 8.4.0 |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Windows 8.1 Pro | 2.6, 2.7, 3.2, 3.3, 3.4 | 2.4.0 |x86,x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Windows 8 Pro | 2.6, 2.7, 3.2, 3.3, 3.4a3 | 2.2.0 |x86,x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Windows 7 Professional | 3.7 | 7.0.0 |x86,x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ -| Windows Server 2008 R2 Enterprise| 3.3 | |x86-64 | -+----------------------------------+---------------------------+------------------+--------------+ ++----------------------------------+----------------------------+------------------+--------------+ +| Operating system | | Tested Python | | Latest tested | | Tested | +| | | versions | | Pillow version | | processors | ++==================================+============================+==================+==============+ +| macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.1.0 |arm | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 13 Ventura | 3.8, 3.9, 3.10, 3.11 | 10.0.1 |arm | +| +----------------------------+------------------+ | +| | 3.7 | 9.5.0 | | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 12 Monterey | 3.7, 3.8, 3.9, 3.10, 3.11 | 9.3.0 |arm | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 11 Big Sur | 3.7, 3.8, 3.9, 3.10 | 8.4.0 |arm | +| +----------------------------+------------------+--------------+ +| | 3.7, 3.8, 3.9, 3.10, 3.11 | 9.4.0 |x86-64 | +| +----------------------------+------------------+ | +| | 3.6 | 8.4.0 | | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 10.15 Catalina | 3.6, 3.7, 3.8, 3.9 | 8.3.2 |x86-64 | +| +----------------------------+------------------+ | +| | 3.5 | 7.2.0 | | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 10.14 Mojave | 3.5, 3.6, 3.7, 3.8 | 7.2.0 |x86-64 | +| +----------------------------+------------------+ | +| | 2.7 | 6.0.0 | | +| +----------------------------+------------------+ | +| | 3.4 | 5.4.1 | | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 10.13 High Sierra | 2.7, 3.4, 3.5, 3.6 | 4.2.1 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 10.12 Sierra | 2.7, 3.4, 3.5, 3.6 | 4.1.1 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Mac OS X 10.11 El Capitan | 2.7, 3.4, 3.5, 3.6, 3.7 | 5.4.1 |x86-64 | +| +----------------------------+------------------+ | +| | 3.3 | 4.1.0 | | ++----------------------------------+----------------------------+------------------+--------------+ +| Mac OS X 10.9 Mavericks | 2.7, 3.2, 3.3, 3.4 | 3.0.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Mac OS X 10.8 Mountain Lion | 2.6, 2.7, 3.2, 3.3 | |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Redhat Linux 6 | 2.6 | |x86 | ++----------------------------------+----------------------------+------------------+--------------+ +| CentOS 6.3 | 2.7, 3.3 | |x86 | ++----------------------------------+----------------------------+------------------+--------------+ +| CentOS 8 | 3.9 | 9.0.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Fedora 23 | 2.7, 3.4 | 3.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Ubuntu Linux 12.04 LTS (Precise) | | 2.6, 3.2, 3.3, 3.4, 3.5 | 3.4.1 |x86,x86-64 | +| | | PyPy5.3.1, PyPy3 v2.4.0 | | | +| +----------------------------+------------------+--------------+ +| | 2.7 | 4.3.0 |x86-64 | +| +----------------------------+------------------+--------------+ +| | 2.7, 3.2 | 3.4.1 |ppc | ++----------------------------------+----------------------------+------------------+--------------+ +| Ubuntu Linux 10.04 LTS (Lucid) | 2.6 | 2.3.0 |x86,x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Debian 8.2 Jessie | 2.7, 3.4 | 3.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Raspbian Jessie | 2.7, 3.4 | 3.1.0 |arm | ++----------------------------------+----------------------------+------------------+--------------+ +| Raspbian Stretch | 2.7, 3.5 | 4.0.0 |arm | ++----------------------------------+----------------------------+------------------+--------------+ +| Raspberry Pi OS | 3.6, 3.7, 3.8, 3.9 | 8.2.0 |arm | +| +----------------------------+------------------+ | +| | 2.7 | 6.2.2 | | ++----------------------------------+----------------------------+------------------+--------------+ +| Gentoo Linux | 2.7, 3.2 | 2.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| FreeBSD 11.1 | 2.7, 3.4, 3.5, 3.6 | 4.3.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| FreeBSD 10.3 | 2.7, 3.4, 3.5 | 4.2.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| FreeBSD 10.2 | 2.7, 3.4 | 3.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 11 | 3.9, 3.10, 3.11, 3.12 | 10.1.0 |arm64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 11 Pro | 3.11, 3.12 | 10.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 10 | 3.7 | 7.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 10/Cygwin 3.3 | 3.6, 3.7, 3.8, 3.9 | 8.4.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 8.1 Pro | 2.6, 2.7, 3.2, 3.3, 3.4 | 2.4.0 |x86,x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 8 Pro | 2.6, 2.7, 3.2, 3.3, 3.4a3 | 2.2.0 |x86,x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 7 Professional | 3.7 | 7.0.0 |x86,x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows Server 2008 R2 Enterprise| 3.3 | |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ Old Versions ------------ diff --git a/docs/make.bat b/docs/make.bat index c943319ad..0ed5ee1a5 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -19,6 +19,7 @@ if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files + echo. htmlview to open the index page built by the html target in your browser echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files @@ -44,12 +45,23 @@ if "%1" == "clean" ( goto end ) -if "%1" == "html" ( +set html=false +if "%1%" == "html" set html=true +if "%1%" == "htmlview" set html=true +if "%html%" == "true" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end + + if "%1" == "htmlview" ( + if EXIST "%BUILDDIR%\html\index.html" ( + echo.Opening "%BUILDDIR%\html\index.html" in the default web browser... + start "" "%BUILDDIR%\html\index.html" + ) + ) + + goto end ) if "%1" == "dirhtml" ( diff --git a/docs/newer-versions.csv b/docs/newer-versions.csv index ed2369259..e21caf520 100644 --- a/docs/newer-versions.csv +++ b/docs/newer-versions.csv @@ -1,6 +1,8 @@ -Python,3.11,3.10,3.9,3.8,3.7,3.6,3.5 -Pillow >= 9.3,Yes,Yes,Yes,Yes,Yes,, -Pillow 9.0 - 9.2,,Yes,Yes,Yes,Yes,, -Pillow 8.3.2 - 8.4,,Yes,Yes,Yes,Yes,Yes, -Pillow 8.0 - 8.3.1,,,Yes,Yes,Yes,Yes, -Pillow 7.0 - 7.2,,,,Yes,Yes,Yes,Yes +Python,3.12,3.11,3.10,3.9,3.8,3.7,3.6,3.5 +Pillow >= 10.1,Yes,Yes,Yes,Yes,Yes,,, +Pillow 10.0,,Yes,Yes,Yes,Yes,,, +Pillow 9.3 - 9.5,,Yes,Yes,Yes,Yes,Yes,, +Pillow 9.0 - 9.2,,,Yes,Yes,Yes,Yes,, +Pillow 8.3.2 - 8.4,,,Yes,Yes,Yes,Yes,Yes, +Pillow 8.0 - 8.3.1,,,,Yes,Yes,Yes,Yes, +Pillow 7.0 - 7.2,,,,,Yes,Yes,Yes,Yes diff --git a/docs/older-versions.csv b/docs/older-versions.csv index 6058f0524..aa696bc18 100644 --- a/docs/older-versions.csv +++ b/docs/older-versions.csv @@ -5,4 +5,4 @@ Pillow 5.2 - 5.4,,Yes,Yes,Yes,Yes,,,Yes,,, Pillow 5.0 - 5.1,,,Yes,Yes,Yes,,,Yes,,, Pillow 4,,,Yes,Yes,Yes,Yes,,Yes,,, Pillow 2 - 3,,,,Yes,Yes,Yes,Yes,Yes,Yes,, -Pillow < 2,,,,,,,,Yes,Yes,Yes,Yes \ No newline at end of file +Pillow < 2,,,,,,,,Yes,Yes,Yes,Yes diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 7f6f666c3..4281b182c 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -17,9 +17,7 @@ Open, rotate, and display an image (using the default viewer) The following script loads an image, rotates it 45 degrees, and displays it using an external viewer (usually xv on Unix, and the Paint program on -Windows). - -.. code-block:: python +Windows). :: from PIL import Image with Image.open("hopper.jpg") as im: @@ -29,9 +27,7 @@ Create thumbnails ^^^^^^^^^^^^^^^^^ The following script creates nice thumbnails of all JPEG images in the -current directory preserving aspect ratios with 128x128 max resolution. - -.. code-block:: python +current directory preserving aspect ratios with 128x128 max resolution. :: from PIL import Image import glob, os @@ -97,10 +93,14 @@ Generating images Registering plugins ^^^^^^^^^^^^^^^^^^^ +.. autofunction:: preinit +.. autofunction:: init + .. note:: - These functions are for use by plugin authors. Application authors can - ignore them. + These functions are for use by plugin authors. They are called when a + plugin is loaded as part of :py:meth:`~preinit()` or :py:meth:`~init()`. + Application authors can ignore them. .. autofunction:: register_open .. autofunction:: register_mime @@ -127,9 +127,7 @@ methods. Unless otherwise stated, all methods return a new instance of the .. automethod:: PIL.Image.Image.convert The following example converts an RGB image (linearly calibrated according to -ITU-R 709, using the D65 luminant) to the CIE XYZ color space: - -.. code-block:: python +ITU-R 709, using the D65 luminant) to the CIE XYZ color space:: rgb2xyz = ( 0.412453, 0.357580, 0.180423, 0, @@ -140,9 +138,7 @@ ITU-R 709, using the D65 luminant) to the CIE XYZ color space: .. automethod:: PIL.Image.Image.copy .. automethod:: PIL.Image.Image.crop -This crops the input image with the provided coordinates: - -.. code-block:: python +This crops the input image with the provided coordinates:: from PIL import Image @@ -162,9 +158,7 @@ This crops the input image with the provided coordinates: .. automethod:: PIL.Image.Image.entropy .. automethod:: PIL.Image.Image.filter -This blurs the input image using a filter from the ``ImageFilter`` module: - -.. code-block:: python +This blurs the input image using a filter from the ``ImageFilter`` module:: from PIL import Image, ImageFilter @@ -176,9 +170,7 @@ This blurs the input image using a filter from the ``ImageFilter`` module: .. automethod:: PIL.Image.Image.frombytes .. automethod:: PIL.Image.Image.getbands -This helps to get the bands of the input image: - -.. code-block:: python +This helps to get the bands of the input image:: from PIL import Image @@ -187,9 +179,7 @@ This helps to get the bands of the input image: .. automethod:: PIL.Image.Image.getbbox -This helps to get the bounding box coordinates of the input image: - -.. code-block:: python +This helps to get the bounding box coordinates of the input image:: from PIL import Image @@ -217,9 +207,7 @@ This helps to get the bounding box coordinates of the input image: .. automethod:: PIL.Image.Image.remap_palette .. automethod:: PIL.Image.Image.resize -This resizes the given image from ``(width, height)`` to ``(width/2, height/2)``: - -.. code-block:: python +This resizes the given image from ``(width, height)`` to ``(width/2, height/2)``:: from PIL import Image @@ -231,9 +219,7 @@ This resizes the given image from ``(width, height)`` to ``(width/2, height/2)`` .. automethod:: PIL.Image.Image.rotate -This rotates the input image by ``theta`` degrees counter clockwise: - -.. code-block:: python +This rotates the input image by ``theta`` degrees counter clockwise:: from PIL import Image @@ -256,9 +242,7 @@ This rotates the input image by ``theta`` degrees counter clockwise: .. automethod:: PIL.Image.Image.transpose This flips the input image by using the :data:`Transpose.FLIP_LEFT_RIGHT` -method. - -.. code-block:: python +method. :: from PIL import Image @@ -367,6 +351,8 @@ Instances of the :py:class:`Image` class have the following attributes: .. seealso:: :attr:`~Image.is_animated`, :func:`~Image.seek` and :func:`~Image.tell` +.. autoattribute:: PIL.Image.Image.has_transparency_data + Classes ------- @@ -430,18 +416,7 @@ See :ref:`concept-filters` for details. .. autoclass:: Resampling :members: :undoc-members: - -Some deprecated filters are also available under the following names: - -.. data:: NONE :noindex: - :value: Resampling.NEAREST -.. data:: LINEAR - :value: Resampling.BILINEAR -.. data:: CUBIC - :value: Resampling.BICUBIC -.. data:: ANTIALIAS - :value: Resampling.LANCZOS Dither modes ^^^^^^^^^^^^ @@ -470,7 +445,7 @@ Used to specify the dithering method to use for the Palettes ^^^^^^^^ -Used to specify the pallete to use for the :meth:`~Image.convert` method. +Used to specify the palette to use for the :meth:`~Image.convert` method. .. autoclass:: Palette :members: diff --git a/docs/reference/ImageColor.rst b/docs/reference/ImageColor.rst index 20237eccf..31faeac78 100644 --- a/docs/reference/ImageColor.rst +++ b/docs/reference/ImageColor.rst @@ -59,7 +59,7 @@ Functions .. py:method:: getcolor(color, mode) Same as :py:func:`~PIL.ImageColor.getrgb`, but converts the RGB value to a - greyscale value if the mode is not color or a palette image. If the string + grayscale value if the mode is not color or a palette image. If the string cannot be parsed, this function raises a :py:exc:`ValueError` exception. .. versionadded:: 1.1.4 diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 9aa26916a..4ccfacae7 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -16,7 +16,7 @@ For a more advanced drawing library for PIL, see the `aggdraw module`_. Example: Draw a gray cross over an image ---------------------------------------- -.. code-block:: python +:: import sys from PIL import Image, ImageDraw @@ -78,7 +78,7 @@ libraries, and may not available in all PIL builds. Example: Draw Partial Opacity Text ---------------------------------- -.. code-block:: python +:: from PIL import Image, ImageDraw, ImageFont @@ -105,7 +105,7 @@ Example: Draw Partial Opacity Text Example: Draw Multiline Text ---------------------------- -.. code-block:: python +:: from PIL import Image, ImageDraw, ImageFont @@ -243,6 +243,7 @@ Methods .. py:method:: ImageDraw.line(xy, fill=None, width=0, joint=None) Draws a line between the coordinates in the ``xy`` list. + The coordinate pixels are included in the drawn line. :param xy: Sequence of either 2-tuples like ``[(x, y), (x, y), ...]`` or numeric values like ``[x, y, x, y, ...]``. @@ -287,7 +288,7 @@ Methods The polygon outline consists of straight lines between the given coordinates, plus a straight line between the last and the first - coordinate. + coordinate. The coordinate pixels are included in the drawn polygon. :param xy: Sequence of either 2-tuples like ``[(x, y), (x, y), ...]`` or numeric values like ``[x, y, x, y, ...]``. @@ -296,7 +297,7 @@ Methods :param width: The line width, in pixels. -.. py:method:: ImageDraw.regular_polygon(bounding_circle, n_sides, rotation=0, fill=None, outline=None) +.. py:method:: ImageDraw.regular_polygon(bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1) Draws a regular polygon inscribed in ``bounding_circle``, with ``n_sides``, and rotation of ``rotation`` degrees. @@ -311,6 +312,7 @@ Methods (e.g. ``rotation=90``, applies a 90 degree rotation). :param fill: Color to use for the fill. :param outline: Color to use for the outline. + :param width: The line width, in pixels. .. py:method:: ImageDraw.rectangle(xy, fill=None, outline=None, width=1) @@ -318,25 +320,28 @@ Methods Draws a rectangle. :param xy: Two points to define the bounding box. Sequence of either - ``[(x0, y0), (x1, y1)]`` or ``[x0, y0, x1, y1]``. The bounding box - is inclusive of both endpoints. - :param outline: Color to use for the outline. + ``[(x0, y0), (x1, y1)]`` or ``[x0, y0, x1, y1]``, where ``x1 >= x0`` and + ``y1 >= y0``. The bounding box is inclusive of both endpoints. :param fill: Color to use for the fill. + :param outline: Color to use for the outline. :param width: The line width, in pixels. .. versionadded:: 5.3.0 -.. py:method:: ImageDraw.rounded_rectangle(xy, radius=0, fill=None, outline=None, width=1) +.. py:method:: ImageDraw.rounded_rectangle(xy, radius=0, fill=None, outline=None, width=1, corners=None) Draws a rounded rectangle. :param xy: Two points to define the bounding box. Sequence of either - ``[(x0, y0), (x1, y1)]`` or ``[x0, y0, x1, y1]``. The bounding box - is inclusive of both endpoints. + ``[(x0, y0), (x1, y1)]`` or ``[x0, y0, x1, y1]``, where ``x1 >= x0`` and + ``y1 >= y0``. The bounding box is inclusive of both endpoints. :param radius: Radius of the corners. - :param outline: Color to use for the outline. :param fill: Color to use for the fill. + :param outline: Color to use for the outline. :param width: The line width, in pixels. + :param corners: A tuple of whether to round each corner, + ``(top_left, top_right, bottom_right, bottom_left)``. + Keyword-only argument. .. versionadded:: 8.2.0 @@ -346,7 +351,7 @@ Methods Draw a shape. -.. py:method:: ImageDraw.text(xy, text, fill=None, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, stroke_fill=None, embedded_color=False) +.. py:method:: ImageDraw.text(xy, text, fill=None, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, stroke_fill=None, embedded_color=False, font_size=None) Draws the string at the given position. @@ -357,9 +362,10 @@ Methods :param fill: Color to use for the text. :param font: An :py:class:`~PIL.ImageFont.ImageFont` instance. :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. This parameter is - ignored for non-TrueType fonts. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. + This parameter is ignored for non-TrueType fonts. .. note:: This parameter was present in earlier versions of Pillow, but implemented only in version 8.0.0. @@ -411,8 +417,14 @@ Methods .. versionadded:: 8.0.0 + :param font_size: If ``font`` is not provided, then the size to use for the default + font. + Keyword-only argument. -.. py:method:: ImageDraw.multiline_text(xy, text, fill=None, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, stroke_fill=None, embedded_color=False) + .. versionadded:: 10.1.0 + + +.. py:method:: ImageDraw.multiline_text(xy, text, fill=None, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, stroke_fill=None, embedded_color=False, font_size=None) Draws the string at the given position. @@ -422,9 +434,10 @@ Methods :param font: An :py:class:`~PIL.ImageFont.ImageFont` instance. :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. This parameter is - ignored for non-TrueType fonts. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. + This parameter is ignored for non-TrueType fonts. .. note:: This parameter was present in earlier versions of Pillow, but implemented only in version 8.0.0. @@ -472,117 +485,13 @@ Methods .. versionadded:: 8.0.0 -.. py:method:: ImageDraw.textsize(text, font=None, spacing=4, direction=None, features=None, language=None, stroke_width=0) + :param font_size: If ``font`` is not provided, then the size to use for the default + font. + Keyword-only argument. - .. deprecated:: 9.2.0 + .. versionadded:: 10.1.0 - See :ref:`deprecations ` for more information. - - Use :py:meth:`textlength()` to measure the offset of following text with - 1/64 pixel precision. - Use :py:meth:`textbbox()` to get the exact bounding box based on an anchor. - - Return the size of the given string, in pixels. - - .. note:: For historical reasons this function measures text height from - the ascender line instead of the top, see :ref:`text-anchors`. - If you wish to measure text height from the top, it is recommended - to use :meth:`textbbox` with ``anchor='lt'`` instead. - - :param text: Text to be measured. If it contains any newline characters, - the text is passed on to :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textsize`. - :param font: An :py:class:`~PIL.ImageFont.ImageFont` instance. - :param spacing: If the text is passed on to - :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textsize`, - the number of pixels between lines. - :param direction: Direction of the text. It can be ``"rtl"`` (right to - left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). - Requires libraqm. - - .. versionadded:: 4.2.0 - :param features: A list of OpenType font features to be used during text - layout. This is usually used to turn on optional - font features that are not enabled by default, - for example ``"dlig"`` or ``"ss01"``, but can be also - used to turn off default font features, for - example ``"-liga"`` to disable ligatures or ``"-kern"`` - to disable kerning. To get all supported - features, see `OpenType docs`_. - Requires libraqm. - - .. versionadded:: 4.2.0 - :param language: Language of the text. Different languages may use - different glyph shapes or ligatures. This parameter tells - the font which language the text is in, and to apply the - correct substitutions as appropriate, if available. - It should be a `BCP 47 language code`_. - Requires libraqm. - - .. versionadded:: 6.0.0 - - :param stroke_width: The width of the text stroke. - - .. versionadded:: 6.2.0 - - :return: (width, height) - -.. py:method:: ImageDraw.multiline_textsize(text, font=None, spacing=4, direction=None, features=None, language=None, stroke_width=0) - - .. deprecated:: 9.2.0 - - See :ref:`deprecations ` for more information. - - Use :py:meth:`.multiline_textbbox` instead. - - Return the size of the given string, in pixels. - - Use :py:meth:`textlength()` to measure the offset of following text with - 1/64 pixel precision. - Use :py:meth:`textbbox()` to get the exact bounding box based on an anchor. - - .. note:: For historical reasons this function measures text height as the - distance between the top ascender line and bottom descender line, - not the top and bottom of the text, see :ref:`text-anchors`. - If you wish to measure text height from the top to the bottom of text, - it is recommended to use :meth:`multiline_textbbox` instead. - - :param text: Text to be measured. - :param font: An :py:class:`~PIL.ImageFont.ImageFont` instance. - :param spacing: The number of pixels between lines. - :param direction: Direction of the text. It can be ``"rtl"`` (right to - left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). - Requires libraqm. - - .. versionadded:: 4.2.0 - - :param features: A list of OpenType font features to be used during text - layout. This is usually used to turn on optional - font features that are not enabled by default, - for example ``"dlig"`` or ``"ss01"``, but can be also - used to turn off default font features, for - example ``"-liga"`` to disable ligatures or ``"-kern"`` - to disable kerning. To get all supported - features, see `OpenType docs`_. - Requires libraqm. - - .. versionadded:: 4.2.0 - - :param language: Language of the text. Different languages may use - different glyph shapes or ligatures. This parameter tells - the font which language the text is in, and to apply the - correct substitutions as appropriate, if available. - It should be a `BCP 47 language code`_. - Requires libraqm. - - .. versionadded:: 6.0.0 - - :param stroke_width: The width of the text stroke. - - .. versionadded:: 6.2.0 - - :return: (width, height) - -.. py:method:: ImageDraw.textlength(text, font=None, direction=None, features=None, language=None, embedded_color=False) +.. py:method:: ImageDraw.textlength(text, font=None, direction=None, features=None, language=None, embedded_color=False, font_size=None) Returns length (in pixels with 1/64 precision) of given text when rendered in font with provided direction, features, and language. @@ -597,18 +506,14 @@ Methods string due to kerning. If you need to adjust for kerning, include the following character and subtract its length. - For example, instead of - - .. code-block:: python + For example, instead of :: hello = draw.textlength("Hello", font) world = draw.textlength("World", font) hello_world = hello + world # not adjusted for kerning assert hello_world == draw.textlength("HelloWorld", font) # may fail - use - - .. code-block:: python + use :: hello = draw.textlength("HelloW", font) - draw.textlength( "W", font @@ -617,9 +522,7 @@ Methods hello_world = hello + world # adjusted for kerning assert hello_world == draw.textlength("HelloWorld", font) # True - or disable kerning with (requires libraqm) - - .. code-block:: python + or disable kerning with (requires libraqm) :: hello = draw.textlength("Hello", font, features=["-kern"]) world = draw.textlength("World", font, features=["-kern"]) @@ -649,9 +552,15 @@ Methods It should be a `BCP 47 language code`_. Requires libraqm. :param embedded_color: Whether to use font embedded color glyphs (COLR, CBDT, SBIX). - :return: Width for horizontal, height for vertical text. + :param font_size: If ``font`` is not provided, then the size to use for the default + font. + Keyword-only argument. -.. py:method:: ImageDraw.textbbox(xy, text, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, embedded_color=False) + .. versionadded:: 10.1.0 + + :return: Either width for horizontal text, or height for vertical text. + +.. py:method:: ImageDraw.textbbox(xy, text, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, embedded_color=False, font_size=None) Returns bounding box (in pixels) of given text relative to given anchor when rendered in font with provided direction, features, and language. @@ -669,9 +578,10 @@ Methods :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textbbox`. :param font: A :py:class:`~PIL.ImageFont.FreeTypeFont` instance. :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. This parameter is - ignored for non-TrueType fonts. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. + This parameter is ignored for non-TrueType fonts. :param spacing: If the text is passed on to :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textbbox`, the number of pixels between lines. @@ -699,9 +609,15 @@ Methods Requires libraqm. :param stroke_width: The width of the text stroke. :param embedded_color: Whether to use font embedded color glyphs (COLR, CBDT, SBIX). + :param font_size: If ``font`` is not provided, then the size to use for the default + font. + Keyword-only argument. + + .. versionadded:: 10.1.0 + :return: ``(left, top, right, bottom)`` bounding box -.. py:method:: ImageDraw.multiline_textbbox(xy, text, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, embedded_color=False) +.. py:method:: ImageDraw.multiline_textbbox(xy, text, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, embedded_color=False, font_size=None) Returns bounding box (in pixels) of given text relative to given anchor when rendered in font with provided direction, features, and language. @@ -717,9 +633,10 @@ Methods :param text: Text to be measured. :param font: A :py:class:`~PIL.ImageFont.FreeTypeFont` instance. :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. This parameter is - ignored for non-TrueType fonts. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + 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``. @@ -743,6 +660,12 @@ Methods Requires libraqm. :param stroke_width: The width of the text stroke. :param embedded_color: Whether to use font embedded color glyphs (COLR, CBDT, SBIX). + :param font_size: If ``font`` is not provided, then the size to use for the default + font. + Keyword-only argument. + + .. versionadded:: 10.1.0 + :return: ``(left, top, right, bottom)`` bounding box .. py:method:: getdraw(im=None, hints=None) diff --git a/docs/reference/ImageEnhance.rst b/docs/reference/ImageEnhance.rst index 29ceee314..529acad4a 100644 --- a/docs/reference/ImageEnhance.rst +++ b/docs/reference/ImageEnhance.rst @@ -10,7 +10,7 @@ for image enhancement. Example: Vary the sharpness of an image --------------------------------------- -.. code-block:: python +:: from PIL import ImageEnhance @@ -29,6 +29,8 @@ Classes All enhancement classes implement a common interface, containing a single method: +.. _enhancement-factor: + .. py:class:: _Enhance .. py:method:: enhance(factor) @@ -45,31 +47,35 @@ method: Adjust image color balance. - This class can be used to adjust the colour balance of an image, in - a manner similar to the controls on a colour TV set. An enhancement - factor of 0.0 gives a black and white image. A factor of 1.0 gives - the original image. + This class can be used to adjust the colour balance of an image, in a + manner similar to the controls on a colour TV set. An + :ref:`enhancement factor ` of 0.0 gives a black and + white image. A factor of 1.0 gives the original image. .. py:class:: Contrast(image) Adjust image contrast. - This class can be used to control the contrast of an image, similar - to the contrast control on a TV set. An enhancement factor of 0.0 - gives a solid grey image. A factor of 1.0 gives the original image. + This class can be used to control the contrast of an image, similar to the + contrast control on a TV set. An + :ref:`enhancement factor ` of 0.0 gives a solid gray + image, a factor of 1.0 gives the original image, and greater values + increase the contrast of the image. .. py:class:: Brightness(image) Adjust image brightness. - This class can be used to control the brightness of an image. An - enhancement factor of 0.0 gives a black image. A factor of 1.0 gives the - original image. + This class can be used to control the brightness of an image. An + :ref:`enhancement factor ` of 0.0 gives a black image, + a factor of 1.0 gives the original image, and greater values increase the + brightness of the image. .. py:class:: Sharpness(image) Adjust image sharpness. This class can be used to adjust the sharpness of an image. An - enhancement factor of 0.0 gives a blurred image, a factor of 1.0 gives the - original image, and a factor of 2.0 gives a sharpened image. + :ref:`enhancement factor ` of 0.0 gives a blurred + image, a factor of 1.0 gives the original image, and a factor of 2.0 gives + a sharpened image. diff --git a/docs/reference/ImageFile.rst b/docs/reference/ImageFile.rst index 3cf59c610..047990f1c 100644 --- a/docs/reference/ImageFile.rst +++ b/docs/reference/ImageFile.rst @@ -15,7 +15,7 @@ and **xmllib** modules. Example: Parse an image ----------------------- -.. code-block:: python +:: from PIL import ImageFile diff --git a/docs/reference/ImageFilter.rst b/docs/reference/ImageFilter.rst index c85da4fb5..044aede62 100644 --- a/docs/reference/ImageFilter.rst +++ b/docs/reference/ImageFilter.rst @@ -11,7 +11,7 @@ filters, which can be be used with the :py:meth:`Image.filter() Example: Filter an image ------------------------ -.. code-block:: python +:: from PIL import ImageFilter diff --git a/docs/reference/ImageFont.rst b/docs/reference/ImageFont.rst index 516fa63a7..6edf4b05c 100644 --- a/docs/reference/ImageFont.rst +++ b/docs/reference/ImageFont.rst @@ -10,7 +10,7 @@ this class store bitmap fonts, and are used with the PIL uses its own font file format to store bitmap fonts, limited to 256 characters. You can use `pilfont.py `_ -from `pillow-scripts `_ to convert BDF and +from :pypi:`pillow-scripts` to convert BDF and PCF font descriptors (X window font formats) to this format. Starting with version 1.1.4, PIL can be configured to support TrueType and @@ -18,10 +18,19 @@ OpenType fonts (as well as other font formats supported by the FreeType library). For earlier versions, TrueType support is only available as part of the imToolkit package. +.. warning:: + To protect against potential DOS attacks when using arbitrary strings as + text input, Pillow will raise a :py:exc:`ValueError` if the number of characters + is over a certain limit, :py:data:`MAX_STRING_LENGTH`. + + This threshold can be changed by setting + :py:data:`MAX_STRING_LENGTH`. It can be disabled by setting + ``ImageFont.MAX_STRING_LENGTH = None``. + Example ------- -.. code-block:: python +:: from PIL import ImageFont, ImageDraw @@ -61,15 +70,23 @@ Methods Constants --------- -.. data:: PIL.ImageFont.Layout.BASIC +.. class:: Layout - Use basic text layout for TrueType font. - Advanced features such as text direction are not supported. + .. py:attribute:: BASIC -.. data:: PIL.ImageFont.Layout.RAQM + Use basic text layout for TrueType font. + Advanced features such as text direction are not supported. - Use Raqm text layout for TrueType font. - Advanced features are supported. + .. py:attribute:: RAQM - Requires Raqm, you can check support using - :py:func:`PIL.features.check_feature` with ``feature="raqm"``. + Use Raqm text layout for TrueType font. + Advanced features are supported. + + Requires Raqm, you can check support using + :py:func:`PIL.features.check_feature` with ``feature="raqm"``. + +.. data:: MAX_STRING_LENGTH + + Set to 1,000,000, to protect against potential DOS attacks. Pillow will + raise a :py:exc:`ValueError` if the number of characters is over this limit. The + check can be disabled by setting ``ImageFont.MAX_STRING_LENGTH = None``. diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst index 3086ba8c3..0b94032d5 100644 --- a/docs/reference/ImageGrab.rst +++ b/docs/reference/ImageGrab.rst @@ -15,8 +15,9 @@ or the clipboard to a PIL image memory. returned as an "RGBA" on macOS, or an "RGB" image otherwise. If the bounding box is omitted, the entire screen is copied. - On Linux, if ``xdisplay`` is ``None`` then ``gnome-screenshot`` will be used if it - is installed. To capture the default X11 display instead, pass ``xdisplay=""``. + On Linux, if ``xdisplay`` is ``None`` and the default X11 display does not return + a snapshot of the screen, ``gnome-screenshot`` will be used as fallback if it is + installed. To disable this behaviour, pass ``xdisplay=""`` instead. .. versionadded:: 1.1.3 (Windows), 3.0.0 (macOS), 7.1.0 (Linux) @@ -39,9 +40,11 @@ or the clipboard to a PIL image memory. .. py:function:: grabclipboard() - Take a snapshot of the clipboard image, if any. Only macOS and Windows are currently supported. + Take a snapshot of the clipboard image, if any. - .. versionadded:: 1.1.4 (Windows), 3.3.0 (macOS) + On Linux, ``wl-paste`` or ``xclip`` is required. + + .. versionadded:: 1.1.4 (Windows), 3.3.0 (macOS), 9.4.0 (Linux) :return: On Windows, an image, a list of filenames, or None if the clipboard does not contain image data or filenames. @@ -49,3 +52,5 @@ or the clipboard to a PIL image memory. On Mac, an image, or None if the clipboard does not contain image data. + + On Linux, an image. diff --git a/docs/reference/ImageMath.rst b/docs/reference/ImageMath.rst index 63f88fddd..ee07efa01 100644 --- a/docs/reference/ImageMath.rst +++ b/docs/reference/ImageMath.rst @@ -11,7 +11,7 @@ an expression string and one or more images. Example: Using the :py:mod:`~PIL.ImageMath` module -------------------------------------------------- -.. code-block:: python +:: from PIL import Image, ImageMath @@ -72,7 +72,7 @@ pixel bits. Note that the operands are converted to 32-bit signed integers before the bitwise operation is applied. This means that you’ll get negative values if -you invert an ordinary greyscale image. You can use the and (&) operator to +you invert an ordinary grayscale image. You can use the and (&) operator to mask off unwanted bits. Bitwise operators don’t work on floating point images. diff --git a/docs/reference/ImageOps.rst b/docs/reference/ImageOps.rst index d1c43cf60..475253078 100644 --- a/docs/reference/ImageOps.rst +++ b/docs/reference/ImageOps.rst @@ -12,14 +12,11 @@ only work on L and RGB images. .. autofunction:: autocontrast .. autofunction:: colorize -.. autofunction:: contain -.. autofunction:: pad .. autofunction:: crop .. autofunction:: scale .. autofunction:: deform .. autofunction:: equalize .. autofunction:: expand -.. autofunction:: fit .. autofunction:: flip .. autofunction:: grayscale .. autofunction:: invert @@ -27,3 +24,38 @@ only work on L and RGB images. .. autofunction:: posterize .. autofunction:: solarize .. autofunction:: exif_transpose + +.. _relative-resize: + +Resize relative to a given size +------------------------------- + +:: + + from PIL import Image, ImageOps + size = (100, 150) + with Image.open("Tests/images/hopper.png") as im: + ImageOps.contain(im, size).save("imageops_contain.png") + ImageOps.cover(im, size).save("imageops_cover.png") + ImageOps.fit(im, size).save("imageops_fit.png") + ImageOps.pad(im, size, color="#f00").save("imageops_pad.png") + + # thumbnail() can also be used, + # but will modify the image object in place + im.thumbnail(size) + im.save("imageops_thumbnail.png") + ++----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | ++================+===========================================+============================================+==========================================+========================================+========================================+ +|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ++----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +|Resulting image | .. image:: ../example/image_thumbnail.png | .. image:: ../example/imageops_contain.png | .. image:: ../example/imageops_cover.png | .. image:: ../example/imageops_fit.png | .. image:: ../example/imageops_pad.png | ++----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ +|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | ++----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ + +.. autofunction:: contain +.. autofunction:: cover +.. autofunction:: fit +.. autofunction:: pad diff --git a/docs/reference/ImagePath.rst b/docs/reference/ImagePath.rst index b9bdfc507..500096ef7 100644 --- a/docs/reference/ImagePath.rst +++ b/docs/reference/ImagePath.rst @@ -48,7 +48,7 @@ vector data. Path objects can be passed to the methods on the Maps the path through a function. -.. py:method:: PIL.ImagePath.Path.tolist(flat=0) +.. py:method:: PIL.ImagePath.Path.tolist(flat=False) Converts the path to a Python list [(x, y), …]. @@ -60,9 +60,7 @@ vector data. Path objects can be passed to the methods on the .. py:method:: PIL.ImagePath.Path.transform(matrix) Transforms the path in place, using an affine transform. The matrix is a - 6-tuple (a, b, c, d, e, f), and each point is mapped as follows: - - .. code-block:: python + 6-tuple (a, b, c, d, e, f), and each point is mapped as follows:: xOut = xIn * a + yIn * b + c yOut = xIn * d + yIn * e + f diff --git a/docs/reference/ImageQt.rst b/docs/reference/ImageQt.rst index 15d052d1c..7e67a44d3 100644 --- a/docs/reference/ImageQt.rst +++ b/docs/reference/ImageQt.rst @@ -4,16 +4,8 @@ :py:mod:`~PIL.ImageQt` Module ============================= -The :py:mod:`~PIL.ImageQt` module contains support for creating PyQt6, PySide6, PyQt5 -or PySide2 QImage objects from PIL images. - -`Qt 5 reached end-of-life `_ on 2020-12-08 for -open-source users (and will reach EOL on 2023-12-08 for commercial licence holders). - -Support for PyQt5 and PySide2 has been deprecated from ``ImageQt`` and will be removed -in Pillow 10 (2023-07-01). Upgrade to -`PyQt6 `_ or -`PySide6 `_ instead. +The :py:mod:`~PIL.ImageQt` module contains support for creating PyQt6 or PySide6 +QImage objects from PIL images. .. versionadded:: 1.1.6 @@ -22,7 +14,7 @@ in Pillow 10 (2023-07-01). Upgrade to Creates an :py:class:`~PIL.ImageQt.ImageQt` object from a PIL :py:class:`~PIL.Image.Image` object. This class is a subclass of QtGui.QImage, which means that you can pass the resulting objects directly - to PyQt6/PySide6/PyQt5/PySide2 API functions and methods. + to PyQt6/PySide6 API functions and methods. This operation is currently supported for mode 1, L, P, RGB, and RGBA images. To handle other modes, you need to convert the image first. diff --git a/docs/reference/ImageSequence.rst b/docs/reference/ImageSequence.rst index f2e7d9edd..a27b2fb4e 100644 --- a/docs/reference/ImageSequence.rst +++ b/docs/reference/ImageSequence.rst @@ -10,7 +10,7 @@ iterate over the frames of an image sequence. Extracting frames from an animation ----------------------------------- -.. code-block:: python +:: from PIL import Image, ImageSequence diff --git a/docs/reference/ImageWin.rst b/docs/reference/ImageWin.rst index 2ee3cadb7..4151be4a7 100644 --- a/docs/reference/ImageWin.rst +++ b/docs/reference/ImageWin.rst @@ -9,9 +9,7 @@ Windows. ImageWin can be used with PythonWin and other user interface toolkits that provide access to Windows device contexts or window handles. For example, -Tkinter makes the window handle available via the winfo_id method: - -.. code-block:: python +Tkinter makes the window handle available via the winfo_id method:: from PIL import ImageWin diff --git a/docs/reference/PixelAccess.rst b/docs/reference/PixelAccess.rst index b234b7b4e..04d6f5dcd 100644 --- a/docs/reference/PixelAccess.rst +++ b/docs/reference/PixelAccess.rst @@ -18,9 +18,7 @@ Example ------- The following script loads an image, accesses one pixel from it, then -changes it. - -.. code-block:: python +changes it. :: from PIL import Image @@ -35,9 +33,7 @@ Results in the following:: (23, 24, 68) (0, 0, 0) -Access using negative indexes is also possible. - -.. code-block:: python +Access using negative indexes is also possible. :: px[-1, -1] = (0, 0, 0) print(px[-1, -1]) diff --git a/docs/reference/PyAccess.rst b/docs/reference/PyAccess.rst index f9eb9b524..ed58ca3a5 100644 --- a/docs/reference/PyAccess.rst +++ b/docs/reference/PyAccess.rst @@ -17,9 +17,7 @@ The :py:mod:`~PIL.PyAccess` module provides a CFFI/Python implementation of the Example ------- -The following script loads an image, accesses one pixel from it, then changes it. - -.. code-block:: python +The following script loads an image, accesses one pixel from it, then changes it. :: from PIL import Image @@ -34,9 +32,7 @@ Results in the following:: (23, 24, 68) (0, 0, 0) -Access using negative indexes is also possible. - -.. code-block:: python +Access using negative indexes is also possible. :: px[-1, -1] = (0, 0, 0) print(px[-1, -1]) diff --git a/docs/reference/c_extension_debugging.rst b/docs/reference/c_extension_debugging.rst index dc4c2bf94..5e8586905 100644 --- a/docs/reference/c_extension_debugging.rst +++ b/docs/reference/c_extension_debugging.rst @@ -10,19 +10,13 @@ distributions. - ``python3-dbg`` package for the gdb extensions and python symbols - ``gdb`` and ``valgrind`` -- Potentially debug symbols for libraries. On ubuntu they're shipped - in package-dbgsym packages, from a different repo. +- Potentially debug symbols for libraries. On Ubuntu you can follow those + instructions to install the corresponding packages: `Debug Symbol Packages `_ -:: +Then ``sudo apt-get install libtiff5-dbgsym`` - deb http://ddebs.ubuntu.com focal main restricted universe multiverse - deb http://ddebs.ubuntu.com focal-updates main restricted universe multiverse - deb http://ddebs.ubuntu.com focal-proposed main restricted universe multiverse - -Then ``sudo apt-get update && sudo apt-get install libtiff5-dbgsym`` - -- There's a bug with the dbg package for at least python 3.8 on ubuntu - 20.04, and you need to add a new link or two to make it autoload when +- There's a bug with the ``python3-dbg`` package for at least Python 3.8 on + Ubuntu 20.04, and you need to add a new link or two to make it autoload when running python: :: diff --git a/docs/reference/open_files.rst b/docs/reference/open_files.rst index 6bfd50588..f31941c9a 100644 --- a/docs/reference/open_files.rst +++ b/docs/reference/open_files.rst @@ -61,9 +61,7 @@ Image Lifecycle * ``Image.Image.close()`` Closes the file and destroys the core image object. The Pillow context manager will also close the file, but will not destroy - the core image object. e.g.: - -.. code-block:: python + the core image object. e.g.:: with Image.open("test.jpg") as img: img.load() diff --git a/docs/reference/plugins.rst b/docs/reference/plugins.rst index fcf4514a8..18cd99cf3 100644 --- a/docs/reference/plugins.rst +++ b/docs/reference/plugins.rst @@ -33,6 +33,14 @@ Plugin reference :undoc-members: :show-inheritance: +:mod:`~PIL.DdsImagePlugin` Module +--------------------------------- + +.. automodule:: PIL.DdsImagePlugin + :members: + :undoc-members: + :show-inheritance: + :mod:`~PIL.EpsImagePlugin` Module --------------------------------- diff --git a/docs/releasenotes/10.0.0.rst b/docs/releasenotes/10.0.0.rst new file mode 100644 index 000000000..a3f238119 --- /dev/null +++ b/docs/releasenotes/10.0.0.rst @@ -0,0 +1,209 @@ +10.0.0 +------ + +Backwards Incompatible Changes +============================== + +Categories +^^^^^^^^^^ + +``im.category`` has been removed, along with the related ``Image.NORMAL``, +``Image.SEQUENCE`` and ``Image.CONTAINER`` attributes. + +To determine if an image has multiple frames or not, +``getattr(im, "is_animated", False)`` can be used instead. + +Tk/Tcl 8.4 +^^^^^^^^^^ + +Support for Tk/Tcl 8.4 has been removed. + +JpegImagePlugin.convert_dict_qtables +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Since deprecation in Pillow 8.3.0, the ``convert_dict_qtables`` method no longer +performed any operations on the data given to it, and has been removed. + +ImagePalette size parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Before Pillow 8.3.0, ``ImagePalette`` required palette data of particular lengths by +default, and the ``size`` parameter could be used to override that. Pillow 8.3.0 +removed the default required length, also removing the need for the ``size`` parameter. + +ImageShow.Viewer.show_file file argument +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``file`` argument in :py:meth:`~PIL.ImageShow.Viewer.show_file()` has been +removed and replaced by ``path``. + +In effect, ``viewer.show_file("test.jpg")`` will continue to work unchanged. + +Constants +^^^^^^^^^ + +A number of constants have been removed. +Instead, ``enum.IntEnum`` classes have been added. + +===================================================== ============================================================ +Removed Use instead +===================================================== ============================================================ +``Image.LINEAR`` ``Image.BILINEAR`` or ``Image.Resampling.BILINEAR`` +``Image.CUBIC`` ``Image.BICUBIC`` or ``Image.Resampling.BICUBIC`` +``Image.ANTIALIAS`` ``Image.LANCZOS`` or ``Image.Resampling.LANCZOS`` +``ImageCms.INTENT_PERCEPTUAL`` ``ImageCms.Intent.PERCEPTUAL`` +``ImageCms.INTENT_RELATIVE_COLORMETRIC`` ``ImageCms.Intent.RELATIVE_COLORMETRIC`` +``ImageCms.INTENT_SATURATION`` ``ImageCms.Intent.SATURATION`` +``ImageCms.INTENT_ABSOLUTE_COLORIMETRIC`` ``ImageCms.Intent.ABSOLUTE_COLORIMETRIC`` +``ImageCms.DIRECTION_INPUT`` ``ImageCms.Direction.INPUT`` +``ImageCms.DIRECTION_OUTPUT`` ``ImageCms.Direction.OUTPUT`` +``ImageCms.DIRECTION_PROOF`` ``ImageCms.Direction.PROOF`` +``ImageFont.LAYOUT_BASIC`` ``ImageFont.Layout.BASIC`` +``ImageFont.LAYOUT_RAQM`` ``ImageFont.Layout.RAQM`` +``BlpImagePlugin.BLP_FORMAT_JPEG`` ``BlpImagePlugin.Format.JPEG`` +``BlpImagePlugin.BLP_ENCODING_UNCOMPRESSED`` ``BlpImagePlugin.Encoding.UNCOMPRESSED`` +``BlpImagePlugin.BLP_ENCODING_DXT`` ``BlpImagePlugin.Encoding.DXT`` +``BlpImagePlugin.BLP_ENCODING_UNCOMPRESSED_RAW_RGBA`` ``BlpImagePlugin.Encoding.UNCOMPRESSED_RAW_RGBA`` +``BlpImagePlugin.BLP_ALPHA_ENCODING_DXT1`` ``BlpImagePlugin.AlphaEncoding.DXT1`` +``BlpImagePlugin.BLP_ALPHA_ENCODING_DXT3`` ``BlpImagePlugin.AlphaEncoding.DXT3`` +``BlpImagePlugin.BLP_ALPHA_ENCODING_DXT5`` ``BlpImagePlugin.AlphaEncoding.DXT5`` +``FtexImagePlugin.FORMAT_DXT1`` ``FtexImagePlugin.Format.DXT1`` +``FtexImagePlugin.FORMAT_UNCOMPRESSED`` ``FtexImagePlugin.Format.UNCOMPRESSED`` +``PngImagePlugin.APNG_DISPOSE_OP_NONE`` ``PngImagePlugin.Disposal.OP_NONE`` +``PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND`` ``PngImagePlugin.Disposal.OP_BACKGROUND`` +``PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS`` ``PngImagePlugin.Disposal.OP_PREVIOUS`` +``PngImagePlugin.APNG_BLEND_OP_SOURCE`` ``PngImagePlugin.Blend.OP_SOURCE`` +``PngImagePlugin.APNG_BLEND_OP_OVER`` ``PngImagePlugin.Blend.OP_OVER`` +===================================================== ============================================================ + +FitsStubImagePlugin +^^^^^^^^^^^^^^^^^^^ + +The stub image plugin ``FitsStubImagePlugin`` has been removed. +FITS images can be read without a handler through :mod:`~PIL.FitsImagePlugin` instead. + +Font size and offset methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Several functions for computing the size and offset of rendered text have been removed: + +=============================================================== ============================================================================================================= +Removed Use instead +=============================================================== ============================================================================================================= +``FreeTypeFont.getsize()`` and ``FreeTypeFont.getoffset()`` :py:meth:`.FreeTypeFont.getbbox` and :py:meth:`.FreeTypeFont.getlength` +``FreeTypeFont.getsize_multiline()`` :py:meth:`.ImageDraw.multiline_textbbox` +``ImageFont.getsize()`` :py:meth:`.ImageFont.getbbox` and :py:meth:`.ImageFont.getlength` +``TransposedFont.getsize()`` :py:meth:`.TransposedFont.getbbox` and :py:meth:`.TransposedFont.getlength` +``ImageDraw.textsize()`` and ``ImageDraw.multiline_textsize()`` :py:meth:`.ImageDraw.textbbox`, :py:meth:`.ImageDraw.textlength` and :py:meth:`.ImageDraw.multiline_textbbox` +``ImageDraw2.Draw.textsize()`` :py:meth:`.ImageDraw2.Draw.textbbox` and :py:meth:`.ImageDraw2.Draw.textlength` +=============================================================== ============================================================================================================= + +FreeTypeFont.getmask2 fill parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The undocumented ``fill`` parameter of :py:meth:`.FreeTypeFont.getmask2` has been +removed. + +PhotoImage.paste box parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``box`` parameter was unused and has been removed. + +PyQt5 and PySide2 +^^^^^^^^^^^^^^^^^ + +`Qt 5 reached end-of-life `_ on 2020-12-08 for +open-source users (and will reach EOL on 2023-12-08 for commercial licence holders). + +Support for PyQt5 and PySide2 has been removed from ``ImageQt``. Upgrade to +`PyQt6 `_ or +`PySide6 `_ instead. + +Image.coerce_e +^^^^^^^^^^^^^^ + +This undocumented method has been removed. + +Deprecations +============ + +PyAccess and Image.USE_CFFI_ACCESS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Since Pillow's C API is now faster than PyAccess on PyPy, +:py:mod:`~PIL.PyAccess` has been deprecated and will be removed in Pillow +11.0.0 (2024-10-15). Pillow's C API will now be used by default on PyPy instead. + +``Image.USE_CFFI_ACCESS``, for switching from the C API to PyAccess, is +similarly deprecated. + +API Changes +=========== + +Added line width parameter to ImageDraw regular_polygon +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +An optional line ``width`` parameter has been added to +``ImageDraw.Draw.regular_polygon``. + +API Additions +============= + +Added ``alpha_only`` argument to ``getbbox()`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.getbbox` now accepts a keyword argument of +``alpha_only``. This is an optional flag, defaulting to ``True``. If ``True`` +and the image has an alpha channel, trim transparent pixels. Otherwise, trim +pixels when all channels are zero. + +Security +======== + +Limit size even if one dimension is zero +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When performing decompression bomb checks, Pillow did not reject images with +excessive width and zero height, or zero width and excessive height. That has +now been fixed. + +This effectively dates to the PIL fork, since problem images would still have +been processed before Pillow started checking for decompression bombs. + +Added ImageFont.MAX_STRING_LENGTH +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:cve:`2023-44271`: To protect against potential DOS attacks when using arbitrary strings as text +input, Pillow will now raise a :py:exc:`ValueError` if the number of characters +passed into ImageFont methods is over a certain limit, +:py:data:`PIL.ImageFont.MAX_STRING_LENGTH`. + +This threshold can be changed by setting +:py:data:`PIL.ImageFont.MAX_STRING_LENGTH`. It can be disabled by setting +``ImageFont.MAX_STRING_LENGTH = None``. + +Other Changes +============= + +32-bit wheels +^^^^^^^^^^^^^ + +32-bit wheels are no longer provided. + +Support display_jpeg() in IPython +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In addition to ``display()`` and ``display_png``, ``display_jpeg()`` can now +also be used to display images in IPython:: + + from PIL import Image + from IPython.display import display_jpeg + + im = Image.new("RGB", (100, 100), (255, 0, 0)) + display_jpeg(im) + +Support reading signed 8-bit TIFF images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +TIFF images with signed integer data, 8 bits per sample and a photometric +interpretation of BlackIsZero can now be read. diff --git a/docs/releasenotes/10.0.1.rst b/docs/releasenotes/10.0.1.rst new file mode 100644 index 000000000..6ac30e7fc --- /dev/null +++ b/docs/releasenotes/10.0.1.rst @@ -0,0 +1,14 @@ +10.0.1 +------ + +Security +======== + +This release addresses :cve:`2023-4863`, by providing an updated install script and +updated wheels to include libwebp 1.3.2, preventing a potential heap buffer overflow +in WebP. + +Updated tests to pass with latest zlib version +============================================== + +The release of zlib 1.3 caused one of the tests in the Pillow test suite to fail. diff --git a/docs/releasenotes/10.1.0.rst b/docs/releasenotes/10.1.0.rst new file mode 100644 index 000000000..fd556bdf1 --- /dev/null +++ b/docs/releasenotes/10.1.0.rst @@ -0,0 +1,109 @@ +10.1.0 +------ + +API Changes +=========== + +Setting image mode +^^^^^^^^^^^^^^^^^^ + +If you attempt to set the mode of an image directly, e.g. +``im.mode = "RGBA"``, you will now receive an :py:exc:`AttributeError`. This is +not about removing existing functionality, but instead about raising an +explicit error to prevent later consequences. The ``convert`` method is the +correct way to change an image's mode. + +Accept a list in getpixel() +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.getpixel` now accepts a list of coordinates, as well +as a tuple. :: + + from PIL import Image + im = Image.new("RGB", (1, 1)) + im.getpixel((0, 0)) + im.getpixel([0, 0]) + +BoxBlur and GaussianBlur allow for different x and y radii +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:class:`~PIL.ImageFilter.BoxBlur` and +:py:class:`~PIL.ImageFilter.GaussianBlur` now allow a sequence of x and y radii +to be specified, rather than a single number for both dimensions. :: + + from PIL import ImageFilter + ImageFilter.BoxBlur((2, 5)) + ImageFilter.GaussianBlur((2, 5)) + +API Additions +============= + +EpsImagePlugin.gs_binary +^^^^^^^^^^^^^^^^^^^^^^^^ + +``EpsImagePlugin.gs_windows_binary`` stores the name of the Ghostscript +executable on Windows. ``EpsImagePlugin.gs_binary`` has now been added for all +platforms, and can be used to customise the name of the executable, or disable +use entirely through ``EpsImagePlugin.gs_binary = False``. + +has_transparency_data +^^^^^^^^^^^^^^^^^^^^^ + +Images now have :py:attr:`~PIL.Image.Image.has_transparency_data` to indicate +whether the image has transparency data, whether in the form of an alpha +channel, a palette with an alpha channel, or a "transparency" key in the +:py:attr:`~PIL.Image.Image.info` dictionary. + +Even if this attribute is true, the image might still appear solid, if all of +the values shown within are opaque. + +ImageOps.cover +^^^^^^^^^^^^^^ + +Returns a resized version of the image, so that the requested size is covered, +while maintaining the original aspect ratio. + +See :ref:`relative-resize` for a comparison between this and similar ``ImageOps`` +methods. + +size and font_size arguments when using default font +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Pillow has had a "better than nothing" default font, which can only be drawn at +one font size. Now, if FreeType support is available, a version of +`Aileron Regular `_ is loaded, which can be +drawn at chosen font sizes. + +The following ``size`` and ``font_size`` arguments can now be used to specify a +font size for this new builtin font:: + + ImageFont.load_default(size=24) + draw.text((0, 0), "test", font_size=24) + draw.textlength((0, 0), "test", font_size=24) + draw.textbbox((0, 0), "test", font_size=24) + draw.multiline_text((0, 0), "test", font_size=24) + draw.multiline_textbbox((0, 0), "test", font_size=24) + +Other Changes +============= + +Python 3.12 +^^^^^^^^^^^ + +Pillow 10.0.0 had wheels built against Python 3.12 beta, available as a preview to help +others prepare for 3.12, and to ensure Pillow could be used immediately at the release +of 3.12.0 final (2023-10-02, :pep:`693`). + +Pillow 10.1.0 now officially supports Python 3.12. + +Added support for DDS BC5U and 8-bit color indexed images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Support has been added to read BC5U DDS files as RGB images, and +PALETTEINDEXED8 DDS files as P mode images. + +Support reading signed 8-bit YCbCr TIFF images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +TIFF images with unsigned integer data, 8 bits per sample and a photometric +interpretation of YCbCr can now be read. diff --git a/docs/releasenotes/10.2.0.rst b/docs/releasenotes/10.2.0.rst new file mode 100644 index 000000000..9883f10ba --- /dev/null +++ b/docs/releasenotes/10.2.0.rst @@ -0,0 +1,93 @@ +10.2.0 +------ + +Backwards Incompatible Changes +============================== + +TODO +^^^^ + +TODO + +Deprecations +============ + +ImageFile.raise_oserror +^^^^^^^^^^^^^^^^^^^^^^^ + +``ImageFile.raise_oserror()`` has been deprecated and will be removed in Pillow +12.0.0 (2025-10-15). The function is undocumented and is only useful for translating +error codes returned by a codec's ``decode()`` method, which ImageFile already does +automatically. + +TODO +^^^^ + +TODO + +API Changes +=========== + +Zero or negative font size error +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When creating a :py:class:`~PIL.ImageFont.FreeTypeFont` instance, either directly or +through :py:func:`~PIL.ImageFont.truetype`, if the font size is zero or less, a +:py:exc:`ValueError` will now be raised. + +API Additions +============= + +Added DdsImagePlugin enums +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:class:`~PIL.DdsImagePlugin.DDSD`, :py:class:`~PIL.DdsImagePlugin.DDSCAPS`, +:py:class:`~PIL.DdsImagePlugin.DDSCAPS2`, :py:class:`~PIL.DdsImagePlugin.DDPF`, +:py:class:`~PIL.DdsImagePlugin.DXGI_FORMAT` and :py:class:`~PIL.DdsImagePlugin.D3DFMT` +enums have been added to :py:class:`PIL.DdsImagePlugin`. + +JPEG restart marker interval +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When saving JPEG files, ``restart_marker_blocks`` and ``restart_marker_rows`` can now +be used to emit restart markers whenever the specified number of MCU blocks or rows +have been produced. + +JPEG tables-only streamtype +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When saving JPEG files, ``streamtype`` can now be set to 1, for tables-only. This will +output only the quantization and Huffman tables for the image. + +Security +======== + +TODO +^^^^ + +TODO + +Other Changes +============= + +Added DDS BC4U and DX10 BC1 and BC4 reading +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Support has been added to read the BC4U format of DDS images. + +Support has also been added to read DX10 BC1 and BC4, whether UNORM or +TYPELESS. + +Optimized ImageStat.Stat count and extrema +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Calculating the :py:attr:`~PIL.ImageStat.Stat.count` and +:py:attr:`~PIL.ImageStat.Stat.extrema` statistics is now faster. After the +histogram is created in ``st = ImageStat.Stat(im)``, ``st.count`` is 3x as fast +on average and ``st.extrema`` is 12x as fast on average. + +Encoder errors now report error detail as string +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:exc:`OSError` exceptions from image encoders now include a textual description of +the error instead of a numeric error code. diff --git a/docs/releasenotes/2.7.0.rst b/docs/releasenotes/2.7.0.rst index dda814c1f..0b3eeeb49 100644 --- a/docs/releasenotes/2.7.0.rst +++ b/docs/releasenotes/2.7.0.rst @@ -29,84 +29,78 @@ Image resizing filters Image resizing methods :py:meth:`~PIL.Image.Image.resize` and :py:meth:`~PIL.Image.Image.thumbnail` take a ``resample`` argument, which tells which filter should be used for resampling. Possible values are: -:py:data:`PIL.Image.NEAREST`, :py:data:`PIL.Image.BILINEAR`, -:py:data:`PIL.Image.BICUBIC` and :py:data:`PIL.Image.ANTIALIAS`. -Almost all of them were changed in this version. +``NEAREST``, ``BILINEAR``, ``BICUBIC`` and ``ANTIALIAS``. Almost all of them +were changed in this version. Bicubic and bilinear downscaling ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -From the beginning :py:data:`~PIL.Image.BILINEAR` and -:py:data:`~PIL.Image.BICUBIC` filters were based on affine transformations -and used a fixed number of pixels from the source image for every destination -pixel (2x2 pixels for :py:data:`~PIL.Image.BILINEAR` and 4x4 for -:py:data:`~PIL.Image.BICUBIC`). This gave an unsatisfactory result for -downscaling. At the same time, a high quality convolutions-based algorithm with -flexible kernel was used for :py:data:`~PIL.Image.ANTIALIAS` filter. +From the beginning ``BILINEAR`` and ``BICUBIC`` filters were based on affine +transformations and used a fixed number of pixels from the source image for +every destination pixel (2x2 pixels for ``BILINEAR`` and 4x4 for ``BICUBIC``). +This gave an unsatisfactory result for downscaling. At the same time, a high +quality convolutions-based algorithm with flexible kernel was used for +``ANTIALIAS`` filter. Starting from Pillow 2.7.0, a high quality convolutions-based algorithm is used for all of these three filters. If you have previously used any tricks to maintain quality when downscaling with -:py:data:`~PIL.Image.BILINEAR` and :py:data:`~PIL.Image.BICUBIC` filters -(for example, reducing within several steps), they are unnecessary now. +``BILINEAR`` and ``BICUBIC`` filters (for example, reducing within several +steps), they are unnecessary now. Antialias renamed to Lanczos ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -A new :py:data:`PIL.Image.LANCZOS` constant was added instead of -:py:data:`~PIL.Image.ANTIALIAS`. +A new ``LANCZOS`` constant was added instead of ``ANTIALIAS``. -When :py:data:`~PIL.Image.ANTIALIAS` was initially added, it was the only -high-quality filter based on convolutions. It's name was supposed to reflect -this. Starting from Pillow 2.7.0 all resize method are based on convolutions. -All of them are antialias from now on. And the real name of the -:py:data:`~PIL.Image.ANTIALIAS` filter is Lanczos filter. +When ``ANTIALIAS`` was initially added, it was the only high-quality filter +based on convolutions. It's name was supposed to reflect this. Starting from +Pillow 2.7.0 all resize method are based on convolutions. All of them are +antialias from now on. And the real name of the ``ANTIALIAS`` filter is Lanczos +filter. -The :py:data:`~PIL.Image.ANTIALIAS` constant is left for backward compatibility -and is an alias for :py:data:`~PIL.Image.LANCZOS`. +The ``ANTIALIAS`` constant is left for backward compatibility and is an alias +for ``LANCZOS``. Lanczos upscaling quality ^^^^^^^^^^^^^^^^^^^^^^^^^ -The image upscaling quality with :py:data:`~PIL.Image.LANCZOS` filter was -almost the same as :py:data:`~PIL.Image.BILINEAR` due to bug. This has been fixed. +The image upscaling quality with ``LANCZOS`` filter was almost the same as +``BILINEAR`` due to a bug. This has been fixed. Bicubic upscaling quality ^^^^^^^^^^^^^^^^^^^^^^^^^ -The :py:data:`~PIL.Image.BICUBIC` filter for affine transformations produced -sharp, slightly pixelated image for upscaling. Bicubic for convolutions is -more soft. +The ``BICUBIC`` filter for affine transformations produced sharp, slightly +pixelated image for upscaling. Bicubic for convolutions is more soft. Resize performance ^^^^^^^^^^^^^^^^^^ In most cases, convolution is more a expensive algorithm for downscaling because it takes into account all the pixels of source image. Therefore -:py:data:`~PIL.Image.BILINEAR` and :py:data:`~PIL.Image.BICUBIC` filters' -performance can be lower than before. On the other hand the quality of -:py:data:`~PIL.Image.BILINEAR` and :py:data:`~PIL.Image.BICUBIC` was close to -:py:data:`~PIL.Image.NEAREST`. So if such quality is suitable for your tasks -you can switch to :py:data:`~PIL.Image.NEAREST` filter for downscaling, -which will give a huge improvement in performance. +``BILINEAR`` and ``BICUBIC`` filters' performance can be lower than before. +On the other hand the quality of ``BILINEAR`` and ``BICUBIC`` was close to +``NEAREST``. So if such quality is suitable for your tasks you can switch to +``NEAREST`` filter for downscaling, which will give a huge improvement in +performance. At the same time performance of convolution resampling for downscaling has been improved by around a factor of two compared to the previous version. -The upscaling performance of the :py:data:`~PIL.Image.LANCZOS` filter has -remained the same. For :py:data:`~PIL.Image.BILINEAR` filter it has improved by -1.5 times and for :py:data:`~PIL.Image.BICUBIC` by four times. +The upscaling performance of the ``LANCZOS`` filter has remained the same. For +``BILINEAR`` filter it has improved by 1.5 times and for ``BICUBIC`` by four +times. Default filter for thumbnails ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ In Pillow 2.5 the default filter for :py:meth:`~PIL.Image.Image.thumbnail` was -changed from :py:data:`~PIL.Image.NEAREST` to :py:data:`~PIL.Image.ANTIALIAS`. -Antialias was chosen because all the other filters gave poor quality for -reduction. Starting from Pillow 2.7.0, :py:data:`~PIL.Image.ANTIALIAS` has been -replaced with :py:data:`~PIL.Image.BICUBIC`, because it's faster and -:py:data:`~PIL.Image.ANTIALIAS` doesn't give any advantages after -downscaling with libjpeg, which uses supersampling internally, not convolutions. +changed from ``NEAREST`` to ``ANTIALIAS``. Antialias was chosen because all the +other filters gave poor quality for reduction. Starting from Pillow 2.7.0, +``ANTIALIAS`` has been replaced with ``BICUBIC``, because it's faster and +``ANTIALIAS`` doesn't give any advantages after downscaling with libjpeg, which +uses supersampling internally, not convolutions. Image transposition ------------------- diff --git a/docs/releasenotes/2.8.0.rst b/docs/releasenotes/2.8.0.rst index c522fe8b0..4dbbc0bdd 100644 --- a/docs/releasenotes/2.8.0.rst +++ b/docs/releasenotes/2.8.0.rst @@ -10,7 +10,7 @@ operations. As a result PIL was unable to open them as images, requiring a wrap ``cStringIO`` or ``BytesIO``. Now new functionality has been added to ``Image.open()`` by way of an ``.seek(0)`` check and -catch on exception ``AttributeError`` or ``io.UnsupportedOperation``. If this is caught we +catch on exception :py:exc:`AttributeError` or :py:exc:`io.UnsupportedOperation`. If this is caught we attempt to wrap the object using ``io.BytesIO`` (which will only work on buffer-file-like objects). diff --git a/docs/releasenotes/3.0.0.rst b/docs/releasenotes/3.0.0.rst index 67569d337..e8eada73c 100644 --- a/docs/releasenotes/3.0.0.rst +++ b/docs/releasenotes/3.0.0.rst @@ -49,4 +49,3 @@ The external dependencies on libjpeg and zlib are now required by default. If the headers or libraries are not found, then installation will abort with an error. This behaviour can be disabled with the ``--disable-libjpeg`` and ``--disable-zlib`` flags. - diff --git a/docs/releasenotes/3.1.1.rst b/docs/releasenotes/3.1.1.rst index 38118ea39..5d60e116c 100644 --- a/docs/releasenotes/3.1.1.rst +++ b/docs/releasenotes/3.1.1.rst @@ -6,7 +6,7 @@ CVE-2016-0740 -- Buffer overflow in TiffDecode.c ------------------------------------------------ Pillow 3.1.0 and earlier when linked against libtiff >= 4.0.0 on x64 -may overflow a buffer when reading a specially crafted tiff file (:cve:`CVE-2016-0740`). +may overflow a buffer when reading a specially crafted tiff file (:cve:`2016-0740`). Specifically, libtiff >= 4.0.0 changed the return type of ``TIFFScanlineSize`` from ``int32`` to machine dependent @@ -24,7 +24,7 @@ CVE-2016-0775 -- Buffer overflow in FliDecode.c ----------------------------------------------- In all versions of Pillow, dating back at least to the last PIL 1.1.7 -release, FliDecode.c has a buffer overflow error (:cve:`CVE-2016-0775`). +release, FliDecode.c has a buffer overflow error (:cve:`2016-0775`). Around line 192: @@ -53,7 +53,7 @@ CVE-2016-2533 -- Buffer overflow in PcdDecode.c ----------------------------------------------- In all versions of Pillow, dating back at least to the last PIL 1.1.7 -release, ``PcdDecode.c`` has a buffer overflow error (:cve:`CVE-2016-2533`). +release, ``PcdDecode.c`` has a buffer overflow error (:cve:`2016-2533`). The ``state.buffer`` for ``PcdDecode.c`` is allocated based on a 3 bytes per pixel sizing, where ``PcdDecode.c`` wrote into the buffer diff --git a/docs/releasenotes/3.1.2.rst b/docs/releasenotes/3.1.2.rst index b5f7cfe99..04325ad86 100644 --- a/docs/releasenotes/3.1.2.rst +++ b/docs/releasenotes/3.1.2.rst @@ -7,7 +7,7 @@ CVE-2016-3076 -- Buffer overflow in Jpeg2KEncode.c Pillow between 2.5.0 and 3.1.1 may overflow a buffer when writing large Jpeg2000 files, allowing for code execution or other memory -corruption (:cve:`CVE-2016-3076`). +corruption (:cve:`2016-3076`). This occurs specifically in the function ``j2k_encode_entry``, at the line: diff --git a/docs/releasenotes/3.3.2.rst b/docs/releasenotes/3.3.2.rst index 68a09a3c8..8845b976a 100644 --- a/docs/releasenotes/3.3.2.rst +++ b/docs/releasenotes/3.3.2.rst @@ -34,7 +34,3 @@ image size can lead to a smaller allocation than expected, leading to arbitrary writes. This issue was found by Cris Neckar at Divergent Security. - - - - diff --git a/docs/releasenotes/3.4.0.rst b/docs/releasenotes/3.4.0.rst index dc5e2e295..2bbafe741 100644 --- a/docs/releasenotes/3.4.0.rst +++ b/docs/releasenotes/3.4.0.rst @@ -19,7 +19,7 @@ Deprecation Warning when Saving JPEGs JPEG images cannot contain an alpha channel. Pillow prior to 3.4.0 silently drops the alpha channel. With this release Pillow will now -issue a ``DeprecationWarning`` when attempting to save a ``RGBA`` mode +issue a :py:exc:`DeprecationWarning` when attempting to save a ``RGBA`` mode image as a JPEG. This will become an error in Pillow 4.2. New DDS Decoders diff --git a/docs/releasenotes/4.0.0.rst b/docs/releasenotes/4.0.0.rst index cbf131c93..5778de26a 100644 --- a/docs/releasenotes/4.0.0.rst +++ b/docs/releasenotes/4.0.0.rst @@ -17,8 +17,8 @@ Pillow 4.0 supports Python 3.6. OleFileIO.py ============ -OleFileIO.py has been removed as a vendored file and is now installed -from the upstream olefile pypi package. All internal dependencies are +``OleFileIO.py`` has been removed as a vendored file and is now installed +from the upstream :pypi:`olefile` PyPI package. All internal dependencies are redirected to the olefile package. Direct accesses to ``PIL.OlefileIO`` raises a deprecation warning, then patches the upstream olefile into ``sys.modules`` in its place. diff --git a/docs/releasenotes/4.1.1.rst b/docs/releasenotes/4.1.1.rst index 7aa3c1fbf..1b5757015 100644 --- a/docs/releasenotes/4.1.1.rst +++ b/docs/releasenotes/4.1.1.rst @@ -20,5 +20,3 @@ CPython 3.6.1 to not work on installations of C-Python 3.6.0. This fix undefines PySlice_GetIndicesEx if it exists to restore compatibility with both 3.6.0 and 3.6.1. See https://bugs.python.org/issue29943 for more details. - - diff --git a/docs/releasenotes/4.2.1.rst b/docs/releasenotes/4.2.1.rst index c9e953da4..0730936fe 100644 --- a/docs/releasenotes/4.2.1.rst +++ b/docs/releasenotes/4.2.1.rst @@ -8,4 +8,3 @@ Fixed Windows PyPy Build A change in the 4.2.0 cycle broke the Windows PyPy build. This has been fixed, and PyPy is now part of the Windows CI matrix. - diff --git a/docs/releasenotes/5.0.0.rst b/docs/releasenotes/5.0.0.rst index 509edbe6d..be00a45cd 100644 --- a/docs/releasenotes/5.0.0.rst +++ b/docs/releasenotes/5.0.0.rst @@ -28,7 +28,7 @@ Scripts The scripts formerly installed by Pillow have been split into a separate package, pillow-scripts, living at -https://github.com/python-pillow/pillow-scripts . +https://github.com/python-pillow/pillow-scripts. API Changes @@ -37,7 +37,7 @@ API Changes OleFileIO.py ^^^^^^^^^^^^ -The olefile module is no longer a required dependency when installing Pillow. +The :pypi:`olefile` module is no longer a required dependency when installing Pillow. Support for plugins requiring olefile will not be loaded if it is not installed. This allows library consumers to avoid installing this dependency if they choose. Some library consumers have little interest in the format diff --git a/docs/releasenotes/5.3.0.rst b/docs/releasenotes/5.3.0.rst index bff56566b..8f276da24 100644 --- a/docs/releasenotes/5.3.0.rst +++ b/docs/releasenotes/5.3.0.rst @@ -8,7 +8,7 @@ Image size ^^^^^^^^^^ If you attempt to set the size of an image directly, e.g. -``im.size = (100, 100)``, you will now receive an ``AttributeError``. This is +``im.size = (100, 100)``, you will now receive an :py:exc:`AttributeError`. This is not about removing existing functionality, but instead about raising an explicit error to prevent later consequences. The ``resize`` method is the correct way to change an image's size. @@ -16,7 +16,8 @@ correct way to change an image's size. The exceptions to this are: * The ICO and ICNS image formats, which use ``im.size = (100, 100)`` to select a subimage. -* The TIFF image format, which now has a ``DeprecationWarning`` for this action, as direct image size setting was previously necessary to work around an issue with tile extents. +* The TIFF image format, which now has a :py:exc:`DeprecationWarning` for this action, + as direct image size setting was previously necessary to work around an issue with tile extents. API Additions diff --git a/docs/releasenotes/5.4.1.rst b/docs/releasenotes/5.4.1.rst index 78f483db6..bbabd6520 100644 --- a/docs/releasenotes/5.4.1.rst +++ b/docs/releasenotes/5.4.1.rst @@ -15,7 +15,7 @@ PNG: Handle IDAT chunks after image end Some PNG images have multiple IDAT chunks. In some cases, Pillow will stop reading image data before the IDAT chunks finish. A regression caused an -``EOFError`` exception when previously there was none. This is now fixed, and +:py:exc:`EOFError` exception when previously there was none. This is now fixed, and file reading continues in case there are subsequent text chunks. PNG: MIME type @@ -30,7 +30,7 @@ File closing ^^^^^^^^^^^^ A regression caused an unsupported image file to report a -``ValueError: seek of closed file`` exception instead of an ``OSError``. This +``ValueError: seek of closed file`` exception instead of an :py:exc:`OSError`. This has been fixed by ensuring that image plugins only close their internal ``__fp`` if they are not the same as ``ImageFile``'s ``fp``, allowing each to manage their own file pointers. diff --git a/docs/releasenotes/6.0.0.rst b/docs/releasenotes/6.0.0.rst index 3e3b945a0..5e69f0b6b 100644 --- a/docs/releasenotes/6.0.0.rst +++ b/docs/releasenotes/6.0.0.rst @@ -14,8 +14,8 @@ Pillow for Python 3.4 is 5.4.1. Removed deprecated PIL.OleFileIO ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -PIL.OleFileIO was removed as a vendored file and in Pillow 4.0.0 (2017-01) in favour of -the upstream olefile Python package, and replaced with an ``ImportError``. The +``PIL.OleFileIO`` was removed as a vendored file and in Pillow 4.0.0 (2017-01) in favour of +the upstream :pypi:`olefile` Python package, and replaced with an :py:exc:`ImportError`. The deprecated file has now been removed from Pillow. If needed, install from PyPI (eg. ``python3 -m pip install olefile``). @@ -103,7 +103,7 @@ ImageCms.CmsProfile attributes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Some attributes in ``ImageCms.CmsProfile`` have been deprecated since Pillow 3.2.0. From -6.0.0, they issue a ``DeprecationWarning``: +6.0.0, they issue a :py:exc:`DeprecationWarning`: ======================== =============================== Deprecated Use instead diff --git a/docs/releasenotes/6.1.0.rst b/docs/releasenotes/6.1.0.rst index eb4304843..ce3edc5fa 100644 --- a/docs/releasenotes/6.1.0.rst +++ b/docs/releasenotes/6.1.0.rst @@ -13,16 +13,12 @@ Implicitly closing the image's underlying file in ``Image.__del__`` has been dep Use a context manager or call ``Image.close()`` instead to close the file in a deterministic way. -Deprecated: - -.. code-block:: python +Deprecated:: im = Image.open("hopper.png") im.save("out.jpg") -Use instead: - -.. code-block:: python +Use instead:: with Image.open("hopper.png") as im: im.save("out.jpg") @@ -33,10 +29,10 @@ API Additions Image.entropy ^^^^^^^^^^^^^ Calculates and returns the entropy for the image. A bilevel image (mode "1") is treated -as a greyscale ("L") image by this method. If a mask is provided, the method employs +as a grayscale ("L") image by this method. If a mask is provided, the method employs the histogram for those parts of the image where the mask image is non-zero. The mask image must have the same size as the image, and be either a bi-level image (mode "1") or -a greyscale image ("L"). +a grayscale image ("L"). ImageGrab.grab ^^^^^^^^^^^^^^ @@ -62,7 +58,7 @@ file. ``ImageFont.FreeTypeFont`` has four new methods, :py:meth:`PIL.ImageFont.FreeTypeFont.set_variation_by_name` for using named styles, and :py:meth:`PIL.ImageFont.FreeTypeFont.get_variation_axes` and :py:meth:`PIL.ImageFont.FreeTypeFont.set_variation_by_axes` for using font axes -instead. An ``IOError`` will be raised if the font is not a variation font. FreeType +instead. An :py:exc:`IOError` will be raised if the font is not a variation font. FreeType 2.9.1 or greater is required. Other Changes @@ -79,9 +75,7 @@ Image quality for JPEG compressed TIFF The TIFF encoder accepts a ``quality`` parameter for ``jpeg`` compressed TIFF files. A value from 0 (worst) to 100 (best) controls the image quality, similar to the JPEG -encoder. The default is 75. For example: - -.. code-block:: python +encoder. The default is 75. For example:: im.save("out.tif", compression="jpeg", quality=85) diff --git a/docs/releasenotes/6.2.0.rst b/docs/releasenotes/6.2.0.rst index 20a009cc1..7daac1b19 100644 --- a/docs/releasenotes/6.2.0.rst +++ b/docs/releasenotes/6.2.0.rst @@ -10,9 +10,7 @@ Text stroking ``stroke_width`` and ``stroke_fill`` arguments have been added to text drawing operations. They allow text to be outlined, setting the width of the stroke and and the color respectively. If not provided, ``stroke_fill`` will default to -the ``fill`` parameter. - -.. code-block:: python +the ``fill`` parameter. :: from PIL import Image, ImageDraw, ImageFont @@ -28,9 +26,7 @@ the ``fill`` parameter. draw.multiline_text((10, 10), "A\nB", "#f00", font, stroke_width=2, stroke_fill="#0f0") -For example, - -.. code-block:: python +For example, :: from PIL import Image, ImageDraw, ImageFont @@ -73,7 +69,7 @@ Security ======== This release catches several buffer overruns, as well as addressing -:cve:`CVE-2019-16865`. The CVE is regarding DOS problems, such as consuming large +:cve:`2019-16865`. The CVE is regarding DOS problems, such as consuming large amounts of memory, or taking a large amount of time to process an image. In RawDecode.c, an error is now thrown if skip is calculated to be less than diff --git a/docs/releasenotes/6.2.2.rst b/docs/releasenotes/6.2.2.rst index 79d4b88aa..47692a3de 100644 --- a/docs/releasenotes/6.2.2.rst +++ b/docs/releasenotes/6.2.2.rst @@ -6,13 +6,13 @@ Security This release addresses several security problems. -:cve:`CVE-2019-19911` is regarding FPX images. If an image reports that it has a large +:cve:`2019-19911` is regarding FPX images. If an image reports that it has a large number of bands, a large amount of resources will be used when trying to process the image. This is fixed by limiting the number of bands to those usable by Pillow. -Buffer overruns were found when processing an SGI (:cve:`CVE-2020-5311`), -PCX (:cve:`CVE-2020-5312`) or FLI image (:cve:`CVE-2020-5313`). Checks have been added +Buffer overruns were found when processing an SGI (:cve:`2020-5311`), +PCX (:cve:`2020-5312`) or FLI image (:cve:`2020-5313`). Checks have been added to prevent this. -:cve:`CVE-2020-5310`: Overflow checks have been added when calculating the size of a +:cve:`2020-5310`: Overflow checks have been added when calculating the size of a memory block to be reallocated in the processing of a TIFF image. diff --git a/docs/releasenotes/7.0.0.rst b/docs/releasenotes/7.0.0.rst index 80002b0ce..ed6026593 100644 --- a/docs/releasenotes/7.0.0.rst +++ b/docs/releasenotes/7.0.0.rst @@ -85,7 +85,7 @@ Custom unidentified image error ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Pillow will now throw a custom ``UnidentifiedImageError`` when an image cannot be -identified. For backwards compatibility, this will inherit from ``OSError``. +identified. For backwards compatibility, this will inherit from :py:exc:`OSError`. New argument ``reducing_gap`` for Image.resize() and Image.thumbnail() methods ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -118,9 +118,7 @@ Loading WMF images at a given DPI ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ On Windows, Pillow can read WMF files, with a default DPI of 72. An image can -now also be loaded at another resolution: - -.. code-block:: python +now also be loaded at another resolution:: from PIL import Image with Image.open("drawing.wmf") as im: @@ -136,16 +134,12 @@ Implicitly closing the image's underlying file in ``Image.__del__`` has been rem Use a context manager or call :py:meth:`~PIL.Image.Image.close` instead to close the file in a deterministic way. -Previous method: - -.. code-block:: python +Previous method:: im = Image.open("hopper.png") im.save("out.jpg") -Use instead: - -.. code-block:: python +Use instead:: with Image.open("hopper.png") as im: im.save("out.jpg") diff --git a/docs/releasenotes/7.1.0.rst b/docs/releasenotes/7.1.0.rst index 0024a537d..6e231464e 100644 --- a/docs/releasenotes/7.1.0.rst +++ b/docs/releasenotes/7.1.0.rst @@ -10,9 +10,7 @@ Allow saving of zero quality JPEG images If no quality was specified when saving a JPEG, Pillow internally used a value of zero to indicate that the default quality should be used. However, this removed the ability to actually save a JPEG with zero quality. This has now -been resolved. - -.. code-block:: python +been resolved. :: from PIL import Image im = Image.open("hopper.jpg") @@ -74,11 +72,11 @@ Security This release includes security fixes. -* :cve:`CVE-2020-10177` Fix multiple out-of-bounds reads in FLI decoding -* :cve:`CVE-2020-10378` Fix bounds overflow in PCX decoding -* :cve:`CVE-2020-10379` Fix two buffer overflows in TIFF decoding -* :cve:`CVE-2020-10994` Fix bounds overflow in JPEG 2000 decoding -* :cve:`CVE-2020-11538` Fix buffer overflow in SGI-RLE decoding +* :cve:`2020-10177` Fix multiple out-of-bounds reads in FLI decoding +* :cve:`2020-10378` Fix bounds overflow in PCX decoding +* :cve:`2020-10379` Fix two buffer overflows in TIFF decoding +* :cve:`2020-10994` Fix bounds overflow in JPEG 2000 decoding +* :cve:`2020-11538` Fix buffer overflow in SGI-RLE decoding Other Changes ============= diff --git a/docs/releasenotes/7.1.2.rst b/docs/releasenotes/7.1.2.rst index b12d84e33..ec0063e79 100644 --- a/docs/releasenotes/7.1.2.rst +++ b/docs/releasenotes/7.1.2.rst @@ -7,7 +7,7 @@ Fix another regression seeking PNG files This fixes a regression introduced in 7.1.0 when adding support for APNG files. When calling ``seek(n)`` on a regular PNG where ``n > 0``, it failed to raise an -``EOFError`` as it should have done, resulting in: +:py:exc:`EOFError` as it should have done, resulting in: .. code-block:: pycon diff --git a/docs/releasenotes/7.2.0.rst b/docs/releasenotes/7.2.0.rst index ff1b7c9e7..91e54da19 100644 --- a/docs/releasenotes/7.2.0.rst +++ b/docs/releasenotes/7.2.0.rst @@ -53,6 +53,6 @@ a custom :py:class:`~PIL.ImageShow.Viewer` class. ImageFile.raise_ioerror ~~~~~~~~~~~~~~~~~~~~~~~ -``IOError`` was merged into ``OSError`` in Python 3.3. So, ``ImageFile.raise_ioerror`` +:py:exc:`IOError` was merged into :py:exc:`OSError` in Python 3.3. So, ``ImageFile.raise_ioerror`` is now deprecated and will be removed in a future release. Use ``ImageFile.raise_oserror`` instead. diff --git a/docs/releasenotes/8.0.0.rst b/docs/releasenotes/8.0.0.rst index fe2658047..2bf299dd3 100644 --- a/docs/releasenotes/8.0.0.rst +++ b/docs/releasenotes/8.0.0.rst @@ -168,13 +168,10 @@ offset. Error for large BMP files ^^^^^^^^^^^^^^^^^^^^^^^^^ -Previously, if a BMP file was too large, an ``OSError`` would be raised. Now, +Previously, if a BMP file was too large, an :py:exc:`OSError` would be raised. Now, ``DecompressionBombError`` is used instead, as Pillow already uses for other formats. Dark theme for docs ^^^^^^^^^^^^^^^^^^^ The https://pillow.readthedocs.io documentation will use a dark theme if the user has requested the system use one. Uses the ``prefers-color-scheme`` CSS media query. - - - diff --git a/docs/releasenotes/8.0.1.rst b/docs/releasenotes/8.0.1.rst index 3584a5d72..f7a1cea65 100644 --- a/docs/releasenotes/8.0.1.rst +++ b/docs/releasenotes/8.0.1.rst @@ -4,7 +4,7 @@ Security ======== -Update FreeType used in binary wheels to `2.10.4`_ to fix :cve:`CVE-2020-15999`: +Update FreeType used in binary wheels to `2.10.4`_ to fix :cve:`2020-15999`: - A heap buffer overflow has been found in the handling of embedded PNG bitmaps, introduced in FreeType version 2.6. diff --git a/docs/releasenotes/8.1.0.rst b/docs/releasenotes/8.1.0.rst index 8ed1d9d85..69726e628 100644 --- a/docs/releasenotes/8.1.0.rst +++ b/docs/releasenotes/8.1.0.rst @@ -11,7 +11,7 @@ Support for FreeType 2.7 is deprecated and will be removed in Pillow 9.0.0 (2022 when FreeType 2.8 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:`CVE-2020-15999`). +vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`). .. _2.10.4: https://sourceforge.net/projects/freetype/files/freetype2/2.10.4/ @@ -40,13 +40,13 @@ This release includes security fixes. * An out-of-bounds read when saving TIFFs with custom metadata through LibTIFF * An out-of-bounds read when saving a GIF of 1px width -* :cve:`CVE-2020-35653` Buffer read overrun in PCX decoding +* :cve:`2020-35653` Buffer read overrun in PCX decoding The PCX image decoder used the reported image stride to calculate the row buffer, rather than calculating it from the image size. This issue dates back to the PIL fork. Thanks to Google's `OSS-Fuzz`_ project for finding this. -* :cve:`CVE-2020-35654` Fix TIFF out-of-bounds write error +* :cve:`2020-35654` Fix TIFF out-of-bounds write error Out-of-bounds write in ``TiffDecode.c`` when reading corrupt YCbCr files in some LibTIFF versions (4.1.0/Ubuntu 20.04, but not 4.0.9/Ubuntu 18.04). In some cases @@ -55,7 +55,7 @@ an out-of-bounds write in ``TiffDecode.c``. This potentially affects Pillow vers from 6.0.0 to 8.0.1, depending on the version of LibTIFF. This was reported through `Tidelift`_. -* :cve:`CVE-2020-35655` Fix for SGI Decode buffer overrun +* :cve:`2020-35655` Fix for SGI Decode buffer overrun 4 byte read overflow in ``SgiRleDecode.c``, where the code was not correctly checking the offsets and length tables. Independently reported through `Tidelift`_ and Google's diff --git a/docs/releasenotes/8.1.1.rst b/docs/releasenotes/8.1.1.rst index 4081c49ca..18d0a33f1 100644 --- a/docs/releasenotes/8.1.1.rst +++ b/docs/releasenotes/8.1.1.rst @@ -4,19 +4,19 @@ Security ======== -:cve:`CVE-2021-25289`: The previous fix for :cve:`CVE-2020-35654` was insufficient +:cve:`2021-25289`: The previous fix for :cve:`2020-35654` was insufficient due to incorrect error checking in ``TiffDecode.c``. -:cve:`CVE-2021-25290`: In ``TiffDecode.c``, there is a negative-offset ``memcpy`` +:cve:`2021-25290`: In ``TiffDecode.c``, there is a negative-offset ``memcpy`` with an invalid size. -:cve:`CVE-2021-25291`: In ``TiffDecode.c``, invalid tile boundaries could lead to +:cve:`2021-25291`: In ``TiffDecode.c``, invalid tile boundaries could lead to an out-of-bounds read in ``TIFFReadRGBATile``. -:cve:`CVE-2021-25292`: The PDF parser has a catastrophic backtracking regex +:cve:`2021-25292`: The PDF parser has a catastrophic backtracking regex that could be used as a DOS attack. -:cve:`CVE-2021-25293`: There is an out-of-bounds read in ``SgiRleDecode.c``, +:cve:`2021-25293`: There is an out-of-bounds read in ``SgiRleDecode.c``, since Pillow 4.3.0. diff --git a/docs/releasenotes/8.1.2.rst b/docs/releasenotes/8.1.2.rst index 50d132f33..de50a3f1d 100644 --- a/docs/releasenotes/8.1.2.rst +++ b/docs/releasenotes/8.1.2.rst @@ -4,8 +4,8 @@ Security ======== -There is an exhaustion of memory DOS in the BLP (:cve:`CVE-2021-27921`), -ICNS (:cve:`CVE-2021-27922`) and ICO (:cve:`CVE-2021-27923`) container formats +There is an exhaustion of memory DOS in the BLP (:cve:`2021-27921`), +ICNS (:cve:`2021-27922`) and ICO (:cve:`2021-27923`) container formats where Pillow did not properly check the reported size of the contained image. These images could cause arbitrarily large memory allocations. This was reported by Jiayi Lin, Luke Shaffer, Xinran Xie, and Akshay Ajayan of diff --git a/docs/releasenotes/8.2.0.rst b/docs/releasenotes/8.2.0.rst index c902ccf71..452077f1a 100644 --- a/docs/releasenotes/8.2.0.rst +++ b/docs/releasenotes/8.2.0.rst @@ -76,9 +76,7 @@ ImageDraw.rounded_rectangle Added :py:meth:`~PIL.ImageDraw.ImageDraw.rounded_rectangle`. It works the same as :py:meth:`~PIL.ImageDraw.ImageDraw.rectangle`, except with an additional ``radius`` argument. ``radius`` is limited to half of the width or the height, so that users can -create a circle, but not any other ellipse. - -.. code-block:: python +create a circle, but not any other ellipse. :: from PIL import Image, ImageDraw im = Image.new("RGB", (200, 200)) @@ -131,15 +129,15 @@ Security These were all found with `OSS-Fuzz`_. -:cve:`CVE-2021-25287`, :cve:`CVE-2021-25288`: Fix OOB read in Jpeg2KDecode -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2021-25287`, :cve:`2021-25288`: Fix OOB read in Jpeg2KDecode +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * For J2k images with multiple bands, it's legal to have different widths for each band, e.g. 1 byte for ``L``, 4 bytes for ``A``. * This dates to Pillow 2.4.0. -:cve:`CVE-2021-28675`: Fix DOS in PsdImagePlugin -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2021-28675`: Fix DOS in PsdImagePlugin +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * :py:class:`.PsdImagePlugin.PsdImageFile` did not sanity check the number of input layers with regard to the size of the data block, this could lead to a @@ -147,15 +145,15 @@ These were all found with `OSS-Fuzz`_. :py:meth:`~PIL.Image.Image.load`. * This dates to the PIL fork. -:cve:`CVE-2021-28676`: Fix FLI DOS -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2021-28676`: Fix FLI DOS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * ``FliDecode.c`` did not properly check that the block advance was non-zero, potentially leading to an infinite loop on load. * This dates to the PIL fork. -:cve:`CVE-2021-28677`: Fix EPS DOS on _open -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2021-28677`: Fix EPS DOS on _open +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * The readline used in EPS has to deal with any combination of ``\r`` and ``\n`` as line endings. It accidentally used a quadratic method of accumulating lines while looking @@ -164,8 +162,8 @@ These were all found with `OSS-Fuzz`_. open phase, before an image was accepted for opening. * This dates to the PIL fork. -:cve:`CVE-2021-28678`: Fix BLP DOS -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +:cve:`2021-28678`: Fix BLP DOS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * ``BlpImagePlugin`` did not properly check that reads after jumping to file offsets returned data. This could lead to a denial-of-service where the decoder could be run a diff --git a/docs/releasenotes/8.3.0.rst b/docs/releasenotes/8.3.0.rst index 0bfead144..e74880f6f 100644 --- a/docs/releasenotes/8.3.0.rst +++ b/docs/releasenotes/8.3.0.rst @@ -8,7 +8,7 @@ JpegImagePlugin.convert_dict_qtables ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ JPEG ``quantization`` is now automatically converted, but still returned as a -dictionary. The :py:attr:`~PIL.JpegImagePlugin.convert_dict_qtables` method no longer +dictionary. The ``convert_dict_qtables`` method no longer performs any operations on the data given to it, has been deprecated and will be removed in Pillow 10.0.0 (2023-07-01). @@ -85,7 +85,7 @@ Security Buffer overflow ^^^^^^^^^^^^^^^ -This release addresses :cve:`CVE-2021-34552`. PIL since 1.1.4 and Pillow since 1.0 +This release addresses :cve:`2021-34552`. PIL since 1.1.4 and Pillow since 1.0 allowed parameters passed into a convert function to trigger buffer overflow in Convert.c. diff --git a/docs/releasenotes/8.3.1.rst b/docs/releasenotes/8.3.1.rst index e97070c11..6af2b37bf 100644 --- a/docs/releasenotes/8.3.1.rst +++ b/docs/releasenotes/8.3.1.rst @@ -22,9 +22,10 @@ Catch OSError when checking if destination is sys.stdout ======================================================== In 8.3.0, a check to see if the destination was ``sys.stdout`` when saving an image was -updated. This lead to an OSError being raised if the environment restricted access. +updated. This lead to an :py:exc:`OSError` being raised if the environment restricted +access. -The OSError is now silently caught. +The :py:exc:`OSError` is now silently caught. Fixed removing orientation in ImageOps.exif_transpose ===================================================== @@ -34,7 +35,7 @@ original image EXIF data was not modified, and the orientation was only removed the modified copy. However, for certain images the orientation was already missing from the modified -image, leading to a KeyError. +image, leading to a :py:exc:`KeyError`. This error has been resolved, and the copying of metadata to the modified image improved. diff --git a/docs/releasenotes/8.3.2.rst b/docs/releasenotes/8.3.2.rst index 6b5c759fc..3333d63a1 100644 --- a/docs/releasenotes/8.3.2.rst +++ b/docs/releasenotes/8.3.2.rst @@ -4,7 +4,7 @@ Security ======== -* :cve:`CVE-2021-23437`: Avoid a potential ReDoS (regular expression denial of service) +* :cve:`2021-23437`: Avoid a potential ReDoS (regular expression denial of service) in :py:class:`~PIL.ImageColor`'s :py:meth:`~PIL.ImageColor.getrgb` by raising :py:exc:`ValueError` if the color specifier is too long. Present since Pillow 5.2.0. diff --git a/docs/releasenotes/8.4.0.rst b/docs/releasenotes/8.4.0.rst index 9becf9146..e61471e72 100644 --- a/docs/releasenotes/8.4.0.rst +++ b/docs/releasenotes/8.4.0.rst @@ -24,9 +24,7 @@ Added "transparency" argument for loading EPS images This new argument switches the Ghostscript device from "ppmraw" to "pngalpha", generating an RGBA image with a transparent background instead of an RGB image with a -white background. - -.. code-block:: python +white background. :: with Image.open("sample.eps") as im: im.load(transparency=True) diff --git a/docs/releasenotes/9.0.0.rst b/docs/releasenotes/9.0.0.rst index a19da361a..090ec8024 100644 --- a/docs/releasenotes/9.0.0.rst +++ b/docs/releasenotes/9.0.0.rst @@ -43,7 +43,7 @@ FreeType 2.7 Support for FreeType 2.7 has been removed; FreeType 2.8 is the minimum supported. We recommend upgrading to at least `FreeType`_ 2.10.4, which fixed a severe -vulnerability introduced in FreeType 2.6 (:cve:`CVE-2020-15999`). +vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`). .. _FreeType: https://freetype.org/ @@ -63,7 +63,7 @@ a custom :py:class:`~PIL.ImageShow.Viewer` class. ImageFile.raise_ioerror ^^^^^^^^^^^^^^^^^^^^^^^ -``IOError`` was merged into ``OSError`` in Python 3.3. So, ``ImageFile.raise_ioerror`` +:py:exc:`IOError` was merged into :py:exc:`OSError` in Python 3.3. So, ``ImageFile.raise_ioerror`` has been removed. Use ``ImageFile.raise_oserror`` instead. @@ -119,7 +119,7 @@ Google's `OSS-Fuzz`_ project for finding this issue. Restrict builtins available to ImageMath.eval ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -:cve:`CVE-2022-22817`: To limit :py:class:`PIL.ImageMath` to working with images, Pillow +:cve:`2022-22817`: To limit :py:class:`PIL.ImageMath` to working with images, Pillow will now restrict the builtins available to :py:meth:`PIL.ImageMath.eval`. This will help prevent problems arising if users evaluate arbitrary expressions, such as ``ImageMath.eval("exec(exit())")``. @@ -127,7 +127,7 @@ help prevent problems arising if users evaluate arbitrary expressions, such as Fixed ImagePath.Path array handling ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -:cve:`CVE-2022-22815` (:cwe:`CWE-126`) and :cve:`CVE-2022-22816` (:cwe:`CWE-665`) were +:cve:`2022-22815` (:cwe:`126`) and :cve:`2022-22816` (:cwe:`665`) were found when initializing ``ImagePath.Path``. .. _OSS-Fuzz: https://github.com/google/oss-fuzz @@ -155,9 +155,7 @@ altered slightly with this change. Added support for pickling TrueType fonts ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TrueType fonts may now be pickled and unpickled. For example: - -.. code-block:: python +TrueType fonts may now be pickled and unpickled. For example:: import pickle from PIL import ImageFont diff --git a/docs/releasenotes/9.0.1.rst b/docs/releasenotes/9.0.1.rst index c1feee088..acb92dc41 100644 --- a/docs/releasenotes/9.0.1.rst +++ b/docs/releasenotes/9.0.1.rst @@ -6,12 +6,12 @@ Security This release addresses several security problems. -:cve:`CVE-2022-24303`: If the path to the temporary directory on Linux or macOS +:cve:`2022-24303`: If the path to the temporary directory on Linux or macOS contained a space, this would break removal of the temporary image file after ``im.show()`` (and related actions), and potentially remove an unrelated file. This has been present since PIL. -:cve:`CVE-2022-22817`: While Pillow 9.0 restricted top-level builtins available to +:cve:`2022-22817`: While Pillow 9.0 restricted top-level builtins available to :py:meth:`PIL.ImageMath.eval`, it did not prevent builtins available to lambda expressions. These are now also restricted. diff --git a/docs/releasenotes/9.1.0.rst b/docs/releasenotes/9.1.0.rst index 48ce6fef7..02da702a7 100644 --- a/docs/releasenotes/9.1.0.rst +++ b/docs/releasenotes/9.1.0.rst @@ -8,14 +8,14 @@ Raise an error when performing a negative crop ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Performing a negative crop on an image previously just returned a ``(0, 0)`` image. Now -it will raise a ``ValueError``, to help reduce confusion if a user has unintentionally +it will raise a :py:exc:`ValueError`, to help reduce confusion if a user has unintentionally provided the wrong arguments. Added specific error if path coordinate type is incorrect ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Rather than returning a ``SystemError``, passing the incorrect types of coordinates into -a path will now raise a more specific ``ValueError``, with the message "incorrect +Rather than returning a :py:exc:`SystemError`, passing the incorrect types of coordinates into +a path will now raise a more specific :py:exc:`ValueError`, with the message "incorrect coordinate type". Replace requirements.txt with extras @@ -53,6 +53,11 @@ Constants A number of constants have been deprecated and will be removed in Pillow 10.0.0 (2023-07-01). Instead, ``enum.IntEnum`` classes have been added. +.. note:: + + Some of these deprecations were restored in Pillow 9.4.0. See + :ref:`restored-image-constants` + ===================================================== ============================================================ Deprecated Use instead ===================================================== ============================================================ @@ -177,17 +182,13 @@ GifImagePlugin loading strategy Pillow 9.0.0 introduced the conversion of subsequent GIF frames to ``RGB`` or ``RGBA``. This behaviour can now be changed so that the first ``P`` frame is converted to ``RGB`` as -well. - -.. code-block:: python +well. :: from PIL import GifImagePlugin GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS Or subsequent frames can be kept in ``P`` mode as long as there is only a single -palette. - -.. code-block:: python +palette. :: from PIL import GifImagePlugin GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY diff --git a/docs/releasenotes/9.1.1.rst b/docs/releasenotes/9.1.1.rst index f8b155f3d..bab70f8f9 100644 --- a/docs/releasenotes/9.1.1.rst +++ b/docs/releasenotes/9.1.1.rst @@ -6,7 +6,7 @@ Security This release addresses several security problems. -:cve:`CVE-2022-30595`: When reading a TGA file with RLE packets that cross scan lines, +:cve:`2022-30595`: When reading a TGA file with RLE packets that cross scan lines, Pillow reads the information past the end of the first line without deducting that from the length of the remaining file data. This vulnerability was introduced in Pillow 9.1.0, and can cause a heap buffer overflow. diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index 6dbfa2702..b875edf8e 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -15,7 +15,7 @@ open-source users (and will reach EOL on 2023-12-08 for commercial licence holde Support for PyQt5 and PySide2 has been deprecated from ``ImageQt`` and will be removed in Pillow 10 (2023-07-01). Upgrade to `PyQt6 `_ or -`PySide6 `_ instead. +`PySide6 `_ instead. FreeTypeFont.getmask2 fill parameter ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -48,20 +48,18 @@ Font size and offset methods Several functions for computing the size and offset of rendered text have been deprecated and will be removed in Pillow 10 (2023-07-01): -=========================================================================== ============================================================================================================= -Deprecated Use instead -=========================================================================== ============================================================================================================= -:py:meth:`.FreeTypeFont.getsize` and :py:meth:`.FreeTypeFont.getoffset` :py:meth:`.FreeTypeFont.getbbox` and :py:meth:`.FreeTypeFont.getlength` -:py:meth:`.FreeTypeFont.getsize_multiline` :py:meth:`.ImageDraw.multiline_textbbox` -:py:meth:`.ImageFont.getsize` :py:meth:`.ImageFont.getbbox` and :py:meth:`.ImageFont.getlength` -:py:meth:`.TransposedFont.getsize` :py:meth:`.TransposedFont.getbbox` and :py:meth:`.TransposedFont.getlength` -:py:meth:`.ImageDraw.textsize` and :py:meth:`.ImageDraw.multiline_textsize` :py:meth:`.ImageDraw.textbbox`, :py:meth:`.ImageDraw.textlength` and :py:meth:`.ImageDraw.multiline_textbbox` -:py:meth:`.ImageDraw2.Draw.textsize` :py:meth:`.ImageDraw2.Draw.textbbox` and :py:meth:`.ImageDraw2.Draw.textlength` -=========================================================================== ============================================================================================================= +=============================================================== ============================================================================================================= +Deprecated Use instead +=============================================================== ============================================================================================================= +``FreeTypeFont.getsize()`` and ``FreeTypeFont.getoffset()`` :py:meth:`.FreeTypeFont.getbbox` and :py:meth:`.FreeTypeFont.getlength` +``FreeTypeFont.getsize_multiline()`` :py:meth:`.ImageDraw.multiline_textbbox` +``ImageFont.getsize()`` :py:meth:`.ImageFont.getbbox` and :py:meth:`.ImageFont.getlength` +``TransposedFont.getsize()`` :py:meth:`.TransposedFont.getbbox` and :py:meth:`.TransposedFont.getlength` +``ImageDraw.textsize()`` and ``ImageDraw.multiline_textsize()`` :py:meth:`.ImageDraw.textbbox`, :py:meth:`.ImageDraw.textlength` and :py:meth:`.ImageDraw.multiline_textbbox` +``ImageDraw2.Draw.textsize()`` :py:meth:`.ImageDraw2.Draw.textbbox` and :py:meth:`.ImageDraw2.Draw.textlength` +=============================================================== ============================================================================================================= -Previous code: - -.. code-block:: python +Previous code:: from PIL import Image, ImageDraw, ImageFont @@ -76,9 +74,7 @@ Previous code: width, height = font.getsize_multiline("Hello\nworld") width, height = draw.multiline_textsize("Hello\nworld") -Use instead: - -.. code-block:: python +Use instead:: from PIL import Image, ImageDraw, ImageFont diff --git a/docs/releasenotes/9.4.0.rst b/docs/releasenotes/9.4.0.rst index aae3e2b64..0af5bc8ca 100644 --- a/docs/releasenotes/9.4.0.rst +++ b/docs/releasenotes/9.4.0.rst @@ -1,30 +1,6 @@ 9.4.0 ----- -Backwards Incompatible Changes -============================== - -TODO -^^^^ - -TODO - -Deprecations -============ - -TODO -^^^^ - -TODO - -API Changes -=========== - -TODO -^^^^ - -TODO - API Additions ============= @@ -45,6 +21,12 @@ removes the hidden RGB values for better compression by default in libwebp 0.5 or later. By setting this option to ``True``, the encoder will keep the hidden RGB values. +Added ``signed`` option when saving JPEG2000 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If the ``signed`` keyword argument is present and true when saving JPEG2000 +images, then tell the encoder to save the image as signed. + Added IFD, Interop and LightSource ExifTags enums ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -90,10 +72,21 @@ When saving a JPEG image, a comment can now be written from Security ======== -TODO -^^^^ +Fix memory DOS in ImageFont +^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +A corrupt or specially crafted TTF font could have font metrics that lead to +unreasonably large sizes when rendering text in font. ``ImageFont.py`` did not +check the image size before allocating memory for it. This dates to the PIL +fork. Pillow 8.2.0 added a check for large sizes, but did not consider the +case where one dimension is zero. + +Null pointer dereference crash in ImageFont +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Pillow attempted to dereference a null pointer in ``ImageFont``, leading to a +crash. An error is now raised instead. This has been present since +Pillow 8.0.0. Other Changes ============= @@ -103,3 +96,40 @@ Added support for DDS L and LA images Support has been added to read and write L and LA DDS images in the uncompressed format, known as "luminance" textures. + +.. _restored-image-constants: + +Constants +^^^^^^^^^ + +In Pillow 9.1.0, the following constants were deprecated. That has been reversed and +these constants will now remain available. + +- ``Image.NONE`` +- ``Image.NEAREST`` +- ``Image.ORDERED`` +- ``Image.RASTERIZE`` +- ``Image.FLOYDSTEINBERG`` +- ``Image.WEB`` +- ``Image.ADAPTIVE`` +- ``Image.AFFINE`` +- ``Image.EXTENT`` +- ``Image.PERSPECTIVE`` +- ``Image.QUAD`` +- ``Image.MESH`` +- ``Image.FLIP_LEFT_RIGHT`` +- ``Image.FLIP_TOP_BOTTOM`` +- ``Image.ROTATE_90`` +- ``Image.ROTATE_180`` +- ``Image.ROTATE_270`` +- ``Image.TRANSPOSE`` +- ``Image.TRANSVERSE`` +- ``Image.BOX`` +- ``Image.BILINEAR`` +- ``Image.HAMMING`` +- ``Image.BICUBIC`` +- ``Image.LANCZOS`` +- ``Image.MEDIANCUT`` +- ``Image.MAXCOVERAGE`` +- ``Image.FASTOCTREE`` +- ``Image.LIBIMAGEQUANT`` diff --git a/docs/releasenotes/9.5.0.rst b/docs/releasenotes/9.5.0.rst new file mode 100644 index 000000000..b1e982fcc --- /dev/null +++ b/docs/releasenotes/9.5.0.rst @@ -0,0 +1,96 @@ +9.5.0 +----- + +Deprecations +============ + +PSFile +^^^^^^ + +The :py:class:`~PIL.EpsImagePlugin.PSFile` class has been deprecated and will +be removed in Pillow 11 (2024-10-15). This class was only made as a helper to +be used internally, so there is no replacement. If you need this functionality +though, it is a very short class that can easily be recreated in your own code. + +API Additions +============= + +QOI file format +^^^^^^^^^^^^^^^ + +Pillow can now read images in Quite OK Image format. + +Added ``dpi`` argument when saving PDFs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When saving a PDF, resolution could already be specified using the +``resolution`` argument. Now, a tuple of ``(x_resolution, y_resolution)`` can +be provided as ``dpi``. If both are provided, ``dpi`` will override +``resolution``. + +Added ``corners`` argument to ``ImageDraw.rounded_rectangle()`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`.ImageDraw.rounded_rectangle` now accepts a keyword argument of +``corners``. This a tuple of Booleans, specifying whether to round each corner, +``(top_left, top_right, bottom_right, bottom_left)``. + +JPEG2000 comments and PLT marker +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When opening a JPEG2000 image, the comment may now be read into +:py:attr:`~PIL.Image.Image.info`. The ``comment`` keyword argument can be used +to save it back again. + +If OpenJPEG 2.4.0 or later is available and the ``plt`` keyword argument +is present and true when saving JPEG2000 images, tell the encoder to generate +PLT markers. + +Security +======== + +Clear PPM half token after use +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Image files that are small on disk are often prevented from expanding to be +big images consuming a large amount of resources simply because they lack the +data to populate those resources. + +PpmImagePlugin might hold onto the last data read for a pixel value in case the +pixel value has not been finished yet. However, that data was not being cleared +afterwards, meaning that infinite data could be available to fill any image +size. This has been present since Pillow 9.2.0. + +That data is now cleared after use. + +Saving TIFF tag ImageSourceData +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If Pillow incorrectly saved the TIFF tag ImageSourceData as ASCII instead of +UNDEFINED, a segmentation fault was triggered. + +The correct tag type will now be used by default instead. + +Other Changes +============= + +Added support for saving PDFs in RGBA mode +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Using the JPXDecode filter, PDFs can now be saved in RGBA mode. + +Improved I;16N support +^^^^^^^^^^^^^^^^^^^^^^ + +Support has been added for I;16N access, packing and unpacking. Conversion to +and from L mode has also been added. + +BGR;* modes +^^^^^^^^^^^ + +It is now possible to create new BGR;15, BGR;16 and BGR;24 images. Conversely, BGR;32 +has been removed from ImageMode and its associated methods, dropping the little support +Pillow had for the mode. + +With that, all modes listed under :ref:`concept-modes` can now be used to create a new +image. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index a2b588696..d8034853c 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -14,6 +14,11 @@ expected to be backported to earlier versions. .. toctree:: :maxdepth: 2 + 10.2.0 + 10.1.0 + 10.0.1 + 10.0.0 + 9.5.0 9.4.0 9.3.0 9.2.0 diff --git a/docs/releasenotes/template.rst b/docs/releasenotes/template.rst index f7271ae2b..440d04b1c 100644 --- a/docs/releasenotes/template.rst +++ b/docs/releasenotes/template.rst @@ -1,5 +1,5 @@ -x.y.z ------ +xx.y.z +------ Backwards Incompatible Changes ============================== diff --git a/docs/resources/js/activate_tab.js b/docs/resources/js/activate_tab.js new file mode 100644 index 000000000..92522b5ce --- /dev/null +++ b/docs/resources/js/activate_tab.js @@ -0,0 +1,36 @@ +// Based on https://stackoverflow.com/a/38241481/724176 +function getOS() { + const userAgent = window.navigator.userAgent, + platform = window.navigator.userAgentData?.platform || window.navigator.platform, + macosPlatforms = ["macOS", "Macintosh", "MacIntel", "MacPPC", "Mac68K"], + windowsPlatforms = ["Win32", "Win64", "Windows", "WinCE"]; + + if (macosPlatforms.includes(platform)) { + return "macOS"; + } else if (windowsPlatforms.includes(platform)) { + return "Windows"; + } else if (/Android/.test(userAgent)) { + return "Android"; + } else if (/Linux/.test(platform)) { + return "Linux"; + } +} + +function activateTab(tabName) { + // Find all label elements with the specified tab name + const labels = document.querySelectorAll(".tab-label"); + + labels.forEach((label) => { + if (label.textContent == tabName) { + // Find the associated input element using the "for" attribute + const tabInputId = label.getAttribute("for"); + const tabInput = document.getElementById(tabInputId); + + // Check if the input element exists before attempting to set the "checked" attribute + if (tabInput) { + // Activate the tab by setting its "checked" attribute to true + tabInput.checked = true; + } + } + }); +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..193e8c9b2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,158 @@ +[build-system] +build-backend = "backend" +requires = [ + "setuptools>=67.8", +] +backend-path = [ + "_custom_build", +] + +[project] +name = "pillow" +description = "Python Imaging Library (Fork)" +readme = "README.md" +keywords = [ + "Imaging", +] +license = {text = "HPND"} +authors = [{name = "Jeffrey A. Clark (Alex)", email = "aclark@aclark.net"}] +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 6 - Mature", + "License :: OSI Approved :: Historical Permission Notice and Disclaimer (HPND)", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Multimedia :: Graphics", + "Topic :: Multimedia :: Graphics :: Capture :: Digital Camera", + "Topic :: Multimedia :: Graphics :: Capture :: Screen Capture", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", + "Topic :: Multimedia :: Graphics :: Viewers", +] +dynamic = [ + "version", +] +[project.optional-dependencies] +docs = [ + "furo", + "olefile", + "sphinx>=2.4", + "sphinx-copybutton", + "sphinx-inline-tabs", + "sphinx-removed-in", + "sphinxext-opengraph", +] +fpx = [ + "olefile", +] +mic = [ + "olefile", +] +tests = [ + "check-manifest", + "coverage", + "defusedxml", + "markdown2", + "olefile", + "packaging", + "pyroma", + "pytest", + "pytest-cov", + "pytest-timeout", +] +xmp = [ + "defusedxml", +] +[project.urls] +Changelog = "https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst" +Documentation = "https://pillow.readthedocs.io" +Funding = "https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=pypi" +Homepage = "https://python-pillow.org" +Mastodon = "https://fosstodon.org/@pillow" +"Release notes" = "https://pillow.readthedocs.io/en/stable/releasenotes/index.html" +Source = "https://github.com/python-pillow/Pillow" +Twitter = "https://twitter.com/PythonPillow" + +[tool.setuptools] +packages = ["PIL"] +include-package-data = true +package-dir = {"" = "src"} + +[tool.setuptools.dynamic] +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" +test-command = "cd {project} && .github/workflows/wheels-test.sh" +test-extras = "tests" + +[tool.ruff] +select = [ + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "EM", # flake8-errmsg + "F", # pyflakes errors + "I", # isort + "ISC", # flake8-implicit-str-concat + "PGH", # pygrep-hooks + "RUF100", # unused noqa (yesqa) + "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 + # "LOG", # TODO: enable flake8-logging when it's not in preview anymore +] +extend-ignore = [ + "E203", # Whitespace before ':' + "E221", # Multiple spaces before operator + "E226", # Missing whitespace around arithmetic operator + "E241", # Multiple spaces after ',' +] + +[tool.ruff.per-file-ignores] +"Tests/*.py" = ["I001"] + +[tool.ruff.isort] +known-first-party = ["PIL"] +required-imports = ["from __future__ import annotations"] + +[tool.pytest.ini_options] +addopts = "-ra --color=yes" +testpaths = ["Tests"] + +[tool.mypy] +python_version = "3.8" +pretty = true +disallow_any_generics = true +enable_error_code = "ignore-without-code" +extra_checks = true +follow_imports = "silent" +warn_redundant_casts = true +warn_unreachable = true +warn_unused_ignores = true +exclude = [ + '^src/PIL/_tkinter_finder.py$', + '^src/PIL/DdsImagePlugin.py$', + '^src/PIL/FpxImagePlugin.py$', + '^src/PIL/Image.py$', + '^src/PIL/ImageCms.py$', + '^src/PIL/ImageFile.py$', + '^src/PIL/ImageFont.py$', + '^src/PIL/ImageMath.py$', + '^src/PIL/ImageMorph.py$', + '^src/PIL/ImageQt.py$', + '^src/PIL/ImageShow.py$', + '^src/PIL/ImImagePlugin.py$', + '^src/PIL/MicImagePlugin.py$', + '^src/PIL/PdfParser.py$', + '^src/PIL/PyAccess.py$', + '^src/PIL/TiffImagePlugin.py$', + '^src/PIL/TiffTags.py$', + '^src/PIL/WebPImagePlugin.py$', +] diff --git a/selftest.py b/selftest.py index 6eeadd1db..600fd6496 100755 --- a/selftest.py +++ b/selftest.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # minimal sanity check +from __future__ import annotations import sys diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b562e2934..000000000 --- a/setup.cfg +++ /dev/null @@ -1,74 +0,0 @@ -[metadata] -name = Pillow -description = Python Imaging Library (Fork) -long_description = file: README.md -long_description_content_type = text/markdown -url = https://python-pillow.org -author = Alex Clark (PIL Fork Author) -author_email = aclark@python-pillow.org -license = HPND -classifiers = - Development Status :: 6 - Mature - License :: OSI Approved :: Historical Permission Notice and Disclaimer (HPND) - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: Implementation :: CPython - Programming Language :: Python :: Implementation :: PyPy - Topic :: Multimedia :: Graphics - Topic :: Multimedia :: Graphics :: Capture :: Digital Camera - Topic :: Multimedia :: Graphics :: Capture :: Screen Capture - Topic :: Multimedia :: Graphics :: Graphics Conversion - Topic :: Multimedia :: Graphics :: Viewers -keywords = Imaging -project_urls = - Documentation=https://pillow.readthedocs.io - Source=https://github.com/python-pillow/Pillow - Funding=https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=pypi - Release notes=https://pillow.readthedocs.io/en/stable/releasenotes/index.html - Changelog=https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst - Twitter=https://twitter.com/PythonPillow - -[options] -packages = PIL -python_requires = >=3.7 -include_package_data = True -package_dir = - = src - -[options.extras_require] -docs = - furo - olefile - sphinx>=2.4 - sphinx-copybutton - sphinx-inline-tabs - sphinx-issues>=3.0.1 - sphinx-removed-in - sphinxext-opengraph -tests = - check-manifest - coverage - defusedxml - markdown2 - olefile - packaging - pyroma - pytest - pytest-cov - pytest-timeout - -[flake8] -extend-ignore = E203 -max-line-length = 88 - -[isort] -profile = black - -[tool:pytest] -addopts = -ra --color=yes -testpaths = Tests diff --git a/setup.py b/setup.py index f8b7f6959..7241bdee2 100755 --- a/setup.py +++ b/setup.py @@ -7,9 +7,11 @@ # Final rating: 10/10 # Your cheese is so fresh most people think it's a cream: Mascarpone # ------------------------------ +from __future__ import annotations import os import re +import shutil import struct import subprocess import sys @@ -38,7 +40,7 @@ TIFF_ROOT = None ZLIB_ROOT = None FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ -if sys.platform == "win32" and sys.version_info >= (3, 12): +if sys.platform == "win32" and sys.version_info >= (3, 13): import atexit atexit.register( @@ -137,7 +139,6 @@ class RequiredDependencyException(Exception): PLATFORM_MINGW = os.name == "nt" and "GCC" in sys.version -PLATFORM_PYPY = hasattr(sys, "pypy_version_info") def _dbg(s, tp=None): @@ -151,6 +152,7 @@ def _dbg(s, tp=None): def _find_library_dirs_ldconfig(): # Based on ctypes.util from Python 2 + ldconfig = "ldconfig" if shutil.which("ldconfig") else "/sbin/ldconfig" if sys.platform.startswith("linux") or sys.platform.startswith("gnu"): if struct.calcsize("l") == 4: machine = os.uname()[4] + "-32" @@ -167,14 +169,14 @@ def _find_library_dirs_ldconfig(): # Assuming GLIBC's ldconfig (with option -p) # Alpine Linux uses musl that can't print cache - args = ["/sbin/ldconfig", "-p"] + args = [ldconfig, "-p"] expr = rf".*\({abi_type}.*\) => (.*)" env = dict(os.environ) env["LC_ALL"] = "C" env["LANG"] = "C" elif sys.platform.startswith("freebsd"): - args = ["/sbin/ldconfig", "-r"] + args = [ldconfig, "-r"] expr = r".* => (.*)" env = {} @@ -243,7 +245,9 @@ def _find_include_dir(self, dirname, include): return subdir -def _cmd_exists(cmd): +def _cmd_exists(cmd: str) -> bool: + if "PATH" not in os.environ: + return False return any( os.access(os.path.join(path, cmd), os.X_OK) for path in os.environ["PATH"].split(os.pathsep) @@ -264,18 +268,16 @@ def _pkg_config(name): if not DEBUG: command_libs.append("--silence-errors") command_cflags.append("--silence-errors") - libs = ( + libs = re.split( + r"(^|\s+)-L", subprocess.check_output(command_libs, stderr=stderr) .decode("utf8") - .strip() - .replace("-L", "") - ) - cflags = ( - subprocess.check_output(command_cflags) - .decode("utf8") - .strip() - .replace("-I", "") - ) + .strip(), + )[::2][1:] + cflags = re.split( + r"(^|\s+)-I", + subprocess.check_output(command_cflags).decode("utf8").strip(), + )[::2][1:] return libs, cflags except Exception: pass @@ -363,15 +365,15 @@ class pil_build_ext(build_ext): self.feature.required.discard(x) _dbg("Disabling %s", x) if getattr(self, f"enable_{x}"): - raise ValueError( - f"Conflicting options: --enable-{x} and --disable-{x}" - ) + msg = f"Conflicting options: --enable-{x} and --disable-{x}" + raise ValueError(msg) if x == "freetype": _dbg("--disable-freetype implies --disable-raqm") if getattr(self, "enable_raqm"): - raise ValueError( + msg = ( "Conflicting options: --enable-raqm and --disable-freetype" ) + raise ValueError(msg) setattr(self, "disable_raqm", True) if getattr(self, f"enable_{x}"): _dbg("Requiring %s", x) @@ -382,13 +384,11 @@ class pil_build_ext(build_ext): for x in ("raqm", "fribidi"): if getattr(self, f"vendor_{x}"): if getattr(self, "disable_raqm"): - raise ValueError( - f"Conflicting options: --vendor-{x} and --disable-raqm" - ) + msg = f"Conflicting options: --vendor-{x} and --disable-raqm" + raise ValueError(msg) if x == "fribidi" and not getattr(self, "vendor_raqm"): - raise ValueError( - f"Conflicting options: --vendor-{x} and not --vendor-raqm" - ) + msg = f"Conflicting options: --vendor-{x} and not --vendor-raqm" + raise ValueError(msg) _dbg("Using vendored version of %s", x) self.feature.vendor.add(x) @@ -433,7 +433,6 @@ class pil_build_ext(build_ext): return sdk_path def build_extensions(self): - library_dirs = [] include_dirs = [] @@ -443,17 +442,17 @@ class pil_build_ext(build_ext): # # add configured kits - for root_name, lib_name in dict( - JPEG_ROOT="libjpeg", - JPEG2K_ROOT="libopenjp2", - TIFF_ROOT=("libtiff-5", "libtiff-4"), - ZLIB_ROOT="zlib", - FREETYPE_ROOT="freetype2", - HARFBUZZ_ROOT="harfbuzz", - FRIBIDI_ROOT="fribidi", - LCMS_ROOT="lcms2", - IMAGEQUANT_ROOT="libimagequant", - ).items(): + for root_name, lib_name in { + "JPEG_ROOT": "libjpeg", + "JPEG2K_ROOT": "libopenjp2", + "TIFF_ROOT": ("libtiff-5", "libtiff-4"), + "ZLIB_ROOT": "zlib", + "FREETYPE_ROOT": "freetype2", + "HARFBUZZ_ROOT": "harfbuzz", + "FRIBIDI_ROOT": "fribidi", + "LCMS_ROOT": "lcms2", + "IMAGEQUANT_ROOT": "libimagequant", + }.items(): root = globals()[root_name] if root is None and root_name in os.environ: @@ -476,8 +475,16 @@ class pil_build_ext(build_ext): else: lib_root = include_root = root - _add_directory(library_dirs, lib_root) - _add_directory(include_dirs, include_root) + if lib_root is not None: + if not isinstance(lib_root, (tuple, list)): + lib_root = (lib_root,) + for lib_dir in lib_root: + _add_directory(library_dirs, lib_dir) + if include_root is not None: + if not isinstance(include_root, (tuple, list)): + include_root = (include_root,) + for include_dir in include_root: + _add_directory(include_dirs, include_dir) # respect CFLAGS/CPPFLAGS/LDFLAGS for k in ("CFLAGS", "CPPFLAGS", "LDFLAGS"): @@ -509,6 +516,7 @@ class pil_build_ext(build_ext): elif sys.platform == "cygwin": # pythonX.Y.dll.a is in the /usr/lib/pythonX.Y/config directory + self.compiler.shared_lib_extension = ".dll.a" _add_directory( library_dirs, os.path.join( @@ -570,9 +578,7 @@ class pil_build_ext(build_ext): ): for dirname in _find_library_dirs_ldconfig(): _add_directory(library_dirs, dirname) - if sys.platform.startswith("linux") and os.environ.get( - "ANDROID_ROOT", None - ): + if sys.platform.startswith("linux") and os.environ.get("ANDROID_ROOT"): # termux support for android. # system libraries (zlib) are installed in /system/lib # headers are at $PREFIX/include @@ -683,10 +689,6 @@ class pil_build_ext(build_ext): # Add the directory to the include path so we can include # rather than having to cope with the versioned # include path - # FIXME (melvyn-sopacua): - # At this point it's possible that best_path is already in - # self.compiler.include_dirs. Should investigate how that is - # possible. _add_directory(self.compiler.include_dirs, best_path, 0) feature.jpeg2000 = "openjp2" feature.openjpeg_version = ".".join(str(x) for x in best_version) @@ -816,6 +818,15 @@ class pil_build_ext(build_ext): libs = self.add_imaging_libs.split() defs = [] + if feature.tiff: + libs.append(feature.tiff) + defs.append(("HAVE_LIBTIFF", None)) + if sys.platform == "win32": + # This define needs to be defined if-and-only-if it was defined + # when compiling LibTIFF. LibTIFF doesn't expose it in `tiffconf.h`, + # 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) defs.append(("HAVE_LIBJPEG", None)) @@ -830,15 +841,6 @@ class pil_build_ext(build_ext): if feature.imagequant: libs.append(feature.imagequant) defs.append(("HAVE_LIBIMAGEQUANT", None)) - if feature.tiff: - libs.append(feature.tiff) - defs.append(("HAVE_LIBTIFF", None)) - if sys.platform == "win32": - # This define needs to be defined if-and-only-if it was defined - # when compiling LibTIFF. LibTIFF doesn't expose it in `tiffconf.h`, - # 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.xcb: libs.append(feature.xcb) defs.append(("HAVE_XCB", None)) @@ -847,14 +849,7 @@ class pil_build_ext(build_ext): if struct.unpack("h", b"\0\1")[0] == 1: defs.append(("WORDS_BIGENDIAN", None)) - if ( - sys.platform == "win32" - and sys.version_info < (3, 9) - and not (PLATFORM_PYPY or PLATFORM_MINGW) - ): - defs.append(("PILLOW_VERSION", f'"\\"{PILLOW_VERSION}\\""')) - else: - defs.append(("PILLOW_VERSION", f'"{PILLOW_VERSION}"')) + defs.append(("PILLOW_VERSION", f'"{PILLOW_VERSION}"')) self._update_extension("PIL._imaging", libs, defs) @@ -916,7 +911,6 @@ class pil_build_ext(build_ext): self.summary_report(feature) def summary_report(self, feature): - print("-" * 68) print("PIL SETUP SUMMARY") print("-" * 68) @@ -994,7 +988,6 @@ ext_modules = [ try: setup( - version=PILLOW_VERSION, cmdclass={"build_ext": pil_build_ext}, ext_modules=ext_modules, zip_safe=not (debug_build() or PLATFORM_MINGW), diff --git a/src/PIL/BdfFontFile.py b/src/PIL/BdfFontFile.py index 102b72e1d..b12ddc2d4 100644 --- a/src/PIL/BdfFontFile.py +++ b/src/PIL/BdfFontFile.py @@ -20,7 +20,7 @@ """ Parse X Bitmap Distribution Format (BDF) """ - +from __future__ import annotations from . import FontFile, Image @@ -64,16 +64,27 @@ def bdf_char(f): bitmap.append(s[:-1]) bitmap = b"".join(bitmap) - [x, y, l, d] = [int(p) for p in props["BBX"].split()] - [dx, dy] = [int(p) for p in props["DWIDTH"].split()] + # The word BBX + # followed by the width in x (BBw), height in y (BBh), + # and x and y displacement (BBxoff0, BByoff0) + # of the lower left corner from the origin of the character. + width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split()) - bbox = (dx, dy), (l, -d - y, x + l, -d), (0, 0, x, y) + # The word DWIDTH + # followed by the width in x and y of the character in device pixels. + dwx, dwy = (int(p) for p in props["DWIDTH"].split()) + + bbox = ( + (dwx, dwy), + (x_disp, -y_disp - height, width + x_disp, -y_disp), + (0, 0, width, height), + ) try: - im = Image.frombytes("1", (x, y), bitmap, "hex", "1") + im = Image.frombytes("1", (width, height), bitmap, "hex", "1") except ValueError: # deal with zero-width characters - im = Image.new("1", (x, y)) + im = Image.new("1", (width, height)) return id, int(props["ENCODING"]), bbox, im @@ -86,7 +97,8 @@ class BdfFontFile(FontFile.FontFile): s = fp.readline() if s[:13] != b"STARTFONT 2.1": - raise SyntaxError("not a valid BDF file") + msg = "not a valid BDF file" + raise SyntaxError(msg) props = {} comments = [] diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 45987ec03..b8f38b78a 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -28,6 +28,7 @@ BLP files come in many different flavours: - DXT3 compression is used if alpha_encoding == 1. - DXT5 compression is used if alpha_encoding == 7. """ +from __future__ import annotations import os import struct @@ -35,7 +36,6 @@ from enum import IntEnum from io import BytesIO from . import Image, ImageFile -from ._deprecate import deprecate class Format(IntEnum): @@ -54,20 +54,6 @@ class AlphaEncoding(IntEnum): DXT5 = 7 -def __getattr__(name): - for enum, prefix in { - Format: "BLP_FORMAT_", - Encoding: "BLP_ENCODING_", - AlphaEncoding: "BLP_ALPHA_ENCODING_", - }.items(): - if name.startswith(prefix): - name = name[len(prefix) :] - if name in enum.__members__: - deprecate(f"{prefix}{name}", 10, f"{enum.__name__}.{name}") - return enum[name] - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - def unpack_565(i): return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3 @@ -278,9 +264,10 @@ class BlpImageFile(ImageFile.ImageFile): if self.magic in (b"BLP1", b"BLP2"): decoder = self.magic.decode() else: - raise BLPFormatError(f"Bad BLP magic {repr(self.magic)}") + msg = f"Bad BLP magic {repr(self.magic)}" + raise BLPFormatError(msg) - self.mode = "RGBA" if self._blp_alpha_depth else "RGB" + self._mode = "RGBA" if self._blp_alpha_depth else "RGB" self.tile = [(decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))] @@ -292,7 +279,8 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): self._read_blp_header() self._load() except struct.error as e: - raise OSError("Truncated BLP file") from e + msg = "Truncated BLP file" + raise OSError(msg) from e return -1, 0 def _read_blp_header(self): @@ -354,13 +342,11 @@ class BLP1Decoder(_BLPBaseDecoder): data = self._read_bgra(palette) self.set_as_raw(bytes(data)) else: - raise BLPFormatError( - f"Unsupported BLP encoding {repr(self._blp_encoding)}" - ) + msg = f"Unsupported BLP encoding {repr(self._blp_encoding)}" + raise BLPFormatError(msg) else: - raise BLPFormatError( - f"Unsupported BLP compression {repr(self._blp_encoding)}" - ) + msg = f"Unsupported BLP compression {repr(self._blp_encoding)}" + raise BLPFormatError(msg) def _decode_jpeg_stream(self): from .JpegImagePlugin import JpegImageFile @@ -415,16 +401,15 @@ class BLP2Decoder(_BLPBaseDecoder): for d in decode_dxt5(self._safe_read(linesize)): data += d else: - raise BLPFormatError( - f"Unsupported alpha encoding {repr(self._blp_alpha_encoding)}" - ) + msg = f"Unsupported alpha encoding {repr(self._blp_alpha_encoding)}" + raise BLPFormatError(msg) else: - raise BLPFormatError(f"Unknown BLP encoding {repr(self._blp_encoding)}") + msg = f"Unknown BLP encoding {repr(self._blp_encoding)}" + raise BLPFormatError(msg) else: - raise BLPFormatError( - f"Unknown BLP compression {repr(self._blp_compression)}" - ) + msg = f"Unknown BLP compression {repr(self._blp_compression)}" + raise BLPFormatError(msg) self.set_as_raw(bytes(data)) @@ -435,9 +420,11 @@ class BLPEncoder(ImageFile.PyEncoder): def _write_palette(self): data = b"" palette = self.im.getpalette("RGBA", "RGBA") - for i in range(256): + for i in range(len(palette) // 4): r, g, b, a = palette[i * 4 : (i + 1) * 4] data += struct.pack("<4B", b, g, r, a) + while len(data) < 256 * 4: + data += b"\x00" * 4 return data def encode(self, bufsize): @@ -458,9 +445,10 @@ class BLPEncoder(ImageFile.PyEncoder): return len(data), 0, data -def _save(im, fp, filename, save_all=False): +def _save(im, fp, filename): if im.mode != "P": - raise ValueError("Unsupported BLP image mode") + msg = "Unsupported BLP image mode" + raise ValueError(msg) magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2" fp.write(magic) diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index bdf51aa5c..6f730cfef 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -22,7 +22,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os @@ -146,7 +146,8 @@ class BmpImageFile(ImageFile.ImageFile): file_info["a_mask"], ) else: - raise OSError(f"Unsupported BMP header type ({file_info['header_size']})") + msg = f"Unsupported BMP header type ({file_info['header_size']})" + raise OSError(msg) # ------------------ Special case : header is reported 40, which # ---------------------- is shorter than real size for bpp >= 16 @@ -162,9 +163,10 @@ class BmpImageFile(ImageFile.ImageFile): offset += 4 * file_info["colors"] # ---------------------- Check bit depth for unusual unsupported values - self.mode, raw_mode = BIT2MODE.get(file_info["bits"], (None, None)) + self._mode, raw_mode = BIT2MODE.get(file_info["bits"], (None, None)) if self.mode is None: - raise OSError(f"Unsupported BMP pixel depth ({file_info['bits']})") + msg = f"Unsupported BMP pixel depth ({file_info['bits']})" + raise OSError(msg) # ---------------- Process BMP with Bitfields compression (not palette) decoder_name = "raw" @@ -198,52 +200,55 @@ class BmpImageFile(ImageFile.ImageFile): and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]] ): raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])] - self.mode = "RGBA" if "A" in raw_mode else self.mode + self._mode = "RGBA" if "A" in raw_mode else self.mode elif ( file_info["bits"] in (24, 16) and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]] ): raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])] else: - raise OSError("Unsupported BMP bitfields layout") + msg = "Unsupported BMP bitfields layout" + raise OSError(msg) else: - raise OSError("Unsupported BMP bitfields layout") + msg = "Unsupported BMP bitfields layout" + raise OSError(msg) elif file_info["compression"] == self.RAW: if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset - raw_mode, self.mode = "BGRA", "RGBA" + raw_mode, self._mode = "BGRA", "RGBA" elif file_info["compression"] in (self.RLE8, self.RLE4): decoder_name = "bmp_rle" else: - raise OSError(f"Unsupported BMP compression ({file_info['compression']})") + msg = f"Unsupported BMP compression ({file_info['compression']})" + raise OSError(msg) # --------------- Once the header is processed, process the palette/LUT if self.mode == "P": # Paletted for 1, 4 and 8 bit images - # ---------------------------------------------------- 1-bit images if not (0 < file_info["colors"] <= 65536): - raise OSError(f"Unsupported BMP Palette size ({file_info['colors']})") + msg = f"Unsupported BMP Palette size ({file_info['colors']})" + raise OSError(msg) else: padding = file_info["palette_padding"] palette = read(padding * file_info["colors"]) - greyscale = True + grayscale = True indices = ( (0, 255) if file_info["colors"] == 2 else list(range(file_info["colors"])) ) - # ----------------- Check if greyscale and ignore palette if so + # ----------------- Check if grayscale and ignore palette if so for ind, val in enumerate(indices): rgb = palette[ind * padding : ind * padding + 3] if rgb != o8(val) * 3: - greyscale = False + grayscale = False - # ------- If all colors are grey, white or black, ditch palette - if greyscale: - self.mode = "1" if file_info["colors"] == 2 else "L" + # ------- If all colors are gray, white or black, ditch palette + if grayscale: + self._mode = "1" if file_info["colors"] == 2 else "L" raw_mode = self.mode else: - self.mode = "P" + self._mode = "P" self.palette = ImagePalette.raw( "BGRX" if padding == 4 else "BGR", palette ) @@ -271,7 +276,8 @@ class BmpImageFile(ImageFile.ImageFile): head_data = self.fp.read(14) # choke if the file does not have the required magic bytes if not _accept(head_data): - raise SyntaxError("Not a BMP file") + msg = "Not a BMP file" + raise SyntaxError(msg) # read the start position of the BMP image data (u32) offset = i32(head_data, 10) # load bitmap information (offset=raster info) @@ -353,7 +359,6 @@ class BmpRleDecoder(ImageFile.PyDecoder): # Image plugin for the DIB format (BMP alias) # ============================================================================= class DibImageFile(BmpImageFile): - format = "DIB" format_description = "Windows Bitmap" @@ -383,14 +388,15 @@ def _save(im, fp, filename, bitmap_header=True): try: rawmode, bits, colors = SAVE[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as BMP") from e + msg = f"cannot write mode {im.mode} as BMP" + raise OSError(msg) from e info = im.encoderinfo dpi = info.get("dpi", (96, 96)) # 1 meter == 39.3701 inches - ppm = tuple(map(lambda x: int(x * 39.3701 + 0.5), dpi)) + ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi) stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3) header = 40 # or 64 for OS/2 version 2 @@ -411,7 +417,8 @@ def _save(im, fp, filename, bitmap_header=True): offset = 14 + header + colors * 4 file_size = offset + image if file_size > 2**32 - 1: - raise ValueError("File size is too large for the BMP format") + msg = "File size is too large for the BMP format" + raise ValueError(msg) fp.write( b"BM" # file type (magic) + o32(file_size) # file size diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 9510f733e..60f3ec25b 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -8,6 +8,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image, ImageFile @@ -33,21 +34,20 @@ def _accept(prefix): class BufrStubImageFile(ImageFile.StubImageFile): - format = "BUFR" format_description = "BUFR" def _open(self): - offset = self.fp.tell() if not _accept(self.fp.read(4)): - raise SyntaxError("Not a BUFR file") + msg = "Not a BUFR file" + raise SyntaxError(msg) self.fp.seek(offset) # make something up - self.mode = "F" + self._mode = "F" self._size = 1, 1 loader = self._load() @@ -60,7 +60,8 @@ class BufrStubImageFile(ImageFile.StubImageFile): def _save(im, fp, filename): if _handler is None or not hasattr(_handler, "save"): - raise OSError("BUFR save handler not installed") + msg = "BUFR save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/src/PIL/ContainerIO.py b/src/PIL/ContainerIO.py index 45e80b39a..387a4c182 100644 --- a/src/PIL/ContainerIO.py +++ b/src/PIL/ContainerIO.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import io diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py index 42af5cafc..5fb2b0193 100644 --- a/src/PIL/CurImagePlugin.py +++ b/src/PIL/CurImagePlugin.py @@ -15,6 +15,8 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + from . import BmpImagePlugin, Image from ._binary import i16le as i16 from ._binary import i32le as i32 @@ -32,18 +34,17 @@ def _accept(prefix): class CurImageFile(BmpImagePlugin.BmpImageFile): - format = "CUR" format_description = "Windows Cursor" def _open(self): - offset = self.fp.tell() # check magic s = self.fp.read(6) if not _accept(s): - raise SyntaxError("not a CUR file") + msg = "not a CUR file" + raise SyntaxError(msg) # pick the largest cursor in the file m = b"" @@ -54,7 +55,8 @@ class CurImageFile(BmpImagePlugin.BmpImageFile): elif s[0] > m[0] and s[1] > m[1]: m = s if not m: - raise TypeError("No cursors were found") + msg = "No cursors were found" + raise TypeError(msg) # load as bitmap self._bitmap(i32(m, 12) + offset) @@ -64,8 +66,6 @@ class CurImageFile(BmpImagePlugin.BmpImageFile): d, e, o, a = self.tile[0] self.tile[0] = d, (0, 0) + self.size, o, a - return - # # -------------------------------------------------------------------- diff --git a/src/PIL/DcxImagePlugin.py b/src/PIL/DcxImagePlugin.py index aeed1e7c7..f7344df44 100644 --- a/src/PIL/DcxImagePlugin.py +++ b/src/PIL/DcxImagePlugin.py @@ -20,6 +20,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image from ._binary import i32le as i32 @@ -37,17 +38,16 @@ def _accept(prefix): class DcxImageFile(PcxImageFile): - format = "DCX" format_description = "Intel DCX" _close_exclusive_fp_after_loading = False def _open(self): - # Header s = self.fp.read(4) if not _accept(s): - raise SyntaxError("not a DCX file") + msg = "not a DCX file" + raise SyntaxError(msg) # Component directory self._offset = [] diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index f78c8b17c..5b6ac2ead 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -3,109 +3,322 @@ A Pillow loader for .dds files (S3TC-compressed aka DXTC) Jerome Leclanche Documentation: - https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt +https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt The contents of this file are hereby released in the public domain (CC0) Full text of the CC0 license: - https://creativecommons.org/publicdomain/zero/1.0/ +https://creativecommons.org/publicdomain/zero/1.0/ """ +from __future__ import annotations +import io import struct -from io import BytesIO +import sys +from enum import IntEnum, IntFlag -from . import Image, ImageFile +from . import Image, ImageFile, ImagePalette +from ._binary import i32le as i32 from ._binary import o32le as o32 # Magic ("DDS ") DDS_MAGIC = 0x20534444 + # DDS flags -DDSD_CAPS = 0x1 -DDSD_HEIGHT = 0x2 -DDSD_WIDTH = 0x4 -DDSD_PITCH = 0x8 -DDSD_PIXELFORMAT = 0x1000 -DDSD_MIPMAPCOUNT = 0x20000 -DDSD_LINEARSIZE = 0x80000 -DDSD_DEPTH = 0x800000 +class DDSD(IntFlag): + CAPS = 0x1 + HEIGHT = 0x2 + WIDTH = 0x4 + PITCH = 0x8 + PIXELFORMAT = 0x1000 + MIPMAPCOUNT = 0x20000 + LINEARSIZE = 0x80000 + DEPTH = 0x800000 + # DDS caps -DDSCAPS_COMPLEX = 0x8 -DDSCAPS_TEXTURE = 0x1000 -DDSCAPS_MIPMAP = 0x400000 +class DDSCAPS(IntFlag): + COMPLEX = 0x8 + TEXTURE = 0x1000 + MIPMAP = 0x400000 + + +class DDSCAPS2(IntFlag): + CUBEMAP = 0x200 + CUBEMAP_POSITIVEX = 0x400 + CUBEMAP_NEGATIVEX = 0x800 + CUBEMAP_POSITIVEY = 0x1000 + CUBEMAP_NEGATIVEY = 0x2000 + CUBEMAP_POSITIVEZ = 0x4000 + CUBEMAP_NEGATIVEZ = 0x8000 + VOLUME = 0x200000 -DDSCAPS2_CUBEMAP = 0x200 -DDSCAPS2_CUBEMAP_POSITIVEX = 0x400 -DDSCAPS2_CUBEMAP_NEGATIVEX = 0x800 -DDSCAPS2_CUBEMAP_POSITIVEY = 0x1000 -DDSCAPS2_CUBEMAP_NEGATIVEY = 0x2000 -DDSCAPS2_CUBEMAP_POSITIVEZ = 0x4000 -DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x8000 -DDSCAPS2_VOLUME = 0x200000 # Pixel Format -DDPF_ALPHAPIXELS = 0x1 -DDPF_ALPHA = 0x2 -DDPF_FOURCC = 0x4 -DDPF_PALETTEINDEXED8 = 0x20 -DDPF_RGB = 0x40 -DDPF_LUMINANCE = 0x20000 - - -# dds.h - -DDS_FOURCC = DDPF_FOURCC -DDS_RGB = DDPF_RGB -DDS_RGBA = DDPF_RGB | DDPF_ALPHAPIXELS -DDS_LUMINANCE = DDPF_LUMINANCE -DDS_LUMINANCEA = DDPF_LUMINANCE | DDPF_ALPHAPIXELS -DDS_ALPHA = DDPF_ALPHA -DDS_PAL8 = DDPF_PALETTEINDEXED8 - -DDS_HEADER_FLAGS_TEXTURE = DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT -DDS_HEADER_FLAGS_MIPMAP = DDSD_MIPMAPCOUNT -DDS_HEADER_FLAGS_VOLUME = DDSD_DEPTH -DDS_HEADER_FLAGS_PITCH = DDSD_PITCH -DDS_HEADER_FLAGS_LINEARSIZE = DDSD_LINEARSIZE - -DDS_HEIGHT = DDSD_HEIGHT -DDS_WIDTH = DDSD_WIDTH - -DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS_TEXTURE -DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS_COMPLEX | DDSCAPS_MIPMAP -DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS_COMPLEX - -DDS_CUBEMAP_POSITIVEX = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEX -DDS_CUBEMAP_NEGATIVEX = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEX -DDS_CUBEMAP_POSITIVEY = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEY -DDS_CUBEMAP_NEGATIVEY = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEY -DDS_CUBEMAP_POSITIVEZ = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEZ -DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEZ - - -# DXT1 -DXT1_FOURCC = 0x31545844 - -# DXT3 -DXT3_FOURCC = 0x33545844 - -# DXT5 -DXT5_FOURCC = 0x35545844 +class DDPF(IntFlag): + ALPHAPIXELS = 0x1 + ALPHA = 0x2 + FOURCC = 0x4 + PALETTEINDEXED8 = 0x20 + RGB = 0x40 + LUMINANCE = 0x20000 # dxgiformat.h +class DXGI_FORMAT(IntEnum): + UNKNOWN = 0 + R32G32B32A32_TYPELESS = 1 + R32G32B32A32_FLOAT = 2 + R32G32B32A32_UINT = 3 + R32G32B32A32_SINT = 4 + R32G32B32_TYPELESS = 5 + R32G32B32_FLOAT = 6 + R32G32B32_UINT = 7 + R32G32B32_SINT = 8 + R16G16B16A16_TYPELESS = 9 + R16G16B16A16_FLOAT = 10 + R16G16B16A16_UNORM = 11 + R16G16B16A16_UINT = 12 + R16G16B16A16_SNORM = 13 + R16G16B16A16_SINT = 14 + R32G32_TYPELESS = 15 + R32G32_FLOAT = 16 + R32G32_UINT = 17 + R32G32_SINT = 18 + R32G8X24_TYPELESS = 19 + D32_FLOAT_S8X24_UINT = 20 + R32_FLOAT_X8X24_TYPELESS = 21 + X32_TYPELESS_G8X24_UINT = 22 + R10G10B10A2_TYPELESS = 23 + R10G10B10A2_UNORM = 24 + R10G10B10A2_UINT = 25 + R11G11B10_FLOAT = 26 + R8G8B8A8_TYPELESS = 27 + R8G8B8A8_UNORM = 28 + R8G8B8A8_UNORM_SRGB = 29 + R8G8B8A8_UINT = 30 + R8G8B8A8_SNORM = 31 + R8G8B8A8_SINT = 32 + R16G16_TYPELESS = 33 + R16G16_FLOAT = 34 + R16G16_UNORM = 35 + R16G16_UINT = 36 + R16G16_SNORM = 37 + R16G16_SINT = 38 + R32_TYPELESS = 39 + D32_FLOAT = 40 + R32_FLOAT = 41 + R32_UINT = 42 + R32_SINT = 43 + R24G8_TYPELESS = 44 + D24_UNORM_S8_UINT = 45 + R24_UNORM_X8_TYPELESS = 46 + X24_TYPELESS_G8_UINT = 47 + R8G8_TYPELESS = 48 + R8G8_UNORM = 49 + R8G8_UINT = 50 + R8G8_SNORM = 51 + R8G8_SINT = 52 + R16_TYPELESS = 53 + R16_FLOAT = 54 + D16_UNORM = 55 + R16_UNORM = 56 + R16_UINT = 57 + R16_SNORM = 58 + R16_SINT = 59 + R8_TYPELESS = 60 + R8_UNORM = 61 + R8_UINT = 62 + R8_SNORM = 63 + R8_SINT = 64 + A8_UNORM = 65 + R1_UNORM = 66 + R9G9B9E5_SHAREDEXP = 67 + R8G8_B8G8_UNORM = 68 + G8R8_G8B8_UNORM = 69 + BC1_TYPELESS = 70 + BC1_UNORM = 71 + BC1_UNORM_SRGB = 72 + BC2_TYPELESS = 73 + BC2_UNORM = 74 + BC2_UNORM_SRGB = 75 + BC3_TYPELESS = 76 + BC3_UNORM = 77 + BC3_UNORM_SRGB = 78 + BC4_TYPELESS = 79 + BC4_UNORM = 80 + BC4_SNORM = 81 + BC5_TYPELESS = 82 + BC5_UNORM = 83 + BC5_SNORM = 84 + B5G6R5_UNORM = 85 + B5G5R5A1_UNORM = 86 + B8G8R8A8_UNORM = 87 + B8G8R8X8_UNORM = 88 + R10G10B10_XR_BIAS_A2_UNORM = 89 + B8G8R8A8_TYPELESS = 90 + B8G8R8A8_UNORM_SRGB = 91 + B8G8R8X8_TYPELESS = 92 + B8G8R8X8_UNORM_SRGB = 93 + BC6H_TYPELESS = 94 + BC6H_UF16 = 95 + BC6H_SF16 = 96 + BC7_TYPELESS = 97 + BC7_UNORM = 98 + BC7_UNORM_SRGB = 99 + AYUV = 100 + Y410 = 101 + Y416 = 102 + NV12 = 103 + P010 = 104 + P016 = 105 + OPAQUE_420 = 106 + YUY2 = 107 + Y210 = 108 + Y216 = 109 + NV11 = 110 + AI44 = 111 + IA44 = 112 + P8 = 113 + A8P8 = 114 + B4G4R4A4_UNORM = 115 + P208 = 130 + V208 = 131 + V408 = 132 + SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189 + SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190 -DXGI_FORMAT_R8G8B8A8_TYPELESS = 27 -DXGI_FORMAT_R8G8B8A8_UNORM = 28 -DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = 29 -DXGI_FORMAT_BC5_TYPELESS = 82 -DXGI_FORMAT_BC5_UNORM = 83 -DXGI_FORMAT_BC5_SNORM = 84 -DXGI_FORMAT_BC6H_UF16 = 95 -DXGI_FORMAT_BC6H_SF16 = 96 -DXGI_FORMAT_BC7_TYPELESS = 97 -DXGI_FORMAT_BC7_UNORM = 98 -DXGI_FORMAT_BC7_UNORM_SRGB = 99 + +class D3DFMT(IntEnum): + UNKNOWN = 0 + R8G8B8 = 20 + A8R8G8B8 = 21 + X8R8G8B8 = 22 + R5G6B5 = 23 + X1R5G5B5 = 24 + A1R5G5B5 = 25 + A4R4G4B4 = 26 + R3G3B2 = 27 + A8 = 28 + A8R3G3B2 = 29 + X4R4G4B4 = 30 + A2B10G10R10 = 31 + A8B8G8R8 = 32 + X8B8G8R8 = 33 + G16R16 = 34 + A2R10G10B10 = 35 + A16B16G16R16 = 36 + A8P8 = 40 + P8 = 41 + L8 = 50 + A8L8 = 51 + A4L4 = 52 + V8U8 = 60 + L6V5U5 = 61 + X8L8V8U8 = 62 + Q8W8V8U8 = 63 + V16U16 = 64 + A2W10V10U10 = 67 + D16_LOCKABLE = 70 + D32 = 71 + D15S1 = 73 + D24S8 = 75 + D24X8 = 77 + D24X4S4 = 79 + D16 = 80 + D32F_LOCKABLE = 82 + D24FS8 = 83 + D32_LOCKABLE = 84 + S8_LOCKABLE = 85 + L16 = 81 + VERTEXDATA = 100 + INDEX16 = 101 + INDEX32 = 102 + Q16W16V16U16 = 110 + R16F = 111 + G16R16F = 112 + A16B16G16R16F = 113 + R32F = 114 + G32R32F = 115 + A32B32G32R32F = 116 + CxV8U8 = 117 + A1 = 118 + A2B10G10R10_XR_BIAS = 119 + BINARYBUFFER = 199 + + UYVY = i32(b"UYVY") + R8G8_B8G8 = i32(b"RGBG") + YUY2 = i32(b"YUY2") + G8R8_G8B8 = i32(b"GRGB") + DXT1 = i32(b"DXT1") + DXT2 = i32(b"DXT2") + DXT3 = i32(b"DXT3") + DXT4 = i32(b"DXT4") + DXT5 = i32(b"DXT5") + DX10 = i32(b"DX10") + BC4S = i32(b"BC4S") + BC4U = i32(b"BC4U") + BC5S = i32(b"BC5S") + BC5U = i32(b"BC5U") + ATI1 = i32(b"ATI1") + ATI2 = i32(b"ATI2") + MULTI2_ARGB8 = i32(b"MET1") + + +# Backward compatibility layer +module = sys.modules[__name__] +for item in DDSD: + setattr(module, "DDSD_" + item.name, item.value) +for item in DDSCAPS: + setattr(module, "DDSCAPS_" + item.name, item.value) +for item in DDSCAPS2: + setattr(module, "DDSCAPS2_" + item.name, item.value) +for item in DDPF: + setattr(module, "DDPF_" + item.name, item.value) + +DDS_FOURCC = DDPF.FOURCC +DDS_RGB = DDPF.RGB +DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS +DDS_LUMINANCE = DDPF.LUMINANCE +DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS +DDS_ALPHA = DDPF.ALPHA +DDS_PAL8 = DDPF.PALETTEINDEXED8 + +DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT +DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT +DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH +DDS_HEADER_FLAGS_PITCH = DDSD.PITCH +DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE + +DDS_HEIGHT = DDSD.HEIGHT +DDS_WIDTH = DDSD.WIDTH + +DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE +DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP +DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX + +DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX +DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX +DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY +DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY +DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ +DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ + +DXT1_FOURCC = D3DFMT.DXT1 +DXT3_FOURCC = D3DFMT.DXT3 +DXT5_FOURCC = D3DFMT.DXT5 + +DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS +DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM +DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB +DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS +DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM +DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM +DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16 +DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16 +DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS +DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM +DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB class DdsImageFile(ImageFile.ImageFile): @@ -114,117 +327,150 @@ class DdsImageFile(ImageFile.ImageFile): def _open(self): if not _accept(self.fp.read(4)): - raise SyntaxError("not a DDS file") + msg = "not a DDS file" + raise SyntaxError(msg) (header_size,) = struct.unpack(" 255: - raise SyntaxError("not an EPS file") - - try: - m = split.match(s) - except re.error as e: - raise SyntaxError("not an EPS file") from e - - if m: - k, v = m.group(1, 2) - self.info[k] = v - if k == "BoundingBox": + if m: + k, v = m.group(1, 2) + self.info[k] = v + if k == "BoundingBox": + if v == "(atend)": + reading_trailer_comments = True + elif not self._size or ( + trailer_reached and reading_trailer_comments + ): try: # Note: The DSC spec says that BoundingBox # fields should be integers, but some drivers @@ -251,16 +264,58 @@ class EpsImageFile(ImageFile.ImageFile): ] except Exception: pass + return True - else: + while True: + byte = self.fp.read(1) + if byte == b"": + # if we didn't read a byte we must be at the end of the file + if bytes_read == 0: + break + elif byte in b"\r\n": + # if we read a line ending character, ignore it and parse what + # we have already read. if we haven't read any other characters, + # continue reading + if bytes_read == 0: + continue + else: + # ASCII/hexadecimal lines in an EPS file must not exceed + # 255 characters, not including line ending characters + if bytes_read >= 255: + # only enforce this for lines starting with a "%", + # otherwise assume it's binary data + if byte_arr[0] == ord("%"): + msg = "not an EPS file" + raise SyntaxError(msg) + else: + if reading_header_comments: + check_required_header_comments() + reading_header_comments = False + # reset bytes_read so we can keep reading + # data until the end of the line + bytes_read = 0 + byte_arr[bytes_read] = byte[0] + bytes_read += 1 + continue + + if reading_header_comments: + # Load EPS header + + # if this line doesn't start with a "%", + # or does start with "%%EndComments", + # then we've reached the end of the header/comments + if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments": + check_required_header_comments() + reading_header_comments = False + continue + + s = str(bytes_mv[:bytes_read], "latin-1") + if not _read_comment(s): m = field.match(s) if m: k = m.group(1) - - if k == "EndComments": - break if k[:8] == "PS-Adobe": - self.info[k[:8]] = k[9:] + self.info["PS-Adobe"] = k[9:] else: self.info[k] = "" elif s[0] == "%": @@ -268,65 +323,79 @@ class EpsImageFile(ImageFile.ImageFile): # tools mistakenly put in the Comments section pass else: - raise OSError("bad EPS header") + msg = "bad EPS header" + raise OSError(msg) + elif bytes_mv[:11] == b"%ImageData:": + # Check for an "ImageData" descriptor + # https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096 - s_raw = fp.readline() - s = s_raw.strip("\r\n") + # Values: + # columns + # rows + # bit depth (1 or 8) + # mode (1: L, 2: LAB, 3: RGB, 4: CMYK) + # number of padding channels + # block size (number of bytes per row per channel) + # binary/ascii (1: binary, 2: ascii) + # data start identifier (the image data follows after a single line + # consisting only of this quoted value) + image_data_values = byte_arr[11:bytes_read].split(None, 7) + columns, rows, bit_depth, mode_id = ( + int(value) for value in image_data_values[:4] + ) - if s and s[:1] != "%": - break - - # - # Scan for an "ImageData" descriptor - - while s[:1] == "%": - - if len(s) > 255: - raise SyntaxError("not an EPS file") - - if s[:11] == "%ImageData:": - # Encoded bitmapped image. - x, y, bi, mo = s[11:].split(None, 7)[:4] - - if int(bi) == 1: - self.mode = "1" - elif int(bi) == 8: + if bit_depth == 1: + self._mode = "1" + elif bit_depth == 8: try: - self.mode = self.mode_map[int(mo)] + self._mode = self.mode_map[mode_id] except ValueError: break else: break - self._size = int(x), int(y) + self._size = columns, rows return + elif trailer_reached and reading_trailer_comments: + # Load EPS trailer - s = fp.readline().strip("\r\n") - if not s: - break + # if this line starts with "%%EOF", + # then we've reached the end of the file + if bytes_mv[:5] == b"%%EOF": + break - if not box: - raise OSError("cannot determine EPS bounding box") + s = str(bytes_mv[:bytes_read], "latin-1") + _read_comment(s) + elif bytes_mv[:9] == b"%%Trailer": + trailer_reached = True + bytes_read = 0 + + check_required_header_comments() + + if not self._size: + msg = "cannot determine EPS bounding box" + raise OSError(msg) def _find_offset(self, fp): + s = fp.read(4) - s = fp.read(160) - - if s[:4] == b"%!PS": + if s == b"%!PS": # for HEAD without binary preview fp.seek(0, io.SEEK_END) length = fp.tell() offset = 0 - elif i32(s, 0) == 0xC6D3D0C5: + elif i32(s) == 0xC6D3D0C5: # FIX for: Some EPS file not handled correctly / issue #302 # EPS can contain binary data # or start directly with latin coding # more info see: # https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf - offset = i32(s, 4) - length = i32(s, 8) + s = fp.read(8) + offset = i32(s) + length = i32(s, 4) else: - raise SyntaxError("not an EPS file") + msg = "not an EPS file" + raise SyntaxError(msg) return length, offset @@ -334,7 +403,7 @@ class EpsImageFile(ImageFile.ImageFile): # Load EPS via Ghostscript if self.tile: self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) - self.mode = self.im.mode + self._mode = self.im.mode self._size = self.im.size self.tile = [] return Image.Image.load(self) @@ -345,18 +414,15 @@ class EpsImageFile(ImageFile.ImageFile): pass -# # -------------------------------------------------------------------- def _save(im, fp, filename, eps=1): """EPS Writer for the Python Imaging Library.""" - # # make sure image data is available im.load() - # # determine PostScript image mode if im.mode == "L": operator = (8, 1, b"image") @@ -365,10 +431,10 @@ def _save(im, fp, filename, eps=1): elif im.mode == "CMYK": operator = (8, 4, b"false 4 colorimage") else: - raise ValueError("image mode is not supported") + msg = "image mode is not supported" + raise ValueError(msg) if eps: - # # write EPS header fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n") fp.write(b"%%Creator: PIL 0.1 EpsEncode\n") @@ -380,7 +446,6 @@ def _save(im, fp, filename, eps=1): fp.write(b"%%ImageData: %d %d " % im.size) fp.write(b'%d %d 0 1 1 "%s"\n' % operator) - # # image header fp.write(b"gsave\n") fp.write(b"10 dict begin\n") @@ -401,7 +466,6 @@ def _save(im, fp, filename, eps=1): fp.flush() -# # -------------------------------------------------------------------- diff --git a/src/PIL/ExifTags.py b/src/PIL/ExifTags.py index 2347c6d4c..60a4d9774 100644 --- a/src/PIL/ExifTags.py +++ b/src/PIL/ExifTags.py @@ -13,6 +13,7 @@ This module provides constants and clear-text names for various well-known EXIF tags. """ +from __future__ import annotations from enum import IntEnum diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index c16300efa..7dce2d60f 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -8,6 +8,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import math @@ -19,7 +20,6 @@ def _accept(prefix): class FitsImageFile(ImageFile.ImageFile): - format = "FITS" format_description = "FITS" @@ -28,20 +28,23 @@ class FitsImageFile(ImageFile.ImageFile): while True: header = self.fp.read(80) if not header: - raise OSError("Truncated FITS file") + msg = "Truncated FITS file" + raise OSError(msg) keyword = header[:8].strip() if keyword == b"END": break - value = header[8:].strip() + value = header[8:].split(b"/")[0].strip() if value.startswith(b"="): value = value[1:].strip() if not headers and (not _accept(keyword) or value != b"T"): - raise SyntaxError("Not a FITS file") + msg = "Not a FITS file" + raise SyntaxError(msg) headers[keyword] = value naxis = int(headers[b"NAXIS"]) if naxis == 0: - raise ValueError("No image data") + msg = "No image data" + raise ValueError(msg) elif naxis == 1: self._size = 1, int(headers[b"NAXIS1"]) else: @@ -49,15 +52,13 @@ class FitsImageFile(ImageFile.ImageFile): number_of_bits = int(headers[b"BITPIX"]) if number_of_bits == 8: - self.mode = "L" + self._mode = "L" elif number_of_bits == 16: - self.mode = "I" - # rawmode = "I;16S" + self._mode = "I" elif number_of_bits == 32: - self.mode = "I" + self._mode = "I" elif number_of_bits in (-32, -64): - self.mode = "F" - # rawmode = "F" if number_of_bits == -32 else "F;64F" + self._mode = "F" offset = math.ceil(self.fp.tell() / 2880) * 2880 self.tile = [("raw", (0, 0) + self.size, offset, (self.mode, 0, -1))] diff --git a/src/PIL/FitsStubImagePlugin.py b/src/PIL/FitsStubImagePlugin.py deleted file mode 100644 index 440240a99..000000000 --- a/src/PIL/FitsStubImagePlugin.py +++ /dev/null @@ -1,76 +0,0 @@ -# -# The Python Imaging Library -# $Id$ -# -# FITS stub adapter -# -# Copyright (c) 1998-2003 by Fredrik Lundh -# -# See the README file for information on usage and redistribution. -# - -from . import FitsImagePlugin, Image, ImageFile -from ._deprecate import deprecate - -_handler = None - - -def register_handler(handler): - """ - Install application-specific FITS image handler. - - :param handler: Handler object. - """ - global _handler - _handler = handler - - deprecate( - "FitsStubImagePlugin", - 10, - action="FITS images can now be read without " - "a handler through FitsImagePlugin instead", - ) - - # Override FitsImagePlugin with this handler - # for backwards compatibility - try: - Image.ID.remove(FITSStubImageFile.format) - except ValueError: - pass - - Image.register_open( - FITSStubImageFile.format, FITSStubImageFile, FitsImagePlugin._accept - ) - - -class FITSStubImageFile(ImageFile.StubImageFile): - - format = FitsImagePlugin.FitsImageFile.format - format_description = FitsImagePlugin.FitsImageFile.format_description - - def _open(self): - offset = self.fp.tell() - - im = FitsImagePlugin.FitsImageFile(self.fp) - self._size = im.size - self.mode = im.mode - self.tile = [] - - self.fp.seek(offset) - - loader = self._load() - if loader: - loader.open(self) - - def _load(self): - return _handler - - -def _save(im, fp, filename): - raise OSError("FITS save handler not installed") - - -# -------------------------------------------------------------------- -# Registry - -Image.register_save(FITSStubImageFile.format, _save) diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index 908bed9f4..9769761fc 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -14,6 +14,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import os @@ -40,24 +41,23 @@ def _accept(prefix): class FliImageFile(ImageFile.ImageFile): - format = "FLI" format_description = "Autodesk FLI/FLC Animation" _close_exclusive_fp_after_loading = False def _open(self): - # HEAD s = self.fp.read(128) if not (_accept(s) and s[20:22] == b"\x00\x00"): - raise SyntaxError("not an FLI/FLC file") + msg = "not an FLI/FLC file" + raise SyntaxError(msg) # frames self.n_frames = i16(s, 6) self.is_animated = self.n_frames > 1 # image characteristics - self.mode = "P" + self._mode = "P" self._size = i16(s, 8), i16(s, 10) # animation speed @@ -141,7 +141,8 @@ class FliImageFile(ImageFile.ImageFile): self.load() if frame != self.__frame + 1: - raise ValueError(f"cannot seek to frame {frame}") + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) self.__frame = frame # move to next frame @@ -150,7 +151,8 @@ class FliImageFile(ImageFile.ImageFile): s = self.fp.read(4) if not s: - raise EOFError + msg = "missing frame size" + raise EOFError(msg) framesize = i32(s) diff --git a/src/PIL/FontFile.py b/src/PIL/FontFile.py index c5fc80b37..9621770e2 100644 --- a/src/PIL/FontFile.py +++ b/src/PIL/FontFile.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os @@ -36,7 +36,6 @@ class FontFile: bitmap = None def __init__(self): - self.info = {} self.glyph = [None] * 256 @@ -79,7 +78,6 @@ class FontFile: if glyph: d, dst, src, im = glyph xx = src[2] - src[0] - # yy = src[3] - src[1] x0, y0 = x, y x = x + xx if x > WIDTH: diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index a55376d0e..75680a94e 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -14,6 +14,8 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import olefile from . import Image, ImageFile @@ -48,7 +50,6 @@ def _accept(prefix): class FpxImageFile(ImageFile.ImageFile): - format = "FPX" format_description = "FlashPix" @@ -60,10 +61,12 @@ class FpxImageFile(ImageFile.ImageFile): try: self.ole = olefile.OleFileIO(self.fp) except OSError as e: - raise SyntaxError("not an FPX file; invalid OLE file") from e + msg = "not an FPX file; invalid OLE file" + raise SyntaxError(msg) from e if self.ole.root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B": - raise SyntaxError("not an FPX file; bad root CLSID") + msg = "not an FPX file; bad root CLSID" + raise SyntaxError(msg) self._open_index(1) @@ -96,15 +99,15 @@ class FpxImageFile(ImageFile.ImageFile): s = prop[0x2000002 | id] - colors = [] bands = i32(s, 4) if bands > 4: - raise OSError("Invalid number of bands") - for i in range(bands): - # note: for now, we ignore the "uncalibrated" flag - colors.append(i32(s, 8 + i * 4) & 0x7FFFFFFF) + msg = "Invalid number of bands" + raise OSError(msg) - self.mode, self.rawmode = MODES[tuple(colors)] + # note: for now, we ignore the "uncalibrated" flag + colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands)) + + self._mode, self.rawmode = MODES[colors] # load JPEG tables, if any self.jpeg = {} @@ -141,7 +144,8 @@ class FpxImageFile(ImageFile.ImageFile): length = i32(s, 32) if size != self.size: - raise OSError("subimage mismatch") + msg = "subimage mismatch" + raise OSError(msg) # get tile descriptors fp.seek(28 + offset) @@ -153,7 +157,6 @@ class FpxImageFile(ImageFile.ImageFile): self.tile = [] for i in range(0, len(s), length): - x1 = min(xsize, x + xtile) y1 = min(ysize, y + ytile) @@ -170,7 +173,6 @@ class FpxImageFile(ImageFile.ImageFile): ) elif compression == 1: - # FIXME: the fill decoder is not implemented self.tile.append( ( @@ -182,7 +184,6 @@ class FpxImageFile(ImageFile.ImageFile): ) elif compression == 2: - internal_color_conversion = s[14] jpeg_tables = s[15] rawmode = self.rawmode @@ -217,7 +218,8 @@ class FpxImageFile(ImageFile.ImageFile): self.tile_prefix = self.jpeg[jpeg_tables] else: - raise OSError("unknown/invalid compression") + msg = "unknown/invalid compression" + raise OSError(msg) x = x + xtile if x >= xsize: @@ -226,15 +228,23 @@ class FpxImageFile(ImageFile.ImageFile): break # isn't really required self.stream = stream + self._fp = self.fp self.fp = None def load(self): - if not self.fp: self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"]) return ImageFile.ImageFile.load(self) + def close(self): + self.ole.close() + super().close() + + def __exit__(self, *args): + self.ole.close() + super().__exit__() + # # -------------------------------------------------------------------- diff --git a/src/PIL/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py index 1b714eb4f..d5513a56a 100644 --- a/src/PIL/FtexImagePlugin.py +++ b/src/PIL/FtexImagePlugin.py @@ -50,13 +50,13 @@ bytes for that mipmap level. Note: All data is stored in little-Endian (Intel) byte order. """ +from __future__ import annotations import struct from enum import IntEnum from io import BytesIO from . import Image, ImageFile -from ._deprecate import deprecate MAGIC = b"FTEX" @@ -66,28 +66,19 @@ class Format(IntEnum): UNCOMPRESSED = 1 -def __getattr__(name): - for enum, prefix in {Format: "FORMAT_"}.items(): - if name.startswith(prefix): - name = name[len(prefix) :] - if name in enum.__members__: - deprecate(f"{prefix}{name}", 10, f"{enum.__name__}.{name}") - return enum[name] - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - class FtexImageFile(ImageFile.ImageFile): format = "FTEX" format_description = "Texture File Format (IW2:EOC)" def _open(self): if not _accept(self.fp.read(4)): - raise SyntaxError("not an FTEX file") + msg = "not an FTEX file" + raise SyntaxError(msg) struct.unpack(" len(self._frame_palette.palette): + color = 0 color = tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) else: color = (color, color, color) @@ -425,7 +432,7 @@ class GifImageFile(ImageFile.ImageFile): self.im.putpalette(*self._frame_palette.getdata()) else: self.im = None - self.mode = temp_mode + self._mode = temp_mode self._frame_palette = None super().load_prepare() @@ -435,9 +442,9 @@ class GifImageFile(ImageFile.ImageFile): if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: if self._frame_transparency is not None: self.im.putpalettealpha(self._frame_transparency, 0) - self.mode = "RGBA" + self._mode = "RGBA" else: - self.mode = "RGB" + self._mode = "RGB" self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG) return if not self._prev_im: @@ -450,7 +457,7 @@ class GifImageFile(ImageFile.ImageFile): frame_im = self._crop(frame_im, self.dispose_extent) self.im = self._prev_im - self.mode = self.im.mode + self._mode = self.im.mode if frame_im.mode == "RGBA": self.im.paste(frame_im, self.dispose_extent, frame_im) else: @@ -484,7 +491,7 @@ def _normalize_mode(im): if Image.getmodebase(im.mode) == "RGB": im = im.convert("P", palette=Image.Palette.ADAPTIVE) if im.palette.mode == "RGBA": - for rgba in im.palette.colors.keys(): + for rgba in im.palette.colors: if rgba[3] == 0: im.info["transparency"] = im.palette.colors[rgba] break @@ -538,7 +545,15 @@ def _normalize_palette(im, palette, info): else: used_palette_colors = _get_optimize(im, info) if used_palette_colors is not None: - return im.remap_palette(used_palette_colors, source_palette) + im = im.remap_palette(used_palette_colors, source_palette) + if "transparency" in info: + try: + info["transparency"] = used_palette_colors.index( + info["transparency"] + ) + except ValueError: + del info["transparency"] + return im im.palette.palette = source_palette return im @@ -566,21 +581,19 @@ def _write_single_frame(im, fp, palette): def _getbbox(base_im, im_frame): - if _get_palette_bytes(im_frame) == _get_palette_bytes(base_im): - delta = ImageChops.subtract_modulo(im_frame, base_im) - else: - delta = ImageChops.subtract_modulo( - im_frame.convert("RGB"), base_im.convert("RGB") - ) - return delta.getbbox() + if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): + im_frame = im_frame.convert("RGBA") + base_im = base_im.convert("RGBA") + delta = ImageChops.subtract_modulo(im_frame, base_im) + return delta, delta.getbbox(alpha_only=False) def _write_multiple_frames(im, fp, palette): - duration = im.encoderinfo.get("duration") disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) im_frames = [] + previous_im = None frame_count = 0 background_im = None for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): @@ -594,9 +607,9 @@ def _write_multiple_frames(im, fp, palette): im.encoderinfo.setdefault(k, v) encoderinfo = im.encoderinfo.copy() - im_frame = _normalize_palette(im_frame, palette, encoderinfo) if "transparency" in im_frame.info: encoderinfo.setdefault("transparency", im_frame.info["transparency"]) + im_frame = _normalize_palette(im_frame, palette, encoderinfo) if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] elif duration is None and "duration" in im_frame.info: @@ -605,14 +618,16 @@ def _write_multiple_frames(im, fp, palette): encoderinfo["disposal"] = disposal[frame_count] frame_count += 1 + diff_frame = None if im_frames: # delta frame - previous = im_frames[-1] - bbox = _getbbox(previous["im"], im_frame) + delta, bbox = _getbbox(previous_im, im_frame) if not bbox: # This frame is identical to the previous frame if encoderinfo.get("duration"): - previous["encoderinfo"]["duration"] += encoderinfo["duration"] + im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[ + "duration" + ] continue if encoderinfo.get("disposal") == 2: if background_im is None: @@ -622,33 +637,67 @@ def _write_multiple_frames(im, fp, palette): 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) + delta, bbox = _getbbox(background_im, im_frame) + if encoderinfo.get("optimize") and im_frame.mode != "1": + if "transparency" not in encoderinfo: + try: + encoderinfo[ + "transparency" + ] = im_frame.palette._new_color_index(im_frame) + except ValueError: + pass + if "transparency" in encoderinfo: + # When the delta is zero, fill the image with transparency + diff_frame = im_frame.copy() + fill = Image.new( + "P", diff_frame.size, encoderinfo["transparency"] + ) + if delta.mode == "RGBA": + r, g, b, a = delta.split() + mask = ImageMath.eval( + "convert(max(max(max(r, g), b), a) * 255, '1')", + r=r, + g=g, + b=b, + a=a, + ) + else: + if delta.mode == "P": + # Convert to L without considering palette + delta_l = Image.new("L", delta.size) + delta_l.putdata(delta.getdata()) + delta = delta_l + mask = ImageMath.eval("convert(im * 255, '1')", im=delta) + diff_frame.paste(fill, mask=ImageOps.invert(mask)) else: bbox = None - im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) + previous_im = im_frame + im_frames.append( + {"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo} + ) - if len(im_frames) > 1: - for frame_data in im_frames: - im_frame = frame_data["im"] - if not frame_data["bbox"]: - # global header - for s in _get_global_header(im_frame, frame_data["encoderinfo"]): - fp.write(s) - offset = (0, 0) - else: - # compress difference - if not palette: - frame_data["encoderinfo"]["include_color_table"] = True + if len(im_frames) == 1: + if "duration" in im.encoderinfo: + # Since multiple frames will not be written, use the combined duration + im.encoderinfo["duration"] = im_frames[0]["encoderinfo"]["duration"] + return - 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 - elif "duration" in im.encoderinfo and isinstance( - im.encoderinfo["duration"], (list, tuple) - ): - # Since multiple frames will not be written, add together the frame durations - im.encoderinfo["duration"] = sum(im.encoderinfo["duration"]) + for frame_data in im_frames: + im_frame = frame_data["im"] + if not frame_data["bbox"]: + # global header + for s in _get_global_header(im_frame, frame_data["encoderinfo"]): + fp.write(s) + offset = (0, 0) + else: + # compress difference + if not palette: + frame_data["encoderinfo"]["include_color_table"] = True + + 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 def _save_all(im, fp, filename): @@ -661,7 +710,7 @@ def _save(im, fp, filename, save_all=False): palette = im.encoderinfo.get("palette", im.info.get("palette")) else: palette = None - im.encoderinfo["optimize"] = im.encoderinfo.get("optimize", True) + im.encoderinfo.setdefault("optimize", True) if not save_all or not _write_multiple_frames(im, fp, palette): _write_single_frame(im, fp, palette) @@ -683,26 +732,10 @@ def get_interlace(im): def _write_local_header(fp, im, offset, flags): - transparent_color_exists = False try: - if "transparency" in im.encoderinfo: - transparency = im.encoderinfo["transparency"] - else: - transparency = im.info["transparency"] - transparency = int(transparency) - except (KeyError, ValueError): - pass - else: - # optimize the block away if transparent color is not used - transparent_color_exists = True - - used_palette_colors = _get_optimize(im, im.encoderinfo) - if used_palette_colors is not None: - # adjust the transparency index after optimize - try: - transparency = used_palette_colors.index(transparency) - except ValueError: - transparent_color_exists = False + transparency = im.encoderinfo["transparency"] + except KeyError: + transparency = None if "duration" in im.encoderinfo: duration = int(im.encoderinfo["duration"] / 10) @@ -711,11 +744,9 @@ def _write_local_header(fp, im, offset, flags): disposal = int(im.encoderinfo.get("disposal", 0)) - if transparent_color_exists or duration != 0 or disposal: - packed_flag = 1 if transparent_color_exists else 0 + if transparency is not None or duration != 0 or disposal: + packed_flag = 1 if transparency is not None else 0 packed_flag |= disposal << 2 - if not transparent_color_exists: - transparency = 0 fp.write( b"!" @@ -723,7 +754,7 @@ def _write_local_header(fp, im, offset, flags): + o8(4) # length + o8(packed_flag) # packed fields + o16(duration) # duration - + o8(transparency) # transparency index + + o8(transparency or 0) # transparency index + o8(0) ) @@ -749,7 +780,6 @@ def _write_local_header(fp, im, offset, flags): def _save_netpbm(im, fp, filename): - # Unused by default. # To use, uncomment the register_save call at the end of the file. # @@ -812,7 +842,7 @@ def _get_optimize(im, info): :param info: encoderinfo :returns: list of indexes of palette entries in use, or None """ - if im.mode in ("P", "L") and info and info.get("optimize", 0): + if im.mode in ("P", "L") and info and info.get("optimize"): # Potentially expensive operation. # The palette saves 3 bytes per color not used, but palette @@ -882,7 +912,7 @@ def _get_palette_bytes(im): :param im: Image object :returns: Bytes, len<=768 suitable for inclusion in gif header """ - return im.palette.palette + return im.palette.palette if im.palette else b"" def _get_background(im, info_background): @@ -919,7 +949,7 @@ def _get_global_header(im, info): info and ( "transparency" in info - or "loop" in info + or info.get("loop") is not None or info.get("duration") or info.get("comment") ) @@ -944,7 +974,7 @@ def _get_global_header(im, info): # Global Color Table _get_header_palette(palette_bytes), ] - if "loop" in info: + if info.get("loop") is not None: header.append( b"!" + o8(255) # extension intro diff --git a/src/PIL/GimpGradientFile.py b/src/PIL/GimpGradientFile.py index 7ab7f9990..2d8c78ea9 100644 --- a/src/PIL/GimpGradientFile.py +++ b/src/PIL/GimpGradientFile.py @@ -18,7 +18,7 @@ Stuff to translate curve segments to palette values (derived from the corresponding code in GIMP, written by Federico Mena Quintero. See the GIMP distribution for more information.) """ - +from __future__ import annotations from math import log, pi, sin, sqrt @@ -64,18 +64,15 @@ SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing] class GradientFile: - gradient = None def getpalette(self, entries=256): - palette = [] ix = 0 x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix] for i in range(entries): - x = i / (entries - 1) while x1 < x: @@ -105,9 +102,9 @@ class GimpGradientFile(GradientFile): """File handler for GIMP's gradient format.""" def __init__(self, fp): - if fp.readline()[:13] != b"GIMP Gradient": - raise SyntaxError("not a GIMP gradient file") + msg = "not a GIMP gradient file" + raise SyntaxError(msg) line = fp.readline() @@ -120,7 +117,6 @@ class GimpGradientFile(GradientFile): gradient = [] for i in range(count): - s = fp.readline().split() w = [float(x) for x in s[:11]] @@ -133,7 +129,8 @@ class GimpGradientFile(GradientFile): cspace = int(s[12]) if cspace != 0: - raise OSError("cannot handle HSV colour space") + msg = "cannot handle HSV colour space" + raise OSError(msg) gradient.append((x0, x1, xm, rgb0, rgb1, segment)) diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index 4d7cfbaba..a3109ebaa 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -13,6 +13,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import re @@ -25,14 +26,13 @@ class GimpPaletteFile: rawmode = "RGB" def __init__(self, fp): - self.palette = [o8(i) * 3 for i in range(256)] if fp.readline()[:12] != b"GIMP Palette": - raise SyntaxError("not a GIMP palette file") + msg = "not a GIMP palette file" + raise SyntaxError(msg) for i in range(256): - s = fp.readline() if not s: break @@ -41,16 +41,17 @@ class GimpPaletteFile: if re.match(rb"\w+:|#", s): continue if len(s) > 100: - raise SyntaxError("bad palette file") + msg = "bad palette file" + raise SyntaxError(msg) v = tuple(map(int, s.split()[:3])) if len(v) != 3: - raise ValueError("bad palette entry") + msg = "bad palette entry" + raise ValueError(msg) self.palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) self.palette = b"".join(self.palette) def getpalette(self): - return self.palette, self.rawmode diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index 4575f8237..f8106800c 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -8,6 +8,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image, ImageFile @@ -33,21 +34,20 @@ def _accept(prefix): class GribStubImageFile(ImageFile.StubImageFile): - format = "GRIB" format_description = "GRIB" def _open(self): - offset = self.fp.tell() if not _accept(self.fp.read(8)): - raise SyntaxError("Not a GRIB file") + msg = "Not a GRIB file" + raise SyntaxError(msg) self.fp.seek(offset) # make something up - self.mode = "F" + self._mode = "F" self._size = 1, 1 loader = self._load() @@ -60,7 +60,8 @@ class GribStubImageFile(ImageFile.StubImageFile): def _save(im, fp, filename): if _handler is None or not hasattr(_handler, "save"): - raise OSError("GRIB save handler not installed") + msg = "GRIB save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index df11cf2a6..65409e269 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -8,6 +8,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image, ImageFile @@ -33,21 +34,20 @@ def _accept(prefix): class HDF5StubImageFile(ImageFile.StubImageFile): - format = "HDF5" format_description = "HDF5" def _open(self): - offset = self.fp.tell() if not _accept(self.fp.read(8)): - raise SyntaxError("Not an HDF file") + msg = "Not an HDF file" + raise SyntaxError(msg) self.fp.seek(offset) # make something up - self.mode = "F" + self._mode = "F" self._size = 1, 1 loader = self._load() @@ -60,7 +60,8 @@ class HDF5StubImageFile(ImageFile.StubImageFile): def _save(im, fp, filename): if _handler is None or not hasattr(_handler, "save"): - raise OSError("HDF5 save handler not installed") + msg = "HDF5 save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index fa192f053..d877b4ecb 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -16,17 +16,18 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io import os import struct import sys -from PIL import Image, ImageFile, PngImagePlugin, features +from . import Image, ImageFile, PngImagePlugin, features enable_jpeg2k = features.check_codec("jpg_2000") if enable_jpeg2k: - from PIL import Jpeg2KImagePlugin + from . import Jpeg2KImagePlugin MAGIC = b"icns" HEADERSIZE = 8 @@ -42,7 +43,8 @@ def read_32t(fobj, start_length, size): fobj.seek(start) sig = fobj.read(4) if sig != b"\x00\x00\x00\x00": - raise SyntaxError("Unknown signature, expecting 0x00000000") + msg = "Unknown signature, expecting 0x00000000" + raise SyntaxError(msg) return read_32(fobj, (start + 4, length - 4), size) @@ -82,7 +84,8 @@ def read_32(fobj, start_length, size): if bytesleft <= 0: break if bytesleft != 0: - raise SyntaxError(f"Error reading channel [{repr(bytesleft)} left]") + msg = f"Error reading channel [{repr(bytesleft)} left]" + raise SyntaxError(msg) band = Image.frombuffer("L", pixel_size, b"".join(data), "raw", "L", 0, 1) im.im.putband(band.im, band_ix) return {"RGB": im} @@ -113,10 +116,11 @@ def read_png_or_jpeg2000(fobj, start_length, size): or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" ): if not enable_jpeg2k: - raise ValueError( + msg = ( "Unsupported icon subimage format (rebuild PIL " "with JPEG 2000 support to fix this)" ) + raise ValueError(msg) # j2k, jpc or j2c fobj.seek(start) jp2kstream = fobj.read(length) @@ -127,11 +131,11 @@ def read_png_or_jpeg2000(fobj, start_length, size): im = im.convert("RGBA") return {"RGBA": im} else: - raise ValueError("Unsupported icon subimage format") + msg = "Unsupported icon subimage format" + raise ValueError(msg) class IcnsFile: - SIZES = { (512, 512, 2): [(b"ic10", read_png_or_jpeg2000)], (512, 512, 1): [(b"ic09", read_png_or_jpeg2000)], @@ -168,12 +172,14 @@ class IcnsFile: self.fobj = fobj sig, filesize = nextheader(fobj) if not _accept(sig): - raise SyntaxError("not an icns file") + msg = "not an icns file" + raise SyntaxError(msg) i = HEADERSIZE while i < filesize: sig, blocksize = nextheader(fobj) if blocksize <= 0: - raise SyntaxError("invalid block header") + msg = "invalid block header" + raise SyntaxError(msg) i += HEADERSIZE blocksize -= HEADERSIZE dct[sig] = (i, blocksize) @@ -183,7 +189,7 @@ class IcnsFile: def itersizes(self): sizes = [] for size, fmts in self.SIZES.items(): - for (fmt, reader) in fmts: + for fmt, reader in fmts: if fmt in self.dct: sizes.append(size) break @@ -192,7 +198,8 @@ class IcnsFile: def bestsize(self): sizes = self.itersizes() if not sizes: - raise SyntaxError("No 32bit icon resources found") + msg = "No 32bit icon resources found" + raise SyntaxError(msg) return max(sizes) def dataforsize(self, size): @@ -247,7 +254,7 @@ class IcnsImageFile(ImageFile.ImageFile): def _open(self): self.icns = IcnsFile(self.fp) - self.mode = "RGBA" + self._mode = "RGBA" self.info["sizes"] = self.icns.itersizes() self.best_size = self.icns.bestsize() self.size = ( @@ -275,7 +282,8 @@ class IcnsImageFile(ImageFile.ImageFile): if value in simple_sizes: info_size = self.info["sizes"][simple_sizes.index(value)] if info_size not in self.info["sizes"]: - raise ValueError("This is not one of the allowed sizes of this image") + msg = "This is not one of the allowed sizes of this image" + raise ValueError(msg) self._size = value def load(self): @@ -298,7 +306,7 @@ class IcnsImageFile(ImageFile.ImageFile): px = im.load() self.im = im.im - self.mode = im.mode + self._mode = im.mode self.size = im.size return px @@ -384,8 +392,8 @@ if __name__ == "__main__": with open(sys.argv[1], "rb") as fp: imf = IcnsImageFile(fp) for size in imf.info["sizes"]: - imf.size = size - imf.save("out-%s-%s-%s.png" % size) + width, height, scale = imf.size = size + imf.save(f"out-{width}-{height}-{scale}.png") with Image.open(sys.argv[1]) as im: im.save("out.png") if sys.platform == "windows": diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 93b9dfdea..1b22f8645 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -20,7 +20,7 @@ # Icon format references: # * https://en.wikipedia.org/wiki/ICO_(file_format) # * https://msdn.microsoft.com/en-us/library/ms997538.aspx - +from __future__ import annotations import warnings from io import BytesIO @@ -127,7 +127,8 @@ class IcoFile: # check magic s = buf.read(6) if not _accept(s): - raise SyntaxError("not an ICO file") + msg = "not an ICO file" + raise SyntaxError(msg) self.buf = buf self.entry = [] @@ -173,9 +174,7 @@ class IcoFile: self.entry = sorted(self.entry, key=lambda x: x["color_depth"]) # ICO images are usually squares - # self.entry = sorted(self.entry, key=lambda x: x['width']) - self.entry = sorted(self.entry, key=lambda x: x["square"]) - self.entry.reverse() + self.entry = sorted(self.entry, key=lambda x: x["square"], reverse=True) def sizes(self): """ @@ -184,7 +183,7 @@ class IcoFile: return {(h["width"], h["height"]) for h in self.entry} def getentryindex(self, size, bpp=False): - for (i, h) in enumerate(self.entry): + for i, h in enumerate(self.entry): if size == h["dim"] and (bpp is False or bpp == h["color_depth"]): return i return 0 @@ -316,7 +315,8 @@ class IcoImageFile(ImageFile.ImageFile): @size.setter def size(self, value): if value not in self.info["sizes"]: - raise ValueError("This is not one of the allowed sizes of this image") + msg = "This is not one of the allowed sizes of this image" + raise ValueError(msg) self._size = value def load(self): @@ -328,7 +328,7 @@ class IcoImageFile(ImageFile.ImageFile): im.load() self.im = im.im self.pyaccess = None - self.mode = im.mode + self._mode = im.mode if im.size != self.size: warnings.warn("Image was not the expected size") diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 31b0ff469..97d726a8a 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -24,7 +24,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os import re @@ -115,18 +115,17 @@ def number(s): class ImImageFile(ImageFile.ImageFile): - format = "IM" format_description = "IFUNC Image Memory" _close_exclusive_fp_after_loading = False def _open(self): - # Quick rejection: if there's not an LF among the first # 100 bytes, this is (probably) not a text header. if b"\n" not in self.fp.read(100): - raise SyntaxError("not an IM file") + msg = "not an IM file" + raise SyntaxError(msg) self.fp.seek(0) n = 0 @@ -139,7 +138,6 @@ class ImImageFile(ImageFile.ImageFile): self.rawmode = "L" while True: - s = self.fp.read(1) # Some versions of IFUNC uses \n\r instead of \r\n... @@ -153,7 +151,8 @@ class ImImageFile(ImageFile.ImageFile): s = s + self.fp.readline() if len(s) > 100: - raise SyntaxError("not an IM file") + msg = "not an IM file" + raise SyntaxError(msg) if s[-2:] == b"\r\n": s = s[:-2] @@ -163,10 +162,10 @@ class ImImageFile(ImageFile.ImageFile): try: m = split.match(s) except re.error as e: - raise SyntaxError("not an IM file") from e + msg = "not an IM file" + raise SyntaxError(msg) from e if m: - k, v = m.group(1, 2) # Don't know if this is the correct encoding, @@ -197,23 +196,23 @@ class ImImageFile(ImageFile.ImageFile): n += 1 else: - - raise SyntaxError( - "Syntax error in IM header: " + s.decode("ascii", "replace") - ) + msg = "Syntax error in IM header: " + s.decode("ascii", "replace") + raise SyntaxError(msg) if not n: - raise SyntaxError("Not an IM file") + msg = "Not an IM file" + raise SyntaxError(msg) # Basic attributes self._size = self.info[SIZE] - self.mode = self.info[MODE] + self._mode = self.info[MODE] # Skip forward to start of image data while s and s[:1] != b"\x1A": s = self.fp.read(1) if not s: - raise SyntaxError("File truncated") + msg = "File truncated" + raise SyntaxError(msg) if LUT in self.info: # convert lookup table to palette or lut attribute @@ -232,9 +231,9 @@ class ImImageFile(ImageFile.ImageFile): self.lut = list(palette[:256]) else: if self.mode in ["L", "P"]: - self.mode = self.rawmode = "P" + self._mode = self.rawmode = "P" elif self.mode in ["LA", "PA"]: - self.mode = "PA" + self._mode = "PA" self.rawmode = "PA;L" self.palette = ImagePalette.raw("RGB;L", palette) elif self.mode == "RGB": @@ -248,7 +247,6 @@ class ImImageFile(ImageFile.ImageFile): self._fp = self.fp # FIXME: hack if self.rawmode[:2] == "F;": - # ifunc95 formats try: # use bit decoder (if necessary) @@ -328,11 +326,11 @@ SAVE = { def _save(im, fp, filename): - try: image_type, rawmode = SAVE[im.mode] except KeyError as e: - raise ValueError(f"Cannot save {im.mode} images as IM") from e + msg = f"Cannot save {im.mode} images as IM" + raise ValueError(msg) from e frames = im.encoderinfo.get("frames", 1) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index a760de575..1cb484b85 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -24,6 +24,8 @@ # See the README file for information on usage and redistribution. # +from __future__ import annotations + import atexit import builtins import io @@ -40,7 +42,7 @@ from enum import IntEnum from pathlib import Path try: - import defusedxml.ElementTree as ElementTree + from defusedxml import ElementTree except ImportError: ElementTree = None @@ -56,33 +58,8 @@ from . import ( _plugins, ) from ._binary import i32le, o32be, o32le -from ._deprecate import deprecate from ._util import DeferredError, is_path - -def __getattr__(name): - categories = {"NORMAL": 0, "SEQUENCE": 1, "CONTAINER": 2} - if name in categories: - deprecate("Image categories", 10, "is_animated", plural=True) - return categories[name] - elif name in ("NEAREST", "NONE"): - deprecate(name, 10, "Resampling.NEAREST or Dither.NONE") - return 0 - old_resampling = { - "LINEAR": "BILINEAR", - "CUBIC": "BICUBIC", - "ANTIALIAS": "LANCZOS", - } - if name in old_resampling: - deprecate(name, 10, f"Resampling.{old_resampling[name]}") - return Resampling[old_resampling[name]] - for enum in (Transpose, Transform, Resampling, Dither, Palette, Quantize): - if name in enum.__members__: - deprecate(name, 10, f"{enum.__name__}.{name}") - return enum[name] - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - logger = logging.getLogger(__name__) @@ -107,11 +84,12 @@ try: from . import _imaging as core if __version__ != getattr(core, "PILLOW_VERSION", None): - raise ImportError( + msg = ( "The _imaging extension was built for another version of Pillow or PIL:\n" f"Core version: {getattr(core, 'PILLOW_VERSION', None)}\n" f"Pillow version: {__version__}" ) + raise ImportError(msg) except ImportError as v: core = DeferredError(ImportError("The _imaging C module is not installed.")) @@ -131,8 +109,7 @@ except ImportError as v: raise -# works everywhere, win for pypy, not cpython -USE_CFFI_ACCESS = hasattr(sys, "pypy_version_info") +USE_CFFI_ACCESS = False try: import cffi except ImportError: @@ -156,6 +133,7 @@ def isImageType(t): # # Constants + # transpose class Transpose(IntEnum): FLIP_LEFT_RIGHT = 0 @@ -216,6 +194,12 @@ class Quantize(IntEnum): LIBIMAGEQUANT = 3 +module = sys.modules[__name__] +for enum in (Transpose, Transform, Resampling, Dither, Palette, Quantize): + for item in enum: + setattr(module, item.name, item.value) + + if hasattr(core, "DEFAULT_STRATEGY"): DEFAULT_STRATEGY = core.DEFAULT_STRATEGY FILTERED = core.FILTERED @@ -316,7 +300,11 @@ _initialized = 0 def preinit(): - """Explicitly load standard file format drivers.""" + """ + Explicitly loads BMP, GIF, JPEG, PPM and PPM file format drivers. + + It is called when opening or saving images. + """ global _initialized if _initialized >= 1: @@ -352,11 +340,6 @@ def preinit(): assert PngImagePlugin except ImportError: pass - # try: - # import TiffImagePlugin - # assert TiffImagePlugin - # except ImportError: - # pass _initialized = 1 @@ -365,16 +348,20 @@ def init(): """ Explicitly initializes the Python Imaging Library. This function loads all available file format drivers. + + It is called when opening or saving images if :py:meth:`~preinit()` is + insufficient, and by :py:meth:`~PIL.features.pilinfo`. """ global _initialized if _initialized >= 2: return 0 + parent_name = __name__.rpartition(".")[0] for plugin in _plugins: try: logger.debug("Importing %s", plugin) - __import__(f"PIL.{plugin}", globals(), locals(), []) + __import__(f"{parent_name}.{plugin}", globals(), locals(), []) except ImportError as e: logger.debug("Image: failed to import %s: %s", plugin, e) @@ -388,7 +375,6 @@ def init(): def _getdecoder(mode, decoder_name, args, extra=()): - # tweak arguments if args is None: args = () @@ -406,12 +392,12 @@ def _getdecoder(mode, decoder_name, args, extra=()): # get decoder decoder = getattr(core, decoder_name + "_decoder") except AttributeError as e: - raise OSError(f"decoder {decoder_name} not available") from e + msg = f"decoder {decoder_name} not available" + raise OSError(msg) from e return decoder(mode, *args + extra) def _getencoder(mode, encoder_name, args, extra=()): - # tweak arguments if args is None: args = () @@ -429,7 +415,8 @@ def _getencoder(mode, encoder_name, args, extra=()): # get encoder encoder = getattr(core, encoder_name + "_encoder") except AttributeError as e: - raise OSError(f"encoder {encoder_name} not available") from e + msg = f"encoder {encoder_name} not available" + raise OSError(msg) from e return encoder(mode, *args + extra) @@ -437,26 +424,18 @@ def _getencoder(mode, encoder_name, args, extra=()): # Simple expression analyzer -def coerce_e(value): - deprecate("coerce_e", 10) - return value if isinstance(value, _E) else _E(1, value) - - -# _E(scale, offset) represents the affine transformation scale * x + offset. -# The "data" field is named for compatibility with the old implementation, -# and should be renamed once coerce_e is removed. class _E: - def __init__(self, scale, data): + def __init__(self, scale, offset): self.scale = scale - self.data = data + self.offset = offset def __neg__(self): - return _E(-self.scale, -self.data) + return _E(-self.scale, -self.offset) def __add__(self, other): if isinstance(other, _E): - return _E(self.scale + other.scale, self.data + other.data) - return _E(self.scale, self.data + other) + return _E(self.scale + other.scale, self.offset + other.offset) + return _E(self.scale, self.offset + other) __radd__ = __add__ @@ -469,19 +448,19 @@ class _E: def __mul__(self, other): if isinstance(other, _E): return NotImplemented - return _E(self.scale * other, self.data * other) + return _E(self.scale * other, self.offset * other) __rmul__ = __mul__ def __truediv__(self, other): if isinstance(other, _E): return NotImplemented - return _E(self.scale / other, self.data / other) + return _E(self.scale / other, self.offset / other) def _getscaleoffset(expr): a = expr(_E(1, 0)) - return (a.scale, a.data) if isinstance(a, _E) else (0, a) + return (a.scale, a.offset) if isinstance(a, _E) else (0, a) # -------------------------------------------------------------------- @@ -500,29 +479,22 @@ class Image: * :py:func:`~PIL.Image.frombytes` """ - format = None - format_description = None + format: str | None = None + format_description: str | None = None _close_exclusive_fp_after_loading = True def __init__(self): # FIXME: take "new" parameters / other image? # FIXME: turn mode and size into delegating properties? self.im = None - self.mode = "" + self._mode = "" self._size = (0, 0) self.palette = None self.info = {} - self._category = 0 self.readonly = 0 self.pyaccess = None self._exif = None - def __getattr__(self, name): - if name == "category": - deprecate("Image categories", 10, "is_animated", plural=True) - return self._category - raise AttributeError(name) - @property def width(self): return self.size[0] @@ -535,10 +507,14 @@ class Image: def size(self): return self._size + @property + def mode(self): + return self._mode + def _new(self, im): new = Image() new.im = im - new.mode = im.mode + new._mode = im.mode new._size = im.size if im.mode in ("P", "PA"): if self.palette: @@ -576,16 +552,17 @@ class Image: :py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for more information. """ - try: - 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() - self.fp = None - except Exception as msg: - logger.debug("Error closing: %s", msg) + if hasattr(self, "fp"): + try: + 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() + self.fp = None + except Exception as msg: + logger.debug("Error closing: %s", msg) if getattr(self, "map", None): self.map = None @@ -635,7 +612,6 @@ class Image: and self.mode == other.mode and self.size == other.size and self.info == other.info - and self._category == other._category and self.getpalette() == other.getpalette() and self.tobytes() == other.tobytes() ) @@ -666,26 +642,37 @@ class Image: ) ) - def _repr_png_(self): - """iPython display hook support + def _repr_image(self, image_format, **kwargs): + """Helper function for iPython display hook. - :returns: png version of the image as bytes + :param image_format: Image format. + :returns: image as bytes, saved into the given format. """ b = io.BytesIO() try: - self.save(b, "PNG") - except Exception as e: - raise ValueError("Could not save to PNG for display") from e + self.save(b, image_format, **kwargs) + except Exception: + return None return b.getvalue() + def _repr_png_(self): + """iPython display hook support for PNG format. + + :returns: PNG version of the image as bytes + """ + return self._repr_image("PNG", compress_level=1) + + def _repr_jpeg_(self): + """iPython display hook support for JPEG format. + + :returns: JPEG version of the image as bytes + """ + return self._repr_image("JPEG") + @property def __array_interface__(self): # numpy array interface support - new = {} - shape, typestr = _conv_type_shape(self) - new["shape"] = shape - new["typestr"] = typestr - new["version"] = 3 + new = {"version": 3} try: if self.mode == "1": # Binary images need to be extended from bits to bytes @@ -704,16 +691,18 @@ class Image: if parse_version(numpy.__version__) < parse_version("1.23"): warnings.warn(e) raise + new["shape"], new["typestr"] = _conv_type_shape(self) return new def __getstate__(self): - return [self.info, self.mode, self.size, self.getpalette(), self.tobytes()] + im_data = self.tobytes() # load image first + return [self.info, self.mode, self.size, self.getpalette(), im_data] def __setstate__(self, state): Image.__init__(self) info, mode, size, palette, data = state self.info = info - self.mode = mode + self._mode = mode self._size = size self.im = core.new(mode, size) if mode in ("L", "LA", "P", "PA") and palette: @@ -760,16 +749,17 @@ class Image: bufsize = max(65536, self.size[0] * 4) # see RawEncode.c - data = [] + output = [] while True: - l, s, d = e.encode(bufsize) - data.append(d) - if s: + bytes_consumed, errcode, data = e.encode(bufsize) + output.append(data) + if errcode: break - if s < 0: - raise RuntimeError(f"encoder error {s} in tobytes") + if errcode < 0: + msg = f"encoder error {errcode} in tobytes" + raise RuntimeError(msg) - return b"".join(data) + return b"".join(output) def tobitmap(self, name="image"): """ @@ -784,7 +774,8 @@ class Image: self.load() if self.mode != "1": - raise ValueError("not a bitmap") + msg = "not a bitmap" + raise ValueError(msg) data = self.tobytes("xbm") return b"".join( [ @@ -804,6 +795,9 @@ class Image: but loads data into this image instead of creating a new image object. """ + if self.width == 0 or self.height == 0: + return + # may pass tuple instead of argument list if len(args) == 1 and isinstance(args[0], tuple): args = args[0] @@ -818,9 +812,11 @@ class Image: s = d.decode(data) if s[0] >= 0: - raise ValueError("not enough image data") + msg = "not enough image data" + raise ValueError(msg) if s[1] != 0: - raise ValueError("cannot decode image data") + msg = "cannot decode image data" + raise ValueError(msg) def load(self): """ @@ -889,12 +885,12 @@ class Image: "L", "RGB" and "CMYK". The ``matrix`` argument only supports "L" and "RGB". - When translating a color image to greyscale (mode "L"), + When translating a color image to grayscale (mode "L"), the library uses the ITU-R 601-2 luma transform:: L = R * 299/1000 + G * 587/1000 + B * 114/1000 - The default method of converting a greyscale ("L") or "RGB" + The default method of converting a grayscale ("L") or "RGB" image into a bilevel (mode "1") image uses Floyd-Steinberg dither to approximate the original image luminosity levels. If dither is ``None``, all values larger than 127 are set to 255 (white), @@ -926,7 +922,7 @@ class Image: self.load() - has_transparency = self.info.get("transparency") is not None + has_transparency = "transparency" in self.info if not mode and self.mode == "P": # determine default mode if self.palette: @@ -941,11 +937,12 @@ class Image: if matrix: # matrix conversion if mode not in ("L", "RGB"): - raise ValueError("illegal conversion") + msg = "illegal conversion" + raise ValueError(msg) im = self.im.convert_matrix(mode, matrix) - new = self._new(im) + new_im = self._new(im) if has_transparency and self.im.bands == 3: - transparency = new.info["transparency"] + transparency = new_im.info["transparency"] def convert_transparency(m, v): v = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 @@ -958,8 +955,8 @@ class Image: convert_transparency(matrix[i * 4 : i * 4 + 4], transparency) for i in range(0, len(transparency)) ) - new.info["transparency"] = transparency - return new + new_im.info["transparency"] = transparency + return new_im if mode == "P" and self.mode == "RGBA": return self.quantize(colors) @@ -990,7 +987,7 @@ class Image: else: # get the new transparency color. # use existing conversions - trns_im = Image()._new(core.new(self.mode, (1, 1))) + trns_im = new(self.mode, (1, 1)) if self.mode == "P": trns_im.putpalette(self.palette) if isinstance(t, tuple): @@ -1026,27 +1023,30 @@ class Image: elif isinstance(t, int): self.im.putpalettealpha(t, 0) else: - raise ValueError("Transparency for P mode should be bytes or int") + msg = "Transparency for P mode should be bytes or int" + raise ValueError(msg) if mode == "P" and palette == Palette.ADAPTIVE: im = self.im.quantize(colors) - new = self._new(im) + new_im = self._new(im) from . import ImagePalette - new.palette = ImagePalette.ImagePalette("RGB", new.im.getpalette("RGB")) + new_im.palette = ImagePalette.ImagePalette( + "RGB", new_im.im.getpalette("RGB") + ) if delete_trns: # This could possibly happen if we requantize to fewer colors. # The transparency would be totally off in that case. - del new.info["transparency"] + del new_im.info["transparency"] if trns is not None: try: - new.info["transparency"] = new.palette.getcolor(trns, new) + new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) except Exception: # if we can't make a transparent color, don't leave the old # transparency hanging around to mess us up. - del new.info["transparency"] + del new_im.info["transparency"] warnings.warn("Couldn't allocate palette entry for transparency") - return new + return new_im if "LAB" in (self.mode, mode): other_mode = mode if self.mode == "LAB" else self.mode @@ -1076,13 +1076,14 @@ class Image: im = self.im.convert(modebase) im = im.convert(mode, dither) except KeyError as e: - raise ValueError("illegal conversion") from e + msg = "illegal conversion" + raise ValueError(msg) from e new_im = self._new(im) if mode == "P" and palette != Palette.ADAPTIVE: from . import ImagePalette - new_im.palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) + new_im.palette = ImagePalette.ImagePalette("RGB", im.getpalette("RGB")) if delete_trns: # crash fail if we leave a bytes transparency in an rgb/l mode. del new_im.info["transparency"] @@ -1135,7 +1136,6 @@ class Image: Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG` (default). :returns: A new image - """ self.load() @@ -1151,20 +1151,21 @@ class Image: Quantize.LIBIMAGEQUANT, ): # Caller specified an invalid mode. - raise ValueError( + msg = ( "Fast Octree (method == 2) and libimagequant (method == 3) " "are the only valid methods for quantizing RGBA images" ) + raise ValueError(msg) if palette: # use palette from reference image palette.load() if palette.mode != "P": - raise ValueError("bad mode for palette image") - if self.mode != "RGB" and self.mode != "L": - raise ValueError( - "only RGB or L mode images can be quantized to a palette" - ) + msg = "bad mode for palette image" + raise ValueError(msg) + if self.mode not in {"RGB", "L"}: + msg = "only RGB or L mode images can be quantized to a palette" + raise ValueError(msg) im = self.im.convert("P", dither, palette.im) new_im = self._new(im) new_im.palette = palette.palette.copy() @@ -1210,9 +1211,11 @@ class Image: return self.copy() if box[2] < box[0]: - raise ValueError("Coordinate 'right' is less than 'left'") + msg = "Coordinate 'right' is less than 'left'" + raise ValueError(msg) elif box[3] < box[1]: - raise ValueError("Coordinate 'lower' is less than 'upper'") + msg = "Coordinate 'lower' is less than 'upper'" + raise ValueError(msg) self.load() return self._new(self._crop(self.im, box)) @@ -1242,7 +1245,7 @@ class Image: Configures the image file loader so it returns a version of the image that as closely as possible matches the given mode and size. For example, you can use this method to convert a color - JPEG to greyscale while loading it. + JPEG to grayscale while loading it. If any changes are made, returns a tuple with the chosen ``mode`` and ``box`` with coordinates of the original image within the altered one. @@ -1255,7 +1258,8 @@ class Image: currently implemented only for JPEG and MPO images. :param mode: The requested mode. - :param size: The requested size. + :param size: The requested size in pixels, as a 2-tuple: + (width, height). """ pass @@ -1263,7 +1267,7 @@ class Image: if ymargin is None: ymargin = xmargin self.load() - return self._new(self.im.expand(xmargin, ymargin, 0)) + return self._new(self.im.expand(xmargin, ymargin)) def filter(self, filter): """ @@ -1280,17 +1284,16 @@ class Image: if isinstance(filter, Callable): filter = filter() if not hasattr(filter, "filter"): - raise TypeError( - "filter argument should be ImageFilter.Filter instance or class" - ) + msg = "filter argument should be ImageFilter.Filter instance or class" + raise TypeError(msg) multiband = isinstance(filter, ImageFilter.MultibandFilter) if self.im.bands == 1 or multiband: return self._new(filter.filter(self.im)) - ims = [] - for c in range(self.im.bands): - ims.append(self._new(filter.filter(self.im.getband(c)))) + ims = [ + self._new(filter.filter(self.im.getband(c))) for c in range(self.im.bands) + ] return merge(self.mode, ims) def getbands(self): @@ -1303,11 +1306,15 @@ class Image: """ return ImageMode.getmode(self.mode).bands - def getbbox(self): + def getbbox(self, *, alpha_only=True): """ Calculates the bounding box of the non-zero regions in the image. + :param alpha_only: Optional flag, defaulting to ``True``. + If ``True`` and the image has an alpha channel, trim transparent pixels. + Otherwise, trim pixels when all channels are zero. + Keyword-only argument. :returns: The bounding box is returned as a 4-tuple defining the left, upper, right, and lower pixel coordinate. See :ref:`coordinate-system`. If the image is completely empty, this @@ -1316,7 +1323,7 @@ class Image: """ self.load() - return self.im.getbbox() + return self.im.getbbox(alpha_only) def getcolors(self, maxcolors=256): """ @@ -1335,10 +1342,7 @@ class Image: self.load() if self.mode in ("1", "L", "P"): h = self.im.histogram() - out = [] - for i in range(256): - if h[i]: - out.append((h[i], i)) + out = [(h[i], i) for i in range(256) if h[i]] if len(out) > maxcolors: return None return out @@ -1379,15 +1383,12 @@ class Image: self.load() if self.im.bands > 1: - extrema = [] - for i in range(self.im.bands): - extrema.append(self.im.getband(i).getextrema()) - return tuple(extrema) + return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return self.im.getextrema() def _getxmp(self, xmp_tags): def get_name(tag): - return tag.split("}")[1] + return re.sub("^{[^}]+}", "", tag) def get_value(element): value = {get_name(k): v for k, v in element.attrib.items()} @@ -1417,6 +1418,11 @@ class Image: return {get_name(root.tag): get_value(root)} def getexif(self): + """ + Gets EXIF data from the image. + + :returns: an :py:class:`~PIL.Image.Exif` object. + """ if self._exif is None: self._exif = Exif() self._exif._loaded = False @@ -1438,12 +1444,12 @@ class Image: self._exif.load(exif_info) # XMP tags - if 0x0112 not in self._exif: + if ExifTags.Base.Orientation not in self._exif: xmp_tags = self.info.get("XML:com.adobe.xmp") if xmp_tags: match = re.search(r'tiff:Orientation(="|>)([0-9])', xmp_tags) if match: - self._exif[0x0112] = int(match[2]) + self._exif[ExifTags.Base.Orientation] = int(match[2]) return self._exif @@ -1528,10 +1534,29 @@ class Image: rawmode = mode return list(self.im.getpalette(mode, rawmode)) + @property + def has_transparency_data(self) -> bool: + """ + Determine if an image has transparency data, whether in the form of an + alpha channel, a palette with an alpha channel, or a "transparency" key + in the info dictionary. + + Note the image might still appear solid, if all of the values shown + within are opaque. + + :returns: A boolean. + """ + return ( + self.mode in ("LA", "La", "PA", "RGBA", "RGBa") + or (self.mode == "P" and self.palette.mode.endswith("A")) + or "transparency" in self.info + ) + def apply_transparency(self): """ If a P mode image has a "transparency" key in the info dictionary, - remove the key and apply the transparency to the palette instead. + remove the key and instead apply the transparency to the palette. + Otherwise, the image is unchanged. """ if self.mode != "P" or "transparency" not in self.info: return @@ -1563,7 +1588,7 @@ class Image: self.load() if self.pyaccess: return self.pyaccess.getpixel(xy) - return self.im.getpixel(xy) + return self.im.getpixel(tuple(xy)) def getprojection(self): """ @@ -1586,13 +1611,13 @@ class Image: than one band, the histograms for all bands are concatenated (for example, the histogram for an "RGB" image contains 768 values). - A bilevel image (mode "1") is treated as a greyscale ("L") image + A bilevel image (mode "1") is treated as a grayscale ("L") image by this method. If a mask is provided, the method returns a histogram for those parts of the image where the mask image is non-zero. The mask image must have the same size as the image, and be either a - bi-level image (mode "1") or a greyscale image ("L"). + bi-level image (mode "1") or a grayscale image ("L"). :param mask: An optional mask. :param extrema: An optional tuple of manually-specified extrema. @@ -1612,13 +1637,13 @@ class Image: """ Calculates and returns the entropy for the image. - A bilevel image (mode "1") is treated as a greyscale ("L") + A bilevel image (mode "1") is treated as a grayscale ("L") image by this method. If a mask is provided, the method employs the histogram for those parts of the image where the mask image is non-zero. The mask image must have the same size as the image, and be - either a bi-level image (mode "1") or a greyscale image ("L"). + either a bi-level image (mode "1") or a grayscale image ("L"). :param mask: An optional mask. :param extrema: An optional tuple of manually-specified extrema. @@ -1690,7 +1715,8 @@ class Image: size = mask.size else: # FIXME: use self.size here? - raise ValueError("cannot determine region size; use 4-item box") + msg = "cannot determine region size; use 4-item box" + raise ValueError(msg) box += (box[0] + size[0], box[1] + size[1]) if isinstance(im, str): @@ -1729,15 +1755,20 @@ class Image: """ if not isinstance(source, (list, tuple)): - raise ValueError("Source must be a tuple") + msg = "Source must be a tuple" + raise ValueError(msg) if not isinstance(dest, (list, tuple)): - raise ValueError("Destination must be a tuple") - if not len(source) in (2, 4): - raise ValueError("Source must be a 2 or 4-tuple") + msg = "Destination must be a tuple" + raise ValueError(msg) + if len(source) not in (2, 4): + msg = "Source must be a 2 or 4-tuple" + raise ValueError(msg) if not len(dest) == 2: - raise ValueError("Destination must be a 2-tuple") + msg = "Destination must be a 2-tuple" + raise ValueError(msg) if min(source) < 0: - raise ValueError("Source must be non-negative") + msg = "Source must be non-negative" + raise ValueError(msg) if len(source) == 2: source = source + im.size @@ -1802,7 +1833,8 @@ class Image: if self.mode == "F": # FIXME: _imaging returns a confusing error message for this case - raise ValueError("point operation not supported for this mode") + msg = "point operation not supported for this mode" + raise ValueError(msg) if mode != "F": lut = [round(i) for i in lut] @@ -1831,12 +1863,14 @@ class Image: # do things the hard way im = self.im.convert(mode) if im.mode not in ("LA", "PA", "RGBA"): - raise ValueError from e # sanity check + msg = "alpha channel could not be added" + raise ValueError(msg) from e # sanity check self.im = im self.pyaccess = None - self.mode = self.im.mode + self._mode = self.im.mode except KeyError as e: - raise ValueError("illegal image mode") from e + msg = "illegal image mode" + raise ValueError(msg) from e if self.mode in ("LA", "PA"): band = 1 @@ -1846,7 +1880,8 @@ class Image: if isImageType(alpha): # alpha layer if alpha.mode not in ("1", "L"): - raise ValueError("illegal image mode") + msg = "illegal image mode" + raise ValueError(msg) alpha.load() if alpha.mode == "1": alpha = alpha.convert("L") @@ -1902,14 +1937,15 @@ class Image: from . import ImagePalette if self.mode not in ("L", "LA", "P", "PA"): - raise ValueError("illegal image mode") + msg = "illegal image mode" + raise ValueError(msg) if isinstance(data, ImagePalette.ImagePalette): palette = ImagePalette.raw(data.rawmode, data.palette) else: if not isinstance(data, bytes): data = bytes(data) palette = ImagePalette.raw(rawmode, data) - self.mode = "PA" if "A" in self.mode else "P" + self._mode = "PA" if "A" in self.mode else "P" self.palette = palette self.palette.mode = "RGB" self.load() # install new palette @@ -1971,7 +2007,8 @@ class Image: from . import ImagePalette if self.mode not in ("L", "P"): - raise ValueError("illegal image mode") + msg = "illegal image mode" + raise ValueError(msg) bands = 3 palette_mode = "RGB" @@ -2016,7 +2053,7 @@ class Image: mapping_palette = bytearray(new_positions) m_im = self.copy() - m_im.mode = "P" + m_im._mode = "P" m_im.palette = ImagePalette.ImagePalette( palette_mode, palette=mapping_palette * bands @@ -2103,7 +2140,7 @@ class Image: Resampling.BOX, Resampling.HAMMING, ): - message = f"Unknown resampling filter ({resample})." + msg = f"Unknown resampling filter ({resample})." filters = [ f"{filter[1]} ({filter[0]})" @@ -2116,12 +2153,12 @@ class Image: (Resampling.HAMMING, "Image.Resampling.HAMMING"), ) ] - raise ValueError( - message + " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] - ) + msg += " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] + raise ValueError(msg) if reducing_gap is not None and reducing_gap < 1.0: - raise ValueError("reducing_gap must be 1.0 or greater") + msg = "reducing_gap must be 1.0 or greater" + raise ValueError(msg) size = tuple(size) @@ -2379,7 +2416,8 @@ class Image: try: format = EXTENSION[ext] except KeyError as e: - raise ValueError(f"unknown file extension: {ext}") from e + msg = f"unknown file extension: {ext}" + raise ValueError(msg) from e if format.upper() not in SAVE: init() @@ -2431,7 +2469,8 @@ class Image: # overridden by file handlers if frame != 0: - raise EOFError + msg = "no more images in file" + raise EOFError(msg) def show(self, title=None): """ @@ -2443,8 +2482,8 @@ class Image: The image is first saved to a temporary file. By default, it will be in PNG format. - On Unix, the image is then opened using the **display**, **eog** or - **xv** utility, depending on which one can be found. + On Unix, the image is then opened using the **xdg-open**, **display**, + **gm**, **eog** or **xv** utility, depending on which one can be found. On macOS, the image is opened with the native Preview application. @@ -2493,7 +2532,8 @@ class Image: try: channel = self.getbands().index(channel) except ValueError as e: - raise ValueError(f'The image has no channel "{channel}"') from e + msg = f'The image has no channel "{channel}"' + raise ValueError(msg) from e return self._new(self.im.getband(channel)) @@ -2522,7 +2562,8 @@ class Image: apply this method to a :py:meth:`~PIL.Image.Image.copy` of the original image. - :param size: Requested size. + :param size: The requested size in pixels, as a 2-tuple: + (width, height). :param resample: Optional resampling filter. This can be one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, @@ -2588,7 +2629,7 @@ class Image: self.im = im.im self._size = size - self.mode = self.im.mode + self._mode = self.im.mode self.readonly = 0 self.pyaccess = None @@ -2609,7 +2650,8 @@ class Image: given size, and the same mode as the original, and copies data to the new image using the given transform. - :param size: The output size. + :param size: The output size in pixels, as a 2-tuple: + (width, height). :param method: The transformation method. This is one of :py:data:`Transform.EXTENT` (cut out a rectangular subregion), :py:data:`Transform.AFFINE` (affine transform), @@ -2664,7 +2706,8 @@ class Image: method, data = method.getdata() if data is None: - raise ValueError("missing method data") + msg = "missing method data" + raise ValueError(msg) im = new(self.mode, size, fillcolor) if self.mode == "P" and self.palette: @@ -2725,7 +2768,8 @@ class Image: ) else: - raise ValueError("unknown transformation method") + msg = "unknown transformation method" + raise ValueError(msg) if resample not in ( Resampling.NEAREST, @@ -2733,13 +2777,13 @@ class Image: Resampling.BICUBIC, ): if resample in (Resampling.BOX, Resampling.HAMMING, Resampling.LANCZOS): - message = { + msg = { Resampling.BOX: "Image.Resampling.BOX", Resampling.HAMMING: "Image.Resampling.HAMMING", Resampling.LANCZOS: "Image.Resampling.LANCZOS", }[resample] + f" ({resample}) cannot be used." else: - message = f"Unknown resampling filter ({resample})." + msg = f"Unknown resampling filter ({resample})." filters = [ f"{filter[1]} ({filter[0]})" @@ -2749,9 +2793,8 @@ class Image: (Resampling.BICUBIC, "Image.Resampling.BICUBIC"), ) ] - raise ValueError( - message + " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] - ) + msg += " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] + raise ValueError(msg) image.load() @@ -2790,7 +2833,8 @@ class Image: from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.toqimage(self) def toqpixmap(self): @@ -2798,7 +2842,8 @@ class Image: from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.toqpixmap(self) @@ -2832,7 +2877,7 @@ class ImageTransformHandler: def _wedge(): - """Create greyscale wedge (for debugging only)""" + """Create grayscale wedge (for debugging only)""" return Image()._new(core.wedge("L")) @@ -2846,11 +2891,14 @@ def _check_size(size): """ if not isinstance(size, (list, tuple)): - raise ValueError("Size must be a tuple") + msg = "Size must be a tuple" + raise ValueError(msg) if len(size) != 2: - raise ValueError("Size must be a tuple of length 2") + msg = "Size must be a tuple of length 2" + raise ValueError(msg) if size[0] < 0 or size[1] < 0: - raise ValueError("Width and height must be >= 0") + msg = "Width and height must be >= 0" + raise ValueError(msg) return True @@ -2865,7 +2913,7 @@ def new(mode, size, color=0): :param color: What color to use for the image. Default is black. If given, this should be a single integer or floating point value for single-band modes, and a tuple for multi-band modes (one value - per band). When creating RGB images, you can also use color + per band). When creating RGB or HSV images, you can also use color strings as supported by the ImageColor module. If the color is None, the image is not initialised. :returns: An :py:class:`~PIL.Image.Image` object. @@ -2920,15 +2968,16 @@ def frombytes(mode, size, data, decoder_name="raw", *args): _check_size(size) - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] - - if decoder_name == "raw" and args == (): - args = mode - im = new(mode, size) - im.frombytes(data, decoder_name, args) + if im.width != 0 and im.height != 0: + # may pass tuple instead of argument list + if len(args) == 1 and isinstance(args[0], tuple): + args = args[0] + + if decoder_name == "raw" and args == (): + args = mode + + im.frombytes(data, decoder_name, args) return im @@ -2977,7 +3026,7 @@ def frombuffer(mode, size, data, decoder_name="raw", *args): if args == (): args = mode, 0, 1 if args[0] in _MAPMODES: - im = new(mode, (1, 1)) + im = new(mode, (0, 0)) im = im._new(core.map_buffer(data, size, decoder_name, 0, args)) if mode == "P": from . import ImagePalette @@ -2992,21 +3041,29 @@ def frombuffer(mode, size, data, decoder_name="raw", *args): def fromarray(obj, mode=None): """ Creates an image memory from an object exporting the array interface - (using the buffer protocol). + (using the buffer protocol):: + + from PIL import Image + import numpy as np + a = np.zeros((5, 5)) + im = Image.fromarray(a) If ``obj`` is not contiguous, then the ``tobytes`` method is called and :py:func:`~PIL.Image.frombuffer` is used. - If you have an image in NumPy:: + In the case of NumPy, be aware that Pillow modes do not always correspond + to NumPy dtypes. Pillow modes only offer 1-bit pixels, 8-bit pixels, + 32-bit signed integer pixels, and 32-bit floating point pixels. + + Pillow images can also be converted to arrays:: from PIL import Image import numpy as np im = Image.open("hopper.jpg") a = np.asarray(im) - Then this can be used to convert it to a Pillow image:: - - im = Image.fromarray(a) + When converting Pillow images to arrays however, only pixel values are + transferred. This means that P and PA mode images will lose their palette. :param obj: Object with array interface :param mode: Optional mode to use when reading ``obj``. Will be determined from @@ -3036,11 +3093,14 @@ def fromarray(obj, mode=None): try: typekey = (1, 1) + shape[2:], arr["typestr"] except KeyError as e: - raise TypeError("Cannot handle this data type") from e + msg = "Cannot handle this data type" + raise TypeError(msg) from e try: mode, rawmode = _fromarray_typemap[typekey] except KeyError as e: - raise TypeError("Cannot handle this data type: %s, %s" % typekey) from e + typekey_shape, typestr = typekey + msg = f"Cannot handle this data type: {typekey_shape}, {typestr}" + raise TypeError(msg) from e else: rawmode = mode if mode in ["1", "L", "I", "P", "F"]: @@ -3050,7 +3110,8 @@ def fromarray(obj, mode=None): else: ndmax = 4 if ndim > ndmax: - raise ValueError(f"Too many dimensions: {ndim} > {ndmax}.") + msg = f"Too many dimensions: {ndim} > {ndmax}." + raise ValueError(msg) size = 1 if ndim == 1 else shape[1], shape[0] if strides is not None: @@ -3067,7 +3128,8 @@ def fromqimage(im): from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.fromqimage(im) @@ -3076,7 +3138,8 @@ def fromqpixmap(im): from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.fromqpixmap(im) @@ -3111,13 +3174,14 @@ def _decompression_bomb_check(size): if MAX_IMAGE_PIXELS is None: return - pixels = size[0] * size[1] + pixels = max(1, size[0]) * max(1, size[1]) if pixels > 2 * MAX_IMAGE_PIXELS: - raise DecompressionBombError( + msg = ( f"Image size ({pixels} pixels) exceeds limit of {2 * MAX_IMAGE_PIXELS} " "pixels, could be decompression bomb DOS attack." ) + raise DecompressionBombError(msg) if pixels > MAX_IMAGE_PIXELS: warnings.warn( @@ -3140,7 +3204,8 @@ def open(fp, mode="r", formats=None): :param fp: A filename (string), pathlib.Path object or a file object. The file object must implement ``file.read``, ``file.seek``, and ``file.tell`` methods, - and be opened in binary mode. + and be opened in binary mode. The file object will also seek to zero + before reading. :param mode: The mode. If given, this argument must be "r". :param formats: A list or tuple of formats to attempt to load the file in. This can be used to restrict the set of formats checked. @@ -3157,17 +3222,20 @@ def open(fp, mode="r", formats=None): """ if mode != "r": - raise ValueError(f"bad mode {repr(mode)}") + msg = f"bad mode {repr(mode)}" + raise ValueError(msg) elif isinstance(fp, io.StringIO): - raise ValueError( + msg = ( "StringIO cannot be used to open an image. " "Binary data must be used instead." ) + raise ValueError(msg) if formats is None: formats = ID elif not isinstance(formats, (list, tuple)): - raise TypeError("formats must be a list or tuple") + msg = "formats must be a list or tuple" + raise TypeError(msg) exclusive_fp = False filename = "" @@ -3220,9 +3288,15 @@ def open(fp, mode="r", formats=None): im = _open_core(fp, filename, prefix, formats) - if im is None: + if im is None and formats is ID: + checked_formats = formats.copy() if init(): - im = _open_core(fp, filename, prefix, formats) + im = _open_core( + fp, + filename, + prefix, + tuple(format for format in formats if format not in checked_formats), + ) if im: im._exclusive_fp = exclusive_fp @@ -3232,9 +3306,8 @@ def open(fp, mode="r", formats=None): fp.close() for message in accept_warnings: warnings.warn(message) - raise UnidentifiedImageError( - "cannot identify image file %r" % (filename if filename else fp) - ) + msg = "cannot identify image file %r" % (filename if filename else fp) + raise UnidentifiedImageError(msg) # @@ -3325,12 +3398,15 @@ def merge(mode, bands): """ if getmodebands(mode) != len(bands) or "*" in mode: - raise ValueError("wrong number of bands") + msg = "wrong number of bands" + raise ValueError(msg) for band in bands[1:]: if band.mode != getmodetype(mode): - raise ValueError("mode mismatch") + msg = "mode mismatch" + raise ValueError(msg) if band.size != bands[0].size: - raise ValueError("size mismatch") + msg = "size mismatch" + raise ValueError(msg) for band in bands: band.load() return bands[0]._new(core.merge(mode, *[b.im for b in bands])) @@ -3351,14 +3427,19 @@ def register_open(id, factory, accept=None): reject images having another format. """ id = id.upper() - ID.append(id) + if id not in ID: + ID.append(id) OPEN[id] = factory, accept def register_mime(id, mimetype): """ - Registers an image MIME type. This function should not be used - in application code. + Registers an image MIME type by populating ``Image.MIME``. This function + should not be used in application code. + + ``Image.MIME`` provides a mapping from image format identifiers to mime + formats, but :py:meth:`~PIL.ImageFile.ImageFile.get_format_mimetype` can + provide a different result for specific images. :param id: An image format identifier. :param mimetype: The image MIME type for this format. @@ -3546,6 +3627,39 @@ atexit.register(core.clear_cache) class Exif(MutableMapping): + """ + This class provides read and write access to EXIF image data:: + + from PIL import Image + im = Image.open("exif.png") + exif = im.getexif() # Returns an instance of this class + + Information can be read and written, iterated over or deleted:: + + print(exif[274]) # 1 + exif[274] = 2 + for k, v in exif.items(): + print("Tag", k, "Value", v) # Tag 274 Value 2 + del exif[274] + + To access information beyond IFD0, :py:meth:`~PIL.Image.Exif.get_ifd` + returns a dictionary:: + + from PIL import ExifTags + im = Image.open("exif_gps.jpg") + exif = im.getexif() + gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) + print(gps_ifd) + + 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:: + + print(exif[ExifTags.Base.Software]) # PIL + print(gps_ifd[ExifTags.GPS.GPSDateStamp]) # 1999:99:99 99:99:99 + """ + endian = None bigtiff = False @@ -3642,6 +3756,7 @@ class Exif(MutableMapping): self.endian = self._info._endian if offset is None: offset = self._info.next + self.fp.tell() self.fp.seek(offset) self._info.load(self.fp) @@ -3686,7 +3801,7 @@ class Exif(MutableMapping): def get_ifd(self, tag): if tag not in self._ifds: if tag == ExifTags.IFD.IFD1: - if self._info is not None: + if self._info is not None and self._info.next != 0: self._ifds[tag] = self._get_ifd_dict(self._info.next) elif tag in [ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo]: offset = self._hidden_data.get(tag, self.get(tag)) @@ -3792,7 +3907,7 @@ class Exif(MutableMapping): def __str__(self): if self._info is not None: # Load all keys into self._data - for tag in self._info.keys(): + for tag in self._info: self[tag] return str(self._data) diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index fec4694b2..29a5c995f 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -15,11 +15,13 @@ # See the README file for information on usage and redistribution. # +from __future__ import annotations + from . import Image -def constant(image, value): - """Fill a channel with a given grey level. +def constant(image: Image.Image, value: int) -> Image.Image: + """Fill a channel with a given gray level. :rtype: :py:class:`~PIL.Image.Image` """ @@ -27,7 +29,7 @@ def constant(image, value): return Image.new("L", image.size, value) -def duplicate(image): +def duplicate(image: Image.Image) -> Image.Image: """Copy a channel. Alias for :py:meth:`PIL.Image.Image.copy`. :rtype: :py:class:`~PIL.Image.Image` @@ -36,11 +38,9 @@ def duplicate(image): return image.copy() -def invert(image): +def invert(image: Image.Image) -> Image.Image: """ - Invert an image (channel). - - .. code-block:: python + Invert an image (channel). :: out = MAX - image @@ -51,12 +51,10 @@ def invert(image): return image._new(image.im.chop_invert()) -def lighter(image1, image2): +def lighter(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Compares the two images, pixel by pixel, and returns a new image containing - the lighter values. - - .. code-block:: python + the lighter values. :: out = max(image1, image2) @@ -68,12 +66,10 @@ def lighter(image1, image2): return image1._new(image1.im.chop_lighter(image2.im)) -def darker(image1, image2): +def darker(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Compares the two images, pixel by pixel, and returns a new image containing - the darker values. - - .. code-block:: python + the darker values. :: out = min(image1, image2) @@ -85,12 +81,10 @@ def darker(image1, image2): return image1._new(image1.im.chop_darker(image2.im)) -def difference(image1, image2): +def difference(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Returns the absolute value of the pixel-by-pixel difference between the two - images. - - .. code-block:: python + images. :: out = abs(image1 - image2) @@ -102,14 +96,12 @@ def difference(image1, image2): return image1._new(image1.im.chop_difference(image2.im)) -def multiply(image1, image2): +def multiply(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other. If you multiply an image with a solid black image, the result is black. If - you multiply with a solid white image, the image is unaffected. - - .. code-block:: python + you multiply with a solid white image, the image is unaffected. :: out = image1 * image2 / MAX @@ -121,11 +113,9 @@ def multiply(image1, image2): return image1._new(image1.im.chop_multiply(image2.im)) -def screen(image1, image2): +def screen(image1: Image.Image, image2: Image.Image) -> Image.Image: """ - Superimposes two inverted images on top of each other. - - .. code-block:: python + Superimposes two inverted images on top of each other. :: out = MAX - ((MAX - image1) * (MAX - image2) / MAX) @@ -137,7 +127,7 @@ def screen(image1, image2): return image1._new(image1.im.chop_screen(image2.im)) -def soft_light(image1, image2): +def soft_light(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other using the Soft Light algorithm @@ -149,7 +139,7 @@ def soft_light(image1, image2): return image1._new(image1.im.chop_soft_light(image2.im)) -def hard_light(image1, image2): +def hard_light(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other using the Hard Light algorithm @@ -161,7 +151,7 @@ def hard_light(image1, image2): return image1._new(image1.im.chop_hard_light(image2.im)) -def overlay(image1, image2): +def overlay(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other using the Overlay algorithm @@ -173,12 +163,12 @@ def overlay(image1, image2): return image1._new(image1.im.chop_overlay(image2.im)) -def add(image1, image2, scale=1.0, offset=0): +def add( + image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0 +) -> Image.Image: """ Adds two images, dividing the result by scale and adding the - offset. If omitted, scale defaults to 1.0, and offset to 0.0. - - .. code-block:: python + offset. If omitted, scale defaults to 1.0, and offset to 0.0. :: out = ((image1 + image2) / scale + offset) @@ -190,12 +180,12 @@ def add(image1, image2, scale=1.0, offset=0): return image1._new(image1.im.chop_add(image2.im, scale, offset)) -def subtract(image1, image2, scale=1.0, offset=0): +def subtract( + image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0 +) -> Image.Image: """ Subtracts two images, dividing the result by scale and adding the offset. - If omitted, scale defaults to 1.0, and offset to 0.0. - - .. code-block:: python + If omitted, scale defaults to 1.0, and offset to 0.0. :: out = ((image1 - image2) / scale + offset) @@ -207,10 +197,8 @@ def subtract(image1, image2, scale=1.0, offset=0): return image1._new(image1.im.chop_subtract(image2.im, scale, offset)) -def add_modulo(image1, image2): - """Add two images, without clipping the result. - - .. code-block:: python +def add_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Add two images, without clipping the result. :: out = ((image1 + image2) % MAX) @@ -222,10 +210,8 @@ def add_modulo(image1, image2): return image1._new(image1.im.chop_add_modulo(image2.im)) -def subtract_modulo(image1, image2): - """Subtract two images, without clipping the result. - - .. code-block:: python +def subtract_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Subtract two images, without clipping the result. :: out = ((image1 - image2) % MAX) @@ -237,15 +223,13 @@ def subtract_modulo(image1, image2): return image1._new(image1.im.chop_subtract_modulo(image2.im)) -def logical_and(image1, image2): +def logical_and(image1: Image.Image, image2: Image.Image) -> Image.Image: """Logical AND between two images. Both of the images must have mode "1". If you would like to perform a logical AND on an image with a mode other than "1", try :py:meth:`~PIL.ImageChops.multiply` instead, using a black-and-white mask - as the second image. - - .. code-block:: python + as the second image. :: out = ((image1 and image2) % MAX) @@ -257,12 +241,10 @@ def logical_and(image1, image2): return image1._new(image1.im.chop_and(image2.im)) -def logical_or(image1, image2): +def logical_or(image1: Image.Image, image2: Image.Image) -> Image.Image: """Logical OR between two images. - Both of the images must have mode "1". - - .. code-block:: python + Both of the images must have mode "1". :: out = ((image1 or image2) % MAX) @@ -274,12 +256,10 @@ def logical_or(image1, image2): return image1._new(image1.im.chop_or(image2.im)) -def logical_xor(image1, image2): +def logical_xor(image1: Image.Image, image2: Image.Image) -> Image.Image: """Logical XOR between two images. - Both of the images must have mode "1". - - .. code-block:: python + Both of the images must have mode "1". :: out = ((bool(image1) != bool(image2)) % MAX) @@ -291,7 +271,7 @@ def logical_xor(image1, image2): return image1._new(image1.im.chop_xor(image2.im)) -def blend(image1, image2, alpha): +def blend(image1: Image.Image, image2: Image.Image, alpha: float) -> Image.Image: """Blend images using constant transparency weight. Alias for :py:func:`PIL.Image.blend`. @@ -301,7 +281,9 @@ def blend(image1, image2, alpha): return Image.blend(image1, image2, alpha) -def composite(image1, image2, mask): +def composite( + image1: Image.Image, image2: Image.Image, mask: Image.Image +) -> Image.Image: """Create composite using transparency mask. Alias for :py:func:`PIL.Image.composite`. @@ -311,7 +293,7 @@ def composite(image1, image2, mask): return Image.composite(image1, image2, mask) -def offset(image, xoffset, yoffset=None): +def offset(image: Image.Image, xoffset: int, yoffset: int | None = None) -> Image.Image: """Returns a copy of the image where data has been offset by the given distances. Data wraps around the edges. If ``yoffset`` is omitted, it is assumed to be equal to ``xoffset``. diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 605252d5d..9d27f2513 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -14,16 +14,15 @@ # See the README file for information on usage and redistribution. See # below for the original description. +from __future__ import annotations import sys from enum import IntEnum -from PIL import Image - -from ._deprecate import deprecate +from . import Image try: - from PIL import _imagingcms + from . import _imagingcms except ImportError as ex: # Allow error import for doc purposes, but error out when accessing # anything in core. @@ -117,16 +116,6 @@ class Direction(IntEnum): PROOF = 2 -def __getattr__(name): - for enum, prefix in {Intent: "INTENT_", Direction: "DIRECTION_"}.items(): - if name.startswith(prefix): - name = name[len(prefix) :] - if name in enum.__members__: - deprecate(f"{prefix}{name}", 10, f"{enum.__name__}.{name}") - return enum[name] - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - # # flags @@ -191,17 +180,14 @@ class ImageCmsProfile: elif isinstance(profile, _imagingcms.CmsProfile): self._set(profile) else: - raise TypeError("Invalid type for Profile") + msg = "Invalid type for Profile" + raise TypeError(msg) def _set(self, profile, filename=None): self.profile = profile self.filename = filename - if profile: - self.product_name = None # profile.product_name - self.product_info = None # profile.product_info - else: - self.product_name = None - self.product_info = None + self.product_name = None # profile.product_name + self.product_info = None # profile.product_info def tobytes(self): """ @@ -269,7 +255,8 @@ class ImageCmsTransform(Image.ImagePointHandler): def apply_in_place(self, im): im.load() if im.mode != self.output_mode: - raise ValueError("mode mismatch") # wrong output mode + msg = "mode mismatch" + raise ValueError(msg) # wrong output mode self.transform.apply(im.im.id, im.im.id) im.info["icc_profile"] = self.output_profile.tobytes() return im @@ -285,7 +272,7 @@ def get_display_profile(handle=None): if sys.platform != "win32": return None - from PIL import ImageWin + from . import ImageWin if isinstance(handle, ImageWin.HDC): profile = core.get_display_profile_win32(handle, 1) @@ -374,10 +361,12 @@ def profileToProfile( outputMode = im.mode if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): - raise PyCMSError("renderingIntent must be an integer between 0 and 3") + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - raise PyCMSError(f"flags must be an integer between 0 and {_MAX_FLAG}") + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) try: if not isinstance(inputProfile, ImageCmsProfile): @@ -489,10 +478,12 @@ def buildTransform( """ if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): - raise PyCMSError("renderingIntent must be an integer between 0 and 3") + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - raise PyCMSError("flags must be an integer between 0 and %s" + _MAX_FLAG) + msg = "flags must be an integer between 0 and %s" + _MAX_FLAG + raise PyCMSError(msg) try: if not isinstance(inputProfile, ImageCmsProfile): @@ -591,10 +582,12 @@ def buildProofTransform( """ if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): - raise PyCMSError("renderingIntent must be an integer between 0 and 3") + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - raise PyCMSError("flags must be an integer between 0 and %s" + _MAX_FLAG) + msg = "flags must be an integer between 0 and %s" + _MAX_FLAG + raise PyCMSError(msg) try: if not isinstance(inputProfile, ImageCmsProfile): @@ -705,17 +698,17 @@ def createProfile(colorSpace, colorTemp=-1): """ if colorSpace not in ["LAB", "XYZ", "sRGB"]: - raise PyCMSError( + msg = ( f"Color space not supported for on-the-fly profile creation ({colorSpace})" ) + raise PyCMSError(msg) if colorSpace == "LAB": try: colorTemp = float(colorTemp) except (TypeError, ValueError) as e: - raise PyCMSError( - f'Color temperature must be numeric, "{colorTemp}" not valid' - ) from e + msg = f'Color temperature must be numeric, "{colorTemp}" not valid' + raise PyCMSError(msg) from e try: return core.createProfile(colorSpace, colorTemp) @@ -795,11 +788,8 @@ def getProfileInfo(profile): # info was description \r\n\r\n copyright \r\n\r\n K007 tag \r\n\r\n whitepoint description = profile.profile.profile_description cpright = profile.profile.copyright - arr = [] - for elt in (description, cpright): - if elt: - arr.append(elt) - return "\r\n\r\n".join(arr) + "\r\n\r\n" + elements = [element for element in (description, cpright) if element] + return "\r\n\r\n".join(elements) + "\r\n\r\n" except (AttributeError, OSError, TypeError, ValueError) as v: raise PyCMSError(v) from v diff --git a/src/PIL/ImageColor.py b/src/PIL/ImageColor.py index 9cbce4143..bfad27c82 100644 --- a/src/PIL/ImageColor.py +++ b/src/PIL/ImageColor.py @@ -16,6 +16,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import re @@ -33,7 +34,8 @@ def getrgb(color): :return: ``(red, green, blue[, alpha])`` """ if len(color) > 100: - raise ValueError("color specifier is too long") + msg = "color specifier is too long" + raise ValueError(msg) color = color.lower() rgb = colormap.get(color, None) @@ -115,14 +117,17 @@ def getrgb(color): m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) if m: return int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4)) - raise ValueError(f"unknown color specifier: {repr(color)}") + msg = f"unknown color specifier: {repr(color)}" + raise ValueError(msg) def getcolor(color, mode): """ - Same as :py:func:`~PIL.ImageColor.getrgb`, but converts the RGB value to a - greyscale value if ``mode`` is not color or a palette image. If the string - cannot be parsed, this function raises a :py:exc:`ValueError` exception. + Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if + ``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is + not color or a palette image, converts the RGB value to a grayscale value. + If the string cannot be parsed, this function raises a :py:exc:`ValueError` + exception. .. versionadded:: 1.1.4 @@ -135,7 +140,13 @@ def getcolor(color, mode): if len(color) == 4: color, alpha = color[:3], color[3] - if Image.getmodebase(mode) == "L": + if mode == "HSV": + from colorsys import rgb_to_hsv + + r, g, b = color + h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255) + return int(h * 255), int(s * 255), int(v * 255) + elif Image.getmodebase(mode) == "L": r, g, b = color # ITU-R Recommendation 601-2 for nonlinear RGB # scaled to 24 bits to match the convert's implementation. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 407544234..84665f54f 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -29,13 +29,13 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import math import numbers -import warnings +import struct from . import Image, ImageColor -from ._deprecate import deprecate """ A simple 2D drawing interface for PIL images. @@ -69,7 +69,8 @@ class ImageDraw: if mode == "RGBA" and im.mode == "RGB": blend = 1 else: - raise ValueError("mode mismatch") + msg = "mode mismatch" + raise ValueError(msg) if mode == "P": self.palette = im.palette else: @@ -114,6 +115,15 @@ class ImageDraw: self.font = ImageFont.load_default() return self.font + def _getfont(self, font_size): + if font_size is not None: + from . import ImageFont + + font = ImageFont.load_default(font_size) + else: + font = self.getfont() + return font + def _getink(self, ink, fill=None): if ink is None and fill is None: if self.fill: @@ -280,11 +290,11 @@ class ImageDraw: self.im.paste(im.im, (0, 0) + im.size, mask.im) def regular_polygon( - self, bounding_circle, n_sides, rotation=0, fill=None, outline=None + self, bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1 ): """Draw a regular polygon.""" xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) - self.polygon(xy, fill, outline) + self.polygon(xy, fill, outline, width) def rectangle(self, xy, fill=None, outline=None, width=1): """Draw a rectangle.""" @@ -294,29 +304,43 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_rectangle(xy, ink, 0, width) - def rounded_rectangle(self, xy, radius=0, fill=None, outline=None, width=1): + def rounded_rectangle( + self, xy, radius=0, fill=None, outline=None, width=1, *, corners=None + ): """Draw a rounded rectangle.""" if isinstance(xy[0], (list, tuple)): (x0, y0), (x1, y1) = xy else: x0, y0, x1, y1 = xy + if x1 < x0: + msg = "x1 must be greater than or equal to x0" + raise ValueError(msg) + if y1 < y0: + msg = "y1 must be greater than or equal to y0" + raise ValueError(msg) + if corners is None: + corners = (True, True, True, True) d = radius * 2 - full_x = d >= x1 - x0 - if full_x: - # The two left and two right corners are joined - d = x1 - x0 - full_y = d >= y1 - y0 - if full_y: - # The two top and two bottom corners are joined - d = y1 - y0 - if full_x and full_y: - # If all corners are joined, that is a circle - return self.ellipse(xy, fill, outline, width) + full_x, full_y = False, False + if all(corners): + full_x = d >= x1 - x0 - 1 + if full_x: + # The two left and two right corners are joined + d = x1 - x0 + full_y = d >= y1 - y0 - 1 + if full_y: + # The two top and two bottom corners are joined + d = y1 - y0 + if full_x and full_y: + # If all corners are joined, that is a circle + return self.ellipse(xy, fill, outline, width) - if d == 0: - # If the corners have no curve, that is a rectangle + if d == 0 or not any(corners): + # If the corners have no curve, + # or there are no corners, + # that is a rectangle return self.rectangle(xy, fill, outline, width) r = d // 2 @@ -337,12 +361,17 @@ class ImageDraw: ) else: # Draw four separate corners - parts = ( - ((x1 - d, y0, x1, y0 + d), 270, 360), - ((x1 - d, y1 - d, x1, y1), 0, 90), - ((x0, y1 - d, x0 + d, y1), 90, 180), - ((x0, y0, x0 + d, y0 + d), 180, 270), - ) + parts = [] + for i, part in enumerate( + ( + ((x0, y0, x0 + d, y0 + d), 180, 270), + ((x1 - d, y0, x1, y0 + d), 270, 360), + ((x1 - d, y1 - d, x1, y1), 0, 90), + ((x0, y1 - d, x0 + d, y1), 90, 180), + ) + ): + if corners[i]: + parts.append(part) for part in parts: if pieslice: self.draw.draw_pieslice(*(part + (fill, 1))) @@ -357,28 +386,52 @@ class ImageDraw: else: self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill, 1) if not full_x and not full_y: - self.draw.draw_rectangle((x0, y0 + r + 1, x0 + r, y1 - r - 1), fill, 1) - self.draw.draw_rectangle((x1 - r, y0 + r + 1, x1, y1 - r - 1), fill, 1) + left = [x0, y0, x0 + r, y1] + if corners[0]: + left[1] += r + 1 + if corners[3]: + left[3] -= r + 1 + self.draw.draw_rectangle(left, fill, 1) + + right = [x1 - r, y0, x1, y1] + if corners[1]: + right[1] += r + 1 + if corners[2]: + right[3] -= r + 1 + self.draw.draw_rectangle(right, fill, 1) if ink is not None and ink != fill and width != 0: draw_corners(False) if not full_x: - self.draw.draw_rectangle( - (x0 + r + 1, y0, x1 - r - 1, y0 + width - 1), ink, 1 - ) - self.draw.draw_rectangle( - (x0 + r + 1, y1 - width + 1, x1 - r - 1, y1), ink, 1 - ) + top = [x0, y0, x1, y0 + width - 1] + if corners[0]: + top[0] += r + 1 + if corners[1]: + top[2] -= r + 1 + self.draw.draw_rectangle(top, ink, 1) + + bottom = [x0, y1 - width + 1, x1, y1] + if corners[3]: + bottom[0] += r + 1 + if corners[2]: + bottom[2] -= r + 1 + self.draw.draw_rectangle(bottom, ink, 1) if not full_y: - self.draw.draw_rectangle( - (x0, y0 + r + 1, x0 + width - 1, y1 - r - 1), ink, 1 - ) - self.draw.draw_rectangle( - (x1 - width + 1, y0 + r + 1, x1, y1 - r - 1), ink, 1 - ) + left = [x0, y0, x0 + width - 1, y1] + if corners[0]: + left[1] += r + 1 + if corners[3]: + left[3] -= r + 1 + self.draw.draw_rectangle(left, ink, 1) + + right = [x1 - width + 1, y0, x1, y1] + if corners[1]: + right[1] += r + 1 + if corners[2]: + right[3] -= r + 1 + self.draw.draw_rectangle(right, ink, 1) def _multiline_check(self, text): - """Draw text.""" split_character = "\n" if isinstance(text, str) else b"\n" return split_character in text @@ -389,17 +442,11 @@ class ImageDraw: return text.split(split_character) def _multiline_spacing(self, font, spacing, stroke_width): - # this can be replaced with self.textbbox(...)[3] when textsize is removed - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - return ( - self.textsize( - "A", - font=font, - stroke_width=stroke_width, - )[1] - + spacing - ) + return ( + self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3] + + stroke_width + + spacing + ) def text( self, @@ -419,6 +466,14 @@ class ImageDraw: *args, **kwargs, ): + """Draw text.""" + if embedded_color and self.mode not in ("RGB", "RGBA"): + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) + + if font is None: + font = self._getfont(kwargs.get("font_size")) + if self._multiline_check(text): return self.multiline_text( xy, @@ -436,12 +491,6 @@ class ImageDraw: embedded_color, ) - if embedded_color and self.mode not in ("RGB", "RGBA"): - raise ValueError("Embedded color supported only in RGB and RGBA modes") - - if font is None: - font = self.getfont() - def getink(fill): ink, fill = self._getink(fill) if ink is None: @@ -495,7 +544,8 @@ class ImageDraw: # font.getmask2(mode="RGBA") returns color in RGB bands and mask in A # extract mask and set text alpha color, mask = mask, mask.getband(3) - color.fillband(3, (ink >> 24) & 0xFF) + ink_alpha = struct.pack("i", ink)[3] + color.fillband(3, ink_alpha) x, y = coord self.im.paste(color, (x, y, x + mask.size[0], y + mask.size[1]), mask) else: @@ -532,16 +582,24 @@ class ImageDraw: stroke_width=0, stroke_fill=None, embedded_color=False, + *, + font_size=None, ): if direction == "ttb": - raise ValueError("ttb direction is unsupported for multiline text") + msg = "ttb direction is unsupported for multiline text" + raise ValueError(msg) if anchor is None: anchor = "la" elif len(anchor) != 2: - raise ValueError("anchor must be a 2 character string") + msg = "anchor must be a 2 character string" + raise ValueError(msg) elif anchor[1] in "tb": - raise ValueError("anchor not supported for multiline text") + msg = "anchor not supported for multiline text" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) widths = [] max_width = 0 @@ -578,7 +636,8 @@ class ImageDraw: elif align == "right": left += width_difference else: - raise ValueError('align must be "left", "center" or "right"') + msg = 'align must be "left", "center" or "right"' + raise ValueError(msg) self.text( (left, top), @@ -595,72 +654,6 @@ class ImageDraw: ) top += line_spacing - def textsize( - self, - text, - font=None, - spacing=4, - direction=None, - features=None, - language=None, - stroke_width=0, - ): - """Get the size of a given string, in pixels.""" - deprecate("textsize", 10, "textbbox or textlength") - if self._multiline_check(text): - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - return self.multiline_textsize( - text, - font, - spacing, - direction, - features, - language, - stroke_width, - ) - - if font is None: - font = self.getfont() - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - return font.getsize( - text, - direction, - features, - language, - stroke_width, - ) - - def multiline_textsize( - self, - text, - font=None, - spacing=4, - direction=None, - features=None, - language=None, - stroke_width=0, - ): - deprecate("multiline_textsize", 10, "multiline_textbbox") - max_width = 0 - lines = self._multiline_split(text) - line_spacing = self._multiline_spacing(font, spacing, stroke_width) - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - for line in lines: - line_width, line_height = self.textsize( - line, - font, - spacing, - direction, - features, - language, - stroke_width, - ) - max_width = max(max_width, line_width) - return max_width, len(lines) * line_spacing - spacing - def textlength( self, text, @@ -669,32 +662,21 @@ class ImageDraw: features=None, language=None, embedded_color=False, + *, + font_size=None, ): """Get the length of a given string, in pixels with 1/64 precision.""" if self._multiline_check(text): - raise ValueError("can't measure length of multiline text") + msg = "can't measure length of multiline text" + raise ValueError(msg) if embedded_color and self.mode not in ("RGB", "RGBA"): - raise ValueError("Embedded color supported only in RGB and RGBA modes") + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) if font is None: - font = self.getfont() + font = self._getfont(font_size) mode = "RGBA" if embedded_color else self.fontmode - try: - return font.getlength(text, mode, direction, features, language) - except AttributeError: - deprecate("textlength support for fonts without getlength", 10) - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - size = self.textsize( - text, - font, - direction=direction, - features=features, - language=language, - ) - if direction == "ttb": - return size[1] - return size[0] + return font.getlength(text, mode, direction, features, language) def textbbox( self, @@ -709,10 +691,16 @@ class ImageDraw: language=None, stroke_width=0, embedded_color=False, + *, + font_size=None, ): """Get the bounding box of a given string, in pixels.""" if embedded_color and self.mode not in ("RGB", "RGBA"): - raise ValueError("Embedded color supported only in RGB and RGBA modes") + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) if self._multiline_check(text): return self.multiline_textbbox( @@ -729,8 +717,6 @@ class ImageDraw: embedded_color, ) - if font is None: - font = self.getfont() mode = "RGBA" if embedded_color else self.fontmode bbox = font.getbbox( text, mode, direction, features, language, stroke_width, anchor @@ -750,16 +736,24 @@ class ImageDraw: language=None, stroke_width=0, embedded_color=False, + *, + font_size=None, ): if direction == "ttb": - raise ValueError("ttb direction is unsupported for multiline text") + msg = "ttb direction is unsupported for multiline text" + raise ValueError(msg) if anchor is None: anchor = "la" elif len(anchor) != 2: - raise ValueError("anchor must be a 2 character string") + msg = "anchor must be a 2 character string" + raise ValueError(msg) elif anchor[1] in "tb": - raise ValueError("anchor not supported for multiline text") + msg = "anchor not supported for multiline text" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) widths = [] max_width = 0 @@ -803,7 +797,8 @@ class ImageDraw: elif align == "right": left += width_difference else: - raise ValueError('align must be "left", "center" or "right"') + msg = 'align must be "left", "center" or "right"' + raise ValueError(msg) bbox_line = self.textbbox( (left, top), @@ -915,8 +910,8 @@ def floodfill(image, xy, value, border=None, thresh=0): full_edge = set() while edge: new_edge = set() - for (x, y) in edge: # 4 adjacent method - for (s, t) in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + for x, y in edge: # 4 adjacent method + for s, t in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): # If already processed, or if a coordinate is negative, skip if (s, t) in full_edge or s < 0 or t < 0: continue @@ -929,7 +924,7 @@ def floodfill(image, xy, value, border=None, thresh=0): if border is None: fill = _color_diff(p, background) <= thresh else: - fill = p != value and p != border + fill = p not in (value, border) if fill: pixel[s, t] = value new_edge.add((s, t)) @@ -979,38 +974,44 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): # 1. Error Handling # 1.1 Check `n_sides` has an appropriate value if not isinstance(n_sides, int): - raise TypeError("n_sides should be an int") + msg = "n_sides should be an int" + raise TypeError(msg) if n_sides < 3: - raise ValueError("n_sides should be an int > 2") + msg = "n_sides should be an int > 2" + raise ValueError(msg) # 1.2 Check `bounding_circle` has an appropriate value if not isinstance(bounding_circle, (list, tuple)): - raise TypeError("bounding_circle should be a tuple") + msg = "bounding_circle should be a tuple" + raise TypeError(msg) if len(bounding_circle) == 3: *centroid, polygon_radius = bounding_circle elif len(bounding_circle) == 2: centroid, polygon_radius = bounding_circle else: - raise ValueError( + msg = ( "bounding_circle should contain 2D coordinates " "and a radius (e.g. (x, y, r) or ((x, y), r) )" ) + raise ValueError(msg) if not all(isinstance(i, (int, float)) for i in (*centroid, polygon_radius)): - raise ValueError("bounding_circle should only contain numeric data") + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) if not len(centroid) == 2: - raise ValueError( - "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" - ) + msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" + raise ValueError(msg) if polygon_radius <= 0: - raise ValueError("bounding_circle radius should be > 0") + msg = "bounding_circle radius should be > 0" + raise ValueError(msg) # 1.3 Check `rotation` has an appropriate value if not isinstance(rotation, (int, float)): - raise ValueError("rotation should be an int or float") + msg = "rotation should be an int or float" + raise ValueError(msg) # 2. Define Helper Functions def _apply_rotation(point, degrees, centroid): diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py index 2667b77dd..35ee5834e 100644 --- a/src/PIL/ImageDraw2.py +++ b/src/PIL/ImageDraw2.py @@ -22,12 +22,9 @@ .. seealso:: :py:mod:`PIL.ImageDraw` """ - - -import warnings +from __future__ import annotations from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath -from ._deprecate import deprecate class Pen: @@ -173,19 +170,6 @@ class Draw: xy.transform(self.transform) self.draw.text(xy, text, font=font.font, fill=font.color) - def textsize(self, text, font): - """ - .. deprecated:: 9.2.0 - - Return the size of the given string, in pixels. - - .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textsize` - """ - deprecate("textsize", 10, "textbbox or textlength") - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - return self.draw.textsize(text, font=font.font) - def textbbox(self, xy, text, font): """ Returns bounding box (in pixels) of given text. diff --git a/src/PIL/ImageEnhance.py b/src/PIL/ImageEnhance.py index 3b79d5c46..93a50d2a2 100644 --- a/src/PIL/ImageEnhance.py +++ b/src/PIL/ImageEnhance.py @@ -17,6 +17,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image, ImageFilter, ImageStat @@ -59,7 +60,7 @@ class Contrast(_Enhance): This class can be used to control the contrast of an image, similar to the contrast control on a TV set. An enhancement factor of 0.0 - gives a solid grey image. A factor of 1.0 gives the original image. + gives a solid gray image. A factor of 1.0 gives the original image. """ def __init__(self, image): diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index dbdc0cb38..ae4e23db1 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -26,13 +26,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io import itertools import struct import sys +from typing import NamedTuple from . import Image +from ._deprecate import deprecate from ._util import is_path MAXBLOCK = 65536 @@ -61,14 +64,25 @@ Dict of known error codes returned from :meth:`.PyDecoder.decode`, # Helpers -def raise_oserror(error): +def _get_oserror(error, *, encoder): try: - message = Image.core.getcodecstatus(error) + msg = Image.core.getcodecstatus(error) except AttributeError: - message = ERRORS.get(error) - if not message: - message = f"decoder error {error}" - raise OSError(message + " when reading image file") + msg = ERRORS.get(error) + if not msg: + msg = f"{'encoder' if encoder else 'decoder'} error {error}" + msg += f" when {'writing' if encoder else 'reading'} image file" + return OSError(msg) + + +def raise_oserror(error): + deprecate( + "raise_oserror", + 12, + action="It is only useful for translating error codes returned by a codec's " + "decode() method, which ImageFile already does automatically.", + ) + raise _get_oserror(error, encoder=False) def _tilesort(t): @@ -76,6 +90,13 @@ def _tilesort(t): return t[2] +class _Tile(NamedTuple): + encoder_name: str + extents: tuple[int, int, int, int] + offset: int + args: tuple | str | None + + # # -------------------------------------------------------------------- # ImageFile base class @@ -124,7 +145,8 @@ class ImageFile(Image.Image): raise SyntaxError(v) from v if not self.mode or self.size[0] <= 0 or self.size[1] <= 0: - raise SyntaxError("not identified by this driver") + msg = "not identified by this driver" + raise SyntaxError(msg) except BaseException: # close the file only if we have opened it this constructor if self._exclusive_fp: @@ -154,7 +176,8 @@ class ImageFile(Image.Image): """Load image data based on tile list""" if self.tile is None: - raise OSError("cannot load this image") + msg = "cannot load this image" + raise OSError(msg) pixel = Image.Image.load(self) if not self.tile: @@ -184,6 +207,8 @@ class ImageFile(Image.Image): if use_mmap: # try memory mapping decoder_name, extents, offset, args = self.tile[0] + if isinstance(args, str): + args = (args, 0, 1) if ( decoder_name == "raw" and len(args) >= 3 @@ -197,8 +222,8 @@ class ImageFile(Image.Image): with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) if offset + self.size[1] * args[1] > self.map.size(): - # buffer is not large enough - raise OSError + msg = "buffer is not large enough" + raise OSError(msg) self.im = Image.core.map_buffer( self.map, self.size, decoder_name, offset, args ) @@ -249,16 +274,18 @@ class ImageFile(Image.Image): if LOAD_TRUNCATED_IMAGES: break else: - raise OSError("image file is truncated") from e + msg = "image file is truncated" + raise OSError(msg) from e if not s: # truncated jpeg if LOAD_TRUNCATED_IMAGES: break else: - raise OSError( + msg = ( "image file is truncated " f"({len(b)} bytes not processed)" ) + raise OSError(msg) b = b + s n, err_code = decoder.decode(b) @@ -280,7 +307,7 @@ class ImageFile(Image.Image): if not self.map and not LOAD_TRUNCATED_IMAGES and err_code < 0: # still raised if decoder fails to return anything - raise_oserror(err_code) + raise _get_oserror(err_code, encoder=False) return Image.Image.load(self) @@ -314,7 +341,8 @@ class ImageFile(Image.Image): and frame >= self.n_frames + self._min_frame ) ): - raise EOFError("attempt to seek outside sequence") + msg = "attempt to seek outside sequence" + raise EOFError(msg) return self.tell() != frame @@ -328,12 +356,14 @@ class StubImageFile(ImageFile): """ def _open(self): - raise NotImplementedError("StubImageFile subclass must implement _open") + msg = "StubImageFile subclass must implement _open" + raise NotImplementedError(msg) def load(self): loader = self._load() if loader is None: - raise OSError(f"cannot find loader for this {self.format} file") + msg = f"cannot find loader for this {self.format} file" + raise OSError(msg) image = loader.load(self) assert image is not None # become the other object (!) @@ -343,7 +373,8 @@ class StubImageFile(ImageFile): def _load(self): """(Hook) Find actual image loader.""" - raise NotImplementedError("StubImageFile subclass must implement _load") + msg = "StubImageFile subclass must implement _load" + raise NotImplementedError(msg) class Parser: @@ -386,7 +417,6 @@ class Parser: # parse what we have if self.decoder: - if self.offset > 0: # skip header skip = min(len(self.data), self.offset) @@ -404,27 +434,24 @@ class Parser: if e < 0: # decoding error self.image = None - raise_oserror(e) + raise _get_oserror(e, encoder=False) else: # end of image return self.data = self.data[n:] elif self.image: - # if we end up here with no decoder, this file cannot # be incrementally parsed. wait until we've gotten all # available data pass else: - # attempt to open this file try: with io.BytesIO(self.data) as fp: im = Image.open(fp) except OSError: - # traceback.print_exc() pass # not enough data else: flag = hasattr(im, "load_seek") or hasattr(im, "load_read") @@ -468,9 +495,11 @@ class Parser: self.feed(b"") self.data = self.decoder = None if not self.finished: - raise OSError("image was incomplete") + msg = "image was incomplete" + raise OSError(msg) if not self.image: - raise OSError("cannot parse this image") + msg = "cannot parse this image" + raise OSError(msg) if self.data: # incremental parsing not possible; reopen the file # not that we have all data @@ -513,29 +542,29 @@ def _save(im, fp, tile, bufsize=0): fp.flush() -def _encode_tile(im, fp, tile, bufsize, fh, exc=None): - for e, b, o, a in tile: - if o > 0: - fp.seek(o) - encoder = Image._getencoder(im.mode, e, a, im.encoderconfig) +def _encode_tile(im, fp, tile: list[_Tile], bufsize, fh, exc=None): + for encoder_name, extents, offset, args in tile: + if offset > 0: + fp.seek(offset) + encoder = Image._getencoder(im.mode, encoder_name, args, im.encoderconfig) try: - encoder.setimage(im.im, b) + encoder.setimage(im.im, extents) if encoder.pushes_fd: encoder.setfd(fp) - l, s = encoder.encode_to_pyfd() + errcode = encoder.encode_to_pyfd()[1] else: if exc: # compress to Python file-compatible object while True: - l, s, d = encoder.encode(bufsize) - fp.write(d) - if s: + errcode, data = encoder.encode(bufsize)[1:] + fp.write(data) + if errcode: break else: # slight speedup: compress to real file object - s = encoder.encode_to_file(fh, bufsize) - if s < 0: - raise OSError(f"encoder error {s} when writing image file") from exc + errcode = encoder.encode_to_file(fh, bufsize) + if errcode < 0: + raise _get_oserror(errcode, encoder=True) from exc finally: encoder.cleanup() @@ -558,7 +587,8 @@ def _safe_read(fp, size): if size <= SAFEBLOCK: data = fp.read(size) if len(data) < size: - raise OSError("Truncated File Read") + msg = "Truncated File Read" + raise OSError(msg) return data data = [] remaining_size = size @@ -569,7 +599,8 @@ def _safe_read(fp, size): data.append(block) remaining_size -= len(block) if sum(len(d) for d in data) < size: - raise OSError("Truncated File Read") + msg = "Truncated File Read" + raise OSError(msg) return b"".join(data) @@ -645,13 +676,15 @@ class PyCodec: self.state.ysize = y1 - y0 if self.state.xsize <= 0 or self.state.ysize <= 0: - raise ValueError("Size cannot be negative") + msg = "Size cannot be negative" + raise ValueError(msg) if ( self.state.xsize + self.state.xoff > self.im.size[0] or self.state.ysize + self.state.yoff > self.im.size[1] ): - raise ValueError("Tile cannot extend outside image") + msg = "Tile cannot extend outside image" + raise ValueError(msg) class PyDecoder(PyCodec): @@ -677,7 +710,8 @@ class PyDecoder(PyCodec): If finished with decoding return -1 for the bytes consumed. Err codes are from :data:`.ImageFile.ERRORS`. """ - raise NotImplementedError() + msg = "unavailable in base decoder" + raise NotImplementedError(msg) def set_as_raw(self, data, rawmode=None): """ @@ -696,9 +730,11 @@ class PyDecoder(PyCodec): s = d.decode(data) if s[0] >= 0: - raise ValueError("not enough image data") + msg = "not enough image data" + raise ValueError(msg) if s[1] != 0: - raise ValueError("cannot decode image data") + msg = "cannot decode image data" + raise ValueError(msg) class PyEncoder(PyCodec): @@ -724,7 +760,8 @@ class PyEncoder(PyCodec): If finished with encoding return 1 for the error code. Err codes are from :data:`.ImageFile.ERRORS`. """ - raise NotImplementedError() + msg = "unavailable in base encoder" + raise NotImplementedError(msg) def encode_to_pyfd(self): """ diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index e10c6fdf1..021b40c0e 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -14,6 +14,8 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import functools @@ -28,13 +30,14 @@ class MultibandFilter(Filter): class BuiltinFilter(MultibandFilter): def filter(self, image): if image.mode == "P": - raise ValueError("cannot filter palette images") + msg = "cannot filter palette images" + raise ValueError(msg) return image.filter(*self.filterargs) class Kernel(BuiltinFilter): """ - Create a convolution kernel. The current version only + Create a convolution kernel. The current version only supports 3x3 and 5x5 integer and floating point kernels. In the current version, kernels can only be applied to @@ -42,9 +45,10 @@ class Kernel(BuiltinFilter): :param size: Kernel size, given as (width, height). In the current version, this must be (3,3) or (5,5). - :param kernel: A sequence containing kernel weights. + :param kernel: A sequence containing kernel weights. The kernel will + be flipped vertically before being applied to the image. :param scale: Scale factor. If given, the result for each pixel is - divided by this value. The default is the sum of the + divided by this value. The default is the sum of the kernel weights. :param offset: Offset. If given, this value is added to the result, after it has been divided by the scale factor. @@ -57,7 +61,8 @@ class Kernel(BuiltinFilter): # default scale is sum of kernel scale = functools.reduce(lambda a, b: a + b, kernel) if size[0] * size[1] != len(kernel): - raise ValueError("not enough coefficients in kernel") + msg = "not enough coefficients in kernel" + raise ValueError(msg) self.filterargs = size, scale, offset, kernel @@ -80,7 +85,8 @@ class RankFilter(Filter): def filter(self, image): if image.mode == "P": - raise ValueError("cannot filter palette images") + msg = "cannot filter palette images" + raise ValueError(msg) image = image.expand(self.size // 2, self.size // 2) return image.rankfilter(self.size, self.rank) @@ -153,7 +159,8 @@ class GaussianBlur(MultibandFilter): approximates a Gaussian kernel. For details on accuracy see - :param radius: Standard deviation of the Gaussian kernel. + :param radius: Standard deviation of the Gaussian kernel. Either a sequence of two + numbers for x and y, or a single number for both. """ name = "GaussianBlur" @@ -162,7 +169,12 @@ class GaussianBlur(MultibandFilter): self.radius = radius def filter(self, image): - return image.gaussian_blur(self.radius) + xy = self.radius + if not isinstance(xy, (tuple, list)): + xy = (xy, xy) + if xy == (0, 0): + return image.copy() + return image.gaussian_blur(xy) class BoxBlur(MultibandFilter): @@ -172,18 +184,31 @@ class BoxBlur(MultibandFilter): which runs in linear time relative to the size of the image for any radius value. - :param radius: Size of the box in one direction. Radius 0 does not blur, - returns an identical image. Radius 1 takes 1 pixel - in each direction, i.e. 9 pixels in total. + :param radius: Size of the box in a direction. Either a sequence of two numbers for + x and y, or a single number for both. + + Radius 0 does not blur, returns an identical image. + Radius 1 takes 1 pixel in each direction, i.e. 9 pixels in total. """ name = "BoxBlur" def __init__(self, radius): + xy = radius + if not isinstance(xy, (tuple, list)): + xy = (xy, xy) + if xy[0] < 0 or xy[1] < 0: + msg = "radius must be >= 0" + raise ValueError(msg) self.radius = radius def filter(self, image): - return image.box_blur(self.radius) + xy = self.radius + if not isinstance(xy, (tuple, list)): + xy = (xy, xy) + if xy == (0, 0): + return image.copy() + return image.box_blur(xy) class UnsharpMask(MultibandFilter): @@ -199,7 +224,7 @@ class UnsharpMask(MultibandFilter): .. _digital unsharp masking: https://en.wikipedia.org/wiki/Unsharp_masking#Digital_unsharp_masking - """ # noqa: E501 + """ name = "UnsharpMask" @@ -355,7 +380,8 @@ class Color3DLUT(MultibandFilter): def __init__(self, size, table, channels=3, target_mode=None, **kwargs): if channels not in (3, 4): - raise ValueError("Only 3 or 4 output channels are supported") + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) self.size = size = self._check_size(size) self.channels = channels self.mode = target_mode @@ -395,19 +421,21 @@ class Color3DLUT(MultibandFilter): table, raw_table = [], table for pixel in raw_table: if len(pixel) != channels: - raise ValueError( + msg = ( "The elements of the table should " - "have a length of {}.".format(channels) + f"have a length of {channels}." ) + raise ValueError(msg) table.extend(pixel) if wrong_size or len(table) != items * channels: - raise ValueError( + msg = ( "The table should have either channels * size**3 float items " "or size**3 items of channels-sized tuples with floats. " f"Table should be: {channels}x{size[0]}x{size[1]}x{size[2]}. " f"Actual length: {len(table)}" ) + raise ValueError(msg) self.table = table @staticmethod @@ -415,15 +443,15 @@ class Color3DLUT(MultibandFilter): try: _, _, _ = size except ValueError as e: - raise ValueError( - "Size should be either an integer or a tuple of three integers." - ) from e + msg = "Size should be either an integer or a tuple of three integers." + raise ValueError(msg) from e except TypeError: size = (size, size, size) size = [int(x) for x in size] for size_1d in size: if not 2 <= size_1d <= 65: - raise ValueError("Size should be in [2, 65] range.") + msg = "Size should be in [2, 65] range." + raise ValueError(msg) return size @classmethod @@ -441,7 +469,8 @@ class Color3DLUT(MultibandFilter): """ size_1d, size_2d, size_3d = cls._check_size(size) if channels not in (3, 4): - raise ValueError("Only 3 or 4 output channels are supported") + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) table = [0] * (size_1d * size_2d * size_3d * channels) idx_out = 0 @@ -481,7 +510,8 @@ class Color3DLUT(MultibandFilter): lookup table. """ if channels not in (None, 3, 4): - raise ValueError("Only 3 or 4 output channels are supported") + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) ch_in = self.channels ch_out = channels or ch_in size_1d, size_2d, size_3d = self.size diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 3b1a2a23a..6db7cc4ec 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -25,16 +25,18 @@ # See the README file for information on usage and redistribution. # +from __future__ import annotations + import base64 -import math import os import sys import warnings from enum import IntEnum from io import BytesIO +from pathlib import Path +from typing import IO from . import Image -from ._deprecate import deprecate from ._util import is_directory, is_path @@ -43,29 +45,21 @@ class Layout(IntEnum): RAQM = 1 -def __getattr__(name): - for enum, prefix in {Layout: "LAYOUT_"}.items(): - if name.startswith(prefix): - name = name[len(prefix) :] - if name in enum.__members__: - deprecate(f"{prefix}{name}", 10, f"{enum.__name__}.{name}") - return enum[name] - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - -class _ImagingFtNotInstalled: - # module placeholder - def __getattr__(self, id): - raise ImportError("The _imagingft C module is not installed") +MAX_STRING_LENGTH = 1_000_000 try: from . import _imagingft as core -except ImportError: - core = _ImagingFtNotInstalled() +except ImportError as ex: + from ._util import DeferredError + + core = DeferredError(ex) -_UNSPECIFIED = object() +def _string_length_check(text): + if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: + msg = "too many characters in string" + raise ValueError(msg) # FIXME: add support for pilfont2 format (see FontFile.py) @@ -88,7 +82,6 @@ class ImageFont: """PIL font wrapper""" def _load_pilfont(self, filename): - with open(filename, "rb") as fp: image = None for ext in (".png", ".gif", ".pbm"): @@ -105,7 +98,8 @@ class ImageFont: else: if image: image.close() - raise OSError("cannot find glyph data file") + msg = "cannot find glyph data file" + raise OSError(msg) self.file = fullname @@ -113,10 +107,10 @@ class ImageFont: image.close() def _load_pilfont_data(self, file, image): - # read PILfont header if file.readline() != b"PILfont\n": - raise SyntaxError("Not a PILfont file") + msg = "Not a PILfont file" + raise SyntaxError(msg) file.readline().split(b";") self.info = [] # FIXME: should be a dictionary while True: @@ -130,29 +124,13 @@ class ImageFont: # check image if image.mode not in ("1", "L"): - raise TypeError("invalid font image mode") + msg = "invalid font image mode" + raise TypeError(msg) image.load() self.font = Image.core.font(image.im, data) - def getsize(self, text, *args, **kwargs): - """ - .. deprecated:: 9.2.0 - - Use :py:meth:`.getbbox` or :py:meth:`.getlength` instead. - - See :ref:`deprecations ` for more information. - - Returns width and height (in pixels) of given text. - - :param text: Text to measure. - - :return: (width, height) - """ - deprecate("getsize", 10, "getbbox or getlength") - return self.font.getsize(text) - def getmask(self, text, mode="", *args, **kwargs): """ Create a bitmap for the text. @@ -187,6 +165,7 @@ class ImageFont: :return: ``(left, top, right, bottom)`` bounding box """ + _string_length_check(text) width, height = self.font.getsize(text) return 0, 0, width, height @@ -197,6 +176,7 @@ class ImageFont: .. versionadded:: 9.2.0 """ + _string_length_check(text) width, height = self.font.getsize(text) return width @@ -209,9 +189,20 @@ class ImageFont: class FreeTypeFont: """FreeType font wrapper (requires _imagingft service)""" - def __init__(self, font=None, size=10, index=0, encoding="", layout_engine=None): + def __init__( + self, + font: bytes | str | Path | IO | None = None, + size: float = 10, + index: int = 0, + encoding: str = "", + layout_engine: Layout | None = None, + ) -> None: # FIXME: use service provider instead + if size <= 0: + msg = "font size must be greater than 0" + raise ValueError(msg) + self.path = font self.size = size self.index = index @@ -237,6 +228,8 @@ class FreeTypeFont: ) if is_path(font): + if isinstance(font, Path): + font = str(font) if sys.platform == "win32": font_bytes_path = font if isinstance(font, bytes) else font.encode() try: @@ -260,10 +253,6 @@ class FreeTypeFont: path, size, index, encoding, layout_engine = state self.__init__(path, size, index, encoding, layout_engine) - def _multiline_split(self, text): - split_character = "\n" if isinstance(text, str) else b"\n" - return text.split(split_character) - def getname(self): """ :return: A tuple of the font family (e.g. Helvetica) and the font style @@ -294,27 +283,21 @@ class FreeTypeFont: string due to kerning. If you need to adjust for kerning, include the following character and subtract its length. - For example, instead of - - .. code-block:: python + For example, instead of :: hello = font.getlength("Hello") world = font.getlength("World") hello_world = hello + world # not adjusted for kerning assert hello_world == font.getlength("HelloWorld") # may fail - use - - .. code-block:: python + use :: hello = font.getlength("HelloW") - font.getlength("W") # adjusted for kerning world = font.getlength("World") hello_world = hello + world # adjusted for kerning assert hello_world == font.getlength("HelloWorld") # True - or disable kerning with (requires libraqm) - - .. code-block:: python + or disable kerning with (requires libraqm) :: hello = draw.textlength("Hello", font, features=["-kern"]) world = draw.textlength("World", font, features=["-kern"]) @@ -352,8 +335,9 @@ class FreeTypeFont: `_ Requires libraqm. - :return: Width for horizontal, height for vertical text. + :return: Either width for horizontal text, or height for vertical text. """ + _string_length_check(text) return self.font.getlength(text, mode, direction, features, language) / 64 def getbbox( @@ -408,11 +392,13 @@ class FreeTypeFont: :param stroke_width: The width of the text stroke. :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. :return: ``(left, top, right, bottom)`` bounding box """ + _string_length_check(text) size, offset = self.font.getsize( text, mode, direction, features, language, anchor ) @@ -420,165 +406,6 @@ class FreeTypeFont: width, height = size[0] + 2 * stroke_width, size[1] + 2 * stroke_width return left, top, left + width, top + height - def getsize( - self, - text, - direction=None, - features=None, - language=None, - stroke_width=0, - ): - """ - .. deprecated:: 9.2.0 - - Use :py:meth:`getlength()` to measure the offset of following text with - 1/64 pixel precision. - Use :py:meth:`getbbox()` to get the exact bounding box based on an anchor. - - See :ref:`deprecations ` for more information. - - Returns width and height (in pixels) of given text if rendered in font with - provided direction, features, and language. - - .. note:: For historical reasons this function measures text height from - the ascender line instead of the top, see :ref:`text-anchors`. - If you wish to measure text height from the top, it is recommended - to use the bottom value of :meth:`getbbox` with ``anchor='lt'`` instead. - - :param text: Text to measure. - - :param direction: Direction of the text. It can be 'rtl' (right to - left), 'ltr' (left to right) or 'ttb' (top to bottom). - Requires libraqm. - - .. versionadded:: 4.2.0 - - :param features: A list of OpenType font features to be used during text - layout. This is usually used to turn on optional - font features that are not enabled by default, - for example 'dlig' or 'ss01', but can be also - used to turn off default font features for - example '-liga' to disable ligatures or '-kern' - to disable kerning. To get all supported - features, see - https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist - Requires libraqm. - - .. versionadded:: 4.2.0 - - :param language: Language of the text. Different languages may use - different glyph shapes or ligatures. This parameter tells - the font which language the text is in, and to apply the - correct substitutions as appropriate, if available. - It should be a `BCP 47 language code - `_ - Requires libraqm. - - .. versionadded:: 6.0.0 - - :param stroke_width: The width of the text stroke. - - .. versionadded:: 6.2.0 - - :return: (width, height) - """ - deprecate("getsize", 10, "getbbox or getlength") - # vertical offset is added for historical reasons - # see https://github.com/python-pillow/Pillow/pull/4910#discussion_r486682929 - size, offset = self.font.getsize(text, "L", direction, features, language) - return ( - size[0] + stroke_width * 2, - size[1] + stroke_width * 2 + offset[1], - ) - - def getsize_multiline( - self, - text, - direction=None, - spacing=4, - features=None, - language=None, - stroke_width=0, - ): - """ - .. deprecated:: 9.2.0 - - Use :py:meth:`.ImageDraw.multiline_textbbox` instead. - - See :ref:`deprecations ` for more information. - - Returns width and height (in pixels) of given text if rendered in font - with provided direction, features, and language, while respecting - newline characters. - - :param text: Text to measure. - - :param direction: Direction of the text. It can be 'rtl' (right to - left), 'ltr' (left to right) or 'ttb' (top to bottom). - Requires libraqm. - - :param spacing: The vertical gap between lines, defaulting to 4 pixels. - - :param features: A list of OpenType font features to be used during text - layout. This is usually used to turn on optional - font features that are not enabled by default, - for example 'dlig' or 'ss01', but can be also - used to turn off default font features for - example '-liga' to disable ligatures or '-kern' - to disable kerning. To get all supported - features, see - https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist - Requires libraqm. - - :param language: Language of the text. Different languages may use - different glyph shapes or ligatures. This parameter tells - the font which language the text is in, and to apply the - correct substitutions as appropriate, if available. - It should be a `BCP 47 language code - `_ - Requires libraqm. - - .. versionadded:: 6.0.0 - - :param stroke_width: The width of the text stroke. - - .. versionadded:: 6.2.0 - - :return: (width, height) - """ - deprecate("getsize_multiline", 10, "ImageDraw.multiline_textbbox") - max_width = 0 - lines = self._multiline_split(text) - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - line_spacing = self.getsize("A", stroke_width=stroke_width)[1] + spacing - for line in lines: - line_width, line_height = self.getsize( - line, direction, features, language, stroke_width - ) - max_width = max(max_width, line_width) - - return max_width, len(lines) * line_spacing - spacing - - def getoffset(self, text): - """ - .. deprecated:: 9.2.0 - - Use :py:meth:`.getbbox` instead. - - See :ref:`deprecations ` for more information. - - Returns the offset of given text. This is the gap between the - starting coordinate and the first marking. Note that this gap is - included in the result of :py:func:`~PIL.ImageFont.FreeTypeFont.getsize`. - - :param text: Text to measure. - - :return: A tuple of the x and y offset - """ - deprecate("getoffset", 10, "getbbox") - return self.font.getsize(text)[1] - def getmask( self, text, @@ -640,8 +467,9 @@ class FreeTypeFont: .. versionadded:: 6.2.0 :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. .. versionadded:: 8.0.0 @@ -673,7 +501,6 @@ class FreeTypeFont: self, text, mode="", - fill=_UNSPECIFIED, direction=None, features=None, language=None, @@ -699,12 +526,6 @@ class FreeTypeFont: .. versionadded:: 1.1.5 - :param fill: Optional fill function. By default, an internal Pillow function - will be used. - - Deprecated. This parameter will be removed in Pillow 10 - (2023-07-01). - :param direction: Direction of the text. It can be 'rtl' (right to left), 'ltr' (left to right) or 'ttb' (top to bottom). Requires libraqm. @@ -739,8 +560,9 @@ class FreeTypeFont: .. versionadded:: 6.2.0 :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. .. versionadded:: 8.0.0 @@ -757,31 +579,38 @@ class FreeTypeFont: :py:mod:`PIL.Image.core` interface module, and the text offset, the gap between the starting coordinate and the first marking """ - if fill is _UNSPECIFIED: - fill = Image.core.fill - else: - deprecate("fill", 10) - size, offset = self.font.getsize( - text, mode, direction, features, language, anchor - ) + _string_length_check(text) if start is None: start = (0, 0) - size = tuple(math.ceil(size[i] + stroke_width * 2 + start[i]) for i in range(2)) - offset = offset[0] - stroke_width, offset[1] - stroke_width - Image._decompression_bomb_check(size) - im = fill("RGBA" if mode == "RGBA" else "L", size, 0) - self.font.render( + im = None + size = None + + def fill(width, height): + nonlocal im, size + + size = (width, height) + if Image.MAX_IMAGE_PIXELS is not None: + pixels = max(1, width) * max(1, height) + if pixels > 2 * Image.MAX_IMAGE_PIXELS: + return + + im = Image.core.fill("RGBA" if mode == "RGBA" else "L", size) + return im + + offset = self.font.render( text, - im.id, + fill, mode, direction, features, language, stroke_width, + anchor, ink, start[0], start[1], ) + Image._decompression_bomb_check(size) return im, offset def font_variant( @@ -817,7 +646,8 @@ class FreeTypeFont: try: names = self.font.getvarnames() except AttributeError as e: - raise NotImplementedError("FreeType 2.9.1 or greater is required") from e + msg = "FreeType 2.9.1 or greater is required" + raise NotImplementedError(msg) from e return [name.replace(b"\x00", b"") for name in names] def set_variation_by_name(self, name): @@ -847,7 +677,8 @@ class FreeTypeFont: try: axes = self.font.getvaraxes() except AttributeError as e: - raise NotImplementedError("FreeType 2.9.1 or greater is required") from e + msg = "FreeType 2.9.1 or greater is required" + raise NotImplementedError(msg) from e for axis in axes: axis["name"] = axis["name"].replace(b"\x00", b"") return axes @@ -860,7 +691,8 @@ class FreeTypeFont: try: self.font.setvaraxes(axes) except AttributeError as e: - raise NotImplementedError("FreeType 2.9.1 or greater is required") from e + msg = "FreeType 2.9.1 or greater is required" + raise NotImplementedError(msg) from e class TransposedFont: @@ -880,22 +712,6 @@ class TransposedFont: self.font = font self.orientation = orientation # any 'transpose' argument, or None - def getsize(self, text, *args, **kwargs): - """ - .. deprecated:: 9.2.0 - - Use :py:meth:`.getbbox` or :py:meth:`.getlength` instead. - - See :ref:`deprecations ` for more information. - """ - deprecate("getsize", 10, "getbbox or getlength") - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - w, h = self.font.getsize(text) - if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): - return h, w - return w, h - def getmask(self, text, mode="", *args, **kwargs): im = self.font.getmask(text, mode, *args, **kwargs) if self.orientation is not None: @@ -914,9 +730,8 @@ class TransposedFont: def getlength(self, text, *args, **kwargs): if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): - raise ValueError( - "text length is undefined for text rotated by 90 or 270 degrees" - ) + msg = "text length is undefined for text rotated by 90 or 270 degrees" + raise ValueError(msg) return self.font.getlength(text, *args, **kwargs) @@ -979,7 +794,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): This specifies the character set to use. It does not alter the encoding of any text provided in subsequent operations. :param layout_engine: Which layout engine to use, if available: - :data:`.ImageFont.Layout.BASIC` or :data:`.ImageFont.Layout.RAQM`. + :attr:`.ImageFont.Layout.BASIC` or :attr:`.ImageFont.Layout.RAQM`. If it is available, Raqm layout will be used by default. Otherwise, basic layout will be used. @@ -992,6 +807,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): .. versionadded:: 4.2.0 :return: A font object. :exception OSError: If the file could not be read. + :exception ValueError: If the font size is not greater than zero. """ def freetype(font): @@ -1013,7 +829,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): if windir: dirs.append(os.path.join(windir, "fonts")) elif sys.platform in ("linux", "linux2"): - lindirs = os.environ.get("XDG_DATA_DIRS", "") + lindirs = os.environ.get("XDG_DATA_DIRS") if not lindirs: # According to the freedesktop spec, XDG_DATA_DIRS should # default to /usr/share @@ -1061,22 +877,262 @@ def load_path(filename): return load(os.path.join(directory, filename)) except OSError: pass - raise OSError("cannot find font file") + msg = "cannot find font file" + raise OSError(msg) -def load_default(): - """Load a "better than nothing" default font. +def load_default(size=None): + """If FreeType support is available, load a version of Aileron Regular, + https://dotcolon.net/font/aileron, with a more limited character set. + + Otherwise, load a "better than nothing" font. .. versionadded:: 1.1.4 + :param size: The font size of Aileron Regular. + + .. versionadded:: 10.1.0 + :return: A font object. """ - f = ImageFont() - f._load_pilfont_data( - # courB08 - BytesIO( - base64.b64decode( - b""" + if core.__class__.__name__ == "module" or size is not None: + f = truetype( + BytesIO( + base64.b64decode( + b""" +AAEAAAAPAIAAAwBwRkZUTYwDlUAAADFoAAAAHEdERUYAqADnAAAo8AAAACRHUE9ThhmITwAAKfgAA +AduR1NVQnHxefoAACkUAAAA4k9TLzJovoHLAAABeAAAAGBjbWFw5lFQMQAAA6gAAAGqZ2FzcP//AA +MAACjoAAAACGdseWYmRXoPAAAGQAAAHfhoZWFkE18ayQAAAPwAAAA2aGhlYQboArEAAAE0AAAAJGh +tdHjjERZ8AAAB2AAAAdBsb2NhuOexrgAABVQAAADqbWF4cAC7AEYAAAFYAAAAIG5hbWUr+h5lAAAk +OAAAA6Jwb3N0D3oPTQAAJ9wAAAEKAAEAAAABGhxJDqIhXw889QALA+gAAAAA0Bqf2QAAAADhCh2h/ +2r/LgOxAyAAAAAIAAIAAAAAAAAAAQAAA8r/GgAAA7j/av9qA7EAAQAAAAAAAAAAAAAAAAAAAHQAAQ +AAAHQAQwAFAAAAAAACAAAAAQABAAAAQAAAAAAAAAADAfoBkAAFAAgCigJYAAAASwKKAlgAAAFeADI +BPgAAAAAFAAAAAAAAAAAAAAcAAAAAAAAAAAAAAABVS1dOAEAAIPsCAwL/GgDIA8oA5iAAAJMAAAAA +AhICsgAAACAAAwH0AAAAAAAAAU0AAADYAAAA8gA5AVMAVgJEAEYCRAA1AuQAKQKOAEAAsAArATsAZ +AE7AB4CMABVAkQAUADc/+EBEgAgANwAJQEv//sCRAApAkQAggJEADwCRAAtAkQAIQJEADkCRAArAk +QAMgJEACwCRAAxANwAJQDc/+ECRABnAkQAUAJEAEQB8wAjA1QANgJ/AB0CcwBkArsALwLFAGQCSwB +kAjcAZALGAC8C2gBkAQgAZAIgADcCYQBkAj8AZANiAGQCzgBkAuEALwJWAGQC3QAvAmsAZAJJADQC +ZAAiAqoAXgJuACADuAAaAnEAGQJFABMCTwAuATMAYgEv//sBJwAiAkQAUAH0ADIBLAApAhMAJAJjA +EoCEQAeAmcAHgIlAB4BIgAVAmcAHgJRAEoA7gA+AOn/8wIKAEoA9wBGA1cASgJRAEoCSgAeAmMASg +JnAB4BSgBKAcsAGAE5ABQCUABCAgIAAQMRAAEB4v/6AgEAAQHOABQBLwBAAPoAYAEvACECRABNA0Y +AJAItAHgBKgAcAkQAUAEsAHQAygAgAi0AOQD3ADYA9wAWAaEANgGhABYCbAAlAYMAeAGDADkA6/9q +AhsAFAIKABUB/QAVAAAAAwAAAAMAAAAcAAEAAAAAAKQAAwABAAAAHAAEAIgAAAAeABAAAwAOAH4Aq +QCrALEAtAC3ALsgGSAdICYgOiBEISL7Av//AAAAIACpAKsAsAC0ALcAuyAYIBwgJiA5IEQhIvsB// +//4/+5/7j/tP+y/7D/reBR4E/gR+A14CzfTwVxAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAEGAAABAAAAAAAAAAECAAAAAgAAAAAAAAAAAAAAAAAAAAEAAAMEBQYHCAkKCwwNDg8QERIT +FBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMT +U5PUFFSU1RVVldYWVpbXF1eX2BhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGQAAA +AAAAAAYnFmAAAAAABlAAAAAAAAAAAAAAAAAAAAAAAAAAAAY2htAAAAAAAAAABrbGlqAAAAAHAAbm9 +ycwBnAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmACYAJgAmAD4AUgCCAMoBCgFO +AVwBcgGIAaYBvAHKAdYB6AH2AgwCIAJKAogCpgLWAw4DIgNkA5wDugPUA+gD/AQQBEYEogS8BPoFJ +gVSBWoFgAWwBcoF1gX6BhQGJAZMBmgGiga0BuIHGgdUB2YHkAeiB8AH3AfyCAoIHAgqCDoITghcCG +oIogjSCPoJKglYCXwJwgnqCgIKKApACl4Klgq8CtwLDAs8C1YLjAuyC9oL7gwMDCYMSAxgDKAMrAz +qDQoNTA1mDYQNoA2uDcAN2g3oDfYODA4iDkoOXA5sDnoOnA7EDvwAAAAFAAAAAAH0ArwAAwAGAAkA +DAAPAAAxESERAxMhExcRASELARETAfT6qv6syKr+jgFUqsiqArz9RAGLAP/+1P8B/v3VAP8BLP4CA +P8AAgA5//IAuQKyAAMACwAANyMDMwIyFhQGIiY0oE4MZk84JCQ4JLQB/v3AJDgkJDgAAgBWAeUBPA +LfAAMABwAAEyMnMxcjJzOmRgpagkYKWgHl+vr6AAAAAAIARgAAAf4CsgAbAB8AAAEHMxUjByM3Iwc +jNyM1MzcjNTM3MwczNzMHMxUrAQczAZgdZXEvOi9bLzovWmYdZXEvOi9bLzovWp9bHlsBn4w429vb +2ziMONvb29s4jAAAAAMANf+mAg4DDAAfACYALAAAJRQGBxUjNS4BJzMeARcRLgE0Njc1MxUeARcjJ +icVHgEBFBYXNQ4BExU+ATU0Ag5xWDpgcgRcBz41Xl9oVTpVYwpcC1ttXP6cLTQuM5szOrVRZwlOTQ +ZqVzZECAEAGlukZAlOTQdrUG8O7iNlAQgxNhDlCDj+8/YGOjReAAAAAAUAKf/yArsCvAAHAAsAFQA +dACcAABIyFhQGIiY0EyMBMwQiBhUUFjI2NTQSMhYUBiImNDYiBhUUFjI2NTR5iFBQiFCVVwHAV/5c +OiMjOiPmiFBQiFCxOiMjOiMCvFaSVlaS/ZoCsjIzMC80NC8w/uNWklZWkhozMC80NC8wAAAAAgBA/ +/ICbgLAACIALgAAARUjEQYjIiY1NDY3LgE1NDYzMhcVJiMiBhUUFhcWOwE1MxUFFBYzMjc1IyIHDg +ECbmBcYYOOVkg7R4hsQjY4Q0RNRD4SLDxW/pJUXzksPCkUUk0BgUb+zBVUZ0BkDw5RO1huCkULQzp +COAMBcHDHRz0J/AIHRQAAAAEAKwHlAIUC3wADAAATIycze0YKWgHl+gAAAAABAGT/sAEXAwwACQAA +EzMGEBcjLgE0Nt06dXU6OUBAAwzG/jDGVePs4wAAAAEAHv+wANEDDAAJAAATMx4BFAYHIzYQHjo5Q +EA5OnUDDFXj7ONVxgHQAAAAAQBVAFIB2wHbAA4AAAE3FwcXBycHJzcnNxcnMwEtmxOfcTJjYzJxnx +ObCj4BKD07KYolmZkliik7PbMAAQBQAFUB9AIlAAsAAAEjFSM1IzUzNTMVMwH0tTq1tTq1AR/Kyjj +OzgAAAAAB/+H/iACMAGQABAAANwcjNzOMWlFOXVrS3AAAAQAgAP8A8gE3AAMAABMjNTPy0tIA/zgA +AQAl//IApQByAAcAADYyFhQGIiY0STgkJDgkciQ4JCQ4AAAAAf/7/+IBNALQAAMAABcjEzM5Pvs+H +gLuAAAAAAIAKf/yAhsCwAADAAcAABIgECA2IBAgKQHy/g5gATL+zgLA/TJEAkYAAAAAAQCCAAABlg +KyAAgAAAERIxEHNTc2MwGWVr6SIygCsv1OAldxW1sWAAEAPAAAAg4CwAAZAAA3IRUhNRM+ATU0JiM +iDwEjNz4BMzIWFRQGB7kBUv4x+kI2QTt+EAFWAQp8aGVtSl5GRjEA/0RVLzlLmAoKa3FsUkNxXQAA +AAEALf/yAhYCwAAqAAABHgEVFAYjIi8BMxceATMyNjU0KwE1MzI2NTQmIyIGDwEjNz4BMzIWFRQGA +YxBSZJo2RUBVgEHV0JBUaQREUBUQzc5TQcBVgEKfGhfcEMBbxJbQl1x0AoKRkZHPn9GSD80QUVCCg +pfbGBPOlgAAAACACEAAAIkArIACgAPAAAlIxUjNSE1ATMRMyMRBg8BAiRXVv6qAVZWV60dHLCurq4 +rAdn+QgFLMibzAAABADn/8gIZArIAHQAAATIWFRQGIyIvATMXFjMyNjU0JiMiByMTIRUhBzc2ATNv +d5Fl1RQBVgIad0VSTkVhL1IwAYj+vh8rMAHHgGdtgcUKCoFXTU5bYgGRRvAuHQAAAAACACv/8gITA +sAAFwAjAAABMhYVFAYjIhE0NjMyFh8BIycmIyIDNzYTMjY1NCYjIgYVFBYBLmp7imr0l3RZdAgBXA +IYZ5wKJzU6QVNJSz5SUAHSgWltiQFGxcNlVQoKdv7sPiz+ZF1LTmJbU0lhAAAAAQAyAAACGgKyAAY +AAAEVASMBITUCGv6oXAFL/oECsij9dgJsRgAAAAMALP/xAhgCwAAWACAALAAAAR4BFRQGIyImNTQ2 +Ny4BNTQ2MhYVFAYmIgYVFBYyNjU0AzI2NTQmIyIGFRQWAZQ5S5BmbIpPOjA7ecp5P2F8Q0J8RIVJS +0pLTEtOAW0TXTxpZ2ZqPF0SE1A3VWVlVTdQ/UU0N0RENzT9/ko+Ok1NOj1LAAIAMf/yAhkCwAAXAC +MAAAEyERQGIyImLwEzFxYzMhMHBiMiJjU0NhMyNjU0JiMiBhUUFgEl9Jd0WXQIAVwCGGecCic1SWp +7imo+UlBAQVNJAsD+usXDZVUKCnYBFD4sgWltif5kW1NJYV1LTmIAAAACACX/8gClAiAABwAPAAAS +MhYUBiImNBIyFhQGIiY0STgkJDgkJDgkJDgkAiAkOCQkOP52JDgkJDgAAAAC/+H/iAClAiAABwAMA +AASMhYUBiImNBMHIzczSTgkJDgkaFpSTl4CICQ4JCQ4/mba5gAAAQBnAB4B+AH0AAYAAAENARUlNS +UB+P6qAVb+bwGRAbCmpkbJRMkAAAIAUAC7AfQBuwADAAcAAAEhNSERITUhAfT+XAGk/lwBpAGDOP8 +AOAABAEQAHgHVAfQABgAAARUFNS0BNQHV/m8BVv6qAStEyUSmpkYAAAAAAgAj//IB1ALAABgAIAAA +ATIWFRQHDgEHIz4BNz4BNTQmIyIGByM+ARIyFhQGIiY0AQRibmktIAJWBSEqNig+NTlHBFoDezQ4J +CQ4JALAZ1BjaS03JS1DMD5LLDQ/SUVgcv2yJDgkJDgAAAAAAgA2/5gDFgKYADYAQgAAAQMGFRQzMj +Y1NCYjIg4CFRQWMzI2NxcGIyImNTQ+AjMyFhUUBiMiJwcGIyImNTQ2MzIfATcHNzYmIyIGFRQzMjY +Cej8EJjJJlnBAfGQ+oHtAhjUYg5OPx0h2k06Os3xRWQsVLjY5VHtdPBwJETcJDyUoOkZEJz8B0f74 +EQ8kZl6EkTFZjVOLlyknMVm1pmCiaTq4lX6CSCknTVRmmR8wPdYnQzxuSWVGAAIAHQAAAncCsgAHA +AoAACUjByMTMxMjATMDAcj+UVz4dO5d/sjPZPT0ArL9TgE6ATQAAAADAGQAAAJMArIAEAAbACcAAA +EeARUUBgcGKwERMzIXFhUUJRUzMjc2NTQnJiMTPgE1NCcmKwEVMzIBvkdHZkwiNt7LOSGq/oeFHBt +hahIlSTM+cB8Yj5UWAW8QT0VYYgwFArIEF5Fv1eMED2NfDAL93AU+N24PBP0AAAAAAQAv//ICjwLA +ABsAAAEyFh8BIycmIyIGFRQWMzI/ATMHDgEjIiY1NDYBdX+PCwFWAiKiaHx5ZaIiAlYBCpWBk6a0A +sCAagoKpqN/gaOmCgplhcicn8sAAAIAZAAAAp8CsgAMABkAAAEeARUUBgcGKwERMzITPgE1NCYnJi +sBETMyAY59lJp8IzXN0jUVWmdjWRs5d3I4Aq4QqJWUug8EArL9mQ+PeHGHDgX92gAAAAABAGQAAAI +vArIACwAAJRUhESEVIRUhFSEVAi/+NQHB/pUBTf6zRkYCskbwRvAAAAABAGQAAAIlArIACQAAExUh +FSERIxEhFboBQ/69VgHBAmzwRv7KArJGAAAAAAEAL//yAo8CwAAfAAABMxEjNQcGIyImNTQ2MzIWH +wEjJyYjIgYVFBYzMjY1IwGP90wfPnWTprSSf48LAVYCIqJofHllVG+hAU3+s3hARsicn8uAagoKpq +N/gaN1XAAAAAEAZAAAAowCsgALAAABESMRIREjETMRIRECjFb+hFZWAXwCsv1OAS7+0gKy/sQBPAA +AAAABAGQAAAC6ArIAAwAAMyMRM7pWVgKyAAABADf/8gHoArIAEwAAAREUBw4BIyImLwEzFxYzMjc2 +NREB6AIFcGpgbQIBVgIHfXQKAQKy/lYxIltob2EpKYyEFD0BpwAAAAABAGQAAAJ0ArIACwAACQEjA +wcVIxEzEQEzATsBJ3ntQlZWAVVlAWH+nwEnR+ACsv6RAW8AAQBkAAACLwKyAAUAACUVIREzEQIv/j +VWRkYCsv2UAAABAGQAAAMUArIAFAAAAREjETQ3BgcDIwMmJxYVESMRMxsBAxRWAiMxemx8NxsCVo7 +MywKy/U4BY7ZLco7+nAFmoFxLtP6dArL9lwJpAAAAAAEAZAAAAoACsgANAAAhIwEWFREjETMBJjUR +MwKAhP67A1aEAUUDVAJeeov+pwKy/aJ5jAFZAAAAAgAv//ICuwLAAAkAEwAAEiAWFRQGICY1NBIyN +jU0JiIGFRTbATSsrP7MrNrYenrYegLAxaKhxsahov47nIeIm5uIhwACAGQAAAJHArIADgAYAAABHg +EVFAYHBisBESMRMzITNjQnJisBETMyAZRUX2VOHzuAVtY7GlxcGDWIiDUCrgtnVlVpCgT+5gKy/rU +V1BUF/vgAAAACAC//zAK9AsAAEgAcAAAlFhcHJiMiBwYjIiY1NDYgFhUUJRQWMjY1NCYiBgI9PUMx +UDcfKh8omqysATSs/dR62Hp62HpICTg7NgkHxqGixcWitbWHnJyHiJubAAIAZAAAAlgCsgAXACMAA +CUWFyMmJyYnJisBESMRMzIXHgEVFAYHFiUzMjc+ATU0JyYrAQIqDCJfGQwNWhAhglbiOx9QXEY1Tv +6bhDATMj1lGSyMtYgtOXR0BwH+1wKyBApbU0BSESRAAgVAOGoQBAABADT/8gIoAsAAJQAAATIWFyM +uASMiBhUUFhceARUUBiMiJiczHgEzMjY1NCYnLgE1NDYBOmd2ClwGS0E6SUNRdW+HZnKKC1wPWkQ9 +Uk1cZGuEAsBwXUJHNjQ3OhIbZVZZbm5kREo+NT5DFRdYUFdrAAAAAAEAIgAAAmQCsgAHAAABIxEjE +SM1IQJk9lb2AkICbP2UAmxGAAEAXv/yAmQCsgAXAAABERQHDgEiJicmNREzERQXHgEyNjc2NRECZA +IIgfCBCAJWAgZYmlgGAgKy/k0qFFxzc1wUKgGz/lUrEkRQUEQSKwGrAAAAAAEAIAAAAnoCsgAGAAA +hIwMzGwEzAYJ07l3N1FwCsv2PAnEAAAEAGgAAA7ECsgAMAAABAyMLASMDMxsBMxsBA7HAcZyicrZi +kaB0nJkCsv1OAlP9rQKy/ZsCW/2kAmYAAAEAGQAAAm8CsgALAAAhCwEjEwMzGwEzAxMCCsrEY/bkY +re+Y/D6AST+3AFcAVb+5gEa/q3+oQAAAQATAAACUQKyAAgAAAERIxEDMxsBMwFdVvRjwLphARD+8A +EQAaL+sQFPAAABAC4AAAI5ArIACQAAJRUhNQEhNSEVAQI5/fUBof57Aen+YUZGQgIqRkX92QAAAAA +BAGL/sAEFAwwABwAAARUjETMVIxEBBWlpowMMOP0UOANcAAAB//v/4gE0AtAAAwAABSMDMwE0Pvs+ +HgLuAAAAAQAi/7AAxQMMAAcAABcjNTMRIzUzxaNpaaNQOALsOAABAFAA1wH0AmgABgAAJQsBIxMzE +wGwjY1GsESw1wFZ/qcBkf5vAAAAAQAy/6oBwv/iAAMAAAUhNSEBwv5wAZBWOAAAAAEAKQJEALYCsg +ADAAATIycztjhVUAJEbgAAAAACACT/8gHQAiAAHQAlAAAhJwcGIyImNTQ2OwE1NCcmIyIHIz4BMzI +XFh0BFBcnMjY9ASYVFAF6CR0wVUtgkJoiAgdgaQlaBm1Zrg4DCuQ9R+5MOSFQR1tbDiwUUXBUXowf +J8c9SjRORzYSgVwAAAAAAgBK//ICRQLfABEAHgAAATIWFRQGIyImLwEVIxEzETc2EzI2NTQmIyIGH +QEUFgFUcYCVbiNJEyNWVigySElcU01JXmECIJd4i5QTEDRJAt/+3jkq/hRuZV55ZWsdX14AAQAe// +IB9wIgABgAAAEyFhcjJiMiBhUUFjMyNjczDgEjIiY1NDYBF152DFocbEJXU0A1Rw1aE3pbaoKQAiB +oWH5qZm1tPDlaXYuLgZcAAAACAB7/8gIZAt8AEQAeAAABESM1BwYjIiY1NDYzMhYfAREDMjY9ATQm +IyIGFRQWAhlWKDJacYCVbiNJEyOnSV5hQUlcUwLf/SFVOSqXeIuUExA0ARb9VWVrHV9ebmVeeQACA +B7/8gH9AiAAFQAbAAABFAchHgEzMjY3Mw4BIyImNTQ2MzIWJyIGByEmAf0C/oAGUkA1SwlaD4FXbI +WObmt45UBVBwEqDQEYFhNjWD84W16Oh3+akU9aU60AAAEAFQAAARoC8gAWAAATBh0BMxUjESMRIzU +zNTQ3PgEzMhcVJqcDbW1WOTkDB0k8Hx5oAngVITRC/jQBzEIsJRs5PwVHEwAAAAIAHv8uAhkCIAAi +AC8AAAERFAcOASMiLwEzFx4BMzI2NzY9AQcGIyImNTQ2MzIWHwE1AzI2PQE0JiMiBhUUFgIZAQSEd +NwRAVcBBU5DTlUDASgyWnGAlW4jSRMjp0leYUFJXFMCEv5wSh1zeq8KCTI8VU0ZIQk5Kpd4i5QTED +RJ/iJlax1fXm5lXnkAAQBKAAACCgLkABcAAAEWFREjETQnLgEHDgEdASMRMxE3NjMyFgIIAlYCBDs +6RVRWViE5UVViAYUbQP7WASQxGzI7AQJyf+kC5P7TPSxUAAACAD4AAACsAsAABwALAAASMhYUBiIm +NBMjETNeLiAgLiBiVlYCwCAuICAu/WACEgAC//P/LgCnAsAABwAVAAASMhYUBiImNBcRFAcGIyInN +RY3NjURWS4gIC4gYgMLcRwNSgYCAsAgLiAgLo79wCUbZAJGBzMOHgJEAAAAAQBKAAACCALfAAsAAC +EnBxUjETMREzMHEwGTwTJWVvdu9/rgN6kC3/4oAQv6/ugAAQBG//wA3gLfAA8AABMRFBceATcVBiM +iJicmNRGcAQIcIxkkKi4CAQLf/bkhERoSBD4EJC8SNAJKAAAAAQBKAAADEAIgACQAAAEWFREjETQn +JiMiFREjETQnJiMiFREjETMVNzYzMhYXNzYzMhYDCwVWBAxedFYEDF50VlYiJko7ThAvJkpEVAGfI +jn+vAEcQyRZ1v76ARxDJFnW/voCEk08HzYtRB9HAAAAAAEASgAAAgoCIAAWAAABFhURIxE0JyYjIg +YdASMRMxU3NjMyFgIIAlYCCXBEVVZWITlRVWIBhRtA/tYBJDEbbHR/6QISWz0sVAAAAAACAB7/8gI +sAiAABwARAAASIBYUBiAmNBIyNjU0JiIGFRSlAQCHh/8Ah7ieWlqeWgIgn/Cfn/D+s3ZfYHV1YF8A +AgBK/zwCRQIgABEAHgAAATIWFRQGIyImLwERIxEzFTc2EzI2NTQmIyIGHQEUFgFUcYCVbiNJEyNWV +igySElcU01JXmECIJd4i5QTEDT+8wLWVTkq/hRuZV55ZWsdX14AAgAe/zwCGQIgABEAHgAAAREjEQ +cGIyImNTQ2MzIWHwE1AzI2PQE0JiMiBhUUFgIZVigyWnGAlW4jSRMjp0leYUFJXFMCEv0qARk5Kpd +4i5QTEDRJ/iJlax1fXm5lXnkAAQBKAAABPgIeAA0AAAEyFxUmBhURIxEzFTc2ARoWDkdXVlYwIwIe +B0EFVlf+0gISU0cYAAEAGP/yAa0CIAAjAAATMhYXIyYjIgYVFBYXHgEVFAYjIiYnMxYzMjY1NCYnL +gE1NDbkV2MJWhNdKy04PF1XbVhWbgxaE2ktOjlEUllkAiBaS2MrJCUoEBlPQkhOVFZoKCUmLhIWSE +BIUwAAAAEAFP/4ARQCiQAXAAATERQXHgE3FQYjIiYnJjURIzUzNTMVMxWxAQMmMx8qMjMEAUdHVmM +BzP7PGw4mFgY/BSwxDjQBNUJ7e0IAAAABAEL/8gICAhIAFwAAAREjNQcGIyImJyY1ETMRFBceATMy +Nj0BAgJWITlRT2EKBVYEBkA1RFECEv3uWj4qTToiOQE+/tIlJC43c4DpAAAAAAEAAQAAAfwCEgAGA +AABAyMDMxsBAfzJaclfop8CEv3uAhL+LQHTAAABAAEAAAMLAhIADAAAAQMjCwEjAzMbATMbAQMLqW +Z2dmapY3t0a3Z7AhL97gG+/kICEv5AAcD+QwG9AAAB//oAAAHWAhIACwAAARMjJwcjEwMzFzczARq +8ZIuKY763ZoWFYwEO/vLV1QEMAQbNzQAAAQAB/y4B+wISABEAAAEDDgEjIic1FjMyNj8BAzMbAQH7 +2iFZQB8NDRIpNhQH02GenQIS/cFVUAJGASozEwIt/i4B0gABABQAAAGxAg4ACQAAJRUhNQEhNSEVA +QGx/mMBNP7iAYL+zkREQgGIREX+ewAAAAABAED/sAEOAwwALAAAASMiBhUUFxYVFAYHHgEVFAcGFR +QWOwEVIyImNTQ3NjU0JzU2NTQnJjU0NjsBAQ4MKiMLDS4pKS4NCyMqDAtERAwLUlILDERECwLUGBk +WTlsgKzUFBTcrIFtOFhkYOC87GFVMIkUIOAhFIkxVGDsvAAAAAAEAYP84AJoDIAADAAAXIxEzmjo6 +yAPoAAEAIf+wAO8DDAAsAAATFQYVFBcWFRQGKwE1MzI2NTQnJjU0NjcuATU0NzY1NCYrATUzMhYVF +AcGFRTvUgsMREQLDCojCw0uKSkuDQsjKgwLREQMCwF6OAhFIkxVGDsvOBgZFk5bICs1BQU3KyBbTh +YZGDgvOxhVTCJFAAABAE0A3wH2AWQAEwAAATMUIyImJyYjIhUjNDMyFhcWMzIBvjhuGywtQR0xOG4 +bLC1BHTEBZIURGCNMhREYIwAAAwAk/94DIgLoAAcAEQApAAAAIBYQBiAmECQgBhUUFiA2NTQlMhYX +IyYjIgYUFjMyNjczDgEjIiY1NDYBAQFE3d3+vN0CB/7wubkBELn+xVBnD1wSWDo+QTcqOQZcEmZWX +HN2Aujg/rbg4AFKpr+Mjb6+jYxbWEldV5ZZNShLVn5na34AAgB4AFIB9AGeAAUACwAAAQcXIyc3Mw +cXIyc3AUqJiUmJifOJiUmJiQGepqampqampqYAAAIAHAHSAQ4CwAAHAA8AABIyFhQGIiY0NiIGFBY +yNjRgakREakSTNCEhNCECwEJqQkJqCiM4IyM4AAAAAAIAUAAAAfQCCwALAA8AAAEzFSMVIzUjNTM1 +MxMhNSEBP7W1OrW1OrX+XAGkAVs4tLQ4sP31OAAAAQB0AkQBAQKyAAMAABMjNzOsOD1QAkRuAAAAA +AEAIADsAKoBdgAHAAASMhYUBiImNEg6KCg6KAF2KDooKDoAAAIAOQBSAbUBngAFAAsAACUHIzcnMw +UHIzcnMwELiUmJiUkBM4lJiYlJ+KampqampqYAAAABADYB5QDhAt8ABAAAEzczByM2Xk1OXQHv8Po +AAQAWAeUAwQLfAAQAABMHIzczwV5NTl0C1fD6AAIANgHlAYsC3wAEAAkAABM3MwcjPwEzByM2Xk1O +XapeTU5dAe/w+grw+gAAAgAWAeUBawLfAAQACQAAEwcjNzMXByM3M8FeTU5dql5NTl0C1fD6CvD6A +AADACX/8gI1AHIABwAPABcAADYyFhQGIiY0NjIWFAYiJjQ2MhYUBiImNEk4JCQ4JOw4JCQ4JOw4JC +Q4JHIkOCQkOCQkOCQkOCQkOCQkOAAAAAEAeABSAUoBngAFAAABBxcjJzcBSomJSYmJAZ6mpqamAAA +AAAEAOQBSAQsBngAFAAAlByM3JzMBC4lJiYlJ+KampgAAAf9qAAABgQKyAAMAACsBATM/VwHAVwKy +AAAAAAIAFAHIAdwClAAHABQAABMVIxUjNSM1BRUjNwcjJxcjNTMXN9pKMkoByDICKzQqATJLKysCl +CmjoykBy46KiY3Lm5sAAQAVAAABvALyABgAAAERIxEjESMRIzUzNTQ3NjMyFxUmBgcGHQEBvFbCVj +k5AxHHHx5iVgcDAg798gHM/jQBzEIOJRuWBUcIJDAVIRYAAAABABX//AHkAvIAJQAAJR4BNxUGIyI +mJyY1ESYjIgcGHQEzFSMRIxEjNTM1NDc2MzIXERQBowIcIxkkKi4CAR4nXgwDbW1WLy8DEbNdOmYa +EQQ/BCQvEjQCFQZWFSEWQv40AcxCDiUblhP9uSEAAAAAAAAWAQ4AAQAAAAAAAAATACgAAQAAAAAAA +QAHAEwAAQAAAAAAAgAHAGQAAQAAAAAAAwAaAKIAAQAAAAAABAAHAM0AAQAAAAAABQA8AU8AAQAAAA +AABgAPAawAAQAAAAAACAALAdQAAQAAAAAACQALAfgAAQAAAAAACwAXAjQAAQAAAAAADAAXAnwAAwA +BBAkAAAAmAAAAAwABBAkAAQAOADwAAwABBAkAAgAOAFQAAwABBAkAAwA0AGwAAwABBAkABAAOAL0A +AwABBAkABQB4ANUAAwABBAkABgAeAYwAAwABBAkACAAWAbwAAwABBAkACQAWAeAAAwABBAkACwAuA +gQAAwABBAkADAAuAkwATgBvACAAUgBpAGcAaAB0AHMAIABSAGUAcwBlAHIAdgBlAGQALgAATm8gUm +lnaHRzIFJlc2VydmVkLgAAQQBpAGwAZQByAG8AbgAAQWlsZXJvbgAAUgBlAGcAdQBsAGEAcgAAUmV +ndWxhcgAAMQAuADEAMAAyADsAVQBLAFcATgA7AEEAaQBsAGUAcgBvAG4ALQBSAGUAZwB1AGwAYQBy +AAAxLjEwMjtVS1dOO0FpbGVyb24tUmVndWxhcgAAQQBpAGwAZQByAG8AbgAAQWlsZXJvbgAAVgBlA +HIAcwBpAG8AbgAgADEALgAxADAAMgA7AFAAUwAgADAAMAAxAC4AMQAwADIAOwBoAG8AdABjAG8Abg +B2ACAAMQAuADAALgA3ADAAOwBtAGEAawBlAG8AdABmAC4AbABpAGIAMgAuADUALgA1ADgAMwAyADk +AAFZlcnNpb24gMS4xMDI7UFMgMDAxLjEwMjtob3Rjb252IDEuMC43MDttYWtlb3RmLmxpYjIuNS41 +ODMyOQAAQQBpAGwAZQByAG8AbgAtAFIAZQBnAHUAbABhAHIAAEFpbGVyb24tUmVndWxhcgAAUwBvA +HIAYQAgAFMAYQBnAGEAbgBvAABTb3JhIFNhZ2FubwAAUwBvAHIAYQAgAFMAYQBnAGEAbgBvAABTb3 +JhIFNhZ2FubwAAaAB0AHQAcAA6AC8ALwB3AHcAdwAuAGQAbwB0AGMAbwBsAG8AbgAuAG4AZQB0AAB +odHRwOi8vd3d3LmRvdGNvbG9uLm5ldAAAaAB0AHQAcAA6AC8ALwB3AHcAdwAuAGQAbwB0AGMAbwBs +AG8AbgAuAG4AZQB0AABodHRwOi8vd3d3LmRvdGNvbG9uLm5ldAAAAAACAAAAAAAA/4MAMgAAAAAAA +AAAAAAAAAAAAAAAAAAAAHQAAAABAAIAAwAEAAUABgAHAAgACQAKAAsADAANAA4ADwAQABEAEgATAB +QAFQAWABcAGAAZABoAGwAcAB0AHgAfACAAIQAiACMAJAAlACYAJwAoACkAKgArACwALQAuAC8AMAA +xADIAMwA0ADUANgA3ADgAOQA6ADsAPAA9AD4APwBAAEEAQgBDAEQARQBGAEcASABJAEoASwBMAE0A +TgBPAFAAUQBSAFMAVABVAFYAVwBYAFkAWgBbAFwAXQBeAF8AYABhAIsAqQCDAJMAjQDDAKoAtgC3A +LQAtQCrAL4AvwC8AIwAwADBAAAAAAAB//8AAgABAAAADAAAABwAAAACAAIAAwBxAAEAcgBzAAIABA +AAAAIAAAABAAAACgBMAGYAAkRGTFQADmxhdG4AGgAEAAAAAP//AAEAAAAWAANDQVQgAB5NT0wgABZ +ST00gABYAAP//AAEAAAAA//8AAgAAAAEAAmxpZ2EADmxvY2wAFAAAAAEAAQAAAAEAAAACAAYAEAAG +AAAAAgASADQABAAAAAEATAADAAAAAgAQABYAAQAcAAAAAQABAE8AAQABAGcAAQABAE8AAwAAAAIAE +AAWAAEAHAAAAAEAAQAvAAEAAQBnAAEAAQAvAAEAGgABAAgAAgAGAAwAcwACAE8AcgACAEwAAQABAE +kAAAABAAAACgBGAGAAAkRGTFQADmxhdG4AHAAEAAAAAP//AAIAAAABABYAA0NBVCAAFk1PTCAAFlJ +PTSAAFgAA//8AAgAAAAEAAmNwc3AADmtlcm4AFAAAAAEAAAAAAAEAAQACAAYADgABAAAAAQASAAIA +AAACAB4ANgABAAoABQAFAAoAAgABACQAPQAAAAEAEgAEAAAAAQAMAAEAOP/nAAEAAQAkAAIGigAEA +AAFJAXKABoAGQAA//gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAD/sv+4/+z/7v/MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAD/xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/9T/6AAAAAD/8QAA +ABD/vQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/7gAAAAAAAAAAAAAAAAAA//MAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABIAAAAAAAAAAP/5AAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/gAAD/4AAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//L/9AAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAA/+gAAAAAAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/zAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/mAAAAAAAAAAAAAAAAAAD +/4gAA//AAAAAA//YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+AAAAAAAAP/OAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/zv/qAAAAAP/0AAAACAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/ZAAD/egAA/1kAAAAA/5D/rgAAAAAAAAAAAA +AAAAAAAAAAAAAAAAD/9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAD/8AAA/7b/8P+wAAD/8P/E/98AAAAA/8P/+P/0//oAAAAAAAAAAAAA//gA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+AAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/w//C/9MAAP/SAAD/9wAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAD/yAAA/+kAAAAA//QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/9wAAAAD//QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAP/2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAP/cAAAAAAAAAAAAAAAA/7YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAP/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/6AAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAkAFAAEAAAAAQACwAAABcA +BgAAAAAAAAAIAA4AAAAAAAsAEgAAAAAAAAATABkAAwANAAAAAQAJAAAAAAAAAAAAAAAAAAAAGAAAA +AAABwAAAAAAAAAAAAAAFQAFAAAAAAAYABgAAAAUAAAACgAAAAwAAgAPABEAFgAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAEAEQBdAAYAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAcAAAAAAAAABwAAAAAACAAAAAAAAAAAAAcAAAAHAAAAEwAJ +ABUADgAPAAAACwAQAAAAAAAAAAAAAAAAAAUAGAACAAIAAgAAAAIAGAAXAAAAGAAAABYAFgACABYAA +gAWAAAAEQADAAoAFAAMAA0ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASAAAAEgAGAAEAHgAkAC +YAJwApACoALQAuAC8AMgAzADcAOAA5ADoAPAA9AEUASABOAE8AUgBTAFUAVwBZAFoAWwBcAF0AcwA +AAAAAAQAAAADa3tfFAAAAANAan9kAAAAA4QodoQ== +""" + ) + ), + 10 if size is None else size, + layout_engine=Layout.BASIC, + ) + else: + f = ImageFont() + f._load_pilfont_data( + # courB08 + BytesIO( + base64.b64decode( + b""" UElMZm9udAo7Ozs7OzsxMDsKREFUQQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA @@ -1169,12 +1225,12 @@ pQAKAKwAEgAGAAD////4AAYAAACsAAoAswASAAYAAP////gABgAAALMACgC6ABIABgAA////+QAG AAAAugAKAMEAEQAGAAD////4AAYAAgDBAAoAyAAUAAYAAP////kABQACAMgACgDOABMABgAA//// +QAGAAIAzgAKANUAEw== """ - ) - ), - Image.open( - BytesIO( - base64.b64decode( - b""" + ) + ), + Image.open( + BytesIO( + base64.b64decode( + b""" iVBORw0KGgoAAAANSUhEUgAAAx4AAAAUAQAAAAArMtZoAAAEwElEQVR4nABlAJr/AHVE4czCI/4u Mc4b7vuds/xzjz5/3/7u/n9vMe7vnfH/9++vPn/xyf5zhxzjt8GHw8+2d83u8x27199/nxuQ6Od9 M43/5z2I+9n9ZtmDBwMQECDRQw/eQIQohJXxpBCNVE6QCCAAAAD//wBlAJr/AgALyj1t/wINwq0g @@ -1199,8 +1255,8 @@ AAD//2Ji2FrkY3iYpYC5qDeGgeEMAwPDvwQBBoYvcTwOVLMEAAAA//9isDBgkP///0EOg9z35v// Gc/eeW7BwPj5+QGZhANUswMAAAD//2JgqGBgYGBgqEMXlvhMPUsAAAAA//8iYDd1AAAAAP//AwDR w7IkEbzhVQAAAABJRU5ErkJggg== """ + ) ) - ) - ), - ) + ), + ) return f diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 38074cb1b..a4993d3d4 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -14,7 +14,9 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations +import io import os import shutil import subprocess @@ -61,7 +63,17 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N left, top, right, bottom = bbox im = im.crop((left - x0, top - y0, right - x0, bottom - y0)) return im - elif shutil.which("gnome-screenshot"): + try: + if not Image.core.HAVE_XCB: + msg = "Pillow was built without XCB support" + raise OSError(msg) + size, data = Image.core.grabscreen_x11(xdisplay) + except OSError: + if ( + xdisplay is None + and sys.platform not in ("darwin", "win32") + and shutil.which("gnome-screenshot") + ): fh, filepath = tempfile.mkstemp(".png") os.close(fh) subprocess.call(["gnome-screenshot", "-f", filepath]) @@ -73,26 +85,25 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N im.close() return im_cropped return im - # use xdisplay=None for default display on non-win32/macOS systems - if not Image.core.HAVE_XCB: - raise OSError("Pillow was built without XCB support") - size, data = Image.core.grabscreen_x11(xdisplay) - im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1) - if bbox: - im = im.crop(bbox) - return im + else: + raise + else: + im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1) + if bbox: + im = im.crop(bbox) + return im def grabclipboard(): if sys.platform == "darwin": - fh, filepath = tempfile.mkstemp(".jpg") + 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 JPEG picture) to theFile", + " write (the clipboard as «class PNGf») to theFile", "end try", "close access theFile", ] @@ -119,8 +130,6 @@ def grabclipboard(): files = data[o:].decode("mbcs").split("\0") return files[: files.index("")] if isinstance(data, bytes): - import io - data = io.BytesIO(data) if fmt == "png": from . import PngImagePlugin @@ -132,4 +141,38 @@ def grabclipboard(): return BmpImagePlugin.DibImageFile(data) return None else: - raise NotImplementedError("ImageGrab.grabclipboard() is macOS and Windows only") + if os.getenv("WAYLAND_DISPLAY"): + session_type = "wayland" + elif os.getenv("DISPLAY"): + session_type = "x11" + else: # Session type check failed + session_type = None + + if shutil.which("wl-paste") and session_type in ("wayland", None): + output = subprocess.check_output(["wl-paste", "-l"]).decode() + mimetypes = output.splitlines() + if "image/png" in mimetypes: + mimetype = "image/png" + elif mimetypes: + mimetype = mimetypes[0] + else: + mimetype = None + + args = ["wl-paste"] + if mimetype: + args.extend(["-t", mimetype]) + elif shutil.which("xclip") and session_type in ("x11", None): + args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"] + else: + msg = "wl-paste or xclip is required for ImageGrab.grabclipboard() on Linux" + raise NotImplementedError(msg) + + p = subprocess.run(args, capture_output=True) + err = p.stderr + if err: + msg = f"{args[0]} error: {err.strip().decode()}" + raise ChildProcessError(msg) + data = io.BytesIO(p.stdout) + im = Image.open(data) + im.load() + return im diff --git a/src/PIL/ImageMath.py b/src/PIL/ImageMath.py index 09d9898d7..7ca512e75 100644 --- a/src/PIL/ImageMath.py +++ b/src/PIL/ImageMath.py @@ -14,16 +14,13 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import builtins from . import Image, _imagingmath -def _isconstant(v): - return isinstance(v, (int, float)) - - class _Operand: """Wraps an image operand, providing standard operators""" @@ -39,10 +36,11 @@ class _Operand: elif im1.im.mode in ("I", "F"): return im1.im else: - raise ValueError(f"unsupported mode: {im1.im.mode}") + msg = f"unsupported mode: {im1.im.mode}" + raise ValueError(msg) else: # argument was a constant - if _isconstant(im1) and self.im.mode in ("1", "L", "I"): + if isinstance(im1, (int, float)) and self.im.mode in ("1", "L", "I"): return Image.new("I", self.im.size, im1) else: return Image.new("F", self.im.size, im1) @@ -56,7 +54,8 @@ class _Operand: try: op = getattr(_imagingmath, op + "_" + im1.mode) except AttributeError as e: - raise TypeError(f"bad operand type for '{op}'") from e + msg = f"bad operand type for '{op}'" + raise TypeError(msg) from e _imagingmath.unop(op, out.im.id, im1.im.id) else: # binary operation @@ -80,7 +79,8 @@ class _Operand: try: op = getattr(_imagingmath, op + "_" + im1.mode) except AttributeError as e: - raise TypeError(f"bad operand type for '{op}'") from e + msg = f"bad operand type for '{op}'" + raise TypeError(msg) from e _imagingmath.binop(op, out.im.id, im1.im.id, im2.im.id) return _Operand(out) @@ -236,7 +236,7 @@ def eval(expression, _dict={}, **kw): args = ops.copy() args.update(_dict) args.update(kw) - for k, v in list(args.items()): + for k, v in args.items(): if hasattr(v, "im"): args[k] = _Operand(v) @@ -244,12 +244,13 @@ def eval(expression, _dict={}, **kw): def scan(code): for const in code.co_consts: - if type(const) == type(compiled_code): + if type(const) is type(compiled_code): scan(const) for name in code.co_names: if name not in args and name != "abs": - raise ValueError(f"'{name}' not allowed") + msg = f"'{name}' not allowed" + raise ValueError(msg) scan(compiled_code) out = builtins.eval(expression, {"__builtins": {"abs": abs}}, args) diff --git a/src/PIL/ImageMode.py b/src/PIL/ImageMode.py index 0973536c9..d61dd6fea 100644 --- a/src/PIL/ImageMode.py +++ b/src/PIL/ImageMode.py @@ -12,6 +12,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import sys @@ -22,18 +23,25 @@ _modes = None class ModeDescriptor: """Wrapper for mode strings.""" - def __init__(self, mode, bands, basemode, basetype, typestr): + def __init__( + self, + mode: str, + bands: tuple[str, ...], + basemode: str, + basetype: str, + typestr: str, + ) -> None: self.mode = mode self.bands = bands self.basemode = basemode self.basetype = basetype self.typestr = typestr - def __str__(self): + def __str__(self) -> str: return self.mode -def getmode(mode): +def getmode(mode: str) -> ModeDescriptor: """Gets a mode descriptor for the given mode.""" global _modes if not _modes: @@ -58,10 +66,9 @@ def getmode(mode): "HSV": ("RGB", "L", ("H", "S", "V"), "|u1"), # extra experimental modes "RGBa": ("RGB", "L", ("R", "G", "B", "a"), "|u1"), - "BGR;15": ("RGB", "L", ("B", "G", "R"), endian + "u2"), - "BGR;16": ("RGB", "L", ("B", "G", "R"), endian + "u2"), - "BGR;24": ("RGB", "L", ("B", "G", "R"), endian + "u3"), - "BGR;32": ("RGB", "L", ("B", "G", "R"), endian + "u4"), + "BGR;15": ("RGB", "L", ("B", "G", "R"), "|u1"), + "BGR;16": ("RGB", "L", ("B", "G", "R"), "|u1"), + "BGR;24": ("RGB", "L", ("B", "G", "R"), "|u1"), "LA": ("L", "L", ("L", "A"), "|u1"), "La": ("L", "L", ("L", "a"), "|u1"), "PA": ("RGB", "L", ("P", "A"), "|u1"), diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index 1e22c36a8..282e7d2a5 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -4,6 +4,7 @@ # 2014-06-04 Initial version. # # Copyright (c) 2014 Dov Grobgeld +from __future__ import annotations import re @@ -81,7 +82,8 @@ class LutBuilder: ], } if op_name not in known_patterns: - raise Exception("Unknown pattern " + op_name + "!") + msg = "Unknown pattern " + op_name + "!" + raise Exception(msg) self.patterns = known_patterns[op_name] @@ -145,7 +147,8 @@ class LutBuilder: for p in self.patterns: m = re.search(r"(\w*):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", "")) if not m: - raise Exception('Syntax error in pattern "' + p + '"') + msg = 'Syntax error in pattern "' + p + '"' + raise Exception(msg) options = m.group(1) pattern = m.group(2) result = int(m.group(3)) @@ -193,10 +196,12 @@ class MorphOp: Returns a tuple of the number of changed pixels and the morphed image""" if self.lut is None: - raise Exception("No operator loaded") + msg = "No operator loaded" + raise Exception(msg) if image.mode != "L": - raise ValueError("Image mode must be L") + 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) return count, outimage @@ -208,10 +213,12 @@ class MorphOp: Returns a list of tuples of (x,y) coordinates of all matching pixels. See :ref:`coordinate-system`.""" if self.lut is None: - raise Exception("No operator loaded") + msg = "No operator loaded" + raise Exception(msg) if image.mode != "L": - raise ValueError("Image mode must be L") + msg = "Image mode must be L" + raise ValueError(msg) return _imagingmorph.match(bytes(self.lut), image.im.id) def get_on_pixels(self, image): @@ -221,7 +228,8 @@ class MorphOp: of all matching pixels. See :ref:`coordinate-system`.""" if image.mode != "L": - raise ValueError("Image mode must be L") + msg = "Image mode must be L" + raise ValueError(msg) return _imagingmorph.get_on_pixels(image.im.id) def load_lut(self, filename): @@ -231,12 +239,14 @@ class MorphOp: if len(self.lut) != LUT_SIZE: self.lut = None - raise Exception("Wrong size operator file!") + msg = "Wrong size operator file!" + raise Exception(msg) def save_lut(self, filename): """Save an operator to an mrl file""" if self.lut is None: - raise Exception("No operator loaded") + msg = "No operator loaded" + raise Exception(msg) with open(filename, "wb") as f: f.write(self.lut) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 443c540b6..a9e626b2b 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -16,12 +16,13 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import functools import operator import re -from . import Image, ImagePalette +from . import ExifTags, Image, ImagePalette # # helpers @@ -49,13 +50,15 @@ def _color(color, mode): def _lut(image, lut): if image.mode == "P": # FIXME: apply to lookup table, not image data - raise NotImplementedError("mode P support coming soon") + msg = "mode P support coming soon" + raise NotImplementedError(msg) elif image.mode in ("L", "RGB"): if image.mode == "RGB" and len(lut) == 256: lut = lut + lut + lut return image.point(lut) else: - raise OSError("not supported for this image mode") + msg = f"not supported for mode {image.mode}" + raise OSError(msg) # @@ -203,7 +206,6 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi # Create the mapping (2-color) if mid is None: - range_map = range(0, whitepoint - blackpoint) for i in range_map: @@ -213,7 +215,6 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi # Create the mapping (3-color) else: - range_map1 = range(0, midpoint - blackpoint) range_map2 = range(0, whitepoint - midpoint) @@ -242,11 +243,12 @@ def contain(image, size, method=Image.Resampling.BICUBIC): Returns a resized version of the image, set to the maximum width and height within the requested size, while maintaining the original aspect ratio. - :param image: The image to resize and crop. + :param image: The image to resize. :param size: The requested output size in pixels, given as a (width, height) tuple. :param method: Resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :return: An image. """ @@ -265,6 +267,35 @@ def contain(image, size, method=Image.Resampling.BICUBIC): return image.resize(size, resample=method) +def cover(image, size, method=Image.Resampling.BICUBIC): + """ + Returns a resized version of the image, so that the requested size is + covered, while maintaining the original aspect ratio. + + :param image: The image to resize. + :param size: The requested output size in pixels, given as a + (width, height) tuple. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. + :return: An image. + """ + + im_ratio = image.width / image.height + dest_ratio = size[0] / size[1] + + if im_ratio != dest_ratio: + if im_ratio < dest_ratio: + new_height = round(image.height / image.width * size[0]) + if new_height != size[1]: + size = (size[0], new_height) + else: + new_width = round(image.width / image.height * size[1]) + if new_width != size[0]: + size = (new_width, size[1]) + return image.resize(size, resample=method) + + def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5, 0.5)): """ Returns a resized and padded version of the image, expanded to fill the @@ -274,7 +305,8 @@ def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5 :param size: The requested output size in pixels, given as a (width, height) tuple. :param method: Resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :param color: The background color of the padded image. :param centering: Control the position of the original image within the padded version. @@ -326,13 +358,15 @@ def scale(image, factor, resample=Image.Resampling.BICUBIC): :param image: The image to rescale. :param factor: The expansion factor, as a float. :param resample: Resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :returns: An :py:class:`~PIL.Image.Image` object. """ if factor == 1: return image.copy() elif factor <= 0: - raise ValueError("the factor must be greater than 0") + msg = "the factor must be greater than 0" + raise ValueError(msg) else: size = (round(factor * image.width), round(factor * image.height)) return image.resize(size, resample) @@ -422,7 +456,8 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, :param size: The requested output size in pixels, given as a (width, height) tuple. :param method: Resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :param bleed: Remove a border around the outside of the image from all four edges. The value is a decimal percentage (use 0.01 for one percent). The default value is 0 (no border). @@ -523,9 +558,7 @@ def invert(image): :param image: The image to invert. :return: An image. """ - lut = [] - for i in range(256): - lut.append(255 - i) + lut = list(range(255, -1, -1)) return image.point(lut) if image.mode == "1" else _lut(image, lut) @@ -547,10 +580,8 @@ def posterize(image, bits): :param bits: The number of bits to keep for each channel (1-8). :return: An image. """ - lut = [] mask = ~(2 ** (8 - bits) - 1) - for i in range(256): - lut.append(i & mask) + lut = [i & mask for i in range(256)] return _lut(image, lut) @@ -559,7 +590,7 @@ def solarize(image, threshold=128): Invert all pixel values above a threshold. :param image: The image to solarize. - :param threshold: All pixels above this greyscale level are inverted. + :param threshold: All pixels above this grayscale level are inverted. :return: An image. """ lut = [] @@ -571,19 +602,21 @@ def solarize(image, threshold=128): return _lut(image, lut) -def exif_transpose(image): +def exif_transpose(image, *, in_place=False): """ - If an image has an EXIF Orientation tag, other than 1, return a new image - that is transposed accordingly. The new image will have the orientation - data removed. - - Otherwise, return a copy of the image. + If an image has an EXIF Orientation tag, other than 1, transpose the image + accordingly, and remove the orientation data. :param image: The image to transpose. - :return: An image. + :param in_place: Boolean. Keyword-only argument. + If ``True``, the original image is modified in-place, and ``None`` is returned. + If ``False`` (default), a new :py:class:`~PIL.Image.Image` object is returned + with the transposition applied. If there is no transposition, a copy of the + image will be returned. """ - exif = image.getexif() - orientation = exif.get(0x0112) + image.load() + image_exif = image.getexif() + orientation = image_exif.get(ExifTags.Base.Orientation) method = { 2: Image.Transpose.FLIP_LEFT_RIGHT, 3: Image.Transpose.ROTATE_180, @@ -595,22 +628,28 @@ def exif_transpose(image): }.get(orientation) if method is not None: transposed_image = image.transpose(method) - transposed_exif = transposed_image.getexif() - if 0x0112 in transposed_exif: - del transposed_exif[0x0112] - if "exif" in transposed_image.info: - transposed_image.info["exif"] = transposed_exif.tobytes() - elif "Raw profile type exif" in transposed_image.info: - transposed_image.info[ - "Raw profile type exif" - ] = transposed_exif.tobytes().hex() - elif "XML:com.adobe.xmp" in transposed_image.info: + if in_place: + image.im = transposed_image.im + image.pyaccess = None + image._size = transposed_image._size + exif_image = image if in_place else transposed_image + + exif = exif_image.getexif() + if ExifTags.Base.Orientation in exif: + del exif[ExifTags.Base.Orientation] + if "exif" in exif_image.info: + exif_image.info["exif"] = exif.tobytes() + elif "Raw profile type exif" in exif_image.info: + exif_image.info["Raw profile type exif"] = exif.tobytes().hex() + elif "XML:com.adobe.xmp" in exif_image.info: for pattern in ( r'tiff:Orientation="([0-9])"', r"([0-9])", ): - transposed_image.info["XML:com.adobe.xmp"] = re.sub( - pattern, "", transposed_image.info["XML:com.adobe.xmp"] + exif_image.info["XML:com.adobe.xmp"] = re.sub( + pattern, "", exif_image.info["XML:com.adobe.xmp"] ) - return transposed_image - return image.copy() + if not in_place: + return transposed_image + elif not in_place: + return image.copy() diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index fe76c86f4..fbcfa309d 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -15,11 +15,11 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import array from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile -from ._deprecate import deprecate class ImagePalette: @@ -34,15 +34,11 @@ class ImagePalette: Defaults to an empty palette. """ - def __init__(self, mode="RGB", palette=None, size=0): + def __init__(self, mode="RGB", palette=None): self.mode = mode self.rawmode = None # if set, palette contains raw data self.palette = palette or bytearray() self.dirty = None - if size != 0: - deprecate("The size parameter", 10, None) - if size != len(self.palette): - raise ValueError("wrong palette size") @property def palette(self): @@ -97,7 +93,8 @@ class ImagePalette: .. warning:: This method is experimental. """ if self.rawmode: - raise ValueError("palette contains raw palette data") + msg = "palette contains raw palette data" + raise ValueError(msg) if isinstance(self.palette, bytes): return self.palette arr = array.array("B", self.palette) @@ -106,20 +103,44 @@ class ImagePalette: # Declare tostring as an alias for tobytes tostring = tobytes + def _new_color_index(self, image=None, e=None): + if not isinstance(self.palette, bytearray): + self._palette = bytearray(self.palette) + index = len(self.palette) // 3 + special_colors = () + if image: + special_colors = ( + image.info.get("background"), + image.info.get("transparency"), + ) + while index in special_colors: + index += 1 + if index >= 256: + if image: + # Search for an unused index + for i, count in reversed(list(enumerate(image.histogram()))): + if count == 0 and i not in special_colors: + index = i + break + if index >= 256: + msg = "cannot allocate more than 256 colors" + raise ValueError(msg) from e + return index + def getcolor(self, color, image=None): """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. """ if self.rawmode: - raise ValueError("palette contains raw palette data") + msg = "palette contains raw palette data" + raise ValueError(msg) if isinstance(color, tuple): if self.mode == "RGB": if len(color) == 4: if color[3] != 255: - raise ValueError( - "cannot add non-opaque RGBA color to RGB palette" - ) + msg = "cannot add non-opaque RGBA color to RGB palette" + raise ValueError(msg) color = color[:3] elif self.mode == "RGBA": if len(color) == 3: @@ -128,26 +149,7 @@ class ImagePalette: return self.colors[color] except KeyError as e: # allocate new color slot - if not isinstance(self.palette, bytearray): - self._palette = bytearray(self.palette) - index = len(self.palette) // 3 - special_colors = () - if image: - special_colors = ( - image.info.get("background"), - image.info.get("transparency"), - ) - while index in special_colors: - index += 1 - if index >= 256: - if image: - # Search for an unused index - for i, count in reversed(list(enumerate(image.histogram()))): - if count == 0 and i not in special_colors: - index = i - break - if index >= 256: - raise ValueError("cannot allocate more than 256 colors") from e + index = self._new_color_index(image, e) self.colors[color] = index if index * 3 < len(self.palette): self._palette = ( @@ -160,7 +162,8 @@ class ImagePalette: self.dirty = 1 return index else: - raise ValueError(f"unknown color specifier: {repr(color)}") + msg = f"unknown color specifier: {repr(color)}" + raise ValueError(msg) def save(self, fp): """Save palette to text file. @@ -168,7 +171,8 @@ class ImagePalette: .. warning:: This method is experimental. """ if self.rawmode: - raise ValueError("palette contains raw palette data") + msg = "palette contains raw palette data" + raise ValueError(msg) if isinstance(fp, str): fp = open(fp, "w") fp.write("# Palette\n") @@ -201,20 +205,15 @@ def raw(rawmode, data): def make_linear_lut(black, white): - lut = [] if black == 0: - for i in range(256): - lut.append(white * i // 255) - else: - raise NotImplementedError # FIXME - return lut + return [white * i // 255 for i in range(256)] + + msg = "unavailable when black is non-zero" + raise NotImplementedError(msg) # FIXME def make_gamma_lut(exp): - lut = [] - for i in range(256): - lut.append(int(((i / 255.0) ** exp) * 255.0 + 0.5)) - return lut + return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)] def negative(mode="RGB"): @@ -226,9 +225,7 @@ def negative(mode="RGB"): def random(mode="RGB"): from random import randint - palette = [] - for i in range(256 * len(mode)): - palette.append(randint(0, 255)) + palette = [randint(0, 255) for _ in range(256 * len(mode))] return ImagePalette(mode, palette) @@ -243,11 +240,9 @@ def wedge(mode="RGB"): def load(filename): - # FIXME: supports GIMP gradients only with open(filename, "rb") as fp: - for paletteHandler in [ GimpPaletteFile.GimpPaletteFile, GimpGradientFile.GimpGradientFile, @@ -259,10 +254,9 @@ def load(filename): if lut: break except (SyntaxError, ValueError): - # import traceback - # traceback.print_exc() pass else: - raise OSError("cannot load palette") + msg = "cannot load palette" + raise OSError(msg) return lut # data, rawmode diff --git a/src/PIL/ImagePath.py b/src/PIL/ImagePath.py index 3d3538c97..77e8a609a 100644 --- a/src/PIL/ImagePath.py +++ b/src/PIL/ImagePath.py @@ -13,6 +13,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index a34678c78..6377c7501 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -15,19 +15,17 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import sys from io import BytesIO from . import Image -from ._deprecate import deprecate from ._util import is_path qt_versions = [ ["6", "PyQt6"], ["side6", "PySide6"], - ["5", "PyQt5"], - ["side2", "PySide2"], ] # If a version has already been imported, attempt it first @@ -40,16 +38,6 @@ for qt_version, qt_module in qt_versions: elif qt_module == "PySide6": from PySide6.QtCore import QBuffer, QIODevice from PySide6.QtGui import QImage, QPixmap, qRgba - elif qt_module == "PyQt5": - from PyQt5.QtCore import QBuffer, QIODevice - from PyQt5.QtGui import QImage, QPixmap, qRgba - - deprecate("Support for PyQt5", 10, "PyQt6 or PySide6") - elif qt_module == "PySide2": - from PySide2.QtCore import QBuffer, QIODevice - from PySide2.QtGui import QImage, QPixmap, qRgba - - deprecate("Support for PySide2", 10, "PyQt6 or PySide6") except (ImportError, RuntimeError): continue qt_is_installed = True @@ -96,16 +84,6 @@ def fromqimage(im): def fromqpixmap(im): return fromqimage(im) - # buffer = QBuffer() - # buffer.open(QIODevice.ReadWrite) - # # im.save(buffer) - # # What if png doesn't support some image features like animation? - # im.save(buffer, 'ppm') - # bytes_io = BytesIO() - # bytes_io.write(buffer.data()) - # buffer.close() - # bytes_io.seek(0) - # return Image.open(bytes_io) def align8to32(bytes, width, mode): @@ -126,12 +104,10 @@ def align8to32(bytes, width, mode): if not extra_padding: return bytes - new_data = [] - for i in range(len(bytes) // bytes_per_line): - new_data.append( - bytes[i * bytes_per_line : (i + 1) * bytes_per_line] - + b"\x00" * extra_padding - ) + new_data = [ + bytes[i * bytes_per_line : (i + 1) * bytes_per_line] + b"\x00" * extra_padding + for i in range(len(bytes) // bytes_per_line) + ] return b"".join(new_data) @@ -154,15 +130,11 @@ def _toqclass_helper(im): format = qt_format.Format_Mono elif im.mode == "L": format = qt_format.Format_Indexed8 - colortable = [] - for i in range(256): - colortable.append(rgb(i, i, i)) + colortable = [rgb(i, i, i) for i in range(256)] elif im.mode == "P": format = qt_format.Format_Indexed8 - colortable = [] palette = im.getpalette() - for i in range(0, len(palette), 3): - colortable.append(rgb(*palette[i : i + 3])) + 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") @@ -179,7 +151,8 @@ def _toqclass_helper(im): else: if exclusive_fp: im.close() - raise ValueError(f"unsupported image mode {repr(im.mode)}") + msg = f"unsupported image mode {repr(im.mode)}" + raise ValueError(msg) size = im.size __data = data or align8to32(im.tobytes(), size[0], im.mode) @@ -220,9 +193,5 @@ def toqimage(im): def toqpixmap(im): - # # This doesn't work. For now using a dumb approach. - # im_data = _toqclass_helper(im) - # result = QPixmap(im_data["size"][0], im_data["size"][1]) - # result.loadFromData(im_data["data"]) qimage = toqimage(im) return QPixmap.fromImage(qimage) diff --git a/src/PIL/ImageSequence.py b/src/PIL/ImageSequence.py index 9df910a43..e09b001e8 100644 --- a/src/PIL/ImageSequence.py +++ b/src/PIL/ImageSequence.py @@ -14,6 +14,7 @@ # ## +from __future__ import annotations class Iterator: @@ -30,7 +31,8 @@ class Iterator: def __init__(self, im): if not hasattr(im, "seek"): - raise AttributeError("im must have seek method") + msg = "im must have seek method" + raise AttributeError(msg) self.im = im self.position = getattr(self.im, "_min_frame", 0) @@ -39,7 +41,8 @@ class Iterator: self.im.seek(ix) return self.im except EOFError as e: - raise IndexError from e # end of sequence + msg = "end of sequence" + raise IndexError(msg) from e def __iter__(self): return self @@ -50,7 +53,8 @@ class Iterator: self.position += 1 return self.im except EOFError as e: - raise StopIteration from e + msg = "end of sequence" + raise StopIteration(msg) from e def all_frames(im, func=None): diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index 9d5224588..fad3e0980 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -11,15 +11,15 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import os import shutil import subprocess import sys from shlex import quote -from PIL import Image - -from ._deprecate import deprecate +from . import Image _viewers = [] @@ -101,7 +101,8 @@ class Viewer: Returns the command used to display the file. Not implemented in the base class. """ - raise NotImplementedError + msg = "unavailable in base viewer" + raise NotImplementedError(msg) def save_image(self, image): """Save to temporary file and return filename.""" @@ -111,20 +112,10 @@ class Viewer: """Display the given image.""" return self.show_file(self.save_image(image), **options) - def show_file(self, path=None, **options): + def show_file(self, path, **options): """ Display given file. - - Before Pillow 9.1.0, the first argument was ``file``. This is now deprecated, - and will be removed in Pillow 10.0.0 (2023-07-01). ``path`` should be used - instead. """ - if path is None: - if "file" in options: - deprecate("The 'file' argument", 10, "'path'") - path = options.pop("file") - else: - raise TypeError("Missing required argument: 'path'") os.system(self.get_command(path, **options)) # nosec return 1 @@ -163,20 +154,10 @@ class MacViewer(Viewer): command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" return command - def show_file(self, path=None, **options): + def show_file(self, path, **options): """ Display given file. - - Before Pillow 9.1.0, the first argument was ``file``. This is now deprecated, - and will be removed in Pillow 10.0.0 (2023-07-01). ``path`` should be used - instead. """ - if path is None: - if "file" in options: - deprecate("The 'file' argument", 10, "'path'") - path = options.pop("file") - else: - raise TypeError("Missing required argument: 'path'") subprocess.call(["open", "-a", "Preview.app", path]) executable = sys.executable or shutil.which("python3") if executable: @@ -213,20 +194,10 @@ class XDGViewer(UnixViewer): command = executable = "xdg-open" return command, executable - def show_file(self, path=None, **options): + def show_file(self, path, **options): """ Display given file. - - Before Pillow 9.1.0, the first argument was ``file``. This is now deprecated, - and will be removed in Pillow 10.0.0 (2023-07-01). ``path`` should be used - instead. """ - if path is None: - if "file" in options: - deprecate("The 'file' argument", 10, "'path'") - path = options.pop("file") - else: - raise TypeError("Missing required argument: 'path'") subprocess.Popen(["xdg-open", path]) return 1 @@ -243,19 +214,10 @@ class DisplayViewer(UnixViewer): command += f" -title {quote(title)}" return command, executable - def show_file(self, path=None, **options): + def show_file(self, path, **options): """ Display given file. - - Before Pillow 9.1.0, the first argument was ``file``. This is now deprecated, - and ``path`` should be used instead. """ - if path is None: - if "file" in options: - deprecate("The 'file' argument", 10, "'path'") - path = options.pop("file") - else: - raise TypeError("Missing required argument: 'path'") args = ["display"] title = options.get("title") if title: @@ -274,19 +236,10 @@ class GmDisplayViewer(UnixViewer): command = "gm display" return command, executable - def show_file(self, path=None, **options): + def show_file(self, path, **options): """ Display given file. - - Before Pillow 9.1.0, the first argument was ``file``. This is now deprecated, - and ``path`` should be used instead. """ - if path is None: - if "file" in options: - deprecate("The 'file' argument", 10, "'path'") - path = options.pop("file") - else: - raise TypeError("Missing required argument: 'path'") subprocess.Popen(["gm", "display", path]) return 1 @@ -299,19 +252,10 @@ class EogViewer(UnixViewer): command = "eog -n" return command, executable - def show_file(self, path=None, **options): + def show_file(self, path, **options): """ Display given file. - - Before Pillow 9.1.0, the first argument was ``file``. This is now deprecated, - and ``path`` should be used instead. """ - if path is None: - if "file" in options: - deprecate("The 'file' argument", 10, "'path'") - path = options.pop("file") - else: - raise TypeError("Missing required argument: 'path'") subprocess.Popen(["eog", "-n", path]) return 1 @@ -330,19 +274,10 @@ class XVViewer(UnixViewer): command += f" -name {quote(title)}" return command, executable - def show_file(self, path=None, **options): + def show_file(self, path, **options): """ Display given file. - - Before Pillow 9.1.0, the first argument was ``file``. This is now deprecated, - and ``path`` should be used instead. """ - if path is None: - if "file" in options: - deprecate("The 'file' argument", 10, "'path'") - path = options.pop("file") - else: - raise TypeError("Missing required argument: 'path'") args = ["xv"] title = options.get("title") if title: @@ -383,7 +318,6 @@ else: if __name__ == "__main__": - if len(sys.argv) < 2: print("Syntax: python3 ImageShow.py imagefile [title]") sys.exit() diff --git a/src/PIL/ImageStat.py b/src/PIL/ImageStat.py index 1baef7db4..13864e59c 100644 --- a/src/PIL/ImageStat.py +++ b/src/PIL/ImageStat.py @@ -20,10 +20,9 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations -import functools import math -import operator class Stat: @@ -36,7 +35,8 @@ class Stat: except AttributeError: self.h = image_or_list # assume it to be a histogram list if not isinstance(self.h, list): - raise TypeError("first argument must be image or list") + msg = "first argument must be image or list" + raise TypeError(msg) self.bands = list(range(len(self.h) // 256)) def __getattr__(self, id): @@ -52,26 +52,22 @@ class Stat: """Get min/max values for each band in the image""" def minmax(histogram): - n = 255 - x = 0 + res_min, res_max = 255, 0 for i in range(256): if histogram[i]: - n = min(n, i) - x = max(x, i) - return n, x # returns (255, 0) if there's no data in the histogram + res_min = i + break + for i in range(255, -1, -1): + if histogram[i]: + res_max = i + break + return res_min, res_max - v = [] - for i in range(0, len(self.h), 256): - v.append(minmax(self.h[i:])) - return v + return [minmax(self.h[i:]) for i in range(0, len(self.h), 256)] def _getcount(self): """Get total number of pixels in each layer""" - - v = [] - for i in range(0, len(self.h), 256): - v.append(functools.reduce(operator.add, self.h[i : i + 256])) - return v + return [sum(self.h[i : i + 256]) for i in range(0, len(self.h), 256)] def _getsum(self): """Get sum of all pixels in each layer""" @@ -97,11 +93,7 @@ class Stat: def _getmean(self): """Get average pixel level for each layer""" - - v = [] - for i in self.bands: - v.append(self.sum[i] / self.count[i]) - return v + return [self.sum[i] / self.count[i] for i in self.bands] def _getmedian(self): """Get median pixel level for each layer""" @@ -120,28 +112,18 @@ class Stat: def _getrms(self): """Get RMS for each layer""" - - v = [] - for i in self.bands: - v.append(math.sqrt(self.sum2[i] / self.count[i])) - return v + return [math.sqrt(self.sum2[i] / self.count[i]) for i in self.bands] def _getvar(self): """Get variance for each layer""" - - v = [] - for i in self.bands: - n = self.count[i] - v.append((self.sum2[i] - (self.sum[i] ** 2.0) / n) / n) - return v + return [ + (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i] + for i in self.bands + ] def _getstddev(self): """Get standard deviation for each layer""" - - v = [] - for i in self.bands: - v.append(math.sqrt(self.var[i])) - return v + return [math.sqrt(self.var[i]) for i in self.bands] Global = Stat # compatibility diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index 949cf1fbf..10b2cc69a 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -24,12 +24,12 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import tkinter from io import BytesIO from . import Image -from ._deprecate import deprecate # -------------------------------------------------------------------- # Check for Tkinter interface hooks @@ -97,7 +97,6 @@ class PhotoImage: """ def __init__(self, image=None, size=None, **kw): - # Tk compatibility: file or data if image is None: image = _get_image_from_kw(kw) @@ -163,7 +162,7 @@ class PhotoImage: """ return self.__size[1] - def paste(self, im, box=None): + def paste(self, im): """ Paste a PIL image into the photo image. Note that this can be very slow if the photo image is displayed. @@ -171,13 +170,7 @@ class PhotoImage: :param im: A PIL image. The size must match the target region. If the mode does not match, the image is converted to the mode of the bitmap image. - :param box: Deprecated. This parameter will be removed in Pillow 10 - (2023-07-01). """ - - if box is not None: - deprecate("The box parameter", 10, None) - # convert to blittable im.load() image = im.im @@ -209,7 +202,6 @@ class BitmapImage: """ def __init__(self, image=None, **kw): - # Tk compatibility: file or data if image is None: image = _get_image_from_kw(kw) @@ -284,7 +276,8 @@ def _show(image, title): super().__init__(master, image=self.image, bg="black", bd=0) if not tkinter._default_root: - raise OSError("tkinter not initialized") + msg = "tkinter not initialized" + raise OSError(msg) top = tkinter.Toplevel() if title: top.title(title) diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py index 7881f0d26..1fdaa9140 100644 --- a/src/PIL/ImageTransform.py +++ b/src/PIL/ImageTransform.py @@ -12,6 +12,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image diff --git a/src/PIL/ImageWin.py b/src/PIL/ImageWin.py index ca9b14c8a..75910d2d9 100644 --- a/src/PIL/ImageWin.py +++ b/src/PIL/ImageWin.py @@ -16,6 +16,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image @@ -54,9 +55,9 @@ class Dib: "L", "P", or "RGB". If the display requires a palette, this constructor creates a suitable - palette and associates it with the image. For an "L" image, 128 greylevels + palette and associates it with the image. For an "L" image, 128 graylevels are allocated. For an "RGB" image, a 6x6x6 colour cube is used, together - with 20 greylevels. + with 20 graylevels. To make sure that palettes work properly under Windows, you must call the ``palette`` method upon certain events from Windows. diff --git a/src/PIL/ImtImagePlugin.py b/src/PIL/ImtImagePlugin.py index dc7078012..7469c592d 100644 --- a/src/PIL/ImtImagePlugin.py +++ b/src/PIL/ImtImagePlugin.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import re @@ -30,23 +30,21 @@ field = re.compile(rb"([a-z]*) ([^ \r\n]*)") class ImtImageFile(ImageFile.ImageFile): - format = "IMT" format_description = "IM Tools" def _open(self): - # Quick rejection: if there's not a LF among the first # 100 bytes, this is (probably) not a text header. buffer = self.fp.read(100) if b"\n" not in buffer: - raise SyntaxError("not an IM file") + msg = "not an IM file" + raise SyntaxError(msg) xsize = ysize = 0 while True: - if buffer: s = buffer[:1] buffer = buffer[1:] @@ -56,7 +54,6 @@ class ImtImageFile(ImageFile.ImageFile): break if s == b"\x0C": - # image data begins self.tile = [ ( @@ -70,7 +67,6 @@ class ImtImageFile(ImageFile.ImageFile): break else: - # read key/value pair if b"\n" not in buffer: buffer += self.fp.read(100) @@ -93,7 +89,7 @@ class ImtImageFile(ImageFile.ImageFile): ysize = int(v) self._size = xsize, ysize elif k == b"pixel" and v == b"n8": - self.mode = "L" + self._mode = "L" # diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index 0bbe50668..e7dc3e4e4 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -14,14 +14,15 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import os import tempfile from . import Image, ImageFile -from ._binary import i8 +from ._binary import i8, o8 from ._binary import i16be as i16 from ._binary import i32be as i32 -from ._binary import o8 COMPRESSION = {1: "raw", 5: "jpeg"} @@ -48,7 +49,6 @@ def dump(c): class IptcImageFile(ImageFile.ImageFile): - format = "IPTC" format_description = "IPTC/NAA" @@ -59,19 +59,21 @@ class IptcImageFile(ImageFile.ImageFile): # # get a IPTC field header s = self.fp.read(5) - if not len(s): + if not s.strip(b"\x00"): return None, 0 tag = s[1], s[2] # syntax - if s[0] != 0x1C or tag[0] < 1 or tag[0] > 9: - raise SyntaxError("invalid IPTC/NAA file") + if s[0] != 0x1C or tag[0] not in [1, 2, 3, 4, 5, 6, 7, 8, 9, 240]: + msg = "invalid IPTC/NAA file" + raise SyntaxError(msg) # field size size = s[3] if size > 132: - raise OSError("illegal field length in IPTC/NAA file") + msg = "illegal field length in IPTC/NAA file" + raise OSError(msg) elif size == 128: size = 0 elif size > 128: @@ -82,7 +84,6 @@ class IptcImageFile(ImageFile.ImageFile): return tag, size def _open(self): - # load descriptive fields while True: offset = self.fp.tell() @@ -109,11 +110,11 @@ class IptcImageFile(ImageFile.ImageFile): else: id = 0 if layers == 1 and not component: - self.mode = "L" + self._mode = "L" elif layers == 3 and component: - self.mode = "RGB"[id] + self._mode = "RGB"[id] elif layers == 4 and component: - self.mode = "CMYK"[id] + self._mode = "CMYK"[id] # size self._size = self.getint((3, 20)), self.getint((3, 30)) @@ -122,7 +123,8 @@ class IptcImageFile(ImageFile.ImageFile): try: compression = COMPRESSION[self.getint((3, 120))] except KeyError as e: - raise OSError("Unknown IPTC image compression") from e + msg = "Unknown IPTC image compression" + raise OSError(msg) from e # tile if tag == (8, 10): @@ -131,7 +133,6 @@ class IptcImageFile(ImageFile.ImageFile): ] def load(self): - if len(self.tile) != 1 or self.tile[0][0] != "iptc": return ImageFile.ImageFile.load(self) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index c67d8d6bf..4b778a0d3 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -13,11 +13,13 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import io import os import struct -from . import Image, ImageFile +from . import Image, ImageFile, _binary class BoxReader: @@ -44,13 +46,13 @@ class BoxReader: def _read_bytes(self, num_bytes): if not self._can_read(num_bytes): - raise SyntaxError("Not enough data in header") + msg = "Not enough data in header" + raise SyntaxError(msg) data = self.fp.read(num_bytes) if len(data) < num_bytes: - raise OSError( - f"Expected to read {num_bytes} bytes but only got {len(data)}." - ) + msg = f"Expected to read {num_bytes} bytes but only got {len(data)}." + raise OSError(msg) if self.remaining_in_box > 0: self.remaining_in_box -= num_bytes @@ -87,7 +89,8 @@ class BoxReader: hlen = 8 if lbox < hlen or not self._can_read(lbox - hlen): - raise SyntaxError("Invalid header length") + msg = "Invalid header length" + raise SyntaxError(msg) self.remaining_in_box = lbox - hlen return tbox @@ -98,7 +101,7 @@ def _parse_codestream(fp): count from the SIZ marker segment, returning a PIL (size, mode) tuple.""" hdr = fp.read(2) - lsiz = struct.unpack(">H", hdr)[0] + lsiz = _binary.i16be(hdr) siz = hdr + fp.read(lsiz - 2) lsiz, rsiz, xsiz, ysiz, xosiz, yosiz, _, _, _, _, csiz = struct.unpack_from( ">HHIIIIIIIIH", siz @@ -189,7 +192,8 @@ def _parse_jp2_header(fp): break if size is None or mode is None: - raise SyntaxError("Malformed JP2 header") + msg = "Malformed JP2 header" + raise SyntaxError(msg) return size, mode, mimetype, dpi @@ -206,21 +210,25 @@ class Jpeg2KImageFile(ImageFile.ImageFile): sig = self.fp.read(4) if sig == b"\xff\x4f\xff\x51": self.codec = "j2k" - self._size, self.mode = _parse_codestream(self.fp) + self._size, self._mode = _parse_codestream(self.fp) else: sig = sig + self.fp.read(8) if sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a": self.codec = "jp2" header = _parse_jp2_header(self.fp) - self._size, self.mode, self.custom_mimetype, dpi = header + self._size, self._mode, self.custom_mimetype, dpi = header if dpi is not None: self.info["dpi"] = dpi + if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"): + self._parse_comment() else: - raise SyntaxError("not a JPEG 2000 file") + msg = "not a JPEG 2000 file" + raise SyntaxError(msg) if self.size is None or self.mode is None: - raise SyntaxError("unable to determine size/mode") + msg = "unable to determine size/mode" + raise SyntaxError(msg) self._reduce = 0 self.layers = 0 @@ -250,6 +258,28 @@ class Jpeg2KImageFile(ImageFile.ImageFile): ) ] + def _parse_comment(self): + 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: + break + typ = marker[1] + if typ in (0x90, 0xD9): + # Start of tile or end of codestream + break + hdr = self.fp.read(2) + length = _binary.i16be(hdr) + if typ == 0x64: + # Comment + self.info["comment"] = self.fp.read(length - 2)[2:] + break + else: + self.fp.seek(length - 2, os.SEEK_CUR) + @property def reduce(self): # https://github.com/python-pillow/Pillow/issues/4343 found that the @@ -306,13 +336,11 @@ def _save(im, fp, filename): if quality_layers is not None and not ( isinstance(quality_layers, (list, tuple)) and all( - [ - isinstance(quality_layer, (int, float)) - for quality_layer in quality_layers - ] + isinstance(quality_layer, (int, float)) for quality_layer in quality_layers ) ): - raise ValueError("quality_layers must be a sequence of numbers") + msg = "quality_layers must be a sequence of numbers" + raise ValueError(msg) num_resolutions = info.get("num_resolutions", 0) cblk_size = info.get("codeblock_size", None) @@ -321,8 +349,13 @@ def _save(im, fp, filename): progression = info.get("progression", "LRCP") cinema_mode = info.get("cinema_mode", "no") mct = info.get("mct", 0) - fd = -1 + signed = info.get("signed", False) + comment = info.get("comment") + if isinstance(comment, str): + comment = comment.encode() + plt = info.get("plt", False) + fd = -1 if hasattr(fp, "fileno"): try: fd = fp.fileno() @@ -342,7 +375,10 @@ def _save(im, fp, filename): progression, cinema_mode, mct, + signed, fd, + comment, + plt, ) ImageFile._save(im, fp, [("jpeg2k", (0, 0) + im.size, 0, kind)]) diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index eb0db5bb3..59bade303 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -31,6 +31,8 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import array import io import math @@ -41,12 +43,11 @@ import sys import tempfile import warnings -from . import Image, ImageFile, TiffImagePlugin +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 # @@ -166,16 +167,25 @@ def APP(self, marker): except TypeError: dpi = x_resolution if math.isnan(dpi): - raise ValueError + msg = "DPI is not a number" + raise ValueError(msg) if resolution_unit == 3: # cm # 1 dpcm = 2.54 dpi dpi *= 2.54 self.info["dpi"] = dpi, dpi - except (TypeError, KeyError, SyntaxError, ValueError, ZeroDivisionError): - # SyntaxError for invalid/unreadable EXIF + except ( + struct.error, + KeyError, + SyntaxError, + TypeError, + ValueError, + ZeroDivisionError, + ): + # struct.error for truncated EXIF # KeyError for dpi not included - # ZeroDivisionError for invalid dpi rational value + # SyntaxError for invalid/unreadable EXIF # ValueError or TypeError for dpi being an invalid float + # ZeroDivisionError for invalid dpi rational value self.info["dpi"] = 72, 72 @@ -204,17 +214,19 @@ def SOF(self, marker): self.bits = s[0] if self.bits != 8: - raise SyntaxError(f"cannot handle {self.bits}-bit layers") + msg = f"cannot handle {self.bits}-bit layers" + raise SyntaxError(msg) self.layers = s[5] if self.layers == 1: - self.mode = "L" + self._mode = "L" elif self.layers == 3: - self.mode = "RGB" + self._mode = "RGB" elif self.layers == 4: - self.mode = "CMYK" + self._mode = "CMYK" else: - raise SyntaxError(f"cannot handle {self.layers}-layer images") + msg = f"cannot handle {self.layers}-layer images" + raise SyntaxError(msg) if marker in [0xFFC2, 0xFFC6, 0xFFCA, 0xFFCE]: self.info["progressive"] = self.info["progression"] = 1 @@ -223,9 +235,7 @@ def SOF(self, marker): # fixup icc profile self.icclist.sort() # sort by sequence number if self.icclist[0][13] == len(self.icclist): - profile = [] - for p in self.icclist: - profile.append(p[14:]) + profile = [p[14:] for p in self.icclist] icc_profile = b"".join(profile) else: icc_profile = None # wrong number of fragments @@ -253,7 +263,8 @@ def DQT(self, marker): precision = 1 if (v // 16 == 0) else 2 # in bytes qt_length = 1 + precision * 64 if len(s) < qt_length: - raise SyntaxError("bad quantization table marker") + msg = "bad quantization table marker" + raise SyntaxError(msg) data = array.array("B" if precision == 1 else "H", s[1:qt_length]) if sys.byteorder == "little" and precision > 1: data.byteswap() # the values are always big-endian @@ -341,16 +352,15 @@ def _accept(prefix): class JpegImageFile(ImageFile.ImageFile): - format = "JPEG" format_description = "JPEG (ISO 10918)" def _open(self): - s = self.fp.read(3) if not _accept(s): - raise SyntaxError("not a JPEG file") + msg = "not a JPEG file" + raise SyntaxError(msg) s = b"\xFF" # Create attributes @@ -366,7 +376,6 @@ class JpegImageFile(ImageFile.ImageFile): self.icclist = [] while True: - i = s[0] if i == 0xFF: s = s + self.fp.read(1) @@ -388,13 +397,14 @@ class JpegImageFile(ImageFile.ImageFile): # self.__offset = self.fp.tell() break s = self.fp.read(1) - elif i == 0 or i == 0xFFFF: + elif i in {0, 0xFFFF}: # padded marker or junk; move on s = b"\xff" elif i == 0xFF00: # Skip extraneous data (escaped 0xFF) s = self.fp.read(1) else: - raise SyntaxError("no marker found") + msg = "no marker found" + raise SyntaxError(msg) def load_read(self, read_bytes): """ @@ -413,7 +423,6 @@ class JpegImageFile(ImageFile.ImageFile): return s def draft(self, mode, size): - if len(self.tile) != 1: return @@ -426,7 +435,7 @@ class JpegImageFile(ImageFile.ImageFile): original_size = self.size if a[0] == "RGB" and mode in ["L", "YCbCr"]: - self.mode = mode + self._mode = mode a = mode, "" if size: @@ -450,7 +459,6 @@ class JpegImageFile(ImageFile.ImageFile): return self.mode, box def load_djpeg(self): - # ALTERNATIVE: handle JPEGs via the IJG command line utilities f, path = tempfile.mkstemp() @@ -458,7 +466,13 @@ class JpegImageFile(ImageFile.ImageFile): if os.path.exists(self.filename): subprocess.check_call(["djpeg", "-outfile", path, self.filename]) else: - raise ValueError("Invalid Filename") + try: + os.unlink(path) + except OSError: + pass + + msg = "Invalid Filename" + raise ValueError(msg) try: with Image.open(path) as _im: @@ -470,7 +484,7 @@ class JpegImageFile(ImageFile.ImageFile): except OSError: pass - self.mode = self.im.mode + self._mode = self.im.mode self._size = self.im.size self.tile = [] @@ -491,7 +505,7 @@ class JpegImageFile(ImageFile.ImageFile): for segment, content in self.applist: if segment == "APP1": - marker, xmp_tags = content.rsplit(b"\x00", 1) + marker, xmp_tags = content.split(b"\x00")[:2] if marker == b"http://ns.adobe.com/xap/1.0/": return self._getxmp(xmp_tags) return {} @@ -518,18 +532,22 @@ def _getmp(self): head = file_contents.read(8) endianness = ">" if head[:4] == b"\x4d\x4d\x00\x2a" else "<" # process dictionary + from . import TiffImagePlugin + try: info = TiffImagePlugin.ImageFileDirectory_v2(head) file_contents.seek(info.next) info.load(file_contents) mp = dict(info) except Exception as e: - raise SyntaxError("malformed MP Index (unreadable directory)") from e + msg = "malformed MP Index (unreadable directory)" + raise SyntaxError(msg) from e # it's an error not to have a number of images try: quant = mp[0xB001] except KeyError as e: - raise SyntaxError("malformed MP Index (no number of images)") from e + msg = "malformed MP Index (no number of images)" + raise SyntaxError(msg) from e # get MP entries mpentries = [] try: @@ -551,7 +569,8 @@ def _getmp(self): if mpentryattr["ImageDataFormat"] == 0: mpentryattr["ImageDataFormat"] = "JPEG" else: - raise SyntaxError("unsupported picture format in MPO") + msg = "unsupported picture format in MPO" + raise SyntaxError(msg) mptypemap = { 0x000000: "Undefined", 0x010001: "Large Thumbnail (VGA Equivalent)", @@ -566,7 +585,8 @@ def _getmp(self): mpentries.append(mpentry) mp[0xB002] = mpentries except KeyError as e: - raise SyntaxError("malformed MP Index (bad MP Entry)") from e + msg = "malformed MP Index (bad MP Entry)" + raise SyntaxError(msg) from e # Next we should try and parse the individual image unique ID list; # we don't because I've never seen this actually used in a real MPO # file and so can't test it. @@ -605,11 +625,6 @@ samplings = { # fmt: on -def convert_dict_qtables(qtables): - deprecate("convert_dict_qtables", 10, action="Conversion is no longer needed") - return qtables - - def get_sampling(im): # There's no subsampling when images have only 1 layer # (grayscale images) or when they are CMYK (4 layers), @@ -626,12 +641,14 @@ def get_sampling(im): def _save(im, fp, filename): if im.width == 0 or im.height == 0: - raise ValueError("cannot write empty image as JPEG") + msg = "cannot write empty image as JPEG" + raise ValueError(msg) try: rawmode = RAWMODE[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as JPEG") from e + msg = f"cannot write mode {im.mode} as JPEG" + raise OSError(msg) from e info = im.encoderinfo @@ -651,7 +668,8 @@ def _save(im, fp, filename): subsampling = preset.get("subsampling", -1) qtables = preset.get("quantization") elif not isinstance(quality, int): - raise ValueError("Invalid quality setting") + msg = "Invalid quality setting" + raise ValueError(msg) else: if subsampling in presets: subsampling = presets[subsampling].get("subsampling", -1) @@ -670,7 +688,8 @@ def _save(im, fp, filename): subsampling = 2 elif subsampling == "keep": if im.format != "JPEG": - raise ValueError("Cannot use 'keep' when original image is not a JPEG") + msg = "Cannot use 'keep' when original image is not a JPEG" + raise ValueError(msg) subsampling = get_sampling(im) def validate_qtables(qtables): @@ -684,7 +703,8 @@ def _save(im, fp, filename): for num in line.split("#", 1)[0].split() ] except ValueError as e: - raise ValueError("Invalid quantization table") from e + msg = "Invalid quantization table" + raise ValueError(msg) from e else: qtables = [lines[s : s + 64] for s in range(0, len(lines), 64)] if isinstance(qtables, (tuple, list, dict)): @@ -695,30 +715,34 @@ def _save(im, fp, filename): elif isinstance(qtables, tuple): qtables = list(qtables) if not (0 < len(qtables) < 5): - raise ValueError("None or too many quantization tables") + msg = "None or too many quantization tables" + raise ValueError(msg) for idx, table in enumerate(qtables): try: if len(table) != 64: - raise TypeError + msg = "Invalid quantization table" + raise TypeError(msg) table = array.array("H", table) except TypeError as e: - raise ValueError("Invalid quantization table") from e + msg = "Invalid quantization table" + raise ValueError(msg) from e else: qtables[idx] = list(table) return qtables if qtables == "keep": if im.format != "JPEG": - raise ValueError("Cannot use 'keep' when original image is not a JPEG") + msg = "Cannot use 'keep' when original image is not a JPEG" + raise ValueError(msg) qtables = getattr(im, "quantization", None) qtables = validate_qtables(qtables) extra = info.get("extra", b"") + MAX_BYTES_IN_MARKER = 65533 icc_profile = info.get("icc_profile") if icc_profile: ICC_OVERHEAD_LEN = 14 - MAX_BYTES_IN_MARKER = 65533 MAX_DATA_BYTES_IN_MARKER = MAX_BYTES_IN_MARKER - ICC_OVERHEAD_LEN markers = [] while icc_profile: @@ -749,6 +773,9 @@ def _save(im, fp, filename): exif = info.get("exif", b"") if isinstance(exif, Image.Exif): exif = exif.tobytes() + if len(exif) > MAX_BYTES_IN_MARKER: + msg = "EXIF data is too long" + raise ValueError(msg) # get keyword arguments im.encoderconfig = ( @@ -760,6 +787,8 @@ def _save(im, fp, filename): dpi[0], dpi[1], subsampling, + info.get("restart_marker_blocks", 0), + info.get("restart_marker_rows", 0), qtables, comment, extra, @@ -780,10 +809,14 @@ def _save(im, fp, filename): bufsize = 2 * im.size[0] * im.size[1] else: bufsize = im.size[0] * im.size[1] - - # The EXIF info needs to be written as one block, + APP1, + one spare byte. - # Ensure that our buffer is big enough. Same with the icc_profile block. - bufsize = max(ImageFile.MAXBLOCK, bufsize, len(exif) + 5, len(extra) + 1) + if exif: + bufsize += len(exif) + 5 + if extra: + bufsize += len(extra) + 1 + else: + # The EXIF info needs to be written as one block, + APP1, + one spare byte. + # 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) diff --git a/src/PIL/JpegPresets.py b/src/PIL/JpegPresets.py index a678e248e..9ecfdb259 100644 --- a/src/PIL/JpegPresets.py +++ b/src/PIL/JpegPresets.py @@ -62,6 +62,7 @@ Libjpeg ref.: https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html """ +from __future__ import annotations # fmt: off presets = { diff --git a/src/PIL/McIdasImagePlugin.py b/src/PIL/McIdasImagePlugin.py index cd047fe9d..9a85c0d15 100644 --- a/src/PIL/McIdasImagePlugin.py +++ b/src/PIL/McIdasImagePlugin.py @@ -15,6 +15,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import struct @@ -30,16 +31,15 @@ def _accept(s): class McIdasImageFile(ImageFile.ImageFile): - format = "MCIDAS" format_description = "McIdas area file" def _open(self): - # parse area file directory s = self.fp.read(256) if not _accept(s) or len(s) != 256: - raise SyntaxError("not an McIdas area file") + msg = "not an McIdas area file" + raise SyntaxError(msg) self.area_descriptor_raw = s self.area_descriptor = w = [0] + list(struct.unpack("!64i", s)) @@ -56,9 +56,10 @@ class McIdasImageFile(ImageFile.ImageFile): mode = "I" rawmode = "I;32B" else: - raise SyntaxError("unsupported McIdas format") + msg = "unsupported McIdas format" + raise SyntaxError(msg) - self.mode = mode + self._mode = mode self._size = w[10], w[9] offset = w[34] + w[15] diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index d4f6c90f7..f4529d9ae 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -15,7 +15,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import olefile @@ -34,41 +34,40 @@ def _accept(prefix): class MicImageFile(TiffImagePlugin.TiffImageFile): - format = "MIC" format_description = "Microsoft Image Composer" _close_exclusive_fp_after_loading = False def _open(self): - # read the OLE directory and see if this is a likely # to be a Microsoft Image Composer file try: self.ole = olefile.OleFileIO(self.fp) except OSError as e: - raise SyntaxError("not an MIC file; invalid OLE file") from e + msg = "not an MIC file; invalid OLE file" + raise SyntaxError(msg) from e # find ACI subfiles with Image members (maybe not the # best way to identify MIC files, but what the... ;-) - self.images = [] - for path in self.ole.listdir(): - if path[1:] and path[0][-4:] == ".ACI" and path[1] == "Image": - self.images.append(path) + self.images = [ + path + for path in self.ole.listdir() + if path[1:] and path[0][-4:] == ".ACI" and path[1] == "Image" + ] # if we didn't find any images, this is probably not # an MIC file. if not self.images: - raise SyntaxError("not an MIC file; no image entries") + msg = "not an MIC file; no image entries" + raise SyntaxError(msg) self.frame = None self._n_frames = len(self.images) self.is_animated = self._n_frames > 1 - if len(self.images) > 1: - self._category = Image.CONTAINER - + self.__fp = self.fp self.seek(0) def seek(self, frame): @@ -77,7 +76,8 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): try: filename = self.images[frame] except IndexError as e: - raise EOFError("no such frame") from e + msg = "no such frame" + raise EOFError(msg) from e self.fp = self.ole.openstream(filename) @@ -88,6 +88,16 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): def tell(self): return self.frame + def close(self): + self.__fp.close() + self.ole.close() + super().close() + + def __exit__(self, *args): + self.__fp.close() + self.ole.close() + super().__exit__() + # # -------------------------------------------------------------------- diff --git a/src/PIL/MpegImagePlugin.py b/src/PIL/MpegImagePlugin.py index a358dfdce..f4e598ca3 100644 --- a/src/PIL/MpegImagePlugin.py +++ b/src/PIL/MpegImagePlugin.py @@ -12,7 +12,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile from ._binary import i8 @@ -58,18 +58,17 @@ class BitStream: class MpegImageFile(ImageFile.ImageFile): - format = "MPEG" format_description = "MPEG" def _open(self): - s = BitStream(self.fp) if s.read(32) != 0x1B3: - raise SyntaxError("not an MPEG file") + msg = "not an MPEG file" + raise SyntaxError(msg) - self.mode = "RGB" + self._mode = "RGB" self._size = s.read(12), s.read(12) diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 095cfe7ee..199a10090 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -17,6 +17,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import itertools import os @@ -33,9 +34,6 @@ from . import ( from ._binary import i16be as i16 from ._binary import o32le -# def _accept(prefix): -# return JpegImagePlugin._accept(prefix) - def _save(im, fp, filename): JpegImagePlugin._save(im, fp, filename) @@ -101,7 +99,6 @@ def _save_all(im, fp, filename): class MpoImageFile(JpegImagePlugin.JpegImageFile): - format = "MPO" format_description = "MPO (CIPA DC-007)" _close_exclusive_fp_after_loading = False @@ -143,7 +140,8 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): self.fp.seek(self.offset + 2) # skip SOI marker segment = self.fp.read(2) if not segment: - raise ValueError("No data found for frame") + msg = "No data found for frame" + raise ValueError(msg) self._size = self._initial_size if i16(segment) == 0xFFE1: # APP1 n = i16(self.fp.read(2)) - 2 diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index c4d7ddbb4..77dac65b6 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -22,6 +22,7 @@ # Figure 206. Windows Paint Version 2: "LinS" Format. Used in Windows V2.03 # # See also: https://www.fileformat.info/format/mspaint/egff.htm +from __future__ import annotations import io import struct @@ -44,25 +45,25 @@ def _accept(prefix): class MspImageFile(ImageFile.ImageFile): - format = "MSP" format_description = "Windows Paint" def _open(self): - # Header s = self.fp.read(32) if not _accept(s): - raise SyntaxError("not an MSP file") + msg = "not an MSP file" + raise SyntaxError(msg) # Header checksum checksum = 0 for i in range(0, 32, 2): checksum = checksum ^ i16(s, i) if checksum != 0: - raise SyntaxError("bad MSP checksum") + msg = "bad MSP checksum" + raise SyntaxError(msg) - self.mode = "1" + self._mode = "1" self._size = i16(s, 4), i16(s, 6) if s[:4] == b"DanM": @@ -109,7 +110,6 @@ class MspDecoder(ImageFile.PyDecoder): _pulls_fd = True def decode(self, buffer): - img = io.BytesIO() blank_line = bytearray((0xFF,) * ((self.state.xsize + 7) // 8)) try: @@ -118,7 +118,8 @@ class MspDecoder(ImageFile.PyDecoder): f"<{self.state.ysize}H", self.fd.read(self.state.ysize * 2) ) except struct.error as e: - raise OSError("Truncated MSP file in row map") from e + msg = "Truncated MSP file in row map" + raise OSError(msg) from e for x, rowlen in enumerate(rowmap): try: @@ -127,9 +128,8 @@ class MspDecoder(ImageFile.PyDecoder): continue row = self.fd.read(rowlen) if len(row) != rowlen: - raise OSError( - "Truncated MSP file, expected %d bytes on row %s", (rowlen, x) - ) + msg = f"Truncated MSP file, expected {rowlen} bytes on row {x}" + raise OSError(msg) idx = 0 while idx < rowlen: runtype = row[idx] @@ -144,7 +144,8 @@ class MspDecoder(ImageFile.PyDecoder): idx += runcount except struct.error as e: - raise OSError(f"Corrupted MSP file in row {x}") from e + msg = f"Corrupted MSP file in row {x}" + raise OSError(msg) from e self.set_as_raw(img.getvalue(), ("1", 0, 1)) @@ -159,9 +160,9 @@ Image.register_decoder("MSP", MspDecoder) def _save(im, fp, filename): - if im.mode != "1": - raise OSError(f"cannot write mode {im.mode} as MSP") + msg = f"cannot write mode {im.mode} as MSP" + raise OSError(msg) # create MSP header header = [0] * 16 diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index 13b3048f6..848fc2f71 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -14,6 +14,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import sys @@ -109,7 +110,7 @@ class PSDraw: if im.mode == "1": dpi = 200 # fax else: - dpi = 100 # greyscale + dpi = 100 # grayscale # image size (on paper) x = im.size[0] * 72 / dpi y = im.size[1] * 72 / dpi diff --git a/src/PIL/PaletteFile.py b/src/PIL/PaletteFile.py index ee9dca860..dc3175402 100644 --- a/src/PIL/PaletteFile.py +++ b/src/PIL/PaletteFile.py @@ -12,6 +12,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from ._binary import o8 @@ -22,11 +23,9 @@ class PaletteFile: rawmode = "RGB" def __init__(self, fp): - self.palette = [(i, i, i) for i in range(256)] while True: - s = fp.readline() if not s: @@ -34,7 +33,8 @@ class PaletteFile: if s[:1] == b"#": continue if len(s) > 100: - raise SyntaxError("bad palette file") + msg = "bad palette file" + raise SyntaxError(msg) v = [int(x) for x in s.split()] try: @@ -49,5 +49,4 @@ class PaletteFile: self.palette = b"".join(self.palette) def getpalette(self): - return self.palette, self.rawmode diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 700f10e3f..65be7fef7 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -6,6 +6,7 @@ ## # Image plugin for Palm pixmap images (output only). ## +from __future__ import annotations from . import Image, ImageFile from ._binary import o8 @@ -112,9 +113,7 @@ _COMPRESSION_TYPES = {"none": 0xFF, "rle": 0x01, "scanline": 0x00} def _save(im, fp, filename): - if im.mode == "P": - # we assume this is a color Palm image with the standard colormap, # unless the "info" dict has a "custom-colormap" field @@ -126,7 +125,7 @@ def _save(im, fp, filename): if im.encoderinfo.get("bpp") in (1, 2, 4): # this is 8-bit grayscale, so we shift it to get the high-order bits, # and invert it because - # Palm does greyscale from white (0) to black (1) + # Palm does grayscale from white (0) to black (1) bpp = im.encoderinfo["bpp"] im = im.point( lambda x, shift=8 - bpp, maxval=(1 << bpp) - 1: maxval - (x >> shift) @@ -138,7 +137,8 @@ def _save(im, fp, filename): bpp = im.info["bpp"] im = im.point(lambda x, maxval=(1 << bpp) - 1: maxval - (x & maxval)) else: - raise OSError(f"cannot write mode {im.mode} as Palm") + msg = f"cannot write mode {im.mode} as Palm" + raise OSError(msg) # we ignore the palette here im.mode = "P" @@ -146,15 +146,14 @@ def _save(im, fp, filename): version = 1 elif im.mode == "1": - # monochrome -- write it inverted, as is the Palm standard rawmode = "1;I" bpp = 1 version = 0 else: - - raise OSError(f"cannot write mode {im.mode} as Palm") + msg = f"cannot write mode {im.mode} as Palm" + raise OSError(msg) # # make sure image data is available diff --git a/src/PIL/PcdImagePlugin.py b/src/PIL/PcdImagePlugin.py index 38caf5c63..a0515b302 100644 --- a/src/PIL/PcdImagePlugin.py +++ b/src/PIL/PcdImagePlugin.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile @@ -24,18 +24,17 @@ from . import Image, ImageFile class PcdImageFile(ImageFile.ImageFile): - format = "PCD" format_description = "Kodak PhotoCD" def _open(self): - # rough self.fp.seek(2048) s = self.fp.read(2048) if s[:4] != b"PCD_": - raise SyntaxError("not a PCD file") + msg = "not a PCD file" + raise SyntaxError(msg) orientation = s[1538] & 3 self.tile_post_rotate = None @@ -44,7 +43,7 @@ class PcdImageFile(ImageFile.ImageFile): elif orientation == 3: self.tile_post_rotate = -90 - self.mode = "RGB" + self._mode = "RGB" self._size = 768, 512 # FIXME: not correct for rotated images! self.tile = [("pcd", (0, 0) + self.size, 96 * 2048, None)] diff --git a/src/PIL/PcfFontFile.py b/src/PIL/PcfFontFile.py index 442ac70c4..d602a1633 100644 --- a/src/PIL/PcfFontFile.py +++ b/src/PIL/PcfFontFile.py @@ -15,6 +15,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io @@ -58,12 +59,12 @@ class PcfFontFile(FontFile.FontFile): name = "name" def __init__(self, fp, charset_encoding="iso8859-1"): - self.charset_encoding = charset_encoding magic = l32(fp.read(4)) if magic != PCF_MAGIC: - raise SyntaxError("not a PCF file") + msg = "not a PCF file" + raise SyntaxError(msg) super().__init__() @@ -86,12 +87,24 @@ class PcfFontFile(FontFile.FontFile): for ch, ix in enumerate(encoding): if ix is not None: - x, y, l, r, w, a, d, f = metrics[ix] - glyph = (w, 0), (l, d - y, x + l, d), (0, 0, x, y), bitmaps[ix] - self.glyph[ch] = glyph + ( + xsize, + ysize, + left, + right, + width, + ascent, + descent, + attributes, + ) = metrics[ix] + self.glyph[ch] = ( + (width, 0), + (left, descent - ysize, xsize + left, descent), + (0, 0, xsize, ysize), + bitmaps[ix], + ) def _getformat(self, tag): - format, size, offset = self.toc[tag] fp = self.fp @@ -107,7 +120,6 @@ class PcfFontFile(FontFile.FontFile): return fp, format, i16, i32 def _load_properties(self): - # # font properties @@ -118,9 +130,8 @@ class PcfFontFile(FontFile.FontFile): nprops = i32(fp.read(4)) # read property description - p = [] - for i in range(nprops): - p.append((i32(fp.read(4)), i8(fp.read(1)), i32(fp.read(4)))) + p = [(i32(fp.read(4)), i8(fp.read(1)), i32(fp.read(4))) for _ in range(nprops)] + if nprops & 3: fp.seek(4 - (nprops & 3), io.SEEK_CUR) # pad @@ -135,7 +146,6 @@ class PcfFontFile(FontFile.FontFile): return properties def _load_metrics(self): - # # font metrics @@ -146,7 +156,6 @@ class PcfFontFile(FontFile.FontFile): append = metrics.append if (format & 0xFF00) == 0x100: - # "compressed" metrics for i in range(i16(fp.read(2))): left = i8(fp.read(1)) - 128 @@ -159,7 +168,6 @@ class PcfFontFile(FontFile.FontFile): append((xsize, ysize, left, right, width, ascent, descent, 0)) else: - # "jumbo" metrics for i in range(i32(fp.read(4))): left = i16(fp.read(2)) @@ -175,26 +183,20 @@ class PcfFontFile(FontFile.FontFile): return metrics def _load_bitmaps(self, metrics): - # # bitmap data - bitmaps = [] - fp, format, i16, i32 = self._getformat(PCF_BITMAPS) nbitmaps = i32(fp.read(4)) if nbitmaps != len(metrics): - raise OSError("Wrong number of bitmaps") + msg = "Wrong number of bitmaps" + raise OSError(msg) - offsets = [] - for i in range(nbitmaps): - offsets.append(i32(fp.read(4))) + offsets = [i32(fp.read(4)) for _ in range(nbitmaps)] - bitmap_sizes = [] - for i in range(4): - bitmap_sizes.append(i32(fp.read(4))) + bitmap_sizes = [i32(fp.read(4)) for _ in range(4)] # byteorder = format & 4 # non-zero => MSB bitorder = format & 8 # non-zero => MSB @@ -210,10 +212,13 @@ class PcfFontFile(FontFile.FontFile): if bitorder: mode = "1" + bitmaps = [] for i in range(nbitmaps): - x, y, l, r, w, a, d, f = metrics[i] - b, e = offsets[i], offsets[i + 1] - bitmaps.append(Image.frombytes("1", (x, y), data[b:e], "raw", mode, pad(x))) + xsize, ysize = metrics[i][:2] + b, e = offsets[i : i + 2] + bitmaps.append( + Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize)) + ) return bitmaps diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 841c18a22..98ecefd05 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -24,6 +24,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io import logging @@ -45,21 +46,21 @@ def _accept(prefix): class PcxImageFile(ImageFile.ImageFile): - format = "PCX" format_description = "Paintbrush" def _open(self): - # header s = self.fp.read(128) if not _accept(s): - raise SyntaxError("not a PCX file") + msg = "not a PCX file" + raise SyntaxError(msg) # image bbox = i16(s, 4), i16(s, 6), i16(s, 8) + 1, i16(s, 10) + 1 if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]: - raise SyntaxError("bad PCX image size") + msg = "bad PCX image size" + raise SyntaxError(msg) logger.debug("BBox: %s %s %s %s", *bbox) # format @@ -91,7 +92,7 @@ class PcxImageFile(ImageFile.ImageFile): self.fp.seek(-769, io.SEEK_END) s = self.fp.read(769) if len(s) == 769 and s[0] == 12: - # check if the palette is linear greyscale + # check if the palette is linear grayscale for i in range(256): if s[i * 3 + 1 : i * 3 + 4] != o8(i) * 3: mode = rawmode = "P" @@ -105,9 +106,10 @@ class PcxImageFile(ImageFile.ImageFile): rawmode = "RGB;L" else: - raise OSError("unknown PCX mode") + msg = "unknown PCX mode" + raise OSError(msg) - self.mode = mode + self._mode = mode self._size = bbox[2] - bbox[0], bbox[3] - bbox[1] # Don't trust the passed in stride. @@ -140,11 +142,11 @@ SAVE = { def _save(im, fp, filename): - try: version, bits, planes, rawmode = SAVE[im.mode] except KeyError as e: - raise ValueError(f"Cannot save {im.mode} images as PCX") from e + msg = f"Cannot save {im.mode} images as PCX" + raise ValueError(msg) from e # bytes per plane stride = (im.size[0] * bits + 7) // 8 @@ -202,7 +204,7 @@ def _save(im, fp, filename): palette += b"\x00" * (768 - len(palette)) fp.write(palette) # 768 bytes elif im.mode == "L": - # greyscale palette + # grayscale palette fp.write(o8(12)) for i in range(256): fp.write(o8(i) * 3) diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 404759a7f..3506aadce 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -19,6 +19,7 @@ ## # Image plugin for PDF images (output only). ## +from __future__ import annotations import io import math @@ -46,6 +47,132 @@ def _save_all(im, fp, filename): # (Internal) Image save plugin for the PDF format. +def _write_image(im, filename, existing_pdf, image_refs): + # FIXME: Should replace ASCIIHexDecode with RunLengthDecode + # (packbits) or LZWDecode (tiff/lzw compression). Note that + # PDF 1.2 also supports Flatedecode (zip compression). + + params = None + decode = None + + # + # Get image characteristics + + width, height = im.size + + dict_obj = {"BitsPerComponent": 8} + if im.mode == "1": + if features.check("libtiff"): + filter = "CCITTFaxDecode" + dict_obj["BitsPerComponent"] = 1 + params = PdfParser.PdfArray( + [ + PdfParser.PdfDict( + { + "K": -1, + "BlackIs1": True, + "Columns": width, + "Rows": height, + } + ) + ] + ) + else: + filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "L": + filter = "DCTDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "LA": + filter = "JPXDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + procset = "ImageB" # grayscale + dict_obj["SMaskInData"] = 1 + elif im.mode == "P": + filter = "ASCIIHexDecode" + palette = im.getpalette() + dict_obj["ColorSpace"] = [ + PdfParser.PdfName("Indexed"), + PdfParser.PdfName("DeviceRGB"), + len(palette) // 3 - 1, + PdfParser.PdfBinary(palette), + ] + procset = "ImageI" # indexed color + + if "transparency" in im.info: + smask = im.convert("LA").getchannel("A") + smask.encoderinfo = {} + + image_ref = _write_image(smask, filename, existing_pdf, image_refs)[0] + dict_obj["SMask"] = image_ref + elif im.mode == "RGB": + filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB") + procset = "ImageC" # color images + elif im.mode == "RGBA": + filter = "JPXDecode" + procset = "ImageC" # color images + dict_obj["SMaskInData"] = 1 + elif im.mode == "CMYK": + filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK") + procset = "ImageC" # color images + decode = [1, 0, 1, 0, 1, 0, 1, 0] + else: + msg = f"cannot save mode {im.mode}" + raise ValueError(msg) + + # + # image + + op = io.BytesIO() + + if filter == "ASCIIHexDecode": + ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)]) + elif filter == "CCITTFaxDecode": + im.save( + op, + "TIFF", + compression="group4", + # use a single strip + strip_size=math.ceil(width / 8) * height, + ) + elif filter == "DCTDecode": + Image.SAVE["JPEG"](im, op, filename) + elif filter == "JPXDecode": + del dict_obj["BitsPerComponent"] + Image.SAVE["JPEG2000"](im, op, filename) + else: + msg = f"unsupported PDF filter ({filter})" + raise ValueError(msg) + + stream = op.getvalue() + if filter == "CCITTFaxDecode": + stream = stream[8:] + filter = PdfParser.PdfArray([PdfParser.PdfName(filter)]) + else: + filter = PdfParser.PdfName(filter) + + image_ref = image_refs.pop(0) + existing_pdf.write_obj( + image_ref, + stream=stream, + Type=PdfParser.PdfName("XObject"), + Subtype=PdfParser.PdfName("Image"), + Width=width, # * 72.0 / x_resolution, + Height=height, # * 72.0 / y_resolution, + Filter=filter, + Decode=decode, + DecodeParms=params, + **dict_obj, + ) + + return image_ref, procset + + def _save(im, fp, filename, save_all=False): is_appending = im.encoderinfo.get("append", False) if is_appending: @@ -53,7 +180,12 @@ def _save(im, fp, filename, save_all=False): else: existing_pdf = PdfParser.PdfParser(f=fp, filename=filename, mode="w+b") - resolution = im.encoderinfo.get("resolution", 72.0) + dpi = im.encoderinfo.get("dpi") + if dpi: + x_resolution = dpi[0] + y_resolution = dpi[1] + else: + x_resolution = y_resolution = im.encoderinfo.get("resolution", 72.0) info = { "title": None @@ -104,6 +236,9 @@ def _save(im, fp, filename, save_all=False): number_of_pages += im_number_of_pages for i in range(im_number_of_pages): image_refs.append(existing_pdf.next_object_id(0)) + if im.mode == "P" and "transparency" in im.info: + image_refs.append(existing_pdf.next_object_id(0)) + page_refs.append(existing_pdf.next_object_id(0)) contents_refs.append(existing_pdf.next_object_id(0)) existing_pdf.pages.append(page_refs[-1]) @@ -116,110 +251,7 @@ def _save(im, fp, filename, save_all=False): for im_sequence in ims: im_pages = ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] for im in im_pages: - # FIXME: Should replace ASCIIHexDecode with RunLengthDecode - # (packbits) or LZWDecode (tiff/lzw compression). Note that - # PDF 1.2 also supports Flatedecode (zip compression). - - bits = 8 - params = None - decode = None - - # - # Get image characteristics - - width, height = im.size - - if im.mode == "1": - if features.check("libtiff"): - filter = "CCITTFaxDecode" - bits = 1 - params = PdfParser.PdfArray( - [ - PdfParser.PdfDict( - { - "K": -1, - "BlackIs1": True, - "Columns": width, - "Rows": height, - } - ) - ] - ) - else: - filter = "DCTDecode" - colorspace = PdfParser.PdfName("DeviceGray") - procset = "ImageB" # grayscale - elif im.mode == "L": - filter = "DCTDecode" - # params = f"<< /Predictor 15 /Columns {width-2} >>" - colorspace = PdfParser.PdfName("DeviceGray") - procset = "ImageB" # grayscale - elif im.mode == "P": - filter = "ASCIIHexDecode" - palette = im.getpalette() - colorspace = [ - PdfParser.PdfName("Indexed"), - PdfParser.PdfName("DeviceRGB"), - 255, - PdfParser.PdfBinary(palette), - ] - procset = "ImageI" # indexed color - elif im.mode == "RGB": - filter = "DCTDecode" - colorspace = PdfParser.PdfName("DeviceRGB") - procset = "ImageC" # color images - elif im.mode == "CMYK": - filter = "DCTDecode" - colorspace = PdfParser.PdfName("DeviceCMYK") - procset = "ImageC" # color images - decode = [1, 0, 1, 0, 1, 0, 1, 0] - else: - raise ValueError(f"cannot save mode {im.mode}") - - # - # image - - op = io.BytesIO() - - if filter == "ASCIIHexDecode": - ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)]) - elif filter == "CCITTFaxDecode": - im.save( - op, - "TIFF", - compression="group4", - # use a single strip - strip_size=math.ceil(im.width / 8) * im.height, - ) - elif filter == "DCTDecode": - Image.SAVE["JPEG"](im, op, filename) - elif filter == "FlateDecode": - ImageFile._save(im, op, [("zip", (0, 0) + im.size, 0, im.mode)]) - elif filter == "RunLengthDecode": - ImageFile._save(im, op, [("packbits", (0, 0) + im.size, 0, im.mode)]) - else: - raise ValueError(f"unsupported PDF filter ({filter})") - - stream = op.getvalue() - if filter == "CCITTFaxDecode": - stream = stream[8:] - filter = PdfParser.PdfArray([PdfParser.PdfName(filter)]) - else: - filter = PdfParser.PdfName(filter) - - existing_pdf.write_obj( - image_refs[page_number], - stream=stream, - Type=PdfParser.PdfName("XObject"), - Subtype=PdfParser.PdfName("Image"), - Width=width, # * 72.0 / resolution, - Height=height, # * 72.0 / resolution, - Filter=filter, - BitsPerComponent=bits, - Decode=decode, - DecodeParms=params, - ColorSpace=colorspace, - ) + image_ref, procset = _write_image(im, filename, existing_pdf, image_refs) # # page @@ -228,13 +260,13 @@ def _save(im, fp, filename, save_all=False): page_refs[page_number], Resources=PdfParser.PdfDict( ProcSet=[PdfParser.PdfName("PDF"), PdfParser.PdfName(procset)], - XObject=PdfParser.PdfDict(image=image_refs[page_number]), + XObject=PdfParser.PdfDict(image=image_ref), ), MediaBox=[ 0, 0, - width * 72.0 / resolution, - height * 72.0 / resolution, + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, ], Contents=contents_refs[page_number], ) @@ -243,8 +275,8 @@ def _save(im, fp, filename, save_all=False): # page contents page_contents = b"q %f 0 0 %f 0 0 cm /image Do Q\n" % ( - width * 72.0 / resolution, - height * 72.0 / resolution, + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, ) existing_pdf.write_obj(contents_refs[page_number], stream=page_contents) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index fd5cc5a61..014460006 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import calendar import codecs import collections @@ -82,7 +84,7 @@ class IndirectReference( collections.namedtuple("IndirectReferenceTuple", ["object_id", "generation"]) ): def __str__(self): - return "%s %s R" % self + return f"{self.object_id} {self.generation} R" def __bytes__(self): return self.__str__().encode("us-ascii") @@ -103,7 +105,7 @@ class IndirectReference( class IndirectObjectDef(IndirectReference): def __str__(self): - return "%s %s obj" % self + return f"{self.object_id} {self.generation} obj" class XrefTable: @@ -138,9 +140,10 @@ class XrefTable: elif key in self.deleted_entries: generation = self.deleted_entries[key] else: - raise IndexError( + msg = ( "object ID " + str(key) + " cannot be deleted because it doesn't exist" ) + raise IndexError(msg) def __contains__(self, key): return key in self.existing_entries or key in self.new_entries @@ -314,9 +317,8 @@ class PdfStream: expected_length = self.dictionary.Length return zlib.decompress(self.buf, bufsize=int(expected_length)) else: - raise NotImplementedError( - f"stream filter {repr(self.dictionary.Filter)} unknown/unsupported" - ) + msg = f"stream filter {repr(self.dictionary.Filter)} unknown/unsupported" + raise NotImplementedError(msg) def pdf_repr(x): @@ -328,9 +330,7 @@ def pdf_repr(x): return b"null" elif isinstance(x, (PdfName, PdfDict, PdfArray, PdfBinary)): return bytes(x) - elif isinstance(x, int): - return str(x).encode("us-ascii") - elif isinstance(x, float): + elif isinstance(x, (int, float)): return str(x).encode("us-ascii") elif isinstance(x, time.struct_time): return b"(D:" + time.strftime("%Y%m%d%H%M%SZ", x).encode("us-ascii") + b")" @@ -358,7 +358,8 @@ class PdfParser: def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): if buf and f: - raise RuntimeError("specify buf or f or filename, but not both buf and f") + msg = "specify buf or f or filename, but not both buf and f" + raise RuntimeError(msg) self.filename = filename self.buf = buf self.f = f @@ -816,10 +817,10 @@ class PdfParser: try: stream_len = int(result[b"Length"]) except (TypeError, KeyError, ValueError) as e: - raise PdfFormatError( - "bad or missing Length in stream dict (%r)" - % result.get(b"Length", None) - ) from e + msg = "bad or missing Length in stream dict (%r)" % result.get( + b"Length", None + ) + raise PdfFormatError(msg) from e stream_data = data[m.end() : m.end() + stream_len] m = cls.re_stream_end.match(data, m.end() + stream_len) check_format_condition(m, "stream end not found") @@ -873,7 +874,8 @@ class PdfParser: if m: return cls.get_literal_string(data, m.end()) # return None, offset # fallback (only for debugging) - raise PdfFormatError("unrecognized object: " + repr(data[offset : offset + 32])) + msg = "unrecognized object: " + repr(data[offset : offset + 32]) + raise PdfFormatError(msg) re_lit_str_token = re.compile( rb"(\\[nrtbf()\\])|(\\[0-9]{1,3})|(\\(\r\n|\r|\n))|(\r\n|\r|\n)|(\()|(\))" @@ -920,7 +922,8 @@ class PdfParser: result.extend(b")") nesting_depth -= 1 offset = m.end() - raise PdfFormatError("unfinished literal string") + msg = "unfinished literal string" + raise PdfFormatError(msg) re_xref_section_start = re.compile(whitespace_optional + rb"xref" + newline) re_xref_subsection_start = re.compile( @@ -956,14 +959,11 @@ class PdfParser: check_format_condition(m, "xref entry not found") offset = m.end() is_free = m.group(3) == b"f" - generation = int(m.group(2)) if not is_free: + generation = int(m.group(2)) new_entry = (int(m.group(1)), generation) - check_format_condition( - i not in self.xref_table or self.xref_table[i] == new_entry, - "xref entry duplicated (and not identical)", - ) - self.xref_table[i] = new_entry + if i not in self.xref_table: + self.xref_table[i] = new_entry return offset def read_indirect(self, ref, max_nesting=-1): diff --git a/src/PIL/PixarImagePlugin.py b/src/PIL/PixarImagePlugin.py index c4860b6c4..af866feb3 100644 --- a/src/PIL/PixarImagePlugin.py +++ b/src/PIL/PixarImagePlugin.py @@ -18,6 +18,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image, ImageFile from ._binary import i16le as i16 @@ -35,16 +36,15 @@ def _accept(prefix): class PixarImageFile(ImageFile.ImageFile): - format = "PIXAR" format_description = "PIXAR raster image" def _open(self): - # assuming a 4-byte magic label s = self.fp.read(4) if not _accept(s): - raise SyntaxError("not a PIXAR file") + msg = "not a PIXAR file" + raise SyntaxError(msg) # read rest of header s = s + self.fp.read(508) @@ -55,7 +55,7 @@ class PixarImageFile(ImageFile.ImageFile): mode = i16(s, 424), i16(s, 426) if mode == (14, 2): - self.mode = "RGB" + self._mode = "RGB" # FIXME: to be continued... # create tile descriptor (assuming "dumped") diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index b6a3c4cb6..e4ed93880 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -30,6 +30,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import itertools import logging @@ -45,7 +46,6 @@ from ._binary import i32be as i32 from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 -from ._deprecate import deprecate logger = logging.getLogger(__name__) @@ -57,7 +57,7 @@ _MAGIC = b"\211PNG\r\n\032\n" _MODES = { # supported bits/color combinations, and corresponding modes/rawmodes - # Greyscale + # Grayscale (1, 0): ("1", "1"), (2, 0): ("L", "L;2"), (4, 0): ("L", "L;4"), @@ -71,7 +71,7 @@ _MODES = { (2, 3): ("P", "P;2"), (4, 3): ("P", "P;4"), (8, 3): ("P", "P"), - # Greyscale with alpha + # Grayscale with alpha (8, 4): ("LA", "LA"), (16, 4): ("RGBA", "LA;16B"), # LA;16B->LA not yet available # Truecolour with alpha @@ -131,21 +131,12 @@ class Blend(IntEnum): """ -def __getattr__(name): - for enum, prefix in {Disposal: "APNG_DISPOSE_", Blend: "APNG_BLEND_"}.items(): - if name.startswith(prefix): - name = name[len(prefix) :] - if name in enum.__members__: - deprecate(f"{prefix}{name}", 10, f"{enum.__name__}.{name}") - return enum[name] - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - def _safe_zlib_decompress(s): dobj = zlib.decompressobj() plaintext = dobj.decompress(s, MAX_TEXT_CHUNK) if dobj.unconsumed_tail: - raise ValueError("Decompressed Data Too Large") + msg = "Decompressed Data Too Large" + raise ValueError(msg) return plaintext @@ -159,7 +150,6 @@ def _crc32(data, seed=0): class ChunkStream: def __init__(self, fp): - self.fp = fp self.queue = [] @@ -178,7 +168,8 @@ class ChunkStream: if not is_cid(cid): if not ImageFile.LOAD_TRUNCATED_IMAGES: - raise SyntaxError(f"broken PNG file (chunk {repr(cid)})") + msg = f"broken PNG file (chunk {repr(cid)})" + raise SyntaxError(msg) return cid, pos, length @@ -192,7 +183,6 @@ class ChunkStream: self.queue = self.fp = None def push(self, cid, pos, length): - self.queue.append((cid, pos, length)) def call(self, cid, pos, length): @@ -215,13 +205,11 @@ class ChunkStream: crc1 = _crc32(data, _crc32(cid)) crc2 = i32(self.fp.read(4)) if crc1 != crc2: - raise SyntaxError( - f"broken PNG file (bad header checksum in {repr(cid)})" - ) + msg = f"broken PNG file (bad header checksum in {repr(cid)})" + raise SyntaxError(msg) except struct.error as e: - raise SyntaxError( - f"broken PNG file (incomplete checksum in {repr(cid)})" - ) from e + msg = f"broken PNG file (incomplete checksum in {repr(cid)})" + raise SyntaxError(msg) from e def crc_skip(self, cid, data): """Read checksum""" @@ -229,7 +217,6 @@ class ChunkStream: self.fp.read(4) def verify(self, endchunk=b"IEND"): - # Simple approach; just calculate checksum for all remaining # blocks. Must be called directly after open. @@ -239,7 +226,8 @@ class ChunkStream: try: cid, pos, length = self.read() except struct.error as e: - raise OSError("truncated PNG file") from e + msg = "truncated PNG file" + raise OSError(msg) from e if cid == endchunk: break @@ -376,10 +364,11 @@ class PngStream(ChunkStream): def check_text_memory(self, chunklen): self.text_memory += chunklen if self.text_memory > MAX_TEXT_MEMORY: - raise ValueError( + msg = ( "Too much memory used in text chunks: " f"{self.text_memory}>MAX_TEXT_MEMORY" ) + raise ValueError(msg) def save_rewind(self): self.rewind_state = { @@ -394,7 +383,6 @@ class PngStream(ChunkStream): self._seq_num = self.rewind_state["seq_num"] def chunk_iCCP(self, pos, length): - # ICC profile s = ImageFile._safe_read(self.fp, length) # according to PNG spec, the iCCP chunk contains: @@ -407,7 +395,8 @@ class PngStream(ChunkStream): logger.debug("Compression method %s", s[i]) comp_method = s[i] if comp_method != 0: - raise SyntaxError(f"Unknown compression method {comp_method} in iCCP chunk") + msg = f"Unknown compression method {comp_method} in iCCP chunk" + raise SyntaxError(msg) try: icc_profile = _safe_zlib_decompress(s[i + 2 :]) except ValueError: @@ -421,13 +410,13 @@ class PngStream(ChunkStream): return s def chunk_IHDR(self, pos, length): - # image header s = ImageFile._safe_read(self.fp, length) if length < 13: if ImageFile.LOAD_TRUNCATED_IMAGES: return s - raise ValueError("Truncated IHDR chunk") + msg = "Truncated IHDR chunk" + raise ValueError(msg) self.im_size = i32(s, 0), i32(s, 4) try: self.im_mode, self.im_rawmode = _MODES[(s[8], s[9])] @@ -436,11 +425,11 @@ class PngStream(ChunkStream): if s[12]: self.im_info["interlace"] = 1 if s[11]: - raise SyntaxError("unknown filter category") + msg = "unknown filter category" + raise SyntaxError(msg) return s def chunk_IDAT(self, pos, length): - # image data if "bbox" in self.im_info: tile = [("zip", self.im_info["bbox"], pos, self.im_rawmode)] @@ -450,15 +439,14 @@ class PngStream(ChunkStream): tile = [("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] self.im_tile = tile self.im_idat = length - raise EOFError + msg = "image data found" + raise EOFError(msg) def chunk_IEND(self, pos, length): - - # end of PNG image - raise EOFError + msg = "end of PNG image" + raise EOFError(msg) def chunk_PLTE(self, pos, length): - # palette s = ImageFile._safe_read(self.fp, length) if self.im_mode == "P": @@ -466,7 +454,6 @@ class PngStream(ChunkStream): return s def chunk_tRNS(self, pos, length): - # transparency s = ImageFile._safe_read(self.fp, length) if self.im_mode == "P": @@ -512,18 +499,19 @@ class PngStream(ChunkStream): if length < 1: if ImageFile.LOAD_TRUNCATED_IMAGES: return s - raise ValueError("Truncated sRGB chunk") + msg = "Truncated sRGB chunk" + raise ValueError(msg) self.im_info["srgb"] = s[0] return s def chunk_pHYs(self, pos, length): - # pixels per unit s = ImageFile._safe_read(self.fp, length) if length < 9: if ImageFile.LOAD_TRUNCATED_IMAGES: return s - raise ValueError("Truncated pHYs chunk") + msg = "Truncated pHYs chunk" + raise ValueError(msg) px, py = i32(s, 0), i32(s, 4) unit = s[8] if unit == 1: # meter @@ -534,7 +522,6 @@ class PngStream(ChunkStream): return s def chunk_tEXt(self, pos, length): - # text s = ImageFile._safe_read(self.fp, length) try: @@ -554,7 +541,6 @@ class PngStream(ChunkStream): return s def chunk_zTXt(self, pos, length): - # compressed text s = ImageFile._safe_read(self.fp, length) try: @@ -567,7 +553,8 @@ class PngStream(ChunkStream): else: comp_method = 0 if comp_method != 0: - raise SyntaxError(f"Unknown compression method {comp_method} in zTXt chunk") + msg = f"Unknown compression method {comp_method} in zTXt chunk" + raise SyntaxError(msg) try: v = _safe_zlib_decompress(v[1:]) except ValueError: @@ -588,7 +575,6 @@ class PngStream(ChunkStream): return s def chunk_iTXt(self, pos, length): - # international text r = s = ImageFile._safe_read(self.fp, length) try: @@ -639,7 +625,8 @@ class PngStream(ChunkStream): if length < 8: if ImageFile.LOAD_TRUNCATED_IMAGES: return s - raise ValueError("APNG contains truncated acTL chunk") + msg = "APNG contains truncated acTL chunk" + raise ValueError(msg) if self.im_n_frames is not None: self.im_n_frames = None warnings.warn("Invalid APNG, will use default PNG image if possible") @@ -658,18 +645,21 @@ class PngStream(ChunkStream): if length < 26: if ImageFile.LOAD_TRUNCATED_IMAGES: return s - raise ValueError("APNG contains truncated fcTL chunk") + msg = "APNG contains truncated fcTL chunk" + raise ValueError(msg) seq = i32(s) if (self._seq_num is None and seq != 0) or ( self._seq_num is not None and self._seq_num != seq - 1 ): - raise SyntaxError("APNG contains frame sequence errors") + msg = "APNG contains frame sequence errors" + raise SyntaxError(msg) self._seq_num = seq width, height = i32(s, 4), i32(s, 8) px, py = i32(s, 12), i32(s, 16) im_w, im_h = self.im_size if px + width > im_w or py + height > im_h: - raise SyntaxError("APNG contains invalid frames") + msg = "APNG contains invalid frames" + raise SyntaxError(msg) self.im_info["bbox"] = (px, py, px + width, py + height) delay_num, delay_den = i16(s, 20), i16(s, 22) if delay_den == 0: @@ -684,11 +674,13 @@ class PngStream(ChunkStream): if ImageFile.LOAD_TRUNCATED_IMAGES: s = ImageFile._safe_read(self.fp, length) return s - raise ValueError("APNG contains truncated fDAT chunk") + msg = "APNG contains truncated fDAT chunk" + raise ValueError(msg) s = ImageFile._safe_read(self.fp, 4) seq = i32(s) if self._seq_num != seq - 1: - raise SyntaxError("APNG contains frame sequence errors") + msg = "APNG contains frame sequence errors" + raise SyntaxError(msg) self._seq_num = seq return self.chunk_IDAT(pos + 4, length - 4) @@ -706,14 +698,13 @@ def _accept(prefix): class PngImageFile(ImageFile.ImageFile): - format = "PNG" format_description = "Portable network graphics" def _open(self): - if not _accept(self.fp.read(8)): - raise SyntaxError("not a PNG file") + msg = "not a PNG file" + raise SyntaxError(msg) self._fp = self.fp self.__frame = 0 @@ -724,7 +715,6 @@ class PngImageFile(ImageFile.ImageFile): self.png = PngStream(self.fp) while True: - # # get next chunk @@ -749,7 +739,7 @@ class PngImageFile(ImageFile.ImageFile): # difficult to break if things go wrong in the decoder... # (believe me, I've tried ;-) - self.mode = self.png.im_mode + self._mode = self.png.im_mode self._size = self.png.im_size self.info = self.png.im_info self._text = None @@ -797,7 +787,8 @@ class PngImageFile(ImageFile.ImageFile): """Verify PNG file""" if self.fp is None: - raise RuntimeError("verify must be called directly after open") + msg = "verify must be called directly after open" + raise RuntimeError(msg) # back up to beginning of IDAT block self.fp.seek(self.tile[0][2] - 8) @@ -821,7 +812,8 @@ class PngImageFile(ImageFile.ImageFile): self._seek(f) except EOFError as e: self.seek(last_frame) - raise EOFError("no more images in APNG file") from e + msg = "no more images in APNG file" + raise EOFError(msg) from e def _seek(self, frame, rewind=False): if frame == 0: @@ -844,7 +836,8 @@ class PngImageFile(ImageFile.ImageFile): self.__frame = 0 else: if frame != self.__frame + 1: - raise ValueError(f"cannot seek to frame {frame}") + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) # ensure previous frame was loaded self.load() @@ -869,11 +862,13 @@ class PngImageFile(ImageFile.ImageFile): break if cid == b"IEND": - raise EOFError("No more images in APNG file") + msg = "No more images in APNG file" + raise EOFError(msg) if cid == b"fcTL": if frame_start: # there must be at least one fdAT chunk between fcTL chunks - raise SyntaxError("APNG missing frame data") + msg = "APNG missing frame data" + raise SyntaxError(msg) frame_start = True try: @@ -898,7 +893,8 @@ class PngImageFile(ImageFile.ImageFile): self.dispose_extent = self.info.get("bbox") if not self.tile: - raise EOFError + msg = "image not found in APNG frame" + raise EOFError(msg) # 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: @@ -998,9 +994,13 @@ class PngImageFile(ImageFile.ImageFile): else: if self._prev_im and self.blend_op == Blend.OP_OVER: updated = self._crop(self.im, self.dispose_extent) - self._prev_im.paste( - updated, self.dispose_extent, updated.convert("RGBA") - ) + if self.im.mode == "RGB" and "transparency" in self.info: + mask = updated.convert_transparent( + "RGBA", self.info["transparency"] + ) + else: + mask = updated.convert("RGBA") + self._prev_im.paste(updated, self.dispose_extent, mask) self.im = self._prev_im if self.pyaccess: self.pyaccess = None @@ -1045,6 +1045,7 @@ _OUTMODES = { "LA": ("LA", b"\x08\x04"), "I": ("I;16B", b"\x10\x00"), "I;16": ("I;16B", b"\x10\x00"), + "I;16B": ("I;16B", b"\x10\x00"), "P;1": ("P;1", b"\x01\x03"), "P;2": ("P;2", b"\x02\x03"), "P;4": ("P;4", b"\x04\x03"), @@ -1107,10 +1108,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) if im_frame.mode == rawmode: im_frame = im_frame.copy() else: - if rawmode == "P": - im_frame = im_frame.convert(rawmode, palette=im.palette) - else: - im_frame = im_frame.convert(rawmode) + im_frame = im_frame.convert(rawmode) encoderinfo = im.encoderinfo.copy() if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] @@ -1141,21 +1139,27 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) else: base_im = previous["im"] delta = ImageChops.subtract_modulo( - im_frame.convert("RGB"), base_im.convert("RGB") + im_frame.convert("RGBA"), base_im.convert("RGBA") ) - bbox = delta.getbbox() + bbox = delta.getbbox(alpha_only=False) if ( not bbox and prev_disposal == encoderinfo.get("disposal") and prev_blend == encoderinfo.get("blend") ): - if isinstance(duration, (list, tuple)): - previous["encoderinfo"]["duration"] += encoderinfo["duration"] + previous["encoderinfo"]["duration"] += encoderinfo.get( + "duration", duration + ) continue else: bbox = None + if "duration" not in encoderinfo: + encoderinfo["duration"] = duration im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) + if len(im_frames) == 1 and not default_image: + return im_frames[0]["im"] + # animation control chunk( fp, @@ -1166,6 +1170,8 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) # default image IDAT (if it exists) if default_image: + if im.mode != rawmode: + im = im.convert(rawmode) ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) seq_num = 0 @@ -1178,7 +1184,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) im_frame = im_frame.crop(bbox) size = im_frame.size encoderinfo = frame_data["encoderinfo"] - frame_duration = int(round(encoderinfo.get("duration", duration))) + frame_duration = int(round(encoderinfo["duration"])) frame_disposal = encoderinfo.get("disposal", disposal) frame_blend = encoderinfo.get("blend", blend) # frame control @@ -1227,11 +1233,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): ) modes = set() append_images = im.encoderinfo.get("append_images", []) - if default_image: - chain = itertools.chain(append_images) - else: - chain = itertools.chain([im], append_images) - for im_seq in chain: + for im_seq in itertools.chain([im], append_images): for im_frame in ImageSequence.Iterator(im_seq): modes.add(im_frame.mode) for mode in ("RGBA", "RGB", "P"): @@ -1243,7 +1245,6 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): mode = im.mode if mode == "P": - # # attempt to minimize storage requirements for palette images if "bits" in im.encoderinfo: @@ -1277,7 +1278,8 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): try: rawmode, mode = _OUTMODES[mode] except KeyError as e: - raise OSError(f"cannot write mode {mode} as PNG") from e + msg = f"cannot write mode {mode} as PNG" + raise OSError(msg) from e # # write minimal PNG file @@ -1358,7 +1360,8 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): if "transparency" in im.encoderinfo: # don't bother with transparency if it's an RGBA # and it's in the info dict. It's probably just stale. - raise OSError("cannot use transparency for this mode") + msg = "cannot use transparency for this mode" + raise OSError(msg) else: if im.mode == "P" and im.im.getpalettemode() == "RGBA": alpha = im.im.getpalette("RGBA", "A") @@ -1392,8 +1395,10 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk(fp, b"eXIf", exif) if save_all: - _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) - else: + im = _write_multiple_frames( + im, fp, chunk, rawmode, default_image, append_images + ) + if im: ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) if info: diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 1670d9d64..25dbfa5b0 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile from ._binary import i16be as i16 @@ -51,7 +51,6 @@ def _accept(prefix): class PpmImageFile(ImageFile.ImageFile): - format = "PPM" format_description = "Pbmplus image" @@ -84,9 +83,11 @@ class PpmImageFile(ImageFile.ImageFile): token += c if not token: # Token was not even 1 byte - raise ValueError("Reached EOF while reading header") + msg = "Reached EOF while reading header" + raise ValueError(msg) elif len(token) > 10: - raise ValueError(f"Token too long in file header: {token.decode()}") + msg = f"Token too long in file header: {token.decode()}" + raise ValueError(msg) return token def _open(self): @@ -94,7 +95,8 @@ class PpmImageFile(ImageFile.ImageFile): try: mode = MODES[magic_number] except KeyError: - raise SyntaxError("not a PPM file") + msg = "not a PPM file" + raise SyntaxError(msg) if magic_number in (b"P1", b"P4"): self.custom_mimetype = "image/x-portable-bitmap" @@ -114,19 +116,18 @@ class PpmImageFile(ImageFile.ImageFile): elif ix == 1: # token is the y size ysize = token if mode == "1": - self.mode = "1" + self._mode = "1" rawmode = "1;I" break else: - self.mode = rawmode = mode + self._mode = rawmode = mode elif ix == 2: # token is maxval maxval = token if not 0 < maxval < 65536: - raise ValueError( - "maxval must be greater than 0 and less than 65536" - ) + msg = "maxval must be greater than 0 and less than 65536" + raise ValueError(msg) if maxval > 255 and mode == "L": - self.mode = "I" + self._mode = "I" if decoder_name != "ppm_plain": # If maxval matches a bit depth, use the raw decoder directly @@ -208,9 +209,8 @@ class PpmPlainDecoder(ImageFile.PyDecoder): tokens = b"".join(block.split()) for token in tokens: if token not in (48, 49): - raise ValueError( - b"Invalid token for this mode: %s" % bytes([token]) - ) + 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") return data.translate(invert) @@ -237,24 +237,26 @@ class PpmPlainDecoder(ImageFile.PyDecoder): if half_token: block = half_token + block # stitch half_token to new block + half_token = False tokens = block.split() if block and not block[-1:].isspace(): # block might split token half_token = tokens.pop() # save half token for later if len(half_token) > max_len: # prevent buildup of half_token - raise ValueError( + msg = ( b"Token too long found in data: %s" % half_token[: max_len + 1] ) + raise ValueError(msg) for token in tokens: if len(token) > max_len: - raise ValueError( - b"Token too long found in data: %s" % token[: max_len + 1] - ) + msg = b"Token too long found in data: %s" % token[: max_len + 1] + raise ValueError(msg) value = int(token) if value > maxval: - raise ValueError(f"Channel value too large for this mode: {value}") + msg = f"Channel value too large for this mode: {value}" + raise ValueError(msg) value = round(value / maxval * out_max) data += o32(value) if self.mode == "I" else o8(value) if len(data) == total_bytes: # finished! @@ -314,7 +316,8 @@ def _save(im, fp, filename): elif im.mode in ("RGB", "RGBA"): rawmode, head = "RGB", b"P6" else: - raise OSError(f"cannot write mode {im.mode} as PPM") + msg = f"cannot write mode {im.mode} as PPM" + raise OSError(msg) fp.write(head + b"\n%d %d\n" % im.size) if head == b"P6": fp.write(b"255\n") @@ -325,9 +328,6 @@ def _save(im, fp, filename): fp.write(b"65535\n") ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) - # ALTERNATIVE: save via builtin debug function - # im._dump(filename) - # # -------------------------------------------------------------------- diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index bd10e3b95..5cff56413 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -15,6 +15,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io @@ -51,13 +52,11 @@ def _accept(prefix): class PsdImageFile(ImageFile.ImageFile): - format = "PSD" format_description = "Adobe Photoshop" _close_exclusive_fp_after_loading = False def _open(self): - read = self.fp.read # @@ -65,7 +64,8 @@ class PsdImageFile(ImageFile.ImageFile): s = read(26) if not _accept(s) or i16(s, 4) != 1: - raise SyntaxError("not a PSD file") + msg = "not a PSD file" + raise SyntaxError(msg) psd_bits = i16(s, 22) psd_channels = i16(s, 12) @@ -74,12 +74,13 @@ class PsdImageFile(ImageFile.ImageFile): mode, channels = MODES[(psd_mode, psd_bits)] if channels > psd_channels: - raise OSError("not enough channels") + msg = "not enough channels" + raise OSError(msg) if mode == "RGB" and psd_channels == 4: mode = "RGBA" channels = 4 - self.mode = mode + self._mode = mode self._size = i32(s, 18), i32(s, 14) # @@ -146,13 +147,14 @@ class PsdImageFile(ImageFile.ImageFile): # seek to given layer (1..max) try: name, mode, bbox, tile = self.layers[layer - 1] - self.mode = mode + self._mode = mode self.tile = tile self.frame = layer self.fp = self._fp return name, bbox except IndexError as e: - raise EOFError("no such layer") from e + msg = "no such layer" + raise EOFError(msg) from e def tell(self): # return layer number (0=image, 1..max=layers) @@ -170,10 +172,10 @@ def _layerinfo(fp, ct_bytes): # sanity check if ct_bytes < (abs(ct) * 20): - raise SyntaxError("Layer block too short for number of layers requested") + msg = "Layer block too short for number of layers requested" + raise SyntaxError(msg) for _ in range(abs(ct)): - # bounding box y0 = i32(read(4)) x0 = i32(read(4)) @@ -185,6 +187,9 @@ def _layerinfo(fp, ct_bytes): ct_types = i16(read(2)) types = list(range(ct_types)) if len(types) > 4: + fp.seek(len(types) * 6 + 12, io.SEEK_CUR) + size = i32(read(4)) + fp.seek(size, io.SEEK_CUR) continue for _ in types: @@ -234,21 +239,18 @@ def _layerinfo(fp, ct_bytes): layers.append((name, mode, (x0, y0, x1, y1))) # get tiles - i = 0 - for name, mode, bbox in layers: + for i, (name, mode, bbox) in enumerate(layers): tile = [] for m in mode: t = _maketile(fp, m, bbox, 1) if t: tile.extend(t) layers[i] = name, mode, bbox, tile - i += 1 return layers def _maketile(file, mode, bbox, channels): - tile = None read = file.read diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index 039f5ceea..23ff154f6 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -18,10 +18,13 @@ # * Fill.c uses the integer form, but it's still going to use the old # Access.c implementation. # +from __future__ import annotations import logging import sys +from ._deprecate import deprecate + try: from cffi import FFI @@ -47,6 +50,7 @@ logger = logging.getLogger(__name__) class PyAccess: def __init__(self, img, readonly=False): + deprecate("PyAccess", 11) vals = dict(img.im.unsafe_ptrs) self.readonly = readonly self.image8 = ffi.cast("unsigned char **", vals["image8"]) @@ -79,7 +83,8 @@ class PyAccess: :param color: The pixel value. """ if self.readonly: - raise ValueError("Attempt to putpixel a read only image") + msg = "Attempt to putpixel a read only image" + raise ValueError(msg) (x, y) = xy if x < 0: x = self.xsize + x @@ -127,7 +132,8 @@ class PyAccess: def check_xy(self, xy): (x, y) = xy if not (0 <= x < self.xsize and 0 <= y < self.ysize): - raise ValueError("pixel location out of range") + msg = "pixel location out of range" + raise ValueError(msg) return xy @@ -239,7 +245,7 @@ class _PyAccessI16_L(PyAccess): except TypeError: color = min(color[0], 65535) - pixel.l = color & 0xFF # noqa: E741 + pixel.l = color & 0xFF pixel.r = color >> 8 @@ -260,7 +266,7 @@ class _PyAccessI16_B(PyAccess): except Exception: color = min(color[0], 65535) - pixel.l = color >> 8 # noqa: E741 + pixel.l = color >> 8 pixel.r = color & 0xFF @@ -318,6 +324,7 @@ mode_map = { "1": _PyAccess8, "L": _PyAccess8, "P": _PyAccess8, + "I;16N": _PyAccessI16_N, "LA": _PyAccess32_2, "La": _PyAccess32_2, "PA": _PyAccess32_2, diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py new file mode 100644 index 000000000..a7b9d4a9e --- /dev/null +++ b/src/PIL/QoiImagePlugin.py @@ -0,0 +1,106 @@ +# +# The Python Imaging Library. +# +# QOI support for PIL +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os + +from . import Image, ImageFile +from ._binary import i32be as i32 +from ._binary import o8 + + +def _accept(prefix): + return prefix[:4] == b"qoif" + + +class QoiImageFile(ImageFile.ImageFile): + format = "QOI" + format_description = "Quite OK Image" + + def _open(self): + if not _accept(self.fp.read(4)): + msg = "not a QOI file" + raise SyntaxError(msg) + + self._size = tuple(i32(self.fp.read(4)) for i in range(2)) + + 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)] + + +class QoiDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def _add_to_previous_pixels(self, value): + self._previous_pixel = value + + r, g, b, a = value + hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 + self._previously_seen_pixels[hash_value] = value + + def decode(self, buffer): + self._previously_seen_pixels = {} + self._previous_pixel = None + self._add_to_previous_pixels(b"".join(o8(i) for i in (0, 0, 0, 255))) + + data = bytearray() + bands = Image.getmodebands(self.mode) + while len(data) < self.state.xsize * self.state.ysize * bands: + byte = self.fd.read(1)[0] + if byte == 0b11111110: # QOI_OP_RGB + value = self.fd.read(3) + self._previous_pixel[3:] + elif byte == 0b11111111: # QOI_OP_RGBA + value = self.fd.read(4) + else: + op = byte >> 6 + if op == 0: # QOI_OP_INDEX + op_index = byte & 0b00111111 + value = self._previously_seen_pixels.get(op_index, (0, 0, 0, 0)) + elif op == 1: # QOI_OP_DIFF + value = ( + (self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2) + % 256, + (self._previous_pixel[1] + ((byte & 0b00001100) >> 2) - 2) + % 256, + (self._previous_pixel[2] + (byte & 0b00000011) - 2) % 256, + ) + value += (self._previous_pixel[3],) + elif op == 2: # QOI_OP_LUMA + second_byte = self.fd.read(1)[0] + diff_green = (byte & 0b00111111) - 32 + diff_red = ((second_byte & 0b11110000) >> 4) - 8 + diff_blue = (second_byte & 0b00001111) - 8 + + value = tuple( + (self._previous_pixel[i] + diff_green + diff) % 256 + for i, diff in enumerate((diff_red, 0, diff_blue)) + ) + value += (self._previous_pixel[3],) + elif op == 3: # QOI_OP_RUN + run_length = (byte & 0b00111111) + 1 + value = self._previous_pixel + if bands == 3: + value = value[:3] + data += value * run_length + continue + value = b"".join(o8(i) for i in value) + self._add_to_previous_pixels(value) + + if bands == 3: + value = value[:3] + data += value + self.set_as_raw(bytes(data)) + return -1, 0 + + +Image.register_open(QoiImageFile.format, QoiImageFile, _accept) +Image.register_decoder("qoi", QoiDecoder) +Image.register_extension(QoiImageFile.format, ".qoi") diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index f0207bb77..f9a10f610 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -20,7 +20,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os import struct @@ -49,18 +49,17 @@ MODES = { ## # Image plugin for SGI images. class SgiImageFile(ImageFile.ImageFile): - format = "SGI" format_description = "SGI Image File Format" def _open(self): - # HEAD headlen = 512 s = self.fp.read(headlen) if not _accept(s): - raise ValueError("Not an SGI image file") + msg = "Not an SGI image file" + raise ValueError(msg) # compression : verbatim or RLE compression = s[2] @@ -91,10 +90,11 @@ class SgiImageFile(ImageFile.ImageFile): pass if rawmode == "": - raise ValueError("Unsupported SGI image mode") + msg = "Unsupported SGI image mode" + raise ValueError(msg) self._size = xsize, ysize - self.mode = rawmode.split(";")[0] + self._mode = rawmode.split(";")[0] if self.mode == "RGB": self.custom_mimetype = "image/rgb" @@ -123,8 +123,9 @@ class SgiImageFile(ImageFile.ImageFile): def _save(im, fp, filename): - if im.mode != "RGB" and im.mode != "RGBA" and im.mode != "L": - raise ValueError("Unsupported SGI image mode") + if im.mode not in {"RGB", "RGBA", "L"}: + msg = "Unsupported SGI image mode" + raise ValueError(msg) # Get the keyword arguments info = im.encoderinfo @@ -133,7 +134,8 @@ def _save(im, fp, filename): bpc = info.get("bpc", 1) if bpc not in (1, 2): - raise ValueError("Unsupported number of bytes per pixel") + msg = "Unsupported number of bytes per pixel" + raise ValueError(msg) # Flip the image, since the origin of SGI file is the bottom-left corner orientation = -1 @@ -153,14 +155,13 @@ def _save(im, fp, filename): # Z Dimension: Number of channels z = len(im.mode) - if dim == 1 or dim == 2: + if dim in {1, 2}: z = 1 # assert we've got the right number of bands. if len(im.getbands()) != z: - raise ValueError( - f"incorrect number of bands in SGI write: {z} vs {len(im.getbands())}" - ) + msg = f"incorrect number of bands in SGI write: {z} vs {len(im.getbands())}" + raise ValueError(msg) # Minimum Byte value pinmin = 0 diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index acafc320e..86582fb12 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -32,11 +32,13 @@ # Details about the Spider image format: # https://spider.wadsworth.org/spider_doc/spider/docs/image_doc.html # +from __future__ import annotations + import os import struct import sys -from PIL import Image, ImageFile +from . import Image, ImageFile def isInt(f): @@ -91,7 +93,6 @@ def isSpiderImage(filename): class SpiderImageFile(ImageFile.ImageFile): - format = "SPIDER" format_description = "Spider 2D image" _close_exclusive_fp_after_loading = False @@ -110,14 +111,17 @@ class SpiderImageFile(ImageFile.ImageFile): t = struct.unpack("<27f", f) # little-endian hdrlen = isSpiderHeader(t) if hdrlen == 0: - raise SyntaxError("not a valid Spider file") + msg = "not a valid Spider file" + raise SyntaxError(msg) except struct.error as e: - raise SyntaxError("not a valid Spider file") from e + msg = "not a valid Spider file" + raise SyntaxError(msg) from e h = (99,) + t # add 1 value : spider header index starts at 1 iform = int(h[5]) if iform != 1: - raise SyntaxError("not a Spider 2D image") + msg = "not a Spider 2D image" + raise SyntaxError(msg) self._size = int(h[12]), int(h[2]) # size in pixels (width, height) self.istack = int(h[24]) @@ -140,13 +144,14 @@ class SpiderImageFile(ImageFile.ImageFile): offset = hdrlen + self.stkoffset self.istack = 2 # So Image knows it's still a stack else: - raise SyntaxError("inconsistent stack header values") + msg = "inconsistent stack header values" + raise SyntaxError(msg) if self.bigendian: self.rawmode = "F;32BF" else: self.rawmode = "F;32F" - self.mode = "F" + self._mode = "F" self.tile = [("raw", (0, 0) + self.size, offset, (self.rawmode, 0, 1))] self._fp = self.fp # FIXME: hack @@ -168,7 +173,8 @@ class SpiderImageFile(ImageFile.ImageFile): def seek(self, frame): if self.istack == 0: - raise EOFError("attempt to seek in a non-stack file") + msg = "attempt to seek in a non-stack file" + raise EOFError(msg) if not self._seek_check(frame): return self.stkoffset = self.hdrlen + frame * (self.hdrlen + self.imgbytes) @@ -187,7 +193,7 @@ class SpiderImageFile(ImageFile.ImageFile): # returns a ImageTk.PhotoImage object, after rescaling to 0..255 def tkPhotoImage(self): - from PIL import ImageTk + from . import ImageTk return ImageTk.PhotoImage(self.convert2byte(), palette=256) @@ -195,6 +201,7 @@ class SpiderImageFile(ImageFile.ImageFile): # -------------------------------------------------------------------- # Image series + # given a list of filenames, return a list of images def loadImageSeries(filelist=None): """create a list of :py:class:`~PIL.Image.Image` objects for use in a montage""" @@ -233,9 +240,7 @@ def makeSpiderHeader(im): if nvalues < 23: return [] - hdr = [] - for i in range(nvalues): - hdr.append(0.0) + hdr = [0.0] * nvalues # NB these are Fortran indices hdr[1] = 1.0 # nslice (=1 for an image) @@ -260,7 +265,8 @@ def _save(im, fp, filename): hdr = makeSpiderHeader(im) if len(hdr) < 256: - raise OSError("Error creating Spider header") + msg = "Error creating Spider header" + raise OSError(msg) # write the SPIDER header fp.writelines(hdr) @@ -283,7 +289,6 @@ Image.register_open(SpiderImageFile.format, SpiderImageFile) Image.register_save(SpiderImageFile.format, _save_spider) if __name__ == "__main__": - if len(sys.argv) < 2: print("Syntax: python3 SpiderImagePlugin.py [infile] [outfile]") sys.exit() diff --git a/src/PIL/SunImagePlugin.py b/src/PIL/SunImagePlugin.py index c03759a01..11ce3dfef 100644 --- a/src/PIL/SunImagePlugin.py +++ b/src/PIL/SunImagePlugin.py @@ -15,7 +15,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile, ImagePalette from ._binary import i32be as i32 @@ -30,12 +30,10 @@ def _accept(prefix): class SunImageFile(ImageFile.ImageFile): - format = "SUN" format_description = "Sun Raster File" def _open(self): - # The Sun Raster file header is 32 bytes in length # and has the following format: @@ -54,7 +52,8 @@ class SunImageFile(ImageFile.ImageFile): # HEAD s = self.fp.read(32) if not _accept(s): - raise SyntaxError("not an SUN raster file") + msg = "not an SUN raster file" + raise SyntaxError(msg) offset = 32 @@ -67,35 +66,38 @@ class SunImageFile(ImageFile.ImageFile): palette_length = i32(s, 28) if depth == 1: - self.mode, rawmode = "1", "1;I" + self._mode, rawmode = "1", "1;I" elif depth == 4: - self.mode, rawmode = "L", "L;4" + self._mode, rawmode = "L", "L;4" elif depth == 8: - self.mode = rawmode = "L" + self._mode = rawmode = "L" elif depth == 24: if file_type == 3: - self.mode, rawmode = "RGB", "RGB" + self._mode, rawmode = "RGB", "RGB" else: - self.mode, rawmode = "RGB", "BGR" + self._mode, rawmode = "RGB", "BGR" elif depth == 32: if file_type == 3: - self.mode, rawmode = "RGB", "RGBX" + self._mode, rawmode = "RGB", "RGBX" else: - self.mode, rawmode = "RGB", "BGRX" + self._mode, rawmode = "RGB", "BGRX" else: - raise SyntaxError("Unsupported Mode/Bit Depth") + msg = "Unsupported Mode/Bit Depth" + raise SyntaxError(msg) if palette_length: if palette_length > 1024: - raise SyntaxError("Unsupported Color Palette Length") + msg = "Unsupported Color Palette Length" + raise SyntaxError(msg) if palette_type != 1: - raise SyntaxError("Unsupported Palette Type") + msg = "Unsupported Palette Type" + raise SyntaxError(msg) offset = offset + palette_length self.palette = ImagePalette.raw("RGB;L", self.fp.read(palette_length)) if self.mode == "L": - self.mode = "P" + self._mode = "P" rawmode = rawmode.replace("L", "P") # 16 bit boundaries on stride @@ -124,7 +126,8 @@ class SunImageFile(ImageFile.ImageFile): elif file_type == 2: self.tile = [("sun_rle", (0, 0) + self.size, offset, rawmode)] else: - raise SyntaxError("Unsupported Sun Raster file type") + msg = "Unsupported Sun Raster file type" + raise SyntaxError(msg) # diff --git a/src/PIL/TarIO.py b/src/PIL/TarIO.py index d108362fc..26522d93f 100644 --- a/src/PIL/TarIO.py +++ b/src/PIL/TarIO.py @@ -13,6 +13,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io @@ -32,15 +33,16 @@ class TarIO(ContainerIO.ContainerIO): self.fh = open(tarfile, "rb") while True: - s = self.fh.read(512) if len(s) != 512: - raise OSError("unexpected end of tar file") + msg = "unexpected end of tar file" + raise OSError(msg) name = s[:100].decode("utf-8") i = name.find("\0") if i == 0: - raise OSError("cannot find subfile") + msg = "cannot find subfile" + raise OSError(msg) if i > 0: name = name[:i] diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index cd454b755..65c7484f7 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -15,7 +15,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import warnings @@ -46,12 +46,10 @@ MODES = { class TgaImageFile(ImageFile.ImageFile): - format = "TGA" format_description = "Targa" def _open(self): - # process header s = self.fp.read(18) @@ -73,23 +71,25 @@ class TgaImageFile(ImageFile.ImageFile): or self.size[1] <= 0 or depth not in (1, 8, 16, 24, 32) ): - raise SyntaxError("not a TGA file") + msg = "not a TGA file" + raise SyntaxError(msg) # image mode if imagetype in (3, 11): - self.mode = "L" + self._mode = "L" if depth == 1: - self.mode = "1" # ??? + self._mode = "1" # ??? elif depth == 16: - self.mode = "LA" + self._mode = "LA" elif imagetype in (1, 9): - self.mode = "P" + self._mode = "P" elif imagetype in (2, 10): - self.mode = "RGB" + self._mode = "RGB" if depth == 32: - self.mode = "RGBA" + self._mode = "RGBA" else: - raise SyntaxError("unknown TGA mode") + msg = "unknown TGA mode" + raise SyntaxError(msg) # orientation orientation = flags & 0x30 @@ -99,7 +99,8 @@ class TgaImageFile(ImageFile.ImageFile): elif orientation in [0, 0x10]: orientation = -1 else: - raise SyntaxError("unknown TGA orientation") + msg = "unknown TGA orientation" + raise SyntaxError(msg) self.info["orientation"] = orientation @@ -171,11 +172,11 @@ SAVE = { def _save(im, fp, filename): - try: rawmode, bits, colormaptype, imagetype = SAVE[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as TGA") from e + msg = f"cannot write mode {im.mode} as TGA" + raise OSError(msg) from e if "rle" in im.encoderinfo: rle = im.encoderinfo["rle"] diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index aa2a782c2..fc242ca64 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -38,6 +38,8 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import io import itertools import logging @@ -49,7 +51,7 @@ from collections.abc import MutableMapping from fractions import Fraction from numbers import Number, Rational -from . import Image, ImageFile, ImageOps, ImagePalette, TiffTags +from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import o8 @@ -170,6 +172,8 @@ OPEN_INFO = { (MM, 0, (1,), 2, (8,), ()): ("L", "L;IR"), (II, 1, (1,), 1, (8,), ()): ("L", "L"), (MM, 1, (1,), 1, (8,), ()): ("L", "L"), + (II, 1, (2,), 1, (8,), ()): ("L", "L"), + (MM, 1, (2,), 1, (8,), ()): ("L", "L"), (II, 1, (1,), 2, (8,), ()): ("L", "L;R"), (MM, 1, (1,), 2, (8,), ()): ("L", "L;R"), (II, 1, (1,), 1, (12,), ()): ("I;16", "I;12"), @@ -249,6 +253,8 @@ 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"), + (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 # Minimal Baseline TIFF requires YCbCr images to have 3 SamplesPerPixel (II, 6, (1,), 1, (8, 8, 8), ()): ("RGB", "RGBX"), @@ -257,7 +263,7 @@ OPEN_INFO = { (MM, 8, (1,), 1, (8, 8, 8), ()): ("LAB", "LAB"), } -MAX_SAMPLESPERPIXEL = max(len(key_tp[4]) for key_tp in OPEN_INFO.keys()) +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 @@ -425,6 +431,9 @@ class IFDRational(Rational): __ceil__ = _delegate("__ceil__") __floor__ = _delegate("__floor__") __round__ = _delegate("__round__") + # Python >= 3.11 + if hasattr(Fraction, "__int__"): + __int__ = _delegate("__int__") class ImageFileDirectory_v2(MutableMapping): @@ -500,14 +509,16 @@ class ImageFileDirectory_v2(MutableMapping): :param prefix: Override the endianness of the file. """ if not _accept(ifh): - raise SyntaxError(f"not a TIFF file (header {repr(ifh)} not valid)") + msg = f"not a TIFF file (header {repr(ifh)} not valid)" + raise SyntaxError(msg) self._prefix = prefix if prefix is not None else ifh[:2] if self._prefix == MM: self._endian = ">" elif self._prefix == II: self._endian = "<" else: - raise SyntaxError("not a TIFF IFD") + msg = "not a TIFF IFD" + raise SyntaxError(msg) self._bigtiff = ifh[2] == 43 self.group = group self.tagtype = {} @@ -524,7 +535,8 @@ class ImageFileDirectory_v2(MutableMapping): @legacy_api.setter def legacy_api(self, value): - raise Exception("Not allowing setting of legacy api") + msg = "Not allowing setting of legacy api" + raise Exception(msg) def reset(self): self._tags_v1 = {} # will remain empty if legacy_api is false @@ -719,6 +731,8 @@ class ImageFileDirectory_v2(MutableMapping): @_register_writer(1) # Basic type, except for the legacy API. def write_byte(self, data): + if isinstance(data, IFDRational): + data = int(data) if isinstance(data, int): data = bytes((data,)) return data @@ -732,6 +746,8 @@ class ImageFileDirectory_v2(MutableMapping): @_register_writer(2) def write_string(self, value): # remerge of https://github.com/python-pillow/Pillow/pull/1416 + if isinstance(value, int): + value = str(value) if not isinstance(value, bytes): value = value.encode("ascii", "replace") return value + b"\0" @@ -757,6 +773,8 @@ class ImageFileDirectory_v2(MutableMapping): @_register_writer(7) def write_undefined(self, value): + if isinstance(value, int): + value = str(value).encode("ascii", "replace") return value @_register_loader(10, 8) @@ -778,14 +796,14 @@ class ImageFileDirectory_v2(MutableMapping): def _ensure_read(self, fp, size): ret = fp.read(size) if len(ret) != size: - raise OSError( + msg = ( "Corrupt EXIF data. " f"Expecting to read {size} bytes but only got {len(ret)}. " ) + raise OSError(msg) return ret def load(self, fp): - self.reset() self._offset = fp.tell() @@ -809,7 +827,7 @@ class ImageFileDirectory_v2(MutableMapping): try: unit_size, handler = self._load_dispatch[typ] except KeyError: - logger.debug(msg + f" - unsupported type {typ}") + logger.debug("%s - unsupported type %s", msg, typ) continue # ignore unsupported type size = count * unit_size if size > (8 if self._bigtiff else 4): @@ -866,7 +884,7 @@ class ImageFileDirectory_v2(MutableMapping): if tag == STRIPOFFSETS: stripoffsets = len(entries) typ = self.tagtype.get(tag) - logger.debug(f"Tag {tag}, Type: {typ}, Value: {repr(value)}") + 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 == "<": @@ -908,13 +926,14 @@ class ImageFileDirectory_v2(MutableMapping): if stripoffsets is not None: tag, typ, count, value, data = entries[stripoffsets] if data: - raise NotImplementedError("multistrip support not yet implemented") + msg = "multistrip support not yet implemented" + raise NotImplementedError(msg) value = self._pack("L", self._unpack("L", 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(f"{tag} {typ} {count} {repr(value)} {repr(data)}") + logger.debug("%s %s %s %s %s", tag, typ, count, repr(value), repr(data)) result += self._pack("HHL4s", tag, typ, count, value) # -- overwrite here for multi-page -- @@ -929,7 +948,6 @@ class ImageFileDirectory_v2(MutableMapping): return result def save(self, fp): - 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)) @@ -1050,7 +1068,6 @@ ImageFileDirectory = ImageFileDirectory_v1 class TiffImageFile(ImageFile.ImageFile): - format = "TIFF" format_description = "Adobe TIFF" _close_exclusive_fp_after_loading = False @@ -1085,8 +1102,8 @@ class TiffImageFile(ImageFile.ImageFile): self._n_frames = None logger.debug("*** TiffImageFile._open ***") - logger.debug(f"- __first: {self.__first}") - logger.debug(f"- ifh: {repr(ifh)}") # Use repr to avoid str(bytes) + logger.debug("- __first: %s", self.__first) + logger.debug("- ifh: %s", repr(ifh)) # Use repr to avoid str(bytes) # and load the first frame self._seek(0) @@ -1121,14 +1138,18 @@ class TiffImageFile(ImageFile.ImageFile): while len(self._frame_pos) <= frame: if not self.__next: - raise EOFError("no more images in TIFF file") + msg = "no more images in TIFF file" + raise EOFError(msg) logger.debug( - f"Seeking to frame {frame}, on frame {self.__frame}, " - f"__next {self.__next}, location: {self.fp.tell()}" + "Seeking to frame %s, on frame %s, __next %s, location: %s", + frame, + self.__frame, + self.__next, + self.fp.tell(), ) self.fp.seek(self.__next) self._frame_pos.append(self.__next) - logger.debug("Loading tags, location: %s" % self.fp.tell()) + logger.debug("Loading tags, location: %s", self.fp.tell()) self.tag_v2.load(self.fp) if self.tag_v2.next in self._frame_pos: # This IFD has already been processed @@ -1171,7 +1192,7 @@ class TiffImageFile(ImageFile.ImageFile): :returns: Photoshop "Image Resource Blocks" in a dictionary. """ blocks = {} - val = self.tag_v2.get(0x8649) + val = self.tag_v2.get(ExifTags.Base.ImageResources) if val: while val[:4] == b"8BIM": id = i16(val[4:6]) @@ -1189,20 +1210,6 @@ class TiffImageFile(ImageFile.ImageFile): return super().load() def load_end(self): - if self._tile_orientation: - method = { - 2: Image.Transpose.FLIP_LEFT_RIGHT, - 3: Image.Transpose.ROTATE_180, - 4: Image.Transpose.FLIP_TOP_BOTTOM, - 5: Image.Transpose.TRANSPOSE, - 6: Image.Transpose.ROTATE_270, - 7: Image.Transpose.TRANSVERSE, - 8: Image.Transpose.ROTATE_90, - }.get(self._tile_orientation) - if method is not None: - self.im = self.im.transpose(method) - self._size = self.im.size - # 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: @@ -1214,11 +1221,15 @@ class TiffImageFile(ImageFile.ImageFile): # load IFD data from fp before it is closed exif = self.getexif() - for key in TiffTags.TAGS_V2_GROUPS.keys(): + for key in TiffTags.TAGS_V2_GROUPS: if key not in exif: continue exif.get_ifd(key) + ImageOps.exif_transpose(self, in_place=True) + if ExifTags.Base.Orientation in self.tag_v2: + del self.tag_v2[ExifTags.Base.Orientation] + def _load_libtiff(self): """Overload method triggered when we detect a compressed tiff Calls out to libtiff""" @@ -1228,7 +1239,8 @@ class TiffImageFile(ImageFile.ImageFile): self.load_prepare() if not len(self.tile) == 1: - raise OSError("Not exactly one tile") + msg = "Not exactly one tile" + raise OSError(msg) # (self._compression, (extents tuple), # 0, (rawmode, self._compression, fp)) @@ -1238,9 +1250,8 @@ class TiffImageFile(ImageFile.ImageFile): # To be nice on memory footprint, if there's a # file descriptor, use that instead of reading # into a string in python. - # libtiff closes the file descriptor, so pass in a dup. try: - fp = hasattr(self.fp, "fileno") and os.dup(self.fp.fileno()) + fp = hasattr(self.fp, "fileno") and self.fp.fileno() # flush the file descriptor, prevents error on pypy 2.4+ # should also eliminate the need for fp.tell # in _seek @@ -1260,7 +1271,8 @@ class TiffImageFile(ImageFile.ImageFile): try: decoder.setimage(self.im, extents) except ValueError as e: - raise OSError("Couldn't set the image") from e + msg = "Couldn't set the image" + raise OSError(msg) from e close_self_fp = self._exclusive_fp and not self.is_animated if hasattr(self.fp, "getvalue"): @@ -1289,18 +1301,11 @@ class TiffImageFile(ImageFile.ImageFile): # UNDONE -- so much for that buffer size thing. n, err = decoder.decode(self.fp.read()) - if fp: - try: - os.close(fp) - except OSError: - pass - self.tile = [] self.readonly = 0 self.load_end() - # libtiff closed the fp in a, we need to close self.fp, if possible if close_self_fp: self.fp.close() self.fp = None # might be shared @@ -1314,7 +1319,8 @@ class TiffImageFile(ImageFile.ImageFile): """Setup this image object based on current tags""" if 0xBC01 in self.tag_v2: - raise OSError("Windows Media Photo files not yet supported") + msg = "Windows Media Photo files not yet supported" + raise OSError(msg) # extract relevant tags self._compression = COMPRESSION_INFO[self.tag_v2.get(COMPRESSION, 1)] @@ -1331,18 +1337,18 @@ class TiffImageFile(ImageFile.ImageFile): fillorder = self.tag_v2.get(FILLORDER, 1) logger.debug("*** Summary ***") - logger.debug(f"- compression: {self._compression}") - logger.debug(f"- photometric_interpretation: {photo}") - logger.debug(f"- planar_configuration: {self._planar_configuration}") - logger.debug(f"- fill_order: {fillorder}") - logger.debug(f"- YCbCr subsampling: {self.tag.get(YCBCRSUBSAMPLING)}") + logger.debug("- compression: %s", self._compression) + 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)) # size xsize = int(self.tag_v2.get(IMAGEWIDTH)) ysize = int(self.tag_v2.get(IMAGELENGTH)) self._size = xsize, ysize - logger.debug(f"- size: {self.size}") + logger.debug("- size: %s", self.size) sample_format = self.tag_v2.get(SAMPLEFORMAT, (1,)) if len(sample_format) > 1 and max(sample_format) == min(sample_format) == 1: @@ -1373,7 +1379,8 @@ class TiffImageFile(ImageFile.ImageFile): logger.error( "More samples per pixel than can be decoded: %s", samples_per_pixel ) - raise SyntaxError("Invalid value for samples per pixel") + msg = "Invalid value for samples per pixel" + raise SyntaxError(msg) if samples_per_pixel < bps_actual_count: # If a file has more values in bps_tuple than expected, @@ -1385,7 +1392,8 @@ class TiffImageFile(ImageFile.ImageFile): bps_tuple = bps_tuple * samples_per_pixel if len(bps_tuple) != samples_per_pixel: - raise SyntaxError("unknown data organization") + msg = "unknown data organization" + raise SyntaxError(msg) # mode: check photometric interpretation and bits per pixel key = ( @@ -1396,15 +1404,16 @@ class TiffImageFile(ImageFile.ImageFile): bps_tuple, extra_tuple, ) - logger.debug(f"format key: {key}") + logger.debug("format key: %s", key) try: - self.mode, rawmode = OPEN_INFO[key] + self._mode, rawmode = OPEN_INFO[key] except KeyError as e: logger.debug("- unsupported format") - raise SyntaxError("unknown pixel mode") from e + msg = "unknown pixel mode" + raise SyntaxError(msg) from e - logger.debug(f"- raw mode: {rawmode}") - logger.debug(f"- pil mode: {self.mode}") + logger.debug("- raw mode: %s", rawmode) + logger.debug("- pil mode: %s", self.mode) self.info["compression"] = self._compression @@ -1445,11 +1454,11 @@ class TiffImageFile(ImageFile.ImageFile): if fillorder == 2: # Replace fillorder with fillorder=1 key = key[:3] + (1,) + key[4:] - logger.debug(f"format key: {key}") + logger.debug("format key: %s", key) # this should always work, since all the # fillorder==2 modes have a corresponding # fillorder=1 mode - self.mode, rawmode = OPEN_INFO[key] + 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 @@ -1517,7 +1526,8 @@ class TiffImageFile(ImageFile.ImageFile): layer += 1 else: logger.debug("- unsupported data organization") - raise SyntaxError("unknown data organization") + msg = "unknown data organization" + raise SyntaxError(msg) # Fix up info. if ICCPROFILE in self.tag_v2: @@ -1529,8 +1539,6 @@ class TiffImageFile(ImageFile.ImageFile): palette = [o8(b // 256) for b in self.tag_v2[COLORMAP]] self.palette = ImagePalette.raw("RGB;L", b"".join(palette)) - self._tile_orientation = self.tag_v2.get(0x0112) - # # -------------------------------------------------------------------- @@ -1565,11 +1573,11 @@ SAVE_INFO = { def _save(im, fp, filename): - try: rawmode, prefix, photo, format, bits, extra = SAVE_INFO[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as TIFF") from e + msg = f"cannot write mode {im.mode} as TIFF" + raise OSError(msg) from e ifd = ImageFileDirectory_v2(prefix=prefix) @@ -1609,11 +1617,11 @@ def _save(im, fp, filename): info = exif else: info = {} - logger.debug("Tiffinfo Keys: %s" % list(info)) + logger.debug("Tiffinfo Keys: %s", list(info)) if isinstance(info, ImageFileDirectory_v1): info = info.to_v2() for key in info: - if isinstance(info, Image.Exif) and key in TiffTags.TAGS_V2_GROUPS.keys(): + if isinstance(info, Image.Exif) and key in TiffTags.TAGS_V2_GROUPS: ifd[key] = info.get_ifd(key) else: ifd[key] = info.get(key) @@ -1734,15 +1742,15 @@ def _save(im, fp, filename): if "quality" in encoderinfo: quality = encoderinfo["quality"] if not isinstance(quality, int) or quality < 0 or quality > 100: - raise ValueError("Invalid quality setting") + msg = "Invalid quality setting" + raise ValueError(msg) if compression != "jpeg": - raise ValueError( - "quality setting only supported for 'jpeg' compression" - ) + msg = "quality setting only supported for 'jpeg' compression" + raise ValueError(msg) ifd[JPEGQUALITY] = quality logger.debug("Saving using libtiff encoder") - logger.debug("Items: %s" % sorted(ifd.items())) + logger.debug("Items: %s", sorted(ifd.items())) _fp = 0 if hasattr(fp, "fileno"): try: @@ -1788,7 +1796,7 @@ def _save(im, fp, filename): # Custom items are supported for int, float, unicode, string and byte # values. Other types and tuples require a tagtype. if tag not in TiffTags.LIBTIFF_CORE: - if not Image.core.libtiff_support_custom_tags: + if not getattr(Image.core, "libtiff_support_custom_tags", False): continue if tag in ifd.tagtype: @@ -1810,7 +1818,7 @@ def _save(im, fp, filename): if SAMPLEFORMAT in atts and len(atts[SAMPLEFORMAT]) == 1: atts[SAMPLEFORMAT] = atts[SAMPLEFORMAT][0] - logger.debug("Converted items: %s" % sorted(atts.items())) + logger.debug("Converted items: %s", sorted(atts.items())) # libtiff always expects the bytes in native order. # we're storing image byte order. So, if the rawmode @@ -1829,13 +1837,19 @@ def _save(im, fp, filename): e.setimage(im.im, (0, 0) + im.size) while True: # undone, change to self.decodermaxblock: - l, s, d = e.encode(16 * 1024) + errcode, data = e.encode(16 * 1024)[1:] if not _fp: - fp.write(d) - if s: + fp.write(data) + if errcode: break - if s < 0: - raise OSError(f"encoder error {s} when writing image file") + if _fp: + try: + os.close(_fp) + except OSError: + pass + if errcode < 0: + msg = f"encoder error {errcode} when writing image file" + raise OSError(msg) else: for tag in blocklist: @@ -1867,15 +1881,20 @@ class AppendingTiffWriter: 8, # srational 4, # float 8, # double + 4, # ifd + 2, # unicode + 4, # complex + 8, # long8 ] - # StripOffsets = 273 - # FreeOffsets = 288 - # TileOffsets = 324 - # JPEGQTables = 519 - # JPEGDCTables = 520 - # JPEGACTables = 521 - Tags = {273, 288, 324, 519, 520, 521} + Tags = { + 273, # StripOffsets + 288, # FreeOffsets + 324, # TileOffsets + 519, # JPEGQTables + 520, # JPEGDCTables + 521, # JPEGACTables + } def __init__(self, fn, new=False): if hasattr(fn, "read"): @@ -1910,7 +1929,8 @@ class AppendingTiffWriter: elif iimm == b"MM\x00\x2a": self.setEndian(">") else: - raise RuntimeError("Invalid TIFF file header") + msg = "Invalid TIFF file header" + raise RuntimeError(msg) self.skipIFDs() self.goToEnd() @@ -1924,12 +1944,12 @@ class AppendingTiffWriter: iimm = self.f.read(4) if not iimm: - # raise RuntimeError("nothing written into new page") # Make it easy to finish a frame without committing to a new one. return if iimm != self.IIMM: - raise RuntimeError("IIMM of new page doesn't match IIMM of first page") + msg = "IIMM of new page doesn't match IIMM of first page" + raise RuntimeError(msg) ifd_offset = self.readLong() ifd_offset += self.offsetOfNewPage @@ -2003,29 +2023,34 @@ class AppendingTiffWriter: 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: - raise RuntimeError(f"wrote only {bytes_written} bytes but wanted 4") + msg = f"wrote only {bytes_written} bytes but wanted 4" + raise RuntimeError(msg) def rewriteLastShort(self, value): 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: - raise RuntimeError(f"wrote only {bytes_written} bytes but wanted 2") + msg = f"wrote only {bytes_written} bytes but wanted 2" + raise RuntimeError(msg) def rewriteLastLong(self, value): 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: - raise RuntimeError(f"wrote only {bytes_written} bytes but wanted 4") + msg = f"wrote only {bytes_written} bytes but wanted 4" + raise RuntimeError(msg) def writeShort(self, value): bytes_written = self.f.write(struct.pack(self.shortFmt, value)) if bytes_written is not None and bytes_written != 2: - raise RuntimeError(f"wrote only {bytes_written} bytes but wanted 2") + msg = f"wrote only {bytes_written} bytes but wanted 2" + raise RuntimeError(msg) def writeLong(self, value): bytes_written = self.f.write(struct.pack(self.longFmt, value)) if bytes_written is not None and bytes_written != 4: - raise RuntimeError(f"wrote only {bytes_written} bytes but wanted 4") + msg = f"wrote only {bytes_written} bytes but wanted 4" + raise RuntimeError(msg) def close(self): self.finalize() @@ -2068,7 +2093,8 @@ class AppendingTiffWriter: def fixOffsets(self, count, isShort=False, isLong=False): if not isShort and not isLong: - raise RuntimeError("offset is neither short nor long") + msg = "offset is neither short nor long" + raise RuntimeError(msg) for i in range(count): offset = self.readShort() if isShort else self.readLong() @@ -2076,7 +2102,8 @@ class AppendingTiffWriter: if isShort and offset >= 65536: # offset is now too large - we must convert shorts to longs if count != 1: - raise RuntimeError("not implemented") # XXX TODO + 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) diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index 9b5277138..88ff2f4fc 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -16,6 +16,7 @@ # This module provides constants and clear-text names for various # well-known TIFF tags. ## +from __future__ import annotations from collections import namedtuple @@ -56,7 +57,7 @@ def lookup(tag, group=None): ## # Map tag numbers to tag info. # -# id: (Name, Type, Length, enum_values) +# id: (Name, Type, Length[, enum_values]) # # The length here differs from the length in the tiff spec. For # numbers, the tiff spec is for the number of fields returned. We @@ -195,6 +196,7 @@ TAGS_V2 = { 34675: ("ICCProfile", UNDEFINED, 1), 34853: ("GPSInfoIFD", LONG, 1), 36864: ("ExifVersion", UNDEFINED, 1), + 37724: ("ImageSourceData", UNDEFINED, 1), 40965: ("InteroperabilityIFD", LONG, 1), 41730: ("CFAPattern", UNDEFINED, 1), # MPInfo @@ -312,7 +314,7 @@ TAGS = { 34910: "HylaFAX FaxRecvTime", 36864: "ExifVersion", 36867: "DateTimeOriginal", - 36868: "DateTImeDigitized", + 36868: "DateTimeDigitized", 37121: "ComponentsConfiguration", 37122: "CompressedBitsPerPixel", 37724: "ImageSourceData", @@ -426,7 +428,7 @@ def _populate(): TAGS_V2[k] = TagInfo(k, *v) - for group, tags in TAGS_V2_GROUPS.items(): + for tags in TAGS_V2_GROUPS.values(): for k, v in tags.items(): tags[k] = TagInfo(k, *v) @@ -437,22 +439,6 @@ _populate() TYPES = {} -# was: -# TYPES = { -# 1: "byte", -# 2: "ascii", -# 3: "short", -# 4: "long", -# 5: "rational", -# 6: "signed byte", -# 7: "undefined", -# 8: "signed short", -# 9: "signed long", -# 10: "signed rational", -# 11: "float", -# 12: "double", -# } - # # These tags are handled by default in libtiff, without # adding to the custom dictionary. From tif_dir.c, searching for diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py index 0dc695a88..c5bf3e04c 100644 --- a/src/PIL/WalImageFile.py +++ b/src/PIL/WalImageFile.py @@ -22,18 +22,18 @@ and has been tested with a few sample files found using google. is not registered for use with :py:func:`PIL.Image.open()`. To open a WAL file, use the :py:func:`PIL.WalImageFile.open()` function instead. """ +from __future__ import annotations from . import Image, ImageFile from ._binary import i32le as i32 class WalImageFile(ImageFile.ImageFile): - format = "WAL" format_description = "Quake2 Texture" def _open(self): - self.mode = "P" + self._mode = "P" # read header fields header = self.fp.read(32 + 24 + 32 + 12) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 81ed550d9..59556206a 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from io import BytesIO from . import Image, ImageFile @@ -35,7 +37,6 @@ def _accept(prefix): class WebPImageFile(ImageFile.ImageFile): - format = "WEBP" format_description = "WebP image" __loaded = 0 @@ -44,7 +45,7 @@ class WebPImageFile(ImageFile.ImageFile): def _open(self): if not _webp.HAVE_WEBPANIM: # Legacy mode - data, width, height, self.mode, icc_profile, exif = _webp.WebPDecode( + data, width, height, self._mode, icc_profile, exif = _webp.WebPDecode( self.fp.read() ) if icc_profile: @@ -75,7 +76,7 @@ class WebPImageFile(ImageFile.ImageFile): self.info["background"] = (bg_r, bg_g, bg_b, bg_a) self.n_frames = frame_count self.is_animated = self.n_frames > 1 - self.mode = "RGB" if mode == "RGBX" else mode + self._mode = "RGB" if mode == "RGBX" else mode self.rawmode = mode self.tile = [] @@ -130,7 +131,8 @@ class WebPImageFile(ImageFile.ImageFile): if ret is None: self._reset() # Reset just to be safe self.seek(0) - raise EOFError("failed to decode next frame in WebP file") + msg = "failed to decode next frame in WebP file" + raise EOFError(msg) # Compute duration data, timestamp = ret @@ -168,6 +170,9 @@ class WebPImageFile(ImageFile.ImageFile): return super().load() + def load_seek(self, pos): + pass + def tell(self): if not _webp.HAVE_WEBPANIM: return super().tell() @@ -233,9 +238,8 @@ def _save_all(im, fp, filename): or len(background) != 4 or not all(0 <= v < 256 for v in background) ): - raise OSError( - f"Background color is not an RGBA tuple clamped to (0-255): {background}" - ) + msg = f"Background color is not an RGBA tuple clamped to (0-255): {background}" + raise OSError(msg) # Convert to packed uint bg_r, bg_g, bg_b, bg_a = background @@ -286,7 +290,7 @@ def _save_all(im, fp, filename): # Append the frame to the animation encoder enc.add( frame.tobytes("raw", rawmode), - timestamp, + round(timestamp), frame.size[0], frame.size[1], rawmode, @@ -306,12 +310,13 @@ def _save_all(im, fp, filename): im.seek(cur_idx) # Force encoder to flush frames - enc.add(None, timestamp, 0, 0, "", lossless, quality, 0) + enc.add(None, round(timestamp), 0, 0, "", lossless, quality, 0) # Get the final output from the encoder data = enc.assemble(icc_profile, exif, xmp) if data is None: - raise OSError("cannot write file as WebP (encoder returned None)") + msg = "cannot write file as WebP (encoder returned None)" + raise OSError(msg) fp.write(data) @@ -330,12 +335,7 @@ def _save(im, fp, filename): exact = 1 if im.encoderinfo.get("exact") else 0 if im.mode not in _VALID_WEBP_LEGACY_MODES: - alpha = ( - "A" in im.mode - or "a" in im.mode - or (im.mode == "P" and "transparency" in im.info) - ) - im = im.convert("RGBA" if alpha else "RGB") + im = im.convert("RGBA" if im.has_transparency_data else "RGB") data = _webp.WebPEncode( im.tobytes(), @@ -351,7 +351,8 @@ def _save(im, fp, filename): xmp, ) if data is None: - raise OSError("cannot write file as WebP (encoder returned None)") + msg = "cannot write file as WebP (encoder returned None)" + raise OSError(msg) fp.write(data) diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index 2f54cdebb..b5b8c69b1 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -18,6 +18,7 @@ # https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-WMF/[MS-WMF].pdf # http://wvware.sourceforge.net/caolan/index.html # http://wvware.sourceforge.net/caolan/ora-wmf.html +from __future__ import annotations from . import Image, ImageFile from ._binary import i16le as word @@ -42,7 +43,7 @@ if hasattr(Image.core, "drawwmf"): class WmfHandler: def open(self, im): - im.mode = "RGB" + im._mode = "RGB" self.bbox = im.info["wmf_bbox"] def load(self, im): @@ -75,7 +76,6 @@ def _accept(prefix): class WmfStubImageFile(ImageFile.StubImageFile): - format = "WMF" format_description = "Windows Metafile" @@ -86,7 +86,6 @@ class WmfStubImageFile(ImageFile.StubImageFile): s = self.fp.read(80) if s[:6] == b"\xd7\xcd\xc6\x9a\x00\x00": - # placeable windows metafile # get units per inch @@ -109,7 +108,8 @@ class WmfStubImageFile(ImageFile.StubImageFile): # sanity check (standard metafile header) if s[22:26] != b"\x01\x00\t\x00": - raise SyntaxError("Unsupported WMF file format") + msg = "Unsupported WMF file format" + raise SyntaxError(msg) elif s[:4] == b"\x01\x00\x00\x00" and s[40:44] == b" EMF": # enhanced metafile @@ -137,9 +137,10 @@ class WmfStubImageFile(ImageFile.StubImageFile): self.info["dpi"] = xdpi, ydpi else: - raise SyntaxError("Unsupported file format") + msg = "Unsupported file format" + raise SyntaxError(msg) - self.mode = "RGB" + self._mode = "RGB" self._size = size loader = self._load() @@ -162,7 +163,8 @@ class WmfStubImageFile(ImageFile.StubImageFile): def _save(im, fp, filename): if _handler is None or not hasattr(_handler, "save"): - raise OSError("WMF save handler not installed") + msg = "WMF save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/src/PIL/XVThumbImagePlugin.py b/src/PIL/XVThumbImagePlugin.py index 4efedb77e..47ba1c548 100644 --- a/src/PIL/XVThumbImagePlugin.py +++ b/src/PIL/XVThumbImagePlugin.py @@ -16,6 +16,7 @@ # To do: # FIXME: make save work (this requires quantization support) # +from __future__ import annotations from . import Image, ImageFile, ImagePalette from ._binary import o8 @@ -41,15 +42,14 @@ def _accept(prefix): class XVThumbImageFile(ImageFile.ImageFile): - format = "XVThumb" format_description = "XV thumbnail image" def _open(self): - # check magic if not _accept(self.fp.read(6)): - raise SyntaxError("not an XV thumbnail file") + msg = "not an XV thumbnail file" + raise SyntaxError(msg) # Skip to beginning of next line self.fp.readline() @@ -58,14 +58,15 @@ class XVThumbImageFile(ImageFile.ImageFile): while True: s = self.fp.readline() if not s: - raise SyntaxError("Unexpected EOF reading XV thumbnail file") + msg = "Unexpected EOF reading XV thumbnail file" + raise SyntaxError(msg) if s[0] != 35: # ie. when not a comment: '#' break # parse header line (already read) s = s.strip().split() - self.mode = "P" + self._mode = "P" self._size = int(s[0]), int(s[1]) self.palette = ImagePalette.raw("RGB", PALETTE) diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index 59acabeba..566acbfe5 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -18,6 +18,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import re @@ -44,16 +45,15 @@ def _accept(prefix): class XbmImageFile(ImageFile.ImageFile): - format = "XBM" format_description = "X11 Bitmap" def _open(self): - m = xbm_head.match(self.fp.read(512)) if not m: - raise SyntaxError("not a XBM file") + msg = "not a XBM file" + raise SyntaxError(msg) xsize = int(m.group("width")) ysize = int(m.group("height")) @@ -61,16 +61,16 @@ class XbmImageFile(ImageFile.ImageFile): if m.group("hotspot"): self.info["hotspot"] = (int(m.group("xhot")), int(m.group("yhot"))) - self.mode = "1" + self._mode = "1" self._size = xsize, ysize self.tile = [("xbm", (0, 0) + self.size, m.end(), None)] def _save(im, fp, filename): - if im.mode != "1": - raise OSError(f"cannot write mode {im.mode} as XBM") + msg = f"cannot write mode {im.mode} as XBM" + raise OSError(msg) fp.write(f"#define im_width {im.size[0]}\n".encode("ascii")) fp.write(f"#define im_height {im.size[1]}\n".encode("ascii")) diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index aaed2039d..bf73c9bef 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import re @@ -33,20 +33,20 @@ def _accept(prefix): class XpmImageFile(ImageFile.ImageFile): - format = "XPM" format_description = "X11 Pixel Map" def _open(self): - if not _accept(self.fp.read(9)): - raise SyntaxError("not an XPM file") + msg = "not an XPM file" + raise SyntaxError(msg) # skip forward to next string while True: s = self.fp.readline() if not s: - raise SyntaxError("broken XPM file") + msg = "broken XPM file" + raise SyntaxError(msg) m = xpm_head.match(s) if m: break @@ -57,7 +57,8 @@ class XpmImageFile(ImageFile.ImageFile): bpp = int(m.group(4)) if pal > 256 or bpp != 1: - raise ValueError("cannot read this XPM file") + msg = "cannot read this XPM file" + raise ValueError(msg) # # load palette description @@ -65,7 +66,6 @@ class XpmImageFile(ImageFile.ImageFile): palette = [b"\0\0\0"] * 256 for _ in range(pal): - s = self.fp.readline() if s[-2:] == b"\r\n": s = s[:-2] @@ -76,9 +76,7 @@ class XpmImageFile(ImageFile.ImageFile): s = s[2:-2].split() for i in range(0, len(s), 2): - if s[i] == b"c": - # process colour key rgb = s[i + 1] if rgb == b"None": @@ -91,21 +89,21 @@ class XpmImageFile(ImageFile.ImageFile): ) else: # unknown colour - raise ValueError("cannot read this XPM file") + msg = "cannot read this XPM file" + raise ValueError(msg) break else: - # missing colour key - raise ValueError("cannot read this XPM file") + msg = "cannot read this XPM file" + raise ValueError(msg) - self.mode = "P" + self._mode = "P" self.palette = ImagePalette.raw("RGB", b"".join(palette)) self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), ("P", 0, 1))] def load_read(self, bytes): - # # load all image data in one chunk diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index 4908a3fc3..6f8902551 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -1,17 +1,18 @@ """Pillow (Fork of the Python Imaging Library) -Pillow is the friendly PIL fork by Alex Clark and Contributors. +Pillow is the friendly PIL fork by Jeffrey A. Clark (Alex) and contributors. https://github.com/python-pillow/Pillow/ Pillow is forked from PIL 1.1.7. -PIL is the Python Imaging Library by Fredrik Lundh and Contributors. +PIL is the Python Imaging Library by Fredrik Lundh and contributors. Copyright (c) 1999 by Secret Labs AB. Use PIL.__version__ for this Pillow version. ;-) """ +from __future__ import annotations from . import _version @@ -31,7 +32,6 @@ _plugins = [ "DdsImagePlugin", "EpsImagePlugin", "FitsImagePlugin", - "FitsStubImagePlugin", "FliImagePlugin", "FpxImagePlugin", "FtexImagePlugin", @@ -59,6 +59,7 @@ _plugins = [ "PngImagePlugin", "PpmImagePlugin", "PsdImagePlugin", + "QoiImagePlugin", "SgiImagePlugin", "SpiderImagePlugin", "SunImagePlugin", @@ -76,6 +77,10 @@ _plugins = [ class UnidentifiedImageError(OSError): """ Raised in :py:meth:`PIL.Image.open` if an image cannot be opened and identified. + + If a PNG image raises this error, setting :data:`.ImageFile.LOAD_TRUNCATED_IMAGES` + to true may allow the image to be opened after all. The setting will ignore missing + data and checksum failures. """ pass diff --git a/src/PIL/__main__.py b/src/PIL/__main__.py index a05323f93..943789923 100644 --- a/src/PIL/__main__.py +++ b/src/PIL/__main__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .features import pilinfo pilinfo() diff --git a/src/PIL/_binary.py b/src/PIL/_binary.py index a74ee9eb6..c60c9cec1 100644 --- a/src/PIL/_binary.py +++ b/src/PIL/_binary.py @@ -13,7 +13,7 @@ """Binary input/output support routines.""" - +from __future__ import annotations from struct import pack, unpack_from diff --git a/src/PIL/_deprecate.py b/src/PIL/_deprecate.py index 30a8a8971..33a0e07b3 100644 --- a/src/PIL/_deprecate.py +++ b/src/PIL/_deprecate.py @@ -43,14 +43,19 @@ def deprecate( if when is None: removed = "a future version" elif when <= int(__version__.split(".")[0]): - raise RuntimeError(f"{deprecated} {is_} deprecated and should be removed.") - elif when == 10: - removed = "Pillow 10 (2023-07-01)" + msg = f"{deprecated} {is_} deprecated and should be removed." + raise RuntimeError(msg) + elif when == 11: + removed = "Pillow 11 (2024-10-15)" + elif when == 12: + removed = "Pillow 12 (2025-10-15)" else: - raise ValueError(f"Unknown removal version, update {__name__}?") + msg = f"Unknown removal version: {when}. Update {__name__}?" + raise ValueError(msg) if replacement and action: - raise ValueError("Use only one of 'replacement' and 'action'") + msg = "Use only one of 'replacement' and 'action'" + raise ValueError(msg) if replacement: action = f". Use {replacement} instead." diff --git a/src/PIL/_tkinter_finder.py b/src/PIL/_tkinter_finder.py index 5cd7e9b1f..03a6eba44 100644 --- a/src/PIL/_tkinter_finder.py +++ b/src/PIL/_tkinter_finder.py @@ -1,11 +1,11 @@ """ Find compiled module linking to Tcl / Tk libraries """ +from __future__ import annotations + import sys import tkinter from tkinter import _tkinter as tk -from ._deprecate import deprecate - try: if hasattr(sys, "pypy_find_executable"): TKINTER_LIB = tk.tklib_cffi.__file__ @@ -17,7 +17,3 @@ except AttributeError: TKINTER_LIB = None tk_version = str(tkinter.TkVersion) -if tk_version == "8.4": - deprecate( - "Support for Tk/Tcl 8.4", 10, action="Please upgrade to Tk/Tcl 8.5 or newer" - ) diff --git a/src/PIL/_util.py b/src/PIL/_util.py index ba27b7e49..4634d335b 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from pathlib import Path diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 1cc1d0f1c..7d994caf4 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,2 +1,4 @@ # Master version for Pillow -__version__ = "9.4.0.dev0" +from __future__ import annotations + +__version__ = "10.2.0.dev0" diff --git a/src/PIL/features.py b/src/PIL/features.py index 3838568f3..b14d6df13 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import collections import os import sys @@ -24,15 +26,19 @@ def check_module(feature): :returns: ``True`` if available, ``False`` otherwise. :raises ValueError: If the module is not defined in this version of Pillow. """ - if not (feature in modules): - raise ValueError(f"Unknown module {feature}") + if feature not in modules: + msg = f"Unknown module {feature}" + raise ValueError(msg) module, ver = modules[feature] try: __import__(module) return True - except ImportError: + except ModuleNotFoundError: + return False + except ImportError as ex: + warnings.warn(str(ex)) return False @@ -78,7 +84,8 @@ def check_codec(feature): :raises ValueError: If the codec is not defined in this version of Pillow. """ if feature not in codecs: - raise ValueError(f"Unknown codec {feature}") + msg = f"Unknown codec {feature}" + raise ValueError(msg) codec, lib = codecs[feature] @@ -135,14 +142,18 @@ def check_feature(feature): :raises ValueError: If the feature is not defined in this version of Pillow. """ if feature not in features: - raise ValueError(f"Unknown feature {feature}") + msg = f"Unknown feature {feature}" + raise ValueError(msg) module, flag, ver = features[feature] try: imported_module = __import__(module, fromlist=["PIL"]) return getattr(imported_module, flag) - except ImportError: + except ModuleNotFoundError: + return None + except ImportError as ex: + warnings.warn(str(ex)) return None diff --git a/src/Tk/_tkmini.h b/src/Tk/_tkmini.h index 9852fc9d6..68247bc47 100644 --- a/src/Tk/_tkmini.h +++ b/src/Tk/_tkmini.h @@ -119,17 +119,7 @@ typedef struct Tk_PhotoImageBlock { } Tk_PhotoImageBlock; /* Typedefs derived from function signatures in Tk header */ -/* Tk_PhotoPutBlock for Tk <= 8.4 */ -typedef void (*Tk_PhotoPutBlock_84_t)( - Tk_PhotoHandle handle, - Tk_PhotoImageBlock *blockPtr, - int x, - int y, - int width, - int height, - int compRule); -/* Tk_PhotoPutBlock for Tk >= 8.5 */ -typedef int (*Tk_PhotoPutBlock_85_t)( +typedef int (*Tk_PhotoPutBlock_t)( Tcl_Interp *interp, Tk_PhotoHandle handle, Tk_PhotoImageBlock *blockPtr, @@ -138,8 +128,6 @@ typedef int (*Tk_PhotoPutBlock_85_t)( int width, int height, int compRule); -/* Tk_PhotoSetSize for Tk <= 8.4 */ -typedef void (*Tk_PhotoSetSize_84_t)(Tk_PhotoHandle handle, int width, int height); /* Tk_FindPhoto */ typedef Tk_PhotoHandle (*Tk_FindPhoto_t)(Tcl_Interp *interp, const char *imageName); /* Tk_PhotoGetImage */ diff --git a/src/Tk/tkImaging.c b/src/Tk/tkImaging.c index 16b9a2edd..bd3cafe95 100644 --- a/src/Tk/tkImaging.c +++ b/src/Tk/tkImaging.c @@ -48,14 +48,11 @@ * Global vars for Tcl / Tk functions. We load these symbols from the tkinter * extension module or loaded Tcl / Tk libraries at run-time. */ -static int TK_LT_85 = 0; static Tcl_CreateCommand_t TCL_CREATE_COMMAND; static Tcl_AppendResult_t TCL_APPEND_RESULT; static Tk_FindPhoto_t TK_FIND_PHOTO; static Tk_PhotoGetImage_t TK_PHOTO_GET_IMAGE; -static Tk_PhotoPutBlock_84_t TK_PHOTO_PUT_BLOCK_84; -static Tk_PhotoSetSize_84_t TK_PHOTO_SET_SIZE_84; -static Tk_PhotoPutBlock_85_t TK_PHOTO_PUT_BLOCK_85; +static Tk_PhotoPutBlock_t TK_PHOTO_PUT_BLOCK; static Imaging ImagingFind(const char *name) { @@ -130,26 +127,15 @@ PyImagingPhotoPut( block.pitch = im->linesize; block.pixelPtr = (unsigned char *)im->block; - if (TK_LT_85) { /* Tk 8.4 */ - TK_PHOTO_PUT_BLOCK_84( - photo, &block, 0, 0, block.width, block.height, TK_PHOTO_COMPOSITE_SET); - if (strcmp(im->mode, "RGBA") == 0) { - /* Tk workaround: we need apply ToggleComplexAlphaIfNeeded */ - /* (fixed in Tk 8.5a3) */ - TK_PHOTO_SET_SIZE_84(photo, block.width, block.height); - } - } else { - /* Tk >=8.5 */ - TK_PHOTO_PUT_BLOCK_85( - interp, - photo, - &block, - 0, - 0, - block.width, - block.height, - TK_PHOTO_COMPOSITE_SET); - } + TK_PHOTO_PUT_BLOCK( + interp, + photo, + &block, + 0, + 0, + block.width, + block.height, + TK_PHOTO_COMPOSITE_SET); return TCL_OK; } @@ -290,16 +276,7 @@ get_tk(HMODULE hMod) { if ((TK_FIND_PHOTO = (Tk_FindPhoto_t)_dfunc(hMod, "Tk_FindPhoto")) == NULL) { return -1; }; - TK_LT_85 = GetProcAddress(hMod, "Tk_PhotoPutBlock_Panic") == NULL; - /* Tk_PhotoPutBlock_Panic defined as of 8.5.0 */ - if (TK_LT_85) { - TK_PHOTO_PUT_BLOCK_84 = (Tk_PhotoPutBlock_84_t)func; - return ((TK_PHOTO_SET_SIZE_84 = - (Tk_PhotoSetSize_84_t)_dfunc(hMod, "Tk_PhotoSetSize")) == NULL) - ? -1 - : 1; - } - TK_PHOTO_PUT_BLOCK_85 = (Tk_PhotoPutBlock_85_t)func; + TK_PHOTO_PUT_BLOCK = (Tk_PhotoPutBlock_t)func; return 1; } @@ -310,7 +287,7 @@ load_tkinter_funcs(void) { * Return 0 for success, non-zero for failure. */ - HMODULE hMods[1024]; + HMODULE* hMods = NULL; HANDLE hProcess; DWORD cbNeeded; unsigned int i; @@ -327,33 +304,48 @@ load_tkinter_funcs(void) { /* Returns pseudo-handle that does not need to be closed */ hProcess = GetCurrentProcess(); + /* Allocate module handlers array */ + if (!EnumProcessModules(hProcess, NULL, 0, &cbNeeded)) { +#if defined(__CYGWIN__) + PyErr_SetString(PyExc_OSError, "Call to EnumProcessModules failed"); +#else + PyErr_SetFromWindowsErr(0); +#endif + return 1; + } + if (!(hMods = (HMODULE*) malloc(cbNeeded))) { + PyErr_NoMemory(); + return 1; + } + /* Iterate through modules in this process looking for Tcl / Tk names */ - if (EnumProcessModules(hProcess, hMods, sizeof(hMods), &cbNeeded)) { + if (EnumProcessModules(hProcess, hMods, cbNeeded, &cbNeeded)) { for (i = 0; i < (cbNeeded / sizeof(HMODULE)); i++) { if (!found_tcl) { found_tcl = get_tcl(hMods[i]); if (found_tcl == -1) { - return 1; + break; } } if (!found_tk) { found_tk = get_tk(hMods[i]); if (found_tk == -1) { - return 1; + break; } } if (found_tcl && found_tk) { - return 0; + break; } } } + free(hMods); if (found_tcl == 0) { PyErr_SetString(PyExc_RuntimeError, "Could not find Tcl routines"); - } else { + } else if (found_tk == 0) { PyErr_SetString(PyExc_RuntimeError, "Could not find Tk routines"); } - return 1; + return (int) ((found_tcl != 1) || (found_tk != 1)); } #else /* not Windows */ @@ -407,18 +399,9 @@ _func_loader(void *lib) { if ((TK_FIND_PHOTO = (Tk_FindPhoto_t)_dfunc(lib, "Tk_FindPhoto")) == NULL) { return 1; } - /* Tk_PhotoPutBlock_Panic defined as of 8.5.0 */ - TK_LT_85 = (dlsym(lib, "Tk_PhotoPutBlock_Panic") == NULL); - if (TK_LT_85) { - return ( - ((TK_PHOTO_PUT_BLOCK_84 = - (Tk_PhotoPutBlock_84_t)_dfunc(lib, "Tk_PhotoPutBlock")) == NULL) || - ((TK_PHOTO_SET_SIZE_84 = - (Tk_PhotoSetSize_84_t)_dfunc(lib, "Tk_PhotoSetSize")) == NULL)); - } return ( - (TK_PHOTO_PUT_BLOCK_85 = - (Tk_PhotoPutBlock_85_t)_dfunc(lib, "Tk_PhotoPutBlock")) == NULL); + (TK_PHOTO_PUT_BLOCK = + (Tk_PhotoPutBlock_t)_dfunc(lib, "Tk_PhotoPutBlock")) == NULL); } int diff --git a/src/_imaging.c b/src/_imaging.c index aa30f211d..7032852b9 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -251,6 +251,10 @@ PyImaging_GetBuffer(PyObject *buffer, Py_buffer *view) { static const char *must_be_sequence = "argument must be a sequence"; static const char *must_be_two_coordinates = "coordinate list must contain exactly 2 coordinates"; +static const char *incorrectly_ordered_x_coordinate = + "x1 must be greater than or equal to x0"; +static const char *incorrectly_ordered_y_coordinate = + "y1 must be greater than or equal to y0"; static const char *wrong_mode = "unrecognized image mode"; static const char *wrong_raw_mode = "unrecognized raw mode"; static const char *outside_image = "image index out of range"; @@ -471,8 +475,10 @@ getpixel(Imaging im, ImagingAccess access, int x, int y) { case IMAGING_TYPE_FLOAT32: return PyFloat_FromDouble(pixel.f); case IMAGING_TYPE_SPECIAL: - if (strncmp(im->mode, "I;16", 4) == 0) { + if (im->bands == 1) { return PyLong_FromLong(pixel.h); + } else { + return Py_BuildValue("BBB", pixel.b[0], pixel.b[1], pixel.b[2]); } break; } @@ -487,7 +493,7 @@ getink(PyObject *color, Imaging im, char *ink) { int g = 0, b = 0, a = 0; double f = 0; /* Windows 64 bit longs are 32 bits, and 0xFFFFFFFF (white) is a - python long (not int) that raises an overflow error when trying + Python long (not int) that raises an overflow error when trying to return it into a 32 bit C long */ PY_LONG_LONG r = 0; @@ -498,7 +504,8 @@ getink(PyObject *color, Imaging im, char *ink) { be cast to either UINT8 or INT32 */ int rIsInt = 0; - if (PyTuple_Check(color) && PyTuple_GET_SIZE(color) == 1) { + int tupleSize = PyTuple_Check(color) ? PyTuple_GET_SIZE(color) : -1; + if (tupleSize == 1) { color = PyTuple_GetItem(color, 0); } if (im->type == IMAGING_TYPE_UINT8 || im->type == IMAGING_TYPE_INT32 || @@ -509,15 +516,13 @@ getink(PyObject *color, Imaging im, char *ink) { return NULL; } rIsInt = 1; - } else if (im->type == IMAGING_TYPE_UINT8) { - if (!PyTuple_Check(color)) { - PyErr_SetString(PyExc_TypeError, "color must be int or tuple"); - return NULL; - } - } else { + } else if (im->bands == 1) { PyErr_SetString( PyExc_TypeError, "color must be int or single-element tuple"); return NULL; + } else if (tupleSize == -1) { + PyErr_SetString(PyExc_TypeError, "color must be int or tuple"); + return NULL; } } @@ -527,7 +532,7 @@ getink(PyObject *color, Imaging im, char *ink) { if (im->bands == 1) { /* unsigned integer, single layer */ if (rIsInt != 1) { - if (PyTuple_GET_SIZE(color) != 1) { + if (tupleSize != 1) { PyErr_SetString(PyExc_TypeError, "color must be int or single-element tuple"); return NULL; } else if (!PyArg_ParseTuple(color, "L", &r)) { @@ -537,7 +542,6 @@ getink(PyObject *color, Imaging im, char *ink) { ink[0] = (char)CLIP8(r); ink[1] = ink[2] = ink[3] = 0; } else { - a = 255; if (rIsInt) { /* compatibility: ABGR */ a = (UINT8)(r >> 24); @@ -545,7 +549,7 @@ getink(PyObject *color, Imaging im, char *ink) { g = (UINT8)(r >> 8); r = (UINT8)r; } else { - int tupleSize = PyTuple_GET_SIZE(color); + a = 255; if (im->bands == 2) { if (tupleSize != 1 && tupleSize != 2) { PyErr_SetString(PyExc_TypeError, "color must be int, or tuple of one or two elements"); @@ -589,6 +593,41 @@ getink(PyObject *color, Imaging im, char *ink) { ink[1] = (UINT8)(r >> 8); ink[2] = ink[3] = 0; return ink; + } else { + if (rIsInt) { + b = (UINT8)(r >> 16); + g = (UINT8)(r >> 8); + r = (UINT8)r; + } else if (tupleSize != 3) { + PyErr_SetString(PyExc_TypeError, "color must be int, or tuple of one or three elements"); + return NULL; + } else if (!PyArg_ParseTuple(color, "iiL", &b, &g, &r)) { + return NULL; + } + if (!strcmp(im->mode, "BGR;15")) { + UINT16 v = ((((UINT16)r) << 7) & 0x7c00) + + ((((UINT16)g) << 2) & 0x03e0) + + ((((UINT16)b) >> 3) & 0x001f); + + ink[0] = (UINT8)v; + ink[1] = (UINT8)(v >> 8); + ink[2] = ink[3] = 0; + return ink; + } else if (!strcmp(im->mode, "BGR;16")) { + UINT16 v = ((((UINT16)r) << 8) & 0xf800) + + ((((UINT16)g) << 3) & 0x07e0) + + ((((UINT16)b) >> 3) & 0x001f); + ink[0] = (UINT8)v; + ink[1] = (UINT8)(v >> 8); + ink[2] = ink[3] = 0; + return ink; + } else if (!strcmp(im->mode, "BGR;24")) { + ink[0] = (UINT8)b; + ink[1] = (UINT8)g; + ink[2] = (UINT8)r; + ink[3] = 0; + return ink; + } } } @@ -990,12 +1029,11 @@ _crop(ImagingObject *self, PyObject *args) { static PyObject * _expand_image(ImagingObject *self, PyObject *args) { int x, y; - int mode = 0; - if (!PyArg_ParseTuple(args, "ii|i", &x, &y, &mode)) { + if (!PyArg_ParseTuple(args, "ii", &x, &y)) { return NULL; } - return PyImagingNew(ImagingExpand(self->image, x, y, mode)); + return PyImagingNew(ImagingExpand(self->image, x, y)); } static PyObject * @@ -1039,9 +1077,9 @@ _gaussian_blur(ImagingObject *self, PyObject *args) { Imaging imIn; Imaging imOut; - float radius = 0; + float xradius, yradius; int passes = 3; - if (!PyArg_ParseTuple(args, "f|i", &radius, &passes)) { + if (!PyArg_ParseTuple(args, "(ff)|i", &xradius, &yradius, &passes)) { return NULL; } @@ -1051,7 +1089,7 @@ _gaussian_blur(ImagingObject *self, PyObject *args) { return NULL; } - if (!ImagingGaussianBlur(imOut, imIn, radius, passes)) { + if (!ImagingGaussianBlur(imOut, imIn, xradius, yradius, passes)) { ImagingDelete(imOut); return NULL; } @@ -1245,6 +1283,10 @@ _histogram(ImagingObject *self, PyObject *args) { /* Build an integer list containing the histogram */ list = PyList_New(h->bands * 256); + if (list == NULL) { + ImagingHistogramDelete(h); + return NULL; + } for (i = 0; i < h->bands * 256; i++) { PyObject *item; item = PyLong_FromLong(h->histogram[i]); @@ -1532,21 +1574,42 @@ if (PySequence_Check(op)) { \ return NULL; } double value; - if (scale == 1.0 && offset == 0.0) { - /* Clipped data */ + if (image->bands == 1) { + int bigendian = 0; + if (image->type == IMAGING_TYPE_SPECIAL) { + // I;16* + bigendian = strcmp(image->mode, "I;16B") == 0; + } for (i = x = y = 0; i < n; i++) { set_value_to_item(seq, i); - image->image8[y][x] = (UINT8)CLIP8(value); + if (scale != 1.0 || offset != 0.0) { + value = value * scale + offset; + } + if (image->type == IMAGING_TYPE_SPECIAL) { + image->image8[y][x * 2 + (bigendian ? 1 : 0)] = CLIP8((int)value % 256); + image->image8[y][x * 2 + (bigendian ? 0 : 1)] = CLIP8((int)value >> 8); + } else { + image->image8[y][x] = (UINT8)CLIP8(value); + } if (++x >= (int)image->xsize) { x = 0, y++; } } - } else { - /* Scaled and clipped data */ + // BGR;* + int b; for (i = x = y = 0; i < n; i++) { - set_value_to_item(seq, i); - image->image8[y][x] = CLIP8(value * scale + offset); + char ink[4]; + + op = PySequence_Fast_GET_ITEM(seq, i); + if (!op || !getink(op, image, ink)) { + Py_DECREF(seq); + return NULL; + } + /* FIXME: what about scale and offset? */ + for (b = 0; b < image->pixelsize; b++) { + image->image8[y][x * image->pixelsize + b] = ink[b]; + } if (++x >= (int)image->xsize) { x = 0, y++; } @@ -2095,9 +2158,9 @@ _box_blur(ImagingObject *self, PyObject *args) { Imaging imIn; Imaging imOut; - float radius; + float xradius, yradius; int n = 1; - if (!PyArg_ParseTuple(args, "f|i", &radius, &n)) { + if (!PyArg_ParseTuple(args, "(ff)|i", &xradius, &yradius, &n)) { return NULL; } @@ -2107,7 +2170,7 @@ _box_blur(ImagingObject *self, PyObject *args) { return NULL; } - if (!ImagingBoxBlur(imOut, imIn, radius, n)) { + if (!ImagingBoxBlur(imOut, imIn, xradius, yradius, n)) { ImagingDelete(imOut); return NULL; } @@ -2123,9 +2186,15 @@ _isblock(ImagingObject *self) { } static PyObject * -_getbbox(ImagingObject *self) { +_getbbox(ImagingObject *self, PyObject *args) { int bbox[4]; - if (!ImagingGetBBox(self->image, bbox)) { + + int alpha_only = 1; + if (!PyArg_ParseTuple(args, "|i", &alpha_only)) { + return NULL; + } + + if (!ImagingGetBBox(self->image, bbox, alpha_only)) { Py_INCREF(Py_None); return Py_None; } @@ -2154,6 +2223,10 @@ _getcolors(ImagingObject *self, PyObject *args) { Py_INCREF(out); } else { out = PyList_New(colors); + if (out == NULL) { + free(items); + return NULL; + } for (i = 0; i < colors; i++) { ImagingColorItem *v = &items[i]; PyObject *item = Py_BuildValue( @@ -2809,6 +2882,16 @@ _draw_arc(ImagingDrawObject *self, PyObject *args) { free(xy); return NULL; } + if (xy[2] < xy[0]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_x_coordinate); + free(xy); + return NULL; + } + if (xy[3] < xy[1]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_y_coordinate); + free(xy); + return NULL; + } n = ImagingDrawArc( self->image->image, @@ -2890,6 +2973,16 @@ _draw_chord(ImagingDrawObject *self, PyObject *args) { free(xy); return NULL; } + if (xy[2] < xy[0]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_x_coordinate); + free(xy); + return NULL; + } + if (xy[3] < xy[1]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_y_coordinate); + free(xy); + return NULL; + } n = ImagingDrawChord( self->image->image, @@ -2936,6 +3029,16 @@ _draw_ellipse(ImagingDrawObject *self, PyObject *args) { free(xy); return NULL; } + if (xy[2] < xy[0]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_x_coordinate); + free(xy); + return NULL; + } + if (xy[3] < xy[1]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_y_coordinate); + free(xy); + return NULL; + } n = ImagingDrawEllipse( self->image->image, @@ -3105,6 +3208,16 @@ _draw_pieslice(ImagingDrawObject *self, PyObject *args) { free(xy); return NULL; } + if (xy[2] < xy[0]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_x_coordinate); + free(xy); + return NULL; + } + if (xy[3] < xy[1]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_y_coordinate); + free(xy); + return NULL; + } n = ImagingDrawPieslice( self->image->image, @@ -3201,6 +3314,16 @@ _draw_rectangle(ImagingDrawObject *self, PyObject *args) { free(xy); return NULL; } + if (xy[2] < xy[0]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_x_coordinate); + free(xy); + return NULL; + } + if (xy[3] < xy[1]) { + PyErr_SetString(PyExc_ValueError, incorrectly_ordered_y_coordinate); + free(xy); + return NULL; + } n = ImagingDrawRectangle( self->image->image, @@ -3483,7 +3606,7 @@ static struct PyMethodDef methods[] = { {"isblock", (PyCFunction)_isblock, METH_NOARGS}, - {"getbbox", (PyCFunction)_getbbox, METH_NOARGS}, + {"getbbox", (PyCFunction)_getbbox, METH_VARARGS}, {"getcolors", (PyCFunction)_getcolors, METH_VARARGS}, {"getextrema", (PyCFunction)_getextrema, METH_NOARGS}, {"getprojection", (PyCFunction)_getprojection, METH_NOARGS}, @@ -3629,18 +3752,18 @@ static PySequenceMethods image_as_sequence = { static PyTypeObject Imaging_Type = { PyVarObject_HEAD_INIT(NULL, 0) "ImagingCore", /*tp_name*/ - sizeof(ImagingObject), /*tp_size*/ + sizeof(ImagingObject), /*tp_basicsize*/ 0, /*tp_itemsize*/ /* methods */ (destructor)_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - &image_as_sequence, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + &image_as_sequence, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -3664,18 +3787,18 @@ static PyTypeObject Imaging_Type = { static PyTypeObject ImagingFont_Type = { PyVarObject_HEAD_INIT(NULL, 0) "ImagingFont", /*tp_name*/ - sizeof(ImagingFontObject), /*tp_size*/ + sizeof(ImagingFontObject), /*tp_basicsize*/ 0, /*tp_itemsize*/ /* methods */ (destructor)_font_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -3697,18 +3820,18 @@ static PyTypeObject ImagingFont_Type = { static PyTypeObject ImagingDraw_Type = { PyVarObject_HEAD_INIT(NULL, 0) "ImagingDraw", /*tp_name*/ - sizeof(ImagingDrawObject), /*tp_size*/ + sizeof(ImagingDrawObject), /*tp_basicsize*/ 0, /*tp_itemsize*/ /* methods */ (destructor)_draw_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -3739,19 +3862,19 @@ static PyMappingMethods pixel_access_as_mapping = { /* type description */ static PyTypeObject PixelAccess_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "PixelAccess", - sizeof(PixelAccessObject), - 0, + PyVarObject_HEAD_INIT(NULL, 0) "PixelAccess", /*tp_name*/ + sizeof(PixelAccessObject), /*tp_basicsize*/ + 0, /*tp_itemsize*/ /* methods */ (destructor)pixel_access_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - &pixel_access_as_mapping, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + &pixel_access_as_mapping, /*tp_as_mapping*/ 0 /*tp_hash*/ }; @@ -3760,6 +3883,7 @@ static PyTypeObject PixelAccess_Type = { static PyObject * _get_stats(PyObject *self, PyObject *args) { PyObject *d; + PyObject *v; ImagingMemoryArena arena = &ImagingDefaultArena; if (!PyArg_ParseTuple(args, ":get_stats")) { @@ -3770,15 +3894,29 @@ _get_stats(PyObject *self, PyObject *args) { if (!d) { return NULL; } - PyDict_SetItemString(d, "new_count", PyLong_FromLong(arena->stats_new_count)); - PyDict_SetItemString( - d, "allocated_blocks", PyLong_FromLong(arena->stats_allocated_blocks)); - PyDict_SetItemString( - d, "reused_blocks", PyLong_FromLong(arena->stats_reused_blocks)); - PyDict_SetItemString( - d, "reallocated_blocks", PyLong_FromLong(arena->stats_reallocated_blocks)); - PyDict_SetItemString(d, "freed_blocks", PyLong_FromLong(arena->stats_freed_blocks)); - PyDict_SetItemString(d, "blocks_cached", PyLong_FromLong(arena->blocks_cached)); + v = PyLong_FromLong(arena->stats_new_count); + PyDict_SetItemString(d, "new_count", v ? v : Py_None); + Py_XDECREF(v); + + v = PyLong_FromLong(arena->stats_allocated_blocks); + PyDict_SetItemString(d, "allocated_blocks", v ? v : Py_None); + Py_XDECREF(v); + + v = PyLong_FromLong(arena->stats_reused_blocks); + PyDict_SetItemString(d, "reused_blocks", v ? v : Py_None); + Py_XDECREF(v); + + v = PyLong_FromLong(arena->stats_reallocated_blocks); + PyDict_SetItemString(d, "reallocated_blocks", v ? v : Py_None); + Py_XDECREF(v); + + v = PyLong_FromLong(arena->stats_freed_blocks); + PyDict_SetItemString(d, "freed_blocks", v ? v : Py_None); + Py_XDECREF(v); + + v = PyLong_FromLong(arena->blocks_cached); + PyDict_SetItemString(d, "blocks_cached", v ? v : Py_None); + Py_XDECREF(v); return d; } @@ -3990,8 +4128,6 @@ PyImaging_GrabScreenWin32(PyObject *self, PyObject *args); extern PyObject * PyImaging_GrabClipboardWin32(PyObject *self, PyObject *args); extern PyObject * -PyImaging_ListWindowsWin32(PyObject *self, PyObject *args); -extern PyObject * PyImaging_EventLoopWin32(PyObject *self, PyObject *args); extern PyObject * PyImaging_DrawWmf(PyObject *self, PyObject *args); @@ -4076,7 +4212,6 @@ static PyMethodDef functions[] = { {"grabclipboard_win32", (PyCFunction)PyImaging_GrabClipboardWin32, METH_VARARGS}, {"createwindow", (PyCFunction)PyImaging_CreateWindowWin32, METH_VARARGS}, {"eventloop", (PyCFunction)PyImaging_EventLoopWin32, METH_VARARGS}, - {"listwindows", (PyCFunction)PyImaging_ListWindowsWin32, METH_VARARGS}, {"drawwmf", (PyCFunction)PyImaging_DrawWmf, METH_VARARGS}, #endif #ifdef HAVE_XCB @@ -4153,28 +4288,33 @@ setup_module(PyObject *m) { #ifdef HAVE_LIBJPEG { extern const char *ImagingJpegVersion(void); - PyDict_SetItemString( - d, "jpeglib_version", PyUnicode_FromString(ImagingJpegVersion())); + PyObject *v = PyUnicode_FromString(ImagingJpegVersion()); + PyDict_SetItemString(d, "jpeglib_version", v ? v : Py_None); + Py_XDECREF(v); } #endif #ifdef HAVE_OPENJPEG { extern const char *ImagingJpeg2KVersion(void); - PyDict_SetItemString( - d, "jp2klib_version", PyUnicode_FromString(ImagingJpeg2KVersion())); + PyObject *v = PyUnicode_FromString(ImagingJpeg2KVersion()); + PyDict_SetItemString(d, "jp2klib_version", v ? v : Py_None); + Py_XDECREF(v); } #endif PyObject *have_libjpegturbo; #ifdef LIBJPEG_TURBO_VERSION have_libjpegturbo = Py_True; + { #define tostr1(a) #a #define tostr(a) tostr1(a) - PyDict_SetItemString( - d, "libjpeg_turbo_version", PyUnicode_FromString(tostr(LIBJPEG_TURBO_VERSION))); + PyObject *v = PyUnicode_FromString(tostr(LIBJPEG_TURBO_VERSION)); + PyDict_SetItemString(d, "libjpeg_turbo_version", v ? v : Py_None); + Py_XDECREF(v); #undef tostr #undef tostr1 + } #else have_libjpegturbo = Py_False; #endif @@ -4186,8 +4326,9 @@ setup_module(PyObject *m) { have_libimagequant = Py_True; { extern const char *ImagingImageQuantVersion(void); - PyDict_SetItemString( - d, "imagequant_version", PyUnicode_FromString(ImagingImageQuantVersion())); + PyObject *v = PyUnicode_FromString(ImagingImageQuantVersion()); + PyDict_SetItemString(d, "imagequant_version", v ? v : Py_None); + Py_XDECREF(v); } #else have_libimagequant = Py_False; @@ -4204,16 +4345,18 @@ setup_module(PyObject *m) { PyModule_AddIntConstant(m, "FIXED", Z_FIXED); { extern const char *ImagingZipVersion(void); - PyDict_SetItemString( - d, "zlib_version", PyUnicode_FromString(ImagingZipVersion())); + PyObject *v = PyUnicode_FromString(ImagingZipVersion()); + PyDict_SetItemString(d, "zlib_version", v ? v : Py_None); + Py_XDECREF(v); } #endif #ifdef HAVE_LIBTIFF { extern const char *ImagingTiffVersion(void); - PyDict_SetItemString( - d, "libtiff_version", PyUnicode_FromString(ImagingTiffVersion())); + PyObject *v = PyUnicode_FromString(ImagingTiffVersion()); + PyDict_SetItemString(d, "libtiff_version", v ? v : Py_None); + Py_XDECREF(v); // Test for libtiff 4.0 or later, excluding libtiff 3.9.6 and 3.9.7 PyObject *support_custom_tags; @@ -4236,7 +4379,9 @@ setup_module(PyObject *m) { Py_INCREF(have_xcb); PyModule_AddObject(m, "HAVE_XCB", have_xcb); - PyDict_SetItemString(d, "PILLOW_VERSION", PyUnicode_FromString(version)); + PyObject *pillow_version = PyUnicode_FromString(version); + PyDict_SetItemString(d, "PILLOW_VERSION", pillow_version ? pillow_version : Py_None); + Py_XDECREF(pillow_version); return 0; } @@ -4256,6 +4401,7 @@ PyInit__imaging(void) { m = PyModule_Create(&module_def); if (setup_module(m) < 0) { + Py_DECREF(m); return NULL; } diff --git a/src/_imagingcms.c b/src/_imagingcms.c index 9b5a121d7..56d5d73f8 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -116,7 +116,7 @@ cms_profile_open(PyObject *self, PyObject *args) { } static PyObject * -cms_profile_fromstring(PyObject *self, PyObject *args) { +cms_profile_frombytes(PyObject *self, PyObject *args) { cmsHPROFILE hProfile; char *pProfile; @@ -950,6 +950,8 @@ _is_intent_supported(CmsProfileObject *self, int clut) { return Py_None; } PyDict_SetItem(result, id, entry); + Py_DECREF(id); + Py_DECREF(entry); } return result; } @@ -960,8 +962,7 @@ _is_intent_supported(CmsProfileObject *self, int clut) { static PyMethodDef pyCMSdll_methods[] = { {"profile_open", cms_profile_open, METH_VARARGS}, - {"profile_frombytes", cms_profile_fromstring, METH_VARARGS}, - {"profile_fromstring", cms_profile_fromstring, METH_VARARGS}, + {"profile_frombytes", cms_profile_frombytes, METH_VARARGS}, {"profile_tobytes", cms_profile_tobytes, METH_VARARGS}, /* profile and transform functions */ @@ -1420,19 +1421,19 @@ static struct PyGetSetDef cms_profile_getsetters[] = { {NULL}}; static PyTypeObject CmsProfile_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "PIL._imagingcms.CmsProfile", /*tp_name */ - sizeof(CmsProfileObject), - 0, /*tp_basicsize, tp_itemsize */ + PyVarObject_HEAD_INIT(NULL, 0) "PIL._imagingcms.CmsProfile", /*tp_name*/ + sizeof(CmsProfileObject), /*tp_basicsize*/ + 0, /*tp_itemsize*/ /* methods */ (destructor)cms_profile_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -1472,19 +1473,19 @@ static struct PyGetSetDef cms_transform_getsetters[] = { {NULL}}; static PyTypeObject CmsTransform_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "CmsTransform", - sizeof(CmsTransformObject), - 0, + PyVarObject_HEAD_INIT(NULL, 0) "CmsTransform", /*tp_name*/ + sizeof(CmsTransformObject), /*tp_basicsize*/ + 0, /*tp_itemsize*/ /* methods */ (destructor)cms_transform_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -1532,7 +1533,8 @@ setup_module(PyObject *m) { } else { v = PyUnicode_FromFormat("%d.%d", vn / 1000, (vn / 10) % 100); } - PyDict_SetItemString(d, "littlecms_version", v); + PyDict_SetItemString(d, "littlecms_version", v ? v : Py_None); + Py_XDECREF(v); return 0; } diff --git a/src/_imagingft.c b/src/_imagingft.c index b52d6353e..68c66ac2c 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -33,12 +33,6 @@ #include FT_COLOR_H #endif -#define KEEP_PY_UNICODE - -#if !defined(FT_LOAD_TARGET_MONO) -#define FT_LOAD_TARGET_MONO FT_LOAD_MONOCHROME -#endif - /* -------------------------------------------------------------------- */ /* error table */ @@ -122,7 +116,9 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { int error = 0; char *filename = NULL; - Py_ssize_t size; + float size; + FT_Size_RequestRec req; + FT_Long width; Py_ssize_t index = 0; Py_ssize_t layout_engine = 0; unsigned char *encoding; @@ -136,10 +132,31 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { return NULL; } +#if PY_MAJOR_VERSION > 3 || PY_MINOR_VERSION > 11 + PyConfig config; + PyConfig_InitPythonConfig(&config); if (!PyArg_ParseTupleAndKeywords( args, kw, - "etn|nsy#n", + "etf|nsy#n", + kwlist, + config.filesystem_encoding, + &filename, + &size, + &index, + &encoding, + &font_bytes, + &font_bytes_size, + &layout_engine)) { + PyConfig_Clear(&config); + return NULL; + } + PyConfig_Clear(&config); +#else + if (!PyArg_ParseTupleAndKeywords( + args, + kw, + "etf|nsy#n", kwlist, Py_FileSystemDefaultEncoding, &filename, @@ -151,6 +168,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { &layout_engine)) { return NULL; } +#endif self = PyObject_New(FontObject, &Font_Type); if (!self) { @@ -171,7 +189,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { /* Don't free this before FT_Done_Face */ self->font_bytes = PyMem_Malloc(font_bytes_size); if (!self->font_bytes) { - error = 65; // Out of Memory in Freetype. + error = FT_Err_Out_Of_Memory; } if (!error) { memcpy(self->font_bytes, font_bytes, (size_t)font_bytes_size); @@ -185,7 +203,13 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { } if (!error) { - error = FT_Set_Pixel_Sizes(self->face, 0, size); + width = size * 64; + req.type = FT_SIZE_REQUEST_TYPE_NOMINAL; + req.width = width; + req.height = width; + req.horiResolution = 0; + req.vertResolution = 0; + error = FT_Request_Size(self->face, &req); } if (!error && encoding && strlen((char *)encoding) == 4) { @@ -230,9 +254,7 @@ text_layout_raqm( const char *dir, PyObject *features, const char *lang, - GlyphInfo **glyph_info, - int mask, - int color) { + GlyphInfo **glyph_info) { size_t i = 0, count = 0, start = 0; raqm_t *rq; raqm_glyph_t *glyphs = NULL; @@ -420,11 +442,9 @@ text_layout_fallback( if (mask) { load_flags |= FT_LOAD_TARGET_MONO; } -#ifdef FT_LOAD_COLOR if (color) { load_flags |= FT_LOAD_COLOR; } -#endif for (i = 0; font_getchar(string, i, &ch); i++) { (*glyph_info)[i].index = FT_Get_Char_Index(self->face, ch); error = FT_Load_Glyph(self->face, (*glyph_info)[i].index, load_flags); @@ -471,7 +491,7 @@ text_layout( #ifdef HAVE_RAQM if (have_raqm && self->layout_engine == LAYOUT_RAQM) { count = text_layout_raqm( - string, self, dir, features, lang, glyph_info, mask, color); + string, self, dir, features, lang, glyph_info); } else #endif { @@ -529,75 +549,25 @@ font_getlength(FontObject *self, PyObject *args) { return PyLong_FromLong(length); } -static PyObject * -font_getsize(FontObject *self, PyObject *args) { +static int +bounding_box_and_anchors(FT_Face face, const char *anchor, int horizontal_dir, GlyphInfo *glyph_info, size_t count, int load_flags, int *width, int *height, int *x_offset, int *y_offset) { int position; /* pen position along primary axis, in 26.6 precision */ int advanced; /* pen position along primary axis, in pixels */ int px, py; /* position of current glyph, in pixels */ int x_min, x_max, y_min, y_max; /* text bounding box, in pixels */ int x_anchor, y_anchor; /* offset of point drawn at (0, 0), in pixels */ - int load_flags; /* FreeType load_flags parameter */ int error; - FT_Face face; FT_Glyph glyph; - FT_BBox bbox; /* glyph bounding box */ - GlyphInfo *glyph_info = NULL; /* computed text layout */ - size_t i, count; /* glyph_info index and length */ - int horizontal_dir; /* is primary axis horizontal? */ - int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ - int color = 0; /* is FT_LOAD_COLOR enabled? */ - const char *mode = NULL; - const char *dir = NULL; - const char *lang = NULL; - const char *anchor = NULL; - PyObject *features = Py_None; - PyObject *string; - - /* calculate size and bearing for a given string */ - - if (!PyArg_ParseTuple( - args, "O|zzOzz:getsize", &string, &mode, &dir, &features, &lang, &anchor)) { - return NULL; - } - - horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; - - mask = mode && strcmp(mode, "1") == 0; - color = mode && strcmp(mode, "RGBA") == 0; - - if (anchor == NULL) { - anchor = horizontal_dir ? "la" : "lt"; - } - if (strlen(anchor) != 2) { - goto bad_anchor; - } - - count = text_layout(string, self, dir, features, lang, &glyph_info, mask, color); - if (PyErr_Occurred()) { - return NULL; - } - - load_flags = FT_LOAD_DEFAULT; - if (mask) { - load_flags |= FT_LOAD_TARGET_MONO; - } -#ifdef FT_LOAD_COLOR - if (color) { - load_flags |= FT_LOAD_COLOR; - } -#endif - + FT_BBox bbox; /* glyph bounding box */ + size_t i; /* glyph_info index */ /* * text bounds are given by: * - bounding boxes of individual glyphs * - pen line, i.e. 0 to `advanced` along primary axis * this means point (0, 0) is part of the text bounding box */ - face = NULL; position = x_min = x_max = y_min = y_max = 0; for (i = 0; i < count; i++) { - face = self->face; - if (horizontal_dir) { px = PIXEL(position + glyph_info[i].x_offset); py = PIXEL(glyph_info[i].y_offset); @@ -620,12 +590,14 @@ font_getsize(FontObject *self, PyObject *args) { error = FT_Load_Glyph(face, glyph_info[i].index, load_flags); if (error) { - return geterror(error); + geterror(error); + return 1; } error = FT_Get_Glyph(face->glyph, &glyph); if (error) { - return geterror(error); + geterror(error); + return 1; } FT_Glyph_Get_CBox(glyph, FT_GLYPH_BBOX_PIXELS, &bbox); @@ -649,13 +621,15 @@ font_getsize(FontObject *self, PyObject *args) { FT_Done_Glyph(glyph); } - if (glyph_info) { - PyMem_Free(glyph_info); - glyph_info = NULL; + if (anchor == NULL) { + anchor = horizontal_dir ? "la" : "lt"; + } + if (strlen(anchor) != 2) { + goto bad_anchor; } x_anchor = y_anchor = 0; - if (face) { + if (count) { if (horizontal_dir) { switch (anchor[0]) { case 'l': // left @@ -673,15 +647,15 @@ font_getsize(FontObject *self, PyObject *args) { } switch (anchor[1]) { case 'a': // ascender - y_anchor = PIXEL(self->face->size->metrics.ascender); + y_anchor = PIXEL(face->size->metrics.ascender); break; case 't': // top y_anchor = y_max; break; case 'm': // middle (ascender + descender) / 2 y_anchor = PIXEL( - (self->face->size->metrics.ascender + - self->face->size->metrics.descender) / + (face->size->metrics.ascender + + face->size->metrics.descender) / 2); break; case 's': // horizontal baseline @@ -691,7 +665,7 @@ font_getsize(FontObject *self, PyObject *args) { y_anchor = y_min; break; case 'd': // descender - y_anchor = PIXEL(self->face->size->metrics.descender); + y_anchor = PIXEL(face->size->metrics.descender); break; default: goto bad_anchor; @@ -731,17 +705,74 @@ font_getsize(FontObject *self, PyObject *args) { } } } - - return Py_BuildValue( - "(ii)(ii)", - (x_max - x_min), - (y_max - y_min), - (-x_anchor + x_min), - -(-y_anchor + y_max)); + *width = x_max - x_min; + *height = y_max - y_min; + *x_offset = -x_anchor + x_min; + *y_offset = -(-y_anchor + y_max); + return 0; bad_anchor: PyErr_Format(PyExc_ValueError, "bad anchor specified: %s", anchor); - return NULL; + return 1; +} + +static PyObject * +font_getsize(FontObject *self, PyObject *args) { + int width, height, x_offset, y_offset; + int load_flags; /* FreeType load_flags parameter */ + int error; + GlyphInfo *glyph_info = NULL; /* computed text layout */ + size_t count; /* glyph_info length */ + int horizontal_dir; /* is primary axis horizontal? */ + int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ + int color = 0; /* is FT_LOAD_COLOR enabled? */ + const char *mode = NULL; + const char *dir = NULL; + const char *lang = NULL; + const char *anchor = NULL; + PyObject *features = Py_None; + PyObject *string; + + /* calculate size and bearing for a given string */ + + if (!PyArg_ParseTuple( + args, "O|zzOzz:getsize", &string, &mode, &dir, &features, &lang, &anchor)) { + return NULL; + } + + horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; + + mask = mode && strcmp(mode, "1") == 0; + color = mode && strcmp(mode, "RGBA") == 0; + + count = text_layout(string, self, dir, features, lang, &glyph_info, mask, color); + if (PyErr_Occurred()) { + return NULL; + } + + load_flags = FT_LOAD_DEFAULT; + if (mask) { + load_flags |= FT_LOAD_TARGET_MONO; + } + if (color) { + load_flags |= FT_LOAD_COLOR; + } + + error = bounding_box_and_anchors(self->face, anchor, horizontal_dir, glyph_info, count, load_flags, &width, &height, &x_offset, &y_offset); + if (glyph_info) { + PyMem_Free(glyph_info); + glyph_info = NULL; + } + if (error) { + return NULL; + } + + return Py_BuildValue( + "(ii)(ii)", + width, + height, + x_offset, + y_offset); } static PyObject * @@ -765,6 +796,7 @@ font_render(FontObject *self, PyObject *args) { unsigned int bitmap_y; /* glyph bitmap y index */ unsigned char *source; /* glyph bitmap source buffer */ 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? */ @@ -775,24 +807,29 @@ font_render(FontObject *self, PyObject *args) { const char *mode = NULL; const char *dir = NULL; const char *lang = NULL; + const char *anchor = NULL; PyObject *features = Py_None; PyObject *string; + PyObject *fill; float x_start = 0; float y_start = 0; + int width, height, x_offset, y_offset; + int horizontal_dir; /* is primary axis horizontal? */ /* render string into given buffer (the buffer *must* have the right size, or this will crash) */ if (!PyArg_ParseTuple( args, - "On|zzOziLff:render", + "OO|zzOzizLffO:render", &string, - &id, + &fill, &mode, &dir, &features, &lang, &stroke_width, + &anchor, &foreground_ink_long, &x_start, &y_start)) { @@ -821,14 +858,50 @@ font_render(FontObject *self, PyObject *args) { if (PyErr_Occurred()) { return NULL; } - if (count == 0) { - Py_RETURN_NONE; + + load_flags = stroke_width ? FT_LOAD_NO_BITMAP : FT_LOAD_DEFAULT; + if (mask) { + load_flags |= FT_LOAD_TARGET_MONO; + } + if (color) { + load_flags |= FT_LOAD_COLOR; + } + + horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; + + error = bounding_box_and_anchors(self->face, anchor, horizontal_dir, glyph_info, count, load_flags, &width, &height, &x_offset, &y_offset); + if (error) { + PyMem_Del(glyph_info); + return NULL; + } + + width += stroke_width * 2 + ceil(x_start); + height += stroke_width * 2 + ceil(y_start); + image = PyObject_CallFunction(fill, "ii", width, height); + if (image == Py_None) { + PyMem_Del(glyph_info); + return Py_BuildValue("ii", 0, 0); + } else 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; + + x_offset -= stroke_width; + y_offset -= stroke_width; + if (count == 0 || width == 0 || height == 0) { + PyMem_Del(glyph_info); + return Py_BuildValue("ii", x_offset, y_offset); } if (stroke_width) { error = FT_Stroker_New(library, &stroker); if (error) { - return geterror(error); + geterror(error); + goto glyph_error; } FT_Stroker_Set( @@ -839,17 +912,6 @@ font_render(FontObject *self, PyObject *args) { 0); } - im = (Imaging)id; - load_flags = stroke_width ? FT_LOAD_NO_BITMAP : FT_LOAD_DEFAULT; - if (mask) { - load_flags |= FT_LOAD_TARGET_MONO; - } -#ifdef FT_LOAD_COLOR - if (color) { - load_flags |= FT_LOAD_COLOR; - } -#endif - /* * calculate x_min and y_max * must match font_getsize or there may be clipping! @@ -862,7 +924,8 @@ font_render(FontObject *self, PyObject *args) { error = FT_Load_Glyph(self->face, glyph_info[i].index, load_flags | FT_LOAD_RENDER); if (error) { - return geterror(error); + geterror(error); + goto glyph_error; } glyph_slot = self->face->glyph; @@ -893,7 +956,8 @@ font_render(FontObject *self, PyObject *args) { error = FT_Load_Glyph(self->face, glyph_info[i].index, load_flags); if (error) { - return geterror(error); + geterror(error); + goto glyph_error; } glyph_slot = self->face->glyph; @@ -907,7 +971,8 @@ font_render(FontObject *self, PyObject *args) { error = FT_Glyph_To_Bitmap(&glyph, FT_RENDER_MODE_NORMAL, &origin, 1); } if (error) { - return geterror(error); + geterror(error); + goto glyph_error; } bitmap_glyph = (FT_BitmapGlyph)glyph; @@ -921,6 +986,12 @@ font_render(FontObject *self, PyObject *args) { yy = -(py + glyph_slot->bitmap_top); } + // Null buffer, is dereferenced in FT_Bitmap_Convert + if (!bitmap.buffer && bitmap.rows) { + PyErr_SetString(PyExc_OSError, "Bitmap missing for glyph"); + goto glyph_error; + } + /* convert non-8bpp bitmaps */ switch (bitmap.pixel_mode) { case FT_PIXEL_MODE_MONO: @@ -952,13 +1023,11 @@ font_render(FontObject *self, PyObject *args) { /* bitmap is now FT_PIXEL_MODE_GRAY, fall through */ case FT_PIXEL_MODE_GRAY: break; -#ifdef FT_LOAD_COLOR case FT_PIXEL_MODE_BGRA: if (color) { break; } /* we didn't ask for color, fall through to default */ -#endif default: PyErr_SetString(PyExc_OSError, "unsupported bitmap pixel mode"); goto glyph_error; @@ -980,8 +1049,8 @@ font_render(FontObject *self, PyObject *args) { if (yy >= 0 && yy < im->ysize) { /* blend this glyph into the buffer */ int k; - unsigned char v; unsigned char *target; + unsigned int tmp; if (color) { /* target[RGB] returns the color, target[A] returns the mask */ /* target bands get split again in ImageDraw.text */ @@ -989,40 +1058,58 @@ font_render(FontObject *self, PyObject *args) { } else { target = im->image8[yy] + xx; } -#ifdef FT_LOAD_COLOR if (color && bitmap.pixel_mode == FT_PIXEL_MODE_BGRA) { /* paste color glyph */ for (k = x0; k < x1; k++) { - if (target[k * 4 + 3] < source[k * 4 + 3]) { - /* unpremultiply BGRa to RGBA */ - target[k * 4 + 0] = CLIP8( - (255 * (int)source[k * 4 + 2]) / source[k * 4 + 3]); - target[k * 4 + 1] = CLIP8( - (255 * (int)source[k * 4 + 1]) / source[k * 4 + 3]); - target[k * 4 + 2] = CLIP8( - (255 * (int)source[k * 4 + 0]) / source[k * 4 + 3]); - target[k * 4 + 3] = source[k * 4 + 3]; + unsigned int src_alpha = source[k * 4 + 3]; + + /* paste only if source has data */ + if (src_alpha > 0) { + /* unpremultiply BGRa */ + int src_red = CLIP8((255 * (int)source[k * 4 + 2]) / src_alpha); + int src_green = CLIP8((255 * (int)source[k * 4 + 1]) / src_alpha); + int src_blue = CLIP8((255 * (int)source[k * 4 + 0]) / src_alpha); + + /* blend required if target has data */ + if (target[k * 4 + 3] > 0) { + /* blend RGBA colors */ + target[k * 4 + 0] = BLEND(src_alpha, target[k * 4 + 0], src_red, tmp); + target[k * 4 + 1] = BLEND(src_alpha, target[k * 4 + 1], src_green, tmp); + target[k * 4 + 2] = BLEND(src_alpha, target[k * 4 + 2], src_blue, tmp); + target[k * 4 + 3] = CLIP8(src_alpha + MULDIV255(target[k * 4 + 3], (255 - src_alpha), tmp)); + } else { + /* paste unpremultiplied RGBA values */ + target[k * 4 + 0] = src_red; + target[k * 4 + 1] = src_green; + target[k * 4 + 2] = src_blue; + target[k * 4 + 3] = src_alpha; + } } } - } else -#endif - if (bitmap.pixel_mode == FT_PIXEL_MODE_GRAY) { + } else if (bitmap.pixel_mode == FT_PIXEL_MODE_GRAY) { if (color) { unsigned char *ink = (unsigned char *)&foreground_ink; for (k = x0; k < x1; k++) { - v = source[k] * convert_scale; - if (target[k * 4 + 3] < v) { - target[k * 4 + 0] = ink[0]; - target[k * 4 + 1] = ink[1]; - target[k * 4 + 2] = ink[2]; - target[k * 4 + 3] = v; + unsigned int src_alpha = source[k] * convert_scale; + if (src_alpha > 0) { + if (target[k * 4 + 3] > 0) { + target[k * 4 + 0] = BLEND(src_alpha, target[k * 4 + 0], ink[0], tmp); + target[k * 4 + 1] = BLEND(src_alpha, target[k * 4 + 1], ink[1], tmp); + target[k * 4 + 2] = BLEND(src_alpha, target[k * 4 + 2], ink[2], tmp); + target[k * 4 + 3] = CLIP8(src_alpha + MULDIV255(target[k * 4 + 3], (255 - src_alpha), tmp)); + } else { + target[k * 4 + 0] = ink[0]; + target[k * 4 + 1] = ink[1]; + target[k * 4 + 2] = ink[2]; + target[k * 4 + 3] = src_alpha; + } } } } else { for (k = x0; k < x1; k++) { - v = source[k] * convert_scale; - if (target[k] < v) { - target[k] = v; + unsigned int src_alpha = source[k] * convert_scale; + if (src_alpha > 0) { + target[k] = target[k] > 0 ? CLIP8(src_alpha + MULDIV255(target[k], (255 - src_alpha), tmp)) : src_alpha; } } } @@ -1043,11 +1130,18 @@ font_render(FontObject *self, PyObject *args) { if (bitmap_converted_ready) { FT_Bitmap_Done(library, &bitmap_converted); } + Py_DECREF(image); FT_Stroker_Done(stroker); PyMem_Del(glyph_info); - Py_RETURN_NONE; + return Py_BuildValue("ii", x_offset, y_offset); glyph_error: + if (im->destroy) { + im->destroy(im); + } + if (im->image) { + free(im->image); + } if (stroker != NULL) { FT_Done_Glyph(glyph); } @@ -1076,11 +1170,17 @@ font_getvarnames(FontObject *self) { num_namedstyles = master->num_namedstyles; list_names = PyList_New(num_namedstyles); + if (list_names == NULL) { + FT_Done_MM_Var(library, master); + return NULL; + } name_count = FT_Get_Sfnt_Name_Count(self->face); for (i = 0; i < name_count; i++) { error = FT_Get_Sfnt_Name(self->face, i, &name); if (error) { + Py_DECREF(list_names); + FT_Done_MM_Var(library, master); return geterror(error); } @@ -1119,25 +1219,44 @@ font_getvaraxes(FontObject *self) { name_count = FT_Get_Sfnt_Name_Count(self->face); list_axes = PyList_New(num_axis); + if (list_axes == NULL) { + FT_Done_MM_Var(library, master); + return NULL; + } for (i = 0; i < num_axis; i++) { axis = master->axis[i]; list_axis = PyDict_New(); - PyDict_SetItemString( - list_axis, "minimum", PyLong_FromLong(axis.minimum / 65536)); - PyDict_SetItemString(list_axis, "default", PyLong_FromLong(axis.def / 65536)); - PyDict_SetItemString( - list_axis, "maximum", PyLong_FromLong(axis.maximum / 65536)); + if (list_axis == NULL) { + Py_DECREF(list_axes); + FT_Done_MM_Var(library, master); + return NULL; + } + PyObject *minimum = PyLong_FromLong(axis.minimum / 65536); + PyDict_SetItemString(list_axis, "minimum", minimum ? minimum : Py_None); + Py_XDECREF(minimum); + + PyObject *def = PyLong_FromLong(axis.def / 65536); + PyDict_SetItemString(list_axis, "default", def ? def : Py_None); + Py_XDECREF(def); + + PyObject *maximum = PyLong_FromLong(axis.maximum / 65536); + PyDict_SetItemString(list_axis, "maximum", maximum ? maximum : Py_None); + Py_XDECREF(maximum); for (j = 0; j < name_count; j++) { error = FT_Get_Sfnt_Name(self->face, j, &name); if (error) { + Py_DECREF(list_axis); + Py_DECREF(list_axes); + FT_Done_MM_Var(library, master); return geterror(error); } if (name.name_id == axis.strid) { axis_name = Py_BuildValue("y#", name.string, name.string_len); - PyDict_SetItemString(list_axis, "name", axis_name); + PyDict_SetItemString(list_axis, "name", axis_name ? axis_name : Py_None); + Py_XDECREF(axis_name); break; } } @@ -1299,19 +1418,19 @@ static struct PyGetSetDef font_getsetters[] = { {NULL}}; static PyTypeObject Font_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "Font", - sizeof(FontObject), - 0, + PyVarObject_HEAD_INIT(NULL, 0) "Font", /*tp_name*/ + sizeof(FontObject), /*tp_basicsize*/ + 0, /*tp_itemsize*/ /* methods */ - (destructor)font_dealloc, /* tp_dealloc */ - 0, /* tp_print */ + (destructor)font_dealloc, /*tp_dealloc*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -1352,7 +1471,8 @@ setup_module(PyObject *m) { FT_Library_Version(library, &major, &minor, &patch); v = PyUnicode_FromFormat("%d.%d.%d", major, minor, patch); - PyDict_SetItemString(d, "freetype2_version", v); + PyDict_SetItemString(d, "freetype2_version", v ? v : Py_None); + Py_XDECREF(v); #ifdef HAVE_RAQM #if defined(HAVE_RAQM_SYSTEM) || defined(HAVE_FRIBIDI_SYSTEM) @@ -1370,35 +1490,34 @@ setup_module(PyObject *m) { PyDict_SetItemString(d, "HAVE_RAQM", v); PyDict_SetItemString(d, "HAVE_FRIBIDI", v); PyDict_SetItemString(d, "HAVE_HARFBUZZ", v); + Py_DECREF(v); if (have_raqm) { + v = NULL; #ifdef RAQM_VERSION_MAJOR v = PyUnicode_FromString(raqm_version_string()); -#else - v = Py_None; #endif - PyDict_SetItemString(d, "raqm_version", v); + PyDict_SetItemString(d, "raqm_version", v ? v : Py_None); + Py_XDECREF(v); + v = NULL; #ifdef FRIBIDI_MAJOR_VERSION { const char *a = strchr(fribidi_version_info, ')'); const char *b = strchr(fribidi_version_info, '\n'); if (a && b && a + 2 < b) { v = PyUnicode_FromStringAndSize(a + 2, b - (a + 2)); - } else { - v = Py_None; } } -#else - v = Py_None; #endif - PyDict_SetItemString(d, "fribidi_version", v); + PyDict_SetItemString(d, "fribidi_version", v ? v : Py_None); + Py_XDECREF(v); + v = NULL; #ifdef HB_VERSION_STRING v = PyUnicode_FromString(hb_version_string()); -#else - v = Py_None; #endif - PyDict_SetItemString(d, "harfbuzz_version", v); + PyDict_SetItemString(d, "harfbuzz_version", v ? v : Py_None); + Py_XDECREF(v); } return 0; diff --git a/src/_imagingmorph.c b/src/_imagingmorph.c index c0644b616..8815c2b7e 100644 --- a/src/_imagingmorph.c +++ b/src/_imagingmorph.c @@ -136,13 +136,18 @@ match(PyObject *self, PyObject *args) { int row_idx, col_idx; UINT8 **inrows; PyObject *ret = PyList_New(0); + if (ret == NULL) { + 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; } @@ -150,6 +155,7 @@ match(PyObject *self, PyObject *args) { 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; } @@ -158,6 +164,7 @@ match(PyObject *self, PyObject *args) { imgin = (Imaging)i0; if (imgin->type != IMAGING_TYPE_UINT8 || imgin->bands != 1) { + Py_DECREF(ret); PyErr_SetString(PyExc_RuntimeError, "Unsupported image type"); return NULL; } @@ -194,6 +201,7 @@ match(PyObject *self, PyObject *args) { if (lut[lut_idx]) { PyObject *coordObj = Py_BuildValue("(nn)", col_idx, row_idx); PyList_Append(ret, coordObj); + Py_XDECREF(coordObj); } } } @@ -213,10 +221,13 @@ get_on_pixels(PyObject *self, PyObject *args) { int row_idx, col_idx; int width, height; 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; @@ -230,21 +241,13 @@ get_on_pixels(PyObject *self, PyObject *args) { if (row[col_idx]) { PyObject *coordObj = Py_BuildValue("(nn)", col_idx, row_idx); PyList_Append(ret, coordObj); + Py_XDECREF(coordObj); } } } return ret; } -static int -setup_module(PyObject *m) { - PyObject *d = PyModule_GetDict(m); - - PyDict_SetItemString(d, "__version", PyUnicode_FromString("0.1")); - - return 0; -} - static PyMethodDef functions[] = { /* Functions */ {"apply", (PyCFunction)apply, METH_VARARGS, NULL}, @@ -266,9 +269,5 @@ PyInit__imagingmorph(void) { m = PyModule_Create(&module_def); - if (setup_module(m) < 0) { - return NULL; - } - return m; } diff --git a/src/_imagingtk.c b/src/_imagingtk.c index b9273b0b8..efa7fc1b6 100644 --- a/src/_imagingtk.c +++ b/src/_imagingtk.c @@ -58,5 +58,9 @@ PyInit__imagingtk(void) { }; PyObject *m; m = PyModule_Create(&module_def); - return (load_tkinter_funcs() == 0) ? m : NULL; + if (load_tkinter_funcs() != 0) { + Py_DECREF(m); + return NULL; + } + return m; } diff --git a/src/_webp.c b/src/_webp.c index 493e0709c..a1b4dbc1a 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -487,18 +487,18 @@ static struct PyMethodDef _anim_encoder_methods[] = { // WebPAnimEncoder type definition static PyTypeObject WebPAnimEncoder_Type = { PyVarObject_HEAD_INIT(NULL, 0) "WebPAnimEncoder", /*tp_name */ - sizeof(WebPAnimEncoderObject), /*tp_size */ + sizeof(WebPAnimEncoderObject), /*tp_basicsize */ 0, /*tp_itemsize */ /* methods */ (destructor)_anim_encoder_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -530,18 +530,18 @@ static struct PyMethodDef _anim_decoder_methods[] = { // WebPAnimDecoder type definition static PyTypeObject WebPAnimDecoder_Type = { PyVarObject_HEAD_INIT(NULL, 0) "WebPAnimDecoder", /*tp_name */ - sizeof(WebPAnimDecoderObject), /*tp_size */ + sizeof(WebPAnimDecoderObject), /*tp_basicsize */ 0, /*tp_itemsize */ /* methods */ (destructor)_anim_decoder_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -949,20 +949,14 @@ addAnimFlagToModule(PyObject *m) { void addTransparencyFlagToModule(PyObject *m) { - PyModule_AddObject( - m, "HAVE_TRANSPARENCY", PyBool_FromLong(!WebPDecoderBuggyAlpha())); + PyObject *have_transparency = PyBool_FromLong(!WebPDecoderBuggyAlpha()); + if (PyModule_AddObject(m, "HAVE_TRANSPARENCY", have_transparency)) { + Py_DECREF(have_transparency); + } } static int setup_module(PyObject *m) { - PyObject *d = PyModule_GetDict(m); - addMuxFlagToModule(m); - addAnimFlagToModule(m); - addTransparencyFlagToModule(m); - - PyDict_SetItemString( - d, "webpdecoder_version", PyUnicode_FromString(WebPDecoderVersion_str())); - #ifdef HAVE_WEBPANIM /* Ready object types */ if (PyType_Ready(&WebPAnimDecoder_Type) < 0 || @@ -970,6 +964,15 @@ setup_module(PyObject *m) { return -1; } #endif + PyObject *d = PyModule_GetDict(m); + addMuxFlagToModule(m); + addAnimFlagToModule(m); + addTransparencyFlagToModule(m); + + PyObject *v = PyUnicode_FromString(WebPDecoderVersion_str()); + PyDict_SetItemString(d, "webpdecoder_version", v ? v : Py_None); + Py_XDECREF(v); + return 0; } @@ -987,6 +990,7 @@ PyInit__webp(void) { m = PyModule_Create(&module_def); if (setup_module(m) < 0) { + Py_DECREF(m); return NULL; } diff --git a/src/decode.c b/src/decode.c index 7a9b956c5..ea2f3af80 100644 --- a/src/decode.c +++ b/src/decode.c @@ -116,12 +116,11 @@ _dealloc(ImagingDecoderObject *decoder) { static PyObject * _decode(ImagingDecoderObject *decoder, PyObject *args) { - UINT8 *buffer; - Py_ssize_t bufsize; + Py_buffer buffer; int status; ImagingSectionCookie cookie; - if (!PyArg_ParseTuple(args, "y#", &buffer, &bufsize)) { + if (!PyArg_ParseTuple(args, "y*", &buffer)) { return NULL; } @@ -129,12 +128,13 @@ _decode(ImagingDecoderObject *decoder, PyObject *args) { ImagingSectionEnter(&cookie); } - status = decoder->decode(decoder->im, &decoder->state, buffer, bufsize); + status = decoder->decode(decoder->im, &decoder->state, buffer.buf, buffer.len); if (!decoder->pulls_fd) { ImagingSectionLeave(&cookie); } + PyBuffer_Release(&buffer); return Py_BuildValue("ii", status, decoder->state.errcode); } @@ -258,18 +258,18 @@ static struct PyGetSetDef getseters[] = { static PyTypeObject ImagingDecoderType = { PyVarObject_HEAD_INIT(NULL, 0) "ImagingDecoder", /*tp_name*/ - sizeof(ImagingDecoderObject), /*tp_size*/ + sizeof(ImagingDecoderObject), /*tp_basicsize*/ 0, /*tp_itemsize*/ /* methods */ (destructor)_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ diff --git a/src/display.c b/src/display.c index 0ce10e249..ef2ff3754 100644 --- a/src/display.c +++ b/src/display.c @@ -195,20 +195,21 @@ _releasedc(ImagingDisplayObject *display, PyObject *args) { static PyObject * _frombytes(ImagingDisplayObject *display, PyObject *args) { - char *ptr; - Py_ssize_t bytes; + Py_buffer buffer; - if (!PyArg_ParseTuple(args, "y#:frombytes", &ptr, &bytes)) { + if (!PyArg_ParseTuple(args, "y*:frombytes", &buffer)) { return NULL; } - if (display->dib->ysize * display->dib->linesize != bytes) { + if (display->dib->ysize * display->dib->linesize != buffer.len) { + PyBuffer_Release(&buffer); PyErr_SetString(PyExc_ValueError, "wrong size"); return NULL; } - memcpy(display->dib->bits, ptr, bytes); + memcpy(display->dib->bits, buffer.buf, buffer.len); + PyBuffer_Release(&buffer); Py_INCREF(Py_None); return Py_None; } @@ -250,18 +251,18 @@ static struct PyGetSetDef getsetters[] = { static PyTypeObject ImagingDisplayType = { PyVarObject_HEAD_INIT(NULL, 0) "ImagingDisplay", /*tp_name*/ - sizeof(ImagingDisplayObject), /*tp_size*/ + sizeof(ImagingDisplayObject), /*tp_basicsize*/ 0, /*tp_itemsize*/ /* methods */ (destructor)_delete, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -421,79 +422,6 @@ error: return NULL; } -static BOOL CALLBACK -list_windows_callback(HWND hwnd, LPARAM lParam) { - PyObject *window_list = (PyObject *)lParam; - PyObject *item; - PyObject *title; - RECT inner, outer; - int title_size; - int status; - - /* get window title */ - title_size = GetWindowTextLength(hwnd); - if (title_size > 0) { - title = PyUnicode_FromStringAndSize(NULL, title_size); - if (title) { - GetWindowTextW(hwnd, PyUnicode_AS_UNICODE(title), title_size + 1); - } - } else { - title = PyUnicode_FromString(""); - } - if (!title) { - return 0; - } - - /* get bounding boxes */ - GetClientRect(hwnd, &inner); - GetWindowRect(hwnd, &outer); - - item = Py_BuildValue( - F_HANDLE "N(iiii)(iiii)", - hwnd, - title, - inner.left, - inner.top, - inner.right, - inner.bottom, - outer.left, - outer.top, - outer.right, - outer.bottom); - if (!item) { - return 0; - } - - status = PyList_Append(window_list, item); - - Py_DECREF(item); - - if (status < 0) { - return 0; - } - - return 1; -} - -PyObject * -PyImaging_ListWindowsWin32(PyObject *self, PyObject *args) { - PyObject *window_list; - - window_list = PyList_New(0); - if (!window_list) { - return NULL; - } - - EnumWindows(list_windows_callback, (LPARAM)window_list); - - if (PyErr_Occurred()) { - Py_DECREF(window_list); - return NULL; - } - - return window_list; -} - /* -------------------------------------------------------------------- */ /* Windows clipboard grabber */ @@ -509,8 +437,14 @@ PyImaging_GrabClipboardWin32(PyObject *self, PyObject *args) { LPCSTR format_names[] = {"DIB", "DIB", "file", "png", NULL}; if (!OpenClipboard(NULL)) { - PyErr_SetString(PyExc_OSError, "failed to open clipboard"); - return NULL; + // Maybe the clipboard is temporarily in use by another process. + // Wait and try again + Sleep(500); + + if (!OpenClipboard(NULL)) { + PyErr_SetString(PyExc_OSError, "failed to open clipboard"); + return NULL; + } } // find best format as set by clipboard owner diff --git a/src/encode.c b/src/encode.c index 4e2964c25..7232332f3 100644 --- a/src/encode.c +++ b/src/encode.c @@ -324,18 +324,18 @@ static struct PyGetSetDef getseters[] = { static PyTypeObject ImagingEncoderType = { PyVarObject_HEAD_INIT(NULL, 0) "ImagingEncoder", /*tp_name*/ - sizeof(ImagingEncoderObject), /*tp_size*/ + sizeof(ImagingEncoderObject), /*tp_basicsize*/ 0, /*tp_itemsize*/ /* methods */ (destructor)_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ @@ -932,7 +932,7 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { &encoder->state, (ttag_t)key_int, (UINT16)PyLong_AsLong(value)); } else if (type == TIFF_LONG) { status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, (UINT32)PyLong_AsLong(value)); + &encoder->state, (ttag_t)key_int, PyLong_AsLongLong(value)); } else if (type == TIFF_SSHORT) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (INT16)PyLong_AsLong(value)); @@ -1073,6 +1073,8 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { Py_ssize_t streamtype = 0; /* 0=interchange, 1=tables only, 2=image only */ Py_ssize_t xdpi = 0, ydpi = 0; Py_ssize_t subsampling = -1; /* -1=default, 0=none, 1=medium, 2=high */ + Py_ssize_t restart_marker_blocks = 0; + Py_ssize_t restart_marker_rows = 0; PyObject *qtables = NULL; unsigned int *qarrays = NULL; int qtablesLen = 0; @@ -1085,7 +1087,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "ss|nnnnnnnnOz#y#y#", + "ss|nnnnnnnnnnOz#y#y#", &mode, &rawmode, &quality, @@ -1096,6 +1098,8 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { &xdpi, &ydpi, &subsampling, + &restart_marker_blocks, + &restart_marker_rows, &qtables, &comment, &comment_size, @@ -1184,6 +1188,8 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { ((JPEGENCODERSTATE *)encoder->state.context)->streamtype = streamtype; ((JPEGENCODERSTATE *)encoder->state.context)->xdpi = xdpi; ((JPEGENCODERSTATE *)encoder->state.context)->ydpi = ydpi; + ((JPEGENCODERSTATE *)encoder->state.context)->restart_marker_blocks = restart_marker_blocks; + ((JPEGENCODERSTATE *)encoder->state.context)->restart_marker_rows = restart_marker_rows; ((JPEGENCODERSTATE *)encoder->state.context)->comment = comment; ((JPEGENCODERSTATE *)encoder->state.context)->comment_size = comment_size; ((JPEGENCODERSTATE *)encoder->state.context)->extra = extra; @@ -1240,11 +1246,15 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { char *cinema_mode = "no"; OPJ_CINEMA_MODE cine_mode; char mct = 0; + int sgnd = 0; Py_ssize_t fd = -1; + char *comment; + Py_ssize_t comment_size; + int plt = 0; if (!PyArg_ParseTuple( args, - "ss|OOOsOnOOOssbn", + "ss|OOOsOnOOOssbbnz#p", &mode, &format, &offset, @@ -1259,7 +1269,11 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { &progression, &cinema_mode, &mct, - &fd)) { + &sgnd, + &fd, + &comment, + &comment_size, + &plt)) { return NULL; } @@ -1341,6 +1355,26 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { } } + if (comment && comment_size > 0) { + /* Size is stored as as an uint16, subtract 4 bytes for the header */ + if (comment_size >= 65532) { + PyErr_SetString( + PyExc_ValueError, + "JPEG 2000 comment is too long"); + Py_DECREF(encoder); + return NULL; + } + + char *p = malloc(comment_size + 1); + if (!p) { + Py_DECREF(encoder); + return ImagingError_MemoryError(); + } + memcpy(p, comment, comment_size); + p[comment_size] = '\0'; + context->comment = p; + } + if (quality_layers && PySequence_Check(quality_layers)) { context->quality_is_in_db = strcmp(quality_mode, "dB") == 0; context->quality_layers = quality_layers; @@ -1357,6 +1391,8 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) { context->progression = prog_order; context->cinema_mode = cine_mode; context->mct = mct; + context->sgnd = sgnd; + context->plt = plt; return (PyObject *)encoder; } diff --git a/src/libImaging/Access.c b/src/libImaging/Access.c index 83860c38a..091c84e18 100644 --- a/src/libImaging/Access.c +++ b/src/libImaging/Access.c @@ -12,8 +12,8 @@ #include "Imaging.h" /* use make_hash.py from the pillow-scripts repository to calculate these values */ -#define ACCESS_TABLE_SIZE 27 -#define ACCESS_TABLE_HASH 3078 +#define ACCESS_TABLE_SIZE 35 +#define ACCESS_TABLE_HASH 8940 static struct ImagingAccessInstance access_table[ACCESS_TABLE_SIZE]; @@ -46,22 +46,11 @@ add_item(const char *mode) { /* fetch individual pixel */ static void -get_pixel(Imaging im, int x, int y, void *color) { +get_pixel_32_2bands(Imaging im, int x, int y, void *color) { char *out = color; - - /* generic pixel access*/ - - if (im->image8) { - out[0] = im->image8[y][x]; - } else { - UINT8 *p = (UINT8 *)&im->image32[y][x]; - if (im->type == IMAGING_TYPE_UINT8 && im->bands == 2) { - out[0] = p[0]; - out[1] = p[3]; - return; - } - memcpy(out, p, im->pixelsize); - } + UINT8 *p = (UINT8 *)&im->image32[y][x]; + out[0] = p[0]; + out[1] = p[3]; } static void @@ -92,6 +81,37 @@ get_pixel_16B(Imaging im, int x, int y, void *color) { #endif } +static void +get_pixel_16(Imaging im, int x, int y, void *color) { + UINT8 *in = (UINT8 *)&im->image[y][x + x]; + memcpy(color, in, sizeof(UINT16)); +} + +static void +get_pixel_BGR15(Imaging im, int x, int y, void *color) { + UINT8 *in = (UINT8 *)&im->image8[y][x * 2]; + UINT16 pixel = in[0] + (in[1] << 8); + char *out = color; + out[0] = (pixel & 31) * 255 / 31; + out[1] = ((pixel >> 5) & 31) * 255 / 31; + out[2] = ((pixel >> 10) & 31) * 255 / 31; +} + +static void +get_pixel_BGR16(Imaging im, int x, int y, void *color) { + UINT8 *in = (UINT8 *)&im->image8[y][x * 2]; + UINT16 pixel = in[0] + (in[1] << 8); + char *out = color; + out[0] = (pixel & 31) * 255 / 31; + out[1] = ((pixel >> 5) & 63) * 255 / 63; + out[2] = ((pixel >> 11) & 31) * 255 / 31; +} + +static void +get_pixel_BGR24(Imaging im, int x, int y, void *color) { + memcpy(color, &im->image8[y][x * 3], sizeof(UINT8) * 3); +} + static void get_pixel_32(Imaging im, int x, int y, void *color) { memcpy(color, &im->image32[y][x], sizeof(INT32)); @@ -121,15 +141,6 @@ get_pixel_32B(Imaging im, int x, int y, void *color) { /* store individual pixel */ -static void -put_pixel(Imaging im, int x, int y, const void *color) { - if (im->image8) { - im->image8[y][x] = *((UINT8 *)color); - } else { - memcpy(&im->image32[y][x], color, sizeof(INT32)); - } -} - static void put_pixel_8(Imaging im, int x, int y, const void *color) { im->image8[y][x] = *((UINT8 *)color); @@ -148,6 +159,16 @@ put_pixel_16B(Imaging im, int x, int y, const void *color) { out[1] = in[0]; } +static void +put_pixel_BGR1516(Imaging im, int x, int y, const void *color) { + memcpy(&im->image8[y][x * 2], color, 2); +} + +static void +put_pixel_BGR24(Imaging im, int x, int y, const void *color) { + memcpy(&im->image8[y][x * 3], color, 3); +} + static void put_pixel_32L(Imaging im, int x, int y, const void *color) { memcpy(&im->image8[y][x * 4], color, 4); @@ -180,17 +201,21 @@ ImagingAccessInit() { /* populate access table */ ADD("1", get_pixel_8, put_pixel_8); ADD("L", get_pixel_8, put_pixel_8); - ADD("LA", get_pixel, put_pixel); - ADD("La", get_pixel, put_pixel); + ADD("LA", get_pixel_32_2bands, put_pixel_32); + ADD("La", get_pixel_32_2bands, put_pixel_32); ADD("I", get_pixel_32, put_pixel_32); ADD("I;16", get_pixel_16L, put_pixel_16L); ADD("I;16L", get_pixel_16L, put_pixel_16L); ADD("I;16B", get_pixel_16B, put_pixel_16B); + ADD("I;16N", get_pixel_16, put_pixel_16L); ADD("I;32L", get_pixel_32L, put_pixel_32L); ADD("I;32B", get_pixel_32B, put_pixel_32B); ADD("F", get_pixel_32, put_pixel_32); ADD("P", get_pixel_8, put_pixel_8); - ADD("PA", get_pixel, put_pixel); + ADD("PA", get_pixel_32_2bands, put_pixel_32); + ADD("BGR;15", get_pixel_BGR15, put_pixel_BGR1516); + ADD("BGR;16", get_pixel_BGR16, put_pixel_BGR1516); + ADD("BGR;24", get_pixel_BGR24, put_pixel_BGR24); ADD("RGB", get_pixel_32, put_pixel_32); ADD("RGBA", get_pixel_32, put_pixel_32); ADD("RGBa", get_pixel_32, put_pixel_32); diff --git a/src/libImaging/BcnDecode.c b/src/libImaging/BcnDecode.c index 69c317815..583f9c077 100644 --- a/src/libImaging/BcnDecode.c +++ b/src/libImaging/BcnDecode.c @@ -97,8 +97,8 @@ decode_bc3_alpha(char *dst, const UINT8 *src, int stride, int o, int sign) { if (sign == 1) { bc5s_alpha b; memcpy(&b, src, sizeof(bc5s_alpha)); - a0 = (b.a0 + 255) / 2; - a1 = (b.a1 + 255) / 2; + a0 = b.a0 + 128; + a1 = b.a1 + 128; lut1 = b.lut[0] | (b.lut[1] << 8) | (b.lut[2] << 16); lut2 = b.lut[3] | (b.lut[4] << 8) | (b.lut[5] << 16); } else { @@ -829,10 +829,12 @@ decode_bcn( DECODE_LOOP(3, 16, rgba); DECODE_LOOP(4, 8, lum); case 5: + { + int sign = strcmp(pixel_format, "BC5S") == 0 ? 1 : 0; while (bytes >= 16) { rgba col[16]; - memset(col, 0, 16 * sizeof(col[0])); - decode_bc5_block(col, ptr, strcmp(pixel_format, "BC5S") == 0 ? 1 : 0); + memset(col, sign ? 128 : 0, 16 * sizeof(col[0])); + decode_bc5_block(col, ptr, sign); put_block(im, state, (const char *)col, sizeof(col[0]), C); ptr += 16; bytes -= 16; @@ -841,10 +843,13 @@ decode_bcn( } } break; + } case 6: + { + int sign = strcmp(pixel_format, "BC6HS") == 0 ? 1 : 0; while (bytes >= 16) { rgba col[16]; - decode_bc6_block(col, ptr, strcmp(pixel_format, "BC6HS") == 0 ? 1 : 0); + decode_bc6_block(col, ptr, sign); put_block(im, state, (const char *)col, sizeof(col[0]), C); ptr += 16; bytes -= 16; @@ -853,6 +858,7 @@ decode_bcn( } } break; + } DECODE_LOOP(7, 16, rgba); #undef DECODE_LOOP } diff --git a/src/libImaging/BoxBlur.c b/src/libImaging/BoxBlur.c index 2e45a3358..adf425d0d 100644 --- a/src/libImaging/BoxBlur.c +++ b/src/libImaging/BoxBlur.c @@ -230,13 +230,16 @@ ImagingHorizontalBoxBlur(Imaging imOut, Imaging imIn, float floatRadius) { } Imaging -ImagingBoxBlur(Imaging imOut, Imaging imIn, float radius, int n) { +ImagingBoxBlur(Imaging imOut, Imaging imIn, float xradius, float yradius, int n) { int i; Imaging imTransposed; if (n < 1) { return ImagingError_ValueError("number of passes must be greater than zero"); } + if (xradius < 0 || yradius < 0) { + return ImagingError_ValueError("radius must be >= 0"); + } if (strcmp(imIn->mode, imOut->mode) || imIn->type != imOut->type || imIn->bands != imOut->bands || imIn->xsize != imOut->xsize || @@ -255,35 +258,45 @@ ImagingBoxBlur(Imaging imOut, Imaging imIn, float radius, int n) { return ImagingError_ModeError(); } - imTransposed = ImagingNewDirty(imIn->mode, imIn->ysize, imIn->xsize); - if (!imTransposed) { - return NULL; - } - /* Apply blur in one dimension. Use imOut as a destination at first pass, then use imOut as a source too. */ - ImagingHorizontalBoxBlur(imOut, imIn, radius); - for (i = 1; i < n; i++) { - ImagingHorizontalBoxBlur(imOut, imOut, radius); - } - /* Transpose result for blur in another direction. */ - ImagingTranspose(imTransposed, imOut); - /* Reuse imTransposed as a source and destination there. */ - for (i = 0; i < n; i++) { - ImagingHorizontalBoxBlur(imTransposed, imTransposed, radius); + if (xradius != 0) { + ImagingHorizontalBoxBlur(imOut, imIn, xradius); + for (i = 1; i < n; i++) { + ImagingHorizontalBoxBlur(imOut, imOut, xradius); + } } - /* Restore original orientation. */ - ImagingTranspose(imOut, imTransposed); + if (yradius != 0) { + imTransposed = ImagingNewDirty(imIn->mode, imIn->ysize, imIn->xsize); + if (!imTransposed) { + return NULL; + } - ImagingDelete(imTransposed); + /* Transpose result for blur in another direction. */ + ImagingTranspose(imTransposed, xradius == 0 ? imIn : imOut); + + /* Reuse imTransposed as a source and destination there. */ + for (i = 0; i < n; i++) { + ImagingHorizontalBoxBlur(imTransposed, imTransposed, yradius); + } + /* Restore original orientation. */ + ImagingTranspose(imOut, imTransposed); + + ImagingDelete(imTransposed); + } + if (xradius == 0 && yradius == 0) { + if (!ImagingCopy2(imOut, imIn)) { + return NULL; + } + } return imOut; } -Imaging -ImagingGaussianBlur(Imaging imOut, Imaging imIn, float radius, int passes) { +static float +_gaussian_blur_radius(float radius, int passes) { float sigma2, L, l, a; sigma2 = radius * radius / passes; @@ -296,5 +309,16 @@ ImagingGaussianBlur(Imaging imOut, Imaging imIn, float radius, int passes) { a = (2 * l + 1) * (l * (l + 1) - 3 * sigma2); a /= 6 * (sigma2 - (l + 1) * (l + 1)); - return ImagingBoxBlur(imOut, imIn, l + a, passes); + return l + a; +} + +Imaging +ImagingGaussianBlur(Imaging imOut, Imaging imIn, float xradius, float yradius, int passes) { + return ImagingBoxBlur( + imOut, + imIn, + _gaussian_blur_radius(xradius, passes), + _gaussian_blur_radius(yradius, passes), + passes + ); } diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 2b45d0cc4..99d2a4ada 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -479,6 +479,25 @@ rgba2rgbA(UINT8 *out, const UINT8 *in, int xsize) { } } +static void +rgba2rgb_(UINT8 *out, const UINT8 *in, int xsize) { + int x; + unsigned int alpha; + for (x = 0; x < xsize; x++, in += 4) { + alpha = in[3]; + if (alpha == 255 || alpha == 0) { + *out++ = in[0]; + *out++ = in[1]; + *out++ = in[2]; + } else { + *out++ = CLIP8((255 * in[0]) / alpha); + *out++ = CLIP8((255 * in[1]) / alpha); + *out++ = CLIP8((255 * in[2]) / alpha); + } + *out++ = 255; + } +} + /* * Conversion of RGB + single transparent color to RGBA, * where any pixel that matches the color will have the @@ -545,7 +564,7 @@ rgb2cmyk(UINT8 *out, const UINT8 *in, int xsize) { } } -static void +void cmyk2rgb(UINT8 *out, const UINT8 *in, int xsize) { int x, nk, tmp; for (x = 0; x < xsize; x++) { @@ -934,6 +953,7 @@ static struct { {"RGBA", "HSV", rgb2hsv}, {"RGBa", "RGBA", rgba2rgbA}, + {"RGBa", "RGB", rgba2rgb_}, {"RGBX", "1", rgb2bit}, {"RGBX", "L", rgb2l}, @@ -970,6 +990,13 @@ static struct { {"I;16L", "L", I16L_L}, {"L", "I;16B", L_I16B}, {"I;16B", "L", I16B_L}, +#ifdef WORDS_BIGENDIAN + {"L", "I;16N", L_I16B}, + {"I;16N", "L", I16B_L}, +#else + {"L", "I;16N", L_I16L}, + {"I;16N", "L", I16L_L}, +#endif {"I;16", "F", I16L_F}, {"I;16L", "F", I16L_F}, @@ -986,7 +1013,7 @@ static struct { static void p2bit(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { int x; - /* FIXME: precalculate greyscale palette? */ + /* FIXME: precalculate grayscale palette? */ for (x = 0; x < xsize; x++) { *out++ = (L(&palette->palette[in[x] * 4]) >= 128000) ? 255 : 0; } @@ -995,7 +1022,7 @@ p2bit(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { static void pa2bit(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { int x; - /* FIXME: precalculate greyscale palette? */ + /* FIXME: precalculate grayscale palette? */ for (x = 0; x < xsize; x++, in += 4) { *out++ = (L(&palette->palette[in[0] * 4]) >= 128000) ? 255 : 0; } @@ -1004,7 +1031,7 @@ pa2bit(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { static void p2l(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { int x; - /* FIXME: precalculate greyscale palette? */ + /* FIXME: precalculate grayscale palette? */ for (x = 0; x < xsize; x++) { *out++ = L24(&palette->palette[in[x] * 4]) >> 16; } @@ -1013,7 +1040,7 @@ p2l(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { static void pa2l(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { int x; - /* FIXME: precalculate greyscale palette? */ + /* FIXME: precalculate grayscale palette? */ for (x = 0; x < xsize; x++, in += 4) { *out++ = L24(&palette->palette[in[0] * 4]) >> 16; } @@ -1043,7 +1070,7 @@ p2pa(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { static void p2la(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { int x; - /* FIXME: precalculate greyscale palette? */ + /* FIXME: precalculate grayscale palette? */ for (x = 0; x < xsize; x++, out += 4) { const UINT8 *rgba = &palette->palette[*in++ * 4]; out[0] = out[1] = out[2] = L24(rgba) >> 16; @@ -1054,7 +1081,7 @@ p2la(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { static void pa2la(UINT8 *out, const UINT8 *in, int xsize, ImagingPalette palette) { int x; - /* FIXME: precalculate greyscale palette? */ + /* FIXME: precalculate grayscale palette? */ for (x = 0; x < xsize; x++, in += 4, out += 4) { out[0] = out[1] = out[2] = L24(&palette->palette[in[0] * 4]) >> 16; out[3] = in[3]; @@ -1268,7 +1295,6 @@ topalette( int alpha; int x, y; ImagingPalette palette = inpalette; - ; /* Map L or RGB/RGBX/RGBA to palette image */ if (strcmp(imIn->mode, "L") != 0 && strncmp(imIn->mode, "RGB", 3) != 0) { @@ -1280,7 +1306,14 @@ topalette( if (palette == NULL) { /* FIXME: make user configurable */ if (imIn->bands == 1) { - palette = ImagingPaletteNew("RGB"); /* Initialised to grey ramp */ + palette = ImagingPaletteNew("RGB"); + + palette->size = 256; + int i; + for (i = 0; i < 256; i++) { + palette->palette[i * 4] = palette->palette[i * 4 + 1] = + palette->palette[i * 4 + 2] = (UINT8)i; + } } else { palette = ImagingPaletteNewBrowser(); /* Standard colour cube */ } @@ -1302,9 +1335,9 @@ topalette( imOut->palette = ImagingPaletteDuplicate(palette); if (imIn->bands == 1) { - /* greyscale image */ + /* grayscale image */ - /* Greyscale palette: copy data as is */ + /* Grayscale palette: copy data as is */ ImagingSectionEnter(&cookie); for (y = 0; y < imIn->ysize; y++) { if (alpha) { diff --git a/src/libImaging/Convert.h b/src/libImaging/Convert.h new file mode 100644 index 000000000..e688e3018 --- /dev/null +++ b/src/libImaging/Convert.h @@ -0,0 +1,2 @@ +extern void +cmyk2rgb(UINT8 *out, const UINT8 *in, int xsize); diff --git a/src/libImaging/Dib.c b/src/libImaging/Dib.c index f8a2901b8..1b5bfe132 100644 --- a/src/libImaging/Dib.c +++ b/src/libImaging/Dib.c @@ -142,9 +142,9 @@ ImagingNewDIB(const char *mode, int xsize, int ysize) { GetSystemPaletteEntries(dib->dc, 0, 256, pal->palPalEntry); if (strcmp(mode, "L") == 0) { - /* Greyscale DIB. Fill all 236 slots with a greyscale ramp + /* Grayscale DIB. Fill all 236 slots with a grayscale ramp * (this is usually overkill on Windows since VGA only offers - * 6 bits greyscale resolution). Ignore the slots already + * 6 bits grayscale resolution). Ignore the slots already * allocated by Windows */ i = 10; @@ -160,7 +160,7 @@ ImagingNewDIB(const char *mode, int xsize, int ysize) { #ifdef CUBE216 /* Colour DIB. Create a 6x6x6 colour cube (216 entries) and - * add 20 extra greylevels for best result with greyscale + * add 20 extra graylevels for best result with grayscale * images. */ i = 10; diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index 77343e583..0ccf22d58 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -41,6 +41,7 @@ #define FLOOR(v) ((v) >= 0.0 ? (int)(v) : (int)floor(v)) #define INK8(ink) (*(UINT8 *)ink) +#define INK16(ink) (*(UINT16 *)ink) /* * Rounds around zero (up=away from zero, down=towards zero) @@ -68,8 +69,13 @@ static inline void point8(Imaging im, int x, int y, int ink) { if (x >= 0 && x < im->xsize && y >= 0 && y < im->ysize) { if (strncmp(im->mode, "I;16", 4) == 0) { - im->image8[y][x * 2] = (UINT8)ink; +#ifdef WORDS_BIGENDIAN + im->image8[y][x * 2] = (UINT8)(ink >> 8); im->image8[y][x * 2 + 1] = (UINT8)ink; +#else + im->image8[y][x * 2] = (UINT8)ink; + im->image8[y][x * 2 + 1] = (UINT8)(ink >> 8); +#endif } else { im->image8[y][x] = (UINT8)ink; } @@ -85,25 +91,22 @@ point32(Imaging im, int x, int y, int ink) { static inline void point32rgba(Imaging im, int x, int y, int ink) { - unsigned int tmp1; + unsigned int tmp; if (x >= 0 && x < im->xsize && y >= 0 && y < im->ysize) { UINT8 *out = (UINT8 *)im->image[y] + x * 4; UINT8 *in = (UINT8 *)&ink; - out[0] = BLEND(in[3], out[0], in[0], tmp1); - out[1] = BLEND(in[3], out[1], in[1], tmp1); - out[2] = BLEND(in[3], out[2], in[2], tmp1); + out[0] = BLEND(in[3], out[0], in[0], tmp); + out[1] = BLEND(in[3], out[1], in[1], tmp); + out[2] = BLEND(in[3], out[2], in[2], tmp); } } static inline void hline8(Imaging im, int x0, int y0, int x1, int ink) { - int tmp, pixelwidth; + int pixelwidth; if (y0 >= 0 && y0 < im->ysize) { - if (x0 > x1) { - tmp = x0, x0 = x1, x1 = tmp; - } if (x0 < 0) { x0 = 0; } else if (x0 >= im->xsize) { @@ -126,13 +129,9 @@ hline8(Imaging im, int x0, int y0, int x1, int ink) { static inline void hline32(Imaging im, int x0, int y0, int x1, int ink) { - int tmp; INT32 *p; if (y0 >= 0 && y0 < im->ysize) { - if (x0 > x1) { - tmp = x0, x0 = x1, x1 = tmp; - } if (x0 < 0) { x0 = 0; } else if (x0 >= im->xsize) { @@ -152,13 +151,9 @@ hline32(Imaging im, int x0, int y0, int x1, int ink) { static inline void hline32rgba(Imaging im, int x0, int y0, int x1, int ink) { - int tmp; - unsigned int tmp1; + unsigned int tmp; if (y0 >= 0 && y0 < im->ysize) { - if (x0 > x1) { - tmp = x0, x0 = x1, x1 = tmp; - } if (x0 < 0) { x0 = 0; } else if (x0 >= im->xsize) { @@ -173,9 +168,9 @@ hline32rgba(Imaging im, int x0, int y0, int x1, int ink) { UINT8 *out = (UINT8 *)im->image[y0] + x0 * 4; UINT8 *in = (UINT8 *)&ink; while (x0 <= x1) { - out[0] = BLEND(in[3], out[0], in[0], tmp1); - out[1] = BLEND(in[3], out[1], in[1], tmp1); - out[2] = BLEND(in[3], out[2], in[2], tmp1); + out[0] = BLEND(in[3], out[0], in[0], tmp); + out[1] = BLEND(in[3], out[1], in[1], tmp); + out[2] = BLEND(in[3], out[2], in[2], tmp); x0++; out += 4; } @@ -642,13 +637,17 @@ DRAW draw32rgba = {point32rgba, hline32rgba, line32rgba, polygon32rgba}; /* Interface */ /* -------------------------------------------------------------------- */ -#define DRAWINIT() \ - if (im->image8) { \ - draw = &draw8; \ - ink = INK8(ink_); \ - } else { \ - draw = (op) ? &draw32rgba : &draw32; \ - memcpy(&ink, ink_, sizeof(ink)); \ +#define DRAWINIT() \ + if (im->image8) { \ + draw = &draw8; \ + if (strncmp(im->mode, "I;16", 4) == 0) { \ + ink = INK16(ink_); \ + } else { \ + ink = INK8(ink_); \ + } \ + } else { \ + draw = (op) ? &draw32rgba : &draw32; \ + memcpy(&ink, ink_, sizeof(ink)); \ } int diff --git a/src/libImaging/Fill.c b/src/libImaging/Fill.c index f72060228..5b6bfb89c 100644 --- a/src/libImaging/Fill.c +++ b/src/libImaging/Fill.c @@ -24,6 +24,11 @@ ImagingFill(Imaging im, const void *colour) { int x, y; ImagingSectionCookie cookie; + /* 0-width or 0-height image. No need to do anything */ + if (!im->linesize || !im->ysize) { + return im; + } + if (im->type == IMAGING_TYPE_SPECIAL) { /* use generic API */ ImagingAccess access = ImagingAccessNew(im); diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c index fab3b4948..4dcd368ca 100644 --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -37,8 +37,19 @@ clip8(float in) { return (UINT8)in; } +static inline INT32 +clip32(float in) { + if (in <= 0.0) { + return 0; + } + if (in >= pow(2, 31) - 1) { + return pow(2, 31) - 1; + } + return (INT32)in; +} + Imaging -ImagingExpand(Imaging imIn, int xmargin, int ymargin, int mode) { +ImagingExpand(Imaging imIn, int xmargin, int ymargin) { Imaging imOut; int x, y; ImagingSectionCookie cookie; @@ -96,8 +107,8 @@ ImagingExpand(Imaging imIn, int xmargin, int ymargin, int mode) { void ImagingFilter3x3(Imaging imOut, Imaging im, const float *kernel, float offset) { #define KERNEL1x3(in0, x, kernel, d) \ - (_i2f((UINT8)in0[x - d]) * (kernel)[0] + _i2f((UINT8)in0[x]) * (kernel)[1] + \ - _i2f((UINT8)in0[x + d]) * (kernel)[2]) + (_i2f(in0[x - d]) * (kernel)[0] + _i2f(in0[x]) * (kernel)[1] + \ + _i2f(in0[x + d]) * (kernel)[2]) int x = 0, y = 0; @@ -105,21 +116,40 @@ ImagingFilter3x3(Imaging imOut, Imaging im, const float *kernel, float offset) { if (im->bands == 1) { // Add one time for rounding offset += 0.5; - for (y = 1; y < im->ysize - 1; y++) { - UINT8 *in_1 = (UINT8 *)im->image[y - 1]; - UINT8 *in0 = (UINT8 *)im->image[y]; - UINT8 *in1 = (UINT8 *)im->image[y + 1]; - UINT8 *out = (UINT8 *)imOut->image[y]; + if (im->type == IMAGING_TYPE_INT32) { + for (y = 1; y < im->ysize - 1; y++) { + INT32 *in_1 = (INT32 *)im->image[y - 1]; + INT32 *in0 = (INT32 *)im->image[y]; + INT32 *in1 = (INT32 *)im->image[y + 1]; + INT32 *out = (INT32 *)imOut->image[y]; - out[0] = in0[0]; - 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); + out[0] = in0[0]; + 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] = clip32(ss); + } + out[x] = in0[x]; + } + } else { + for (y = 1; y < im->ysize - 1; y++) { + UINT8 *in_1 = (UINT8 *)im->image[y - 1]; + UINT8 *in0 = (UINT8 *)im->image[y]; + UINT8 *in1 = (UINT8 *)im->image[y + 1]; + UINT8 *out = (UINT8 *)imOut->image[y]; + + out[0] = in0[0]; + 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); + } + out[x] = in0[x]; } - out[x] = in0[x]; } } else { // Add one time for rounding @@ -195,10 +225,10 @@ ImagingFilter3x3(Imaging imOut, Imaging im, const float *kernel, float offset) { void ImagingFilter5x5(Imaging imOut, Imaging im, const float *kernel, float offset) { #define KERNEL1x5(in0, x, kernel, d) \ - (_i2f((UINT8)in0[x - d - d]) * (kernel)[0] + \ - _i2f((UINT8)in0[x - d]) * (kernel)[1] + _i2f((UINT8)in0[x]) * (kernel)[2] + \ - _i2f((UINT8)in0[x + d]) * (kernel)[3] + \ - _i2f((UINT8)in0[x + d + d]) * (kernel)[4]) + (_i2f(in0[x - d - d]) * (kernel)[0] + \ + _i2f(in0[x - d]) * (kernel)[1] + _i2f(in0[x]) * (kernel)[2] + \ + _i2f(in0[x + d]) * (kernel)[3] + \ + _i2f(in0[x + d + d]) * (kernel)[4]) int x = 0, y = 0; @@ -207,27 +237,52 @@ ImagingFilter5x5(Imaging imOut, Imaging im, const float *kernel, float offset) { if (im->bands == 1) { // Add one time for rounding offset += 0.5; - for (y = 2; y < im->ysize - 2; y++) { - UINT8 *in_2 = (UINT8 *)im->image[y - 2]; - UINT8 *in_1 = (UINT8 *)im->image[y - 1]; - UINT8 *in0 = (UINT8 *)im->image[y]; - UINT8 *in1 = (UINT8 *)im->image[y + 1]; - UINT8 *in2 = (UINT8 *)im->image[y + 2]; - UINT8 *out = (UINT8 *)imOut->image[y]; + if (im->type == IMAGING_TYPE_INT32) { + for (y = 2; y < im->ysize - 2; y++) { + INT32 *in_2 = (INT32 *)im->image[y - 2]; + INT32 *in_1 = (INT32 *)im->image[y - 1]; + INT32 *in0 = (INT32 *)im->image[y]; + INT32 *in1 = (INT32 *)im->image[y + 1]; + INT32 *in2 = (INT32 *)im->image[y + 2]; + INT32 *out = (INT32 *)imOut->image[y]; - out[0] = in0[0]; - out[1] = in0[1]; - 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); + out[0] = in0[0]; + out[1] = in0[1]; + 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] = clip32(ss); + } + out[x + 0] = in0[x + 0]; + out[x + 1] = in0[x + 1]; + } + } else { + for (y = 2; y < im->ysize - 2; y++) { + UINT8 *in_2 = (UINT8 *)im->image[y - 2]; + UINT8 *in_1 = (UINT8 *)im->image[y - 1]; + UINT8 *in0 = (UINT8 *)im->image[y]; + UINT8 *in1 = (UINT8 *)im->image[y + 1]; + UINT8 *in2 = (UINT8 *)im->image[y + 2]; + UINT8 *out = (UINT8 *)imOut->image[y]; + + out[0] = in0[0]; + out[1] = in0[1]; + 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); + } + 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 { // Add one time for rounding @@ -327,7 +382,7 @@ ImagingFilter(Imaging im, int xsize, int ysize, const FLOAT32 *kernel, FLOAT32 o Imaging imOut; ImagingSectionCookie cookie; - if (!im || im->type != IMAGING_TYPE_UINT8) { + if (im->type != IMAGING_TYPE_UINT8 && im->type != IMAGING_TYPE_INT32) { return (Imaging)ImagingError_ModeError(); } diff --git a/src/libImaging/GetBBox.c b/src/libImaging/GetBBox.c index e73153600..86c687ca0 100644 --- a/src/libImaging/GetBBox.c +++ b/src/libImaging/GetBBox.c @@ -19,7 +19,7 @@ #include "Imaging.h" int -ImagingGetBBox(Imaging im, int bbox[4]) { +ImagingGetBBox(Imaging im, int bbox[4], int alpha_only) { /* Get the bounding box for any non-zero data in the image.*/ int x, y; @@ -58,10 +58,11 @@ ImagingGetBBox(Imaging im, int bbox[4]) { INT32 mask = 0xffffffff; if (im->bands == 3) { ((UINT8 *)&mask)[3] = 0; - } else if ( + } else if (alpha_only && ( strcmp(im->mode, "RGBa") == 0 || strcmp(im->mode, "RGBA") == 0 || strcmp(im->mode, "La") == 0 || strcmp(im->mode, "LA") == 0 || - strcmp(im->mode, "PA") == 0) { + strcmp(im->mode, "PA") == 0 + )) { #ifdef WORDS_BIGENDIAN mask = 0x000000ff; #else diff --git a/src/libImaging/ImPlatform.h b/src/libImaging/ImPlatform.h index af9996ca9..f6e7fb6b9 100644 --- a/src/libImaging/ImPlatform.h +++ b/src/libImaging/ImPlatform.h @@ -25,7 +25,7 @@ #endif #endif -#if defined(_WIN32) || defined(__CYGWIN__) +#if defined(_WIN32) || defined(__CYGWIN__) /* WIN */ #define WIN32_LEAN_AND_MEAN #include @@ -37,15 +37,33 @@ #undef WIN32 #endif -#else +#else /* not WIN */ /* For System that are not Windows, we'll need to define these. */ +/* We have to define them instead of using typedef because the JPEG lib also + 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 +#define UINT8 uint8_t +#define INT16 int16_t +#define UINT16 uint16_t +#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 -#define INT16 short /* most things works just fine anyway... */ +#error Cannot find required 16-bit integer type #endif #if SIZEOF_SHORT == 4 @@ -58,19 +76,13 @@ #error Cannot find required 32-bit integer type #endif -#if SIZEOF_LONG == 8 -#define INT64 long -#elif SIZEOF_LONG_LONG == 8 -#define INT64 long -#endif - -#define INT8 signed char #define UINT8 unsigned char - #define UINT16 unsigned INT16 #define UINT32 unsigned INT32 -#endif +#endif /* < C99 */ + +#endif /* not WIN */ /* assume IEEE; tweak if necessary (patches are welcome) */ #define FLOAT16 UINT16 diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 9e147895c..d108e2189 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -290,7 +290,7 @@ ImagingConvertTransparent(Imaging im, const char *mode, int r, int g, int b); extern Imaging ImagingCrop(Imaging im, int x0, int y0, int x1, int y1); extern Imaging -ImagingExpand(Imaging im, int x, int y, int mode); +ImagingExpand(Imaging im, int x, int y); extern Imaging ImagingFill(Imaging im, const void *ink); extern int @@ -309,7 +309,7 @@ ImagingFlipLeftRight(Imaging imOut, Imaging imIn); extern Imaging ImagingFlipTopBottom(Imaging imOut, Imaging imIn); extern Imaging -ImagingGaussianBlur(Imaging imOut, Imaging imIn, float radius, int passes); +ImagingGaussianBlur(Imaging imOut, Imaging imIn, float xradius, float yradius, int passes); extern Imaging ImagingGetBand(Imaging im, int band); extern Imaging @@ -317,7 +317,7 @@ ImagingMerge(const char *mode, Imaging bands[4]); extern int ImagingSplit(Imaging im, Imaging bands[4]); extern int -ImagingGetBBox(Imaging im, int bbox[4]); +ImagingGetBBox(Imaging im, int bbox[4], int alpha_only); typedef struct { int x, y; INT32 count; @@ -376,7 +376,7 @@ ImagingTransform( extern Imaging ImagingUnsharpMask(Imaging imOut, Imaging im, float radius, int percent, int threshold); extern Imaging -ImagingBoxBlur(Imaging imOut, Imaging imIn, float radius, int n); +ImagingBoxBlur(Imaging imOut, Imaging imIn, float xradius, float yradius, int n); extern Imaging ImagingColorLUT3D_linear( Imaging imOut, diff --git a/src/libImaging/Jpeg.h b/src/libImaging/Jpeg.h index 1d7550818..5cc74e69b 100644 --- a/src/libImaging/Jpeg.h +++ b/src/libImaging/Jpeg.h @@ -83,6 +83,10 @@ typedef struct { /* Chroma Subsampling (-1=default, 0=none, 1=medium, 2=high) */ int subsampling; + /* Restart marker interval, in MCU blocks or MCU rows, or 0 for none */ + unsigned int restart_marker_blocks; + unsigned int restart_marker_rows; + /* Converter input mode (input to the shuffler) */ char rawmode[8 + 1]; diff --git a/src/libImaging/Jpeg2K.h b/src/libImaging/Jpeg2K.h index d030b0c43..e8d92f7b6 100644 --- a/src/libImaging/Jpeg2K.h +++ b/src/libImaging/Jpeg2K.h @@ -85,6 +85,9 @@ typedef struct { /* Set multiple component transformation */ char mct; + /* Signed */ + int sgnd; + /* Progression order (LRCP/RLCP/RPCL/PCRL/CPRL) */ OPJ_PROG_ORDER progression; @@ -94,6 +97,12 @@ typedef struct { /* PRIVATE CONTEXT (set by decoder) */ const char *error_msg; + /* Custom comment */ + char *comment; + + /* Include PLT marker segment */ + int plt; + } JPEG2KENCODESTATE; /* diff --git a/src/libImaging/Jpeg2KEncode.c b/src/libImaging/Jpeg2KEncode.c index fe5511ba5..3295373fd 100644 --- a/src/libImaging/Jpeg2KEncode.c +++ b/src/libImaging/Jpeg2KEncode.c @@ -281,7 +281,6 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { int ret = -1; unsigned prec = 8; - unsigned bpp = 8; unsigned _overflow_scale_factor; stream = opj_stream_create(BUFFER_SIZE, OPJ_FALSE); @@ -313,7 +312,6 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { color_space = OPJ_CLRSPC_GRAY; pack = j2k_pack_i16; prec = 16; - bpp = 12; } else if (strcmp(im->mode, "LA") == 0) { components = 2; color_space = OPJ_CLRSPC_GRAY; @@ -342,8 +340,7 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { image_params[n].h = im->ysize; image_params[n].x0 = image_params[n].y0 = 0; image_params[n].prec = prec; - image_params[n].bpp = bpp; - image_params[n].sgnd = 0; + image_params[n].sgnd = context->sgnd == 0 ? 0 : 1; } image = opj_image_create(components, image_params, color_space); @@ -439,6 +436,10 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { params.tcp_mct = context->mct; } + if (context->comment) { + params.cp_comment = context->comment; + } + params.prog_order = context->progression; params.cp_cinema = context->cinema_mode; @@ -463,7 +464,7 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { } if (!context->num_resolutions) { - while (tile_width < (1 << (params.numresolution - 1U)) || tile_height < (1 << (params.numresolution - 1U))) { + while (tile_width < (1U << (params.numresolution - 1U)) || tile_height < (1U << (params.numresolution - 1U))) { params.numresolution -= 1; } } @@ -487,11 +488,25 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { goto quick_exit; } + if (strcmp(im->mode, "RGBA") == 0) { + image->comps[3].alpha = 1; + } else if (strcmp(im->mode, "LA") == 0) { + image->comps[1].alpha = 1; + } + opj_set_error_handler(codec, j2k_error, context); opj_set_info_handler(codec, j2k_warn, context); opj_set_warning_handler(codec, j2k_warn, context); opj_setup_encoder(codec, ¶ms, image); + /* Enabling PLT markers only supported in OpenJPEG 2.4.0 and up */ +#if ((OPJ_VERSION_MAJOR == 2 && OPJ_VERSION_MINOR >= 4) || OPJ_VERSION_MAJOR > 2) + if (context->plt) { + const char *plt_option[2] = {"PLT=YES", NULL}; + opj_encoder_set_extra_options(codec, plt_option); + } +#endif + /* Start encoding */ if (!opj_start_compress(codec, image, stream)) { state->errcode = IMAGING_CODEC_BROKEN; @@ -624,7 +639,12 @@ ImagingJpeg2KEncodeCleanup(ImagingCodecState state) { free((void *)context->error_msg); } + if (context->comment) { + free((void *)context->comment); + } + context->error_msg = NULL; + context->comment = NULL; return -1; } diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 2a24eff39..9da830b18 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -210,6 +210,8 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { } context->cinfo.smoothing_factor = context->smooth; context->cinfo.optimize_coding = (boolean)context->optimize; + context->cinfo.restart_interval = context->restart_marker_blocks; + context->cinfo.restart_in_rows = context->restart_marker_rows; if (context->xdpi > 0 && context->ydpi > 0) { context->cinfo.write_JFIF_header = TRUE; context->cinfo.density_unit = 1; /* dots per inch */ @@ -218,9 +220,9 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { } switch (context->streamtype) { case 1: - /* tables only -- not yet implemented */ - state->errcode = IMAGING_CODEC_CONFIG; - return -1; + /* tables only */ + jpeg_write_tables(&context->cinfo); + goto cleanup; case 2: /* image only */ jpeg_suppress_tables(&context->cinfo, TRUE); @@ -316,6 +318,7 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { } jpeg_finish_compress(&context->cinfo); +cleanup: /* Clean up */ if (context->comment) { free(context->comment); diff --git a/src/libImaging/Pack.c b/src/libImaging/Pack.c index 1fef6435f..c769830ad 100644 --- a/src/libImaging/Pack.c +++ b/src/libImaging/Pack.c @@ -560,16 +560,16 @@ static struct { {"1", "1;IR", 1, pack1IR}, {"1", "L", 8, pack1L}, - /* greyscale */ + /* grayscale */ {"L", "L", 8, copy1}, {"L", "L;16", 16, packL16}, {"L", "L;16B", 16, packL16B}, - /* greyscale w. alpha */ + /* grayscale w. alpha */ {"LA", "LA", 16, packLA}, {"LA", "LA;L", 16, packLAL}, - /* greyscale w. alpha premultiplied */ + /* grayscale w. alpha premultiplied */ {"La", "La", 16, packLA}, /* palette */ @@ -677,6 +677,7 @@ static struct { #endif {"I;16B", "I;16B", 16, copy2}, {"I;16L", "I;16L", 16, copy2}, + {"I;16N", "I;16N", 16, copy2}, {"I;16", "I;16N", 16, packI16N_I16}, // LibTiff native->image endian. {"I;16L", "I;16N", 16, packI16N_I16}, {"I;16B", "I;16N", 16, packI16N_I16B}, diff --git a/src/libImaging/Palette.c b/src/libImaging/Palette.c index 71a095c2c..78916bca5 100644 --- a/src/libImaging/Palette.c +++ b/src/libImaging/Palette.c @@ -39,11 +39,8 @@ ImagingPaletteNew(const char *mode) { strncpy(palette->mode, mode, IMAGING_MODE_LENGTH - 1); palette->mode[IMAGING_MODE_LENGTH - 1] = 0; - /* Initialize to ramp */ - palette->size = 256; + palette->size = 0; for (i = 0; i < 256; i++) { - palette->palette[i * 4 + 0] = palette->palette[i * 4 + 1] = - palette->palette[i * 4 + 2] = (UINT8)i; palette->palette[i * 4 + 3] = 255; /* opaque */ } @@ -62,16 +59,10 @@ ImagingPaletteNewBrowser(void) { return NULL; } - /* Blank out unused entries */ /* FIXME: Add 10-level windows palette here? */ - for (i = 0; i < 10; i++) { - palette->palette[i * 4 + 0] = palette->palette[i * 4 + 1] = - palette->palette[i * 4 + 2] = 0; - } - /* Simple 6x6x6 colour cube */ - + i = 10; for (b = 0; b < 256; b += 51) { for (g = 0; g < 256; g += 51) { for (r = 0; r < 256; r += 51) { @@ -82,14 +73,9 @@ ImagingPaletteNewBrowser(void) { } } } + palette->size = i; - /* Blank out unused entries */ - /* FIXME: add 30-level greyscale wedge here? */ - - for (; i < 256; i++) { - palette->palette[i * 4 + 0] = palette->palette[i * 4 + 1] = - palette->palette[i * 4 + 2] = 0; - } + /* FIXME: add 30-level grayscale wedge here? */ return palette; } diff --git a/src/libImaging/Paste.c b/src/libImaging/Paste.c index acf5202e5..6684b11ef 100644 --- a/src/libImaging/Paste.c +++ b/src/libImaging/Paste.c @@ -425,7 +425,7 @@ fill_mask_L( *out = BLEND(*mask, *out, ink[0], tmp1); if (strncmp(imOut->mode, "I;16", 4) == 0) { out++; - *out = BLEND(*mask, *out, ink[0], tmp1); + *out = BLEND(*mask, *out, ink[1], tmp1); } out++, mask++; } diff --git a/src/libImaging/Quant.c b/src/libImaging/Quant.c index 783852c24..398fbf6db 100644 --- a/src/libImaging/Quant.c +++ b/src/libImaging/Quant.c @@ -341,7 +341,10 @@ splitlists( PixelList *l, *r, *c, *n; int i; - int nRight, nLeft; + int nRight; +#ifndef NO_OUTPUT + int nLeft; +#endif int splitColourVal; #ifdef TEST_SPLIT @@ -396,12 +399,17 @@ splitlists( } #endif nCount[0] = nCount[1] = 0; - nLeft = nRight = 0; + nRight = 0; +#ifndef NO_OUTPUT + nLeft = 0; +#endif for (left = 0, c = h[axis]; c;) { left = left + c->count; nCount[0] += c->count; c->flag = 0; +#ifndef NO_OUTPUT nLeft++; +#endif c = c->next[axis]; if (left * 2 > pixelCount) { break; @@ -414,7 +422,9 @@ splitlists( break; } c->flag = 0; +#ifndef NO_OUTPUT nLeft++; +#endif nCount[0] += c->count; } } @@ -430,7 +440,9 @@ splitlists( } c->flag = 1; nRight++; +#ifndef NO_OUTPUT nLeft--; +#endif nCount[0] -= c->count; nCount[1] += c->count; } @@ -1685,7 +1697,7 @@ ImagingQuantize(Imaging im, int colors, int mode, int kmeans) { image data? */ if (!strcmp(im->mode, "L")) { - /* greyscale */ + /* grayscale */ /* FIXME: converting a "L" image to "P" with 256 colors should be done by a simple copy... */ @@ -1813,6 +1825,7 @@ ImagingQuantize(Imaging im, int colors, int mode, int kmeans) { free(newData); + imOut->palette->size = (int)paletteLength; pp = imOut->palette->palette; for (i = j = 0; i < (int)paletteLength; i++) { @@ -1820,16 +1833,9 @@ ImagingQuantize(Imaging im, int colors, int mode, int kmeans) { *pp++ = palette[i].c.g; *pp++ = palette[i].c.b; if (withAlpha) { - *pp++ = palette[i].c.a; - } else { - *pp++ = 255; + *pp = palette[i].c.a; } - } - for (; i < 256; i++) { - *pp++ = 0; - *pp++ = 0; - *pp++ = 0; - *pp++ = 255; + pp++; } if (withAlpha) { diff --git a/src/libImaging/Sgi.h b/src/libImaging/Sgi.h index 39dd68825..797e5cbf9 100644 --- a/src/libImaging/Sgi.h +++ b/src/libImaging/Sgi.h @@ -36,4 +36,4 @@ typedef struct { /* image data size from file descriptor */ long bufsize; -} SGISTATE; \ No newline at end of file +} SGISTATE; diff --git a/src/libImaging/Storage.c b/src/libImaging/Storage.c index 76750aaf7..b1b03c515 100644 --- a/src/libImaging/Storage.c +++ b/src/libImaging/Storage.c @@ -37,8 +37,6 @@ #include "Imaging.h" #include -int ImagingNewCount = 0; - /* -------------------------------------------------------------------- * Standard image object. */ @@ -82,18 +80,18 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->palette = ImagingPaletteNew("RGB"); } else if (strcmp(mode, "L") == 0) { - /* 8-bit greyscale (luminance) images */ + /* 8-bit grayscale (luminance) images */ im->bands = im->pixelsize = 1; im->linesize = xsize; } else if (strcmp(mode, "LA") == 0) { - /* 8-bit greyscale (luminance) with alpha */ + /* 8-bit grayscale (luminance) with alpha */ im->bands = 2; im->pixelsize = 4; /* store in image32 memory */ im->linesize = xsize * 4; } else if (strcmp(mode, "La") == 0) { - /* 8-bit greyscale (luminance) with premultiplied alpha */ + /* 8-bit grayscale (luminance) with premultiplied alpha */ im->bands = 2; im->pixelsize = 4; /* store in image32 memory */ im->linesize = xsize * 4; @@ -131,7 +129,7 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { } else if (strcmp(mode, "BGR;15") == 0) { /* EXPERIMENTAL */ /* 15-bit reversed true colour */ - im->bands = 1; + im->bands = 3; im->pixelsize = 2; im->linesize = (xsize * 2 + 3) & -4; im->type = IMAGING_TYPE_SPECIAL; @@ -139,7 +137,7 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { } else if (strcmp(mode, "BGR;16") == 0) { /* EXPERIMENTAL */ /* 16-bit reversed true colour */ - im->bands = 1; + im->bands = 3; im->pixelsize = 2; im->linesize = (xsize * 2 + 3) & -4; im->type = IMAGING_TYPE_SPECIAL; @@ -147,19 +145,11 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { } else if (strcmp(mode, "BGR;24") == 0) { /* EXPERIMENTAL */ /* 24-bit reversed true colour */ - im->bands = 1; + im->bands = 3; im->pixelsize = 3; im->linesize = (xsize * 3 + 3) & -4; im->type = IMAGING_TYPE_SPECIAL; - } else if (strcmp(mode, "BGR;32") == 0) { - /* EXPERIMENTAL */ - /* 32-bit reversed true colour */ - im->bands = 1; - im->pixelsize = 4; - im->linesize = (xsize * 4 + 3) & -4; - im->type = IMAGING_TYPE_SPECIAL; - } else if (strcmp(mode, "RGBX") == 0) { /* 32-bit true colour images with padding */ im->bands = im->pixelsize = 4; diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 428cd93d2..e3b81590e 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -14,6 +14,10 @@ #ifdef HAVE_LIBTIFF +#ifdef HAVE_UNISTD_H +#include /* lseek */ +#endif + #ifndef uint #define uint uint32 #endif @@ -720,7 +724,16 @@ ImagingLibTiffDecode( } decode_err: - TIFFClose(tiff); + // TIFFClose in libtiff calls tif_closeproc and TIFFCleanup + if (clientstate->fp) { + // Pillow 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); + } TRACE(("Done Decoding, Returning \n")); // Returning -1 here to force ImageFile.load to break, rather than // even think about looping back around. diff --git a/src/libImaging/TiffDecode.h b/src/libImaging/TiffDecode.h index c7c7d48ed..02454ba03 100644 --- a/src/libImaging/TiffDecode.h +++ b/src/libImaging/TiffDecode.h @@ -13,12 +13,6 @@ #include #endif -/* UNDONE -- what are we using from this? */ -/*#ifndef _UNISTD_H - # include - # endif -*/ - #ifndef min #define min(x, y) ((x > y) ? y : x) #define max(x, y) ((x < y) ? y : x) diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c index 6d97c2f19..974c01a28 100644 --- a/src/libImaging/Unpack.c +++ b/src/libImaging/Unpack.c @@ -31,6 +31,7 @@ */ #include "Imaging.h" +#include "Convert.h" #define R 0 #define G 1 @@ -830,7 +831,7 @@ ImagingUnpackXBGR(UINT8 *_out, const UINT8 *in, int pixels) { static void unpackRGBALA(UINT8 *_out, const UINT8 *in, int pixels) { int i; - /* greyscale with alpha */ + /* grayscale with alpha */ for (i = 0; i < pixels; i++) { UINT32 iv = MAKE_UINT32(in[0], in[0], in[0], in[1]); memcpy(_out, &iv, sizeof(iv)); @@ -842,7 +843,7 @@ unpackRGBALA(UINT8 *_out, const UINT8 *in, int pixels) { static void unpackRGBALA16B(UINT8 *_out, const UINT8 *in, int pixels) { int i; - /* 16-bit greyscale with alpha, big-endian */ + /* 16-bit grayscale with alpha, big-endian */ for (i = 0; i < pixels; i++) { UINT32 iv = MAKE_UINT32(in[0], in[0], in[0], in[2]); memcpy(_out, &iv, sizeof(iv)); @@ -1119,7 +1120,7 @@ unpackCMYKI(UINT8 *_out, const UINT8 *in, int pixels) { /* There are two representations of LAB images for whatever precision: L: Uint (in PS, it's 0-100) A: Int (in ps, -128 .. 128, or elsewhere 0..255, with 128 as middle. - Channels in PS display a 0 value as middle grey, + Channels in PS display a 0 value as middle gray, LCMS appears to use 128 as the 0 value for these channels) B: Int (as above) @@ -1161,6 +1162,16 @@ unpackI16N_I16(UINT8 *out, const UINT8 *in, int pixels) { } } static void +unpackI16B_I16(UINT8 *out, const UINT8 *in, int pixels) { + int i; + for (i = 0; i < pixels; i++) { + out[0] = in[1]; + out[1] = in[0]; + in += 2; + out += 2; + } +} +static void unpackI16R_I16(UINT8 *out, const UINT8 *in, int pixels) { int i; for (i = 0; i < pixels; i++) { @@ -1173,7 +1184,7 @@ unpackI16R_I16(UINT8 *out, const UINT8 *in, int pixels) { static void unpackI12_I16(UINT8 *out, const UINT8 *in, int pixels) { - /* Fillorder 1/MSB -> LittleEndian, for 12bit integer greyscale tiffs. + /* Fillorder 1/MSB -> LittleEndian, for 12bit integer grayscale tiffs. According to the TIFF spec: @@ -1240,6 +1251,12 @@ copy2(UINT8 *out, const UINT8 *in, int pixels) { memcpy(out, in, pixels * 2); } +static void +copy3(UINT8 *out, const UINT8 *in, int pixels) { + /* BGR;24 */ + memcpy(out, in, pixels * 3); +} + static void copy4(UINT8 *out, const UINT8 *in, int pixels) { /* RGBA, CMYK quadruples */ @@ -1522,7 +1539,7 @@ static struct { {"1", "1;IR", 1, unpack1IR}, {"1", "1;8", 8, unpack18}, - /* greyscale */ + /* grayscale */ {"L", "L;2", 2, unpackL2}, {"L", "L;2I", 2, unpackL2I}, {"L", "L;2R", 2, unpackL2R}, @@ -1539,11 +1556,11 @@ static struct { {"L", "L;16", 16, unpackL16}, {"L", "L;16B", 16, unpackL16B}, - /* greyscale w. alpha */ + /* grayscale w. alpha */ {"LA", "LA", 16, unpackLA}, {"LA", "LA;L", 16, unpackLAL}, - /* greyscale w. alpha premultiplied */ + /* grayscale w. alpha premultiplied */ {"La", "La", 16, unpackLA}, /* palette */ @@ -1554,10 +1571,12 @@ static struct { {"P", "P;4L", 4, unpackP4L}, {"P", "P", 8, copy1}, {"P", "P;R", 8, unpackLR}, + {"P", "L", 8, copy1}, /* palette w. alpha */ {"PA", "PA", 16, unpackLA}, {"PA", "PA;L", 16, unpackLAL}, + {"PA", "LA", 16, unpackLA}, /*2 channel to RGB/RGBA*/ {"RGB", "RG", 16, unpackRG}, @@ -1592,6 +1611,11 @@ static struct { {"RGB", "R;16B", 16, band016B}, {"RGB", "G;16B", 16, band116B}, {"RGB", "B;16B", 16, band216B}, + {"RGB", "CMYK", 32, cmyk2rgb}, + + {"BGR;15", "BGR;15", 16, copy2}, + {"BGR;16", "BGR;16", 16, copy2}, + {"BGR;24", "BGR;24", 24, copy3}, /* true colour w. alpha */ {"RGBA", "LA", 16, unpackRGBALA}, @@ -1777,7 +1801,9 @@ static struct { {"I;16", "I;16", 16, copy2}, {"I;16B", "I;16B", 16, copy2}, {"I;16L", "I;16L", 16, copy2}, + {"I;16N", "I;16N", 16, copy2}, + {"I;16", "I;16B", 16, unpackI16B_I16}, {"I;16", "I;16N", 16, unpackI16N_I16}, // LibTiff native->image endian. {"I;16L", "I;16N", 16, unpackI16N_I16}, // LibTiff native->image endian. {"I;16B", "I;16N", 16, unpackI16N_I16B}, diff --git a/src/libImaging/UnsharpMask.c b/src/libImaging/UnsharpMask.c index 643ced49f..2853ce903 100644 --- a/src/libImaging/UnsharpMask.c +++ b/src/libImaging/UnsharpMask.c @@ -36,7 +36,7 @@ ImagingUnsharpMask( /* First, do a gaussian blur on the image, putting results in imOut temporarily. All format checks are in gaussian blur. */ - result = ImagingGaussianBlur(imOut, imIn, radius, 3); + result = ImagingGaussianBlur(imOut, imIn, radius, radius, 3); if (!result) { return NULL; } diff --git a/src/outline.c b/src/outline.c index 0a9a3646e..27cc255cf 100644 --- a/src/outline.c +++ b/src/outline.c @@ -155,18 +155,18 @@ static struct PyMethodDef _outline_methods[] = { static PyTypeObject OutlineType = { PyVarObject_HEAD_INIT(NULL, 0) "Outline", /*tp_name*/ - sizeof(OutlineObject), /*tp_size*/ + sizeof(OutlineObject), /*tp_basicsize*/ 0, /*tp_itemsize*/ /* methods */ (destructor)_outline_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 0, /*tp_as_async*/ 0, /*tp_repr*/ - 0, /*tp_as_number */ - 0, /*tp_as_sequence */ - 0, /*tp_as_mapping */ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ diff --git a/src/path.c b/src/path.c index 3e3431575..cc0698c4d 100644 --- a/src/path.c +++ b/src/path.c @@ -439,6 +439,9 @@ path_tolist(PyPathObject *self, PyObject *args) { if (flat) { list = PyList_New(self->count * 2); + if (list == NULL) { + return NULL; + } for (i = 0; i < self->count * 2; i++) { PyObject *item; item = PyFloat_FromDouble(self->xy[i]); @@ -449,6 +452,9 @@ path_tolist(PyPathObject *self, PyObject *args) { } } else { list = PyList_New(self->count); + if (list == NULL) { + return NULL; + } for (i = 0; i < self->count; i++) { PyObject *item; item = Py_BuildValue("dd", self->xy[i + i], self->xy[i + i + 1]); @@ -577,18 +583,18 @@ static PyMappingMethods path_as_mapping = { static PyTypeObject PyPathType = { PyVarObject_HEAD_INIT(NULL, 0) "Path", /*tp_name*/ - sizeof(PyPathObject), /*tp_size*/ + sizeof(PyPathObject), /*tp_basicsize*/ 0, /*tp_itemsize*/ /* methods */ (destructor)path_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ + 0, /*tp_vectorcall_offset*/ 0, /*tp_getattr*/ 0, /*tp_setattr*/ - 0, /*tp_compare*/ + 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_as_number*/ + &path_as_sequence, /*tp_as_sequence*/ + &path_as_mapping, /*tp_as_mapping*/ 0, /*tp_hash*/ 0, /*tp_call*/ 0, /*tp_str*/ diff --git a/src/thirdparty/raqm/COPYING b/src/thirdparty/raqm/COPYING index c605a5dc6..97e2489b7 100644 --- a/src/thirdparty/raqm/COPYING +++ b/src/thirdparty/raqm/COPYING @@ -1,7 +1,7 @@ The MIT License (MIT) Copyright © 2015 Information Technology Authority (ITA) -Copyright © 2016-2022 Khaled Hosny +Copyright © 2016-2023 Khaled Hosny Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/thirdparty/raqm/NEWS b/src/thirdparty/raqm/NEWS index ae1128485..e8bf32e0b 100644 --- a/src/thirdparty/raqm/NEWS +++ b/src/thirdparty/raqm/NEWS @@ -1,3 +1,38 @@ +Overview of changes leading to 0.10.1 +Wednesday, April 12, 2023 +==================================== + +Make combining marks always inherit the script of their base. + +Overview of changes leading to 0.10.0 +Wednesday, January 11, 2023 +==================================== + +Fix font feature ranges. + +Fix resolved direction for all-neutral text. + +Implement letter and word spacing support. + +New API: + * raqm_set_text_utf16 + +Overview of changes leading to 0.9.0 +Sunday, January 30, 2022 +==================================== + +Raise the minimum versions of Raqm dependencies: no longer conditionally +enabling any features based on specific dependency version. + +raqm_t objects can now be reused by calling raqm_clear_contents() before +re-use, to potentially reduce the number memory allocations. + +Don't hardcode python3 in tests. + +New API: + * raqm_set_freetype_load_flags_range + * raqm_clear_contents + Overview of changes leading to 0.8.0 Monday, December 13, 2021 ==================================== diff --git a/src/thirdparty/raqm/README.md b/src/thirdparty/raqm/README.md index 3354a4d25..ab729cdc0 100644 --- a/src/thirdparty/raqm/README.md +++ b/src/thirdparty/raqm/README.md @@ -11,7 +11,7 @@ It currently provides bidirectional text support (using [FriBiDi][1] or As a result, Raqm can support most writing systems covered by Unicode. The documentation can be accessed on the web at: -> http://host-oman.github.io/libraqm/ +> https://host-oman.github.io/libraqm/ Raqm (Arabic: رَقْم) is writing, also number or digit and the Arabic word for digital (رَقَمِيّ) shares the same root, so it is a play on “digital writing”. diff --git a/src/thirdparty/raqm/raqm-version.h b/src/thirdparty/raqm/raqm-version.h index 78b70a561..62d2d2064 100644 --- a/src/thirdparty/raqm/raqm-version.h +++ b/src/thirdparty/raqm/raqm-version.h @@ -32,10 +32,10 @@ #define _RAQM_VERSION_H_ #define RAQM_VERSION_MAJOR 0 -#define RAQM_VERSION_MINOR 9 -#define RAQM_VERSION_MICRO 0 +#define RAQM_VERSION_MINOR 10 +#define RAQM_VERSION_MICRO 1 -#define RAQM_VERSION_STRING "0.9.0" +#define RAQM_VERSION_STRING "0.10.1" #define RAQM_VERSION_ATLEAST(major,minor,micro) \ ((major)*10000+(minor)*100+(micro) <= \ diff --git a/src/thirdparty/raqm/raqm.c b/src/thirdparty/raqm/raqm.c index 13f6e1f02..2b331e1af 100644 --- a/src/thirdparty/raqm/raqm.c +++ b/src/thirdparty/raqm/raqm.c @@ -1,6 +1,6 @@ /* * Copyright © 2015 Information Technology Authority (ITA) - * Copyright © 2016-2022 Khaled Hosny + * Copyright © 2016-2023 Khaled Hosny * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to @@ -171,19 +171,23 @@ typedef FriBidiLevel _raqm_bidi_level_t; #endif -typedef struct { +typedef struct +{ FT_Face ftface; int ftloadflags; hb_language_t lang; hb_script_t script; + int spacing_after; } _raqm_text_info; typedef struct _raqm_run raqm_run_t; -struct _raqm { +struct _raqm +{ int ref_count; uint32_t *text; + uint16_t *text_utf16; char *text_utf8; size_t text_len; size_t text_capacity_bytes; @@ -205,7 +209,8 @@ struct _raqm { int invisible_glyph; }; -struct _raqm_run { +struct _raqm_run +{ uint32_t pos; uint32_t len; @@ -217,9 +222,13 @@ struct _raqm_run { raqm_run_t *next; }; -static uint32_t -_raqm_u8_to_u32_index (raqm_t *rq, - uint32_t index); +static size_t +_raqm_encoding_to_u32_index (raqm_t *rq, + size_t index); + +static bool +_raqm_allowed_grapheme_boundary (hb_codepoint_t l_char, + hb_codepoint_t r_char); static void _raqm_init_text_info (raqm_t *rq) @@ -231,6 +240,7 @@ _raqm_init_text_info (raqm_t *rq) rq->text_info[i].ftloadflags = -1; rq->text_info[i].lang = default_lang; rq->text_info[i].script = HB_SCRIPT_INVALID; + rq->text_info[i].spacing_after = 0; } } @@ -263,6 +273,8 @@ _raqm_compare_text_info (_raqm_text_info a, if (a.script != b.script) return false; + /* Spacing shouldn't break runs, so we don't compare them here. */ + return true; } @@ -273,6 +285,7 @@ _raqm_free_text(raqm_t* rq) rq->text = NULL; rq->text_info = NULL; rq->text_utf8 = NULL; + rq->text_utf16 = NULL; rq->text_len = 0; rq->text_capacity_bytes = 0; } @@ -280,12 +293,15 @@ _raqm_free_text(raqm_t* rq) static bool _raqm_alloc_text(raqm_t *rq, size_t len, - bool need_utf8) + bool need_utf8, + bool need_utf16) { /* Allocate contiguous memory block for texts and text_info */ size_t mem_size = (sizeof (uint32_t) + sizeof (_raqm_text_info)) * len; if (need_utf8) mem_size += sizeof (char) * len; + else if (need_utf16) + mem_size += sizeof (uint16_t) * len; if (mem_size > rq->text_capacity_bytes) { @@ -302,6 +318,7 @@ _raqm_alloc_text(raqm_t *rq, rq->text_info = (_raqm_text_info*)(rq->text + len); rq->text_utf8 = need_utf8 ? (char*)(rq->text_info + len) : NULL; + rq->text_utf16 = need_utf16 ? (uint16_t*)(rq->text_info + len) : NULL; return true; } @@ -357,7 +374,7 @@ _raqm_free_runs (raqm_run_t *runs) * Return value: * A newly allocated #raqm_t with a reference count of 1. The initial reference * count should be released with raqm_destroy() when you are done using the - * #raqm_t. Returns %NULL in case of error. + * #raqm_t. Returns `NULL` in case of error. * * Since: 0.1 */ @@ -381,6 +398,7 @@ raqm_create (void) rq->invisible_glyph = 0; rq->text = NULL; + rq->text_utf16 = NULL; rq->text_utf8 = NULL; rq->text_info = NULL; rq->text_capacity_bytes = 0; @@ -498,7 +516,7 @@ raqm_clear_contents (raqm_t *rq) * separately can give improper output. * * Return value: - * %true if no errors happened, %false otherwise. + * `true` if no errors happened, `false` otherwise. * * Since: 0.1 */ @@ -518,7 +536,7 @@ raqm_set_text (raqm_t *rq, if (!len) return true; - if (!_raqm_alloc_text(rq, len, false)) + if (!_raqm_alloc_text(rq, len, false, false)) return false; rq->text_len = len; @@ -575,6 +593,53 @@ _raqm_u8_to_u32 (const char *text, size_t len, uint32_t *unicode) return (out_utf32 - unicode); } +static void * +_raqm_get_utf16_codepoint (const void *str, + uint32_t *out_codepoint) +{ + const uint16_t *s = (const uint16_t *)str; + + if (s[0] > 0xD800 && s[0] < 0xDBFF) + { + if (s[1] > 0xDC00 && s[1] < 0xDFFF) + { + uint32_t X = ((s[0] & ((1 << 6) -1)) << 10) | (s[1] & ((1 << 10) -1)); + uint32_t W = (s[0] >> 6) & ((1 << 5) - 1); + *out_codepoint = (W+1) << 16 | X; + s += 2; + } + else + { + /* A single high surrogate, this is an error. */ + *out_codepoint = s[0]; + s += 1; + } + } + else + { + *out_codepoint = s[0]; + s += 1; + } + return (void *)s; +} + +static size_t +_raqm_u16_to_u32 (const uint16_t *text, size_t len, uint32_t *unicode) +{ + size_t in_len = 0; + uint32_t *out_utf32 = unicode; + const uint16_t *in_utf16 = text; + + while ((*in_utf16 != '\0') && (in_len < len)) + { + in_utf16 = _raqm_get_utf16_codepoint (in_utf16, out_utf32); + ++out_utf32; + ++in_len; + } + + return (out_utf32 - unicode); +} + /** * raqm_set_text_utf8: * @rq: a #raqm_t. @@ -584,7 +649,7 @@ _raqm_u8_to_u32 (const char *text, size_t len, uint32_t *unicode) * Same as raqm_set_text(), but for text encoded in UTF-8 encoding. * * Return value: - * %true if no errors happened, %false otherwise. + * `true` if no errors happened, `false` otherwise. * * Since: 0.1 */ @@ -604,7 +669,7 @@ raqm_set_text_utf8 (raqm_t *rq, if (!len) return true; - if (!_raqm_alloc_text(rq, len, true)) + if (!_raqm_alloc_text(rq, len, true, false)) return false; rq->text_len = _raqm_u8_to_u32 (text, len, rq->text); @@ -614,6 +679,44 @@ raqm_set_text_utf8 (raqm_t *rq, return true; } +/** + * raqm_set_text_utf16: + * @rq: a #raqm_t. + * @text: a UTF-16 encoded text string. + * @len: the length of @text in UTF-16 shorts. + * + * Same as raqm_set_text(), but for text encoded in UTF-16 encoding. + * + * Return value: + * `true` if no errors happened, `false` otherwise. + * + * Since: 0.10 + */ +bool +raqm_set_text_utf16 (raqm_t *rq, + const uint16_t *text, + size_t len) +{ + if (!rq || !text) + return false; + + /* Call raqm_clear_contents to reuse this raqm_t */ + if (rq->text_len) + return false; + + /* Empty string, don’t fail but do nothing */ + if (!len) + return true; + + if (!_raqm_alloc_text(rq, len, false, true)) + return false; + + rq->text_len = _raqm_u16_to_u32 (text, len, rq->text); + memcpy (rq->text_utf16, text, sizeof (uint16_t) * len); + _raqm_init_text_info (rq); + + return true; +} /** * raqm_set_par_direction: * @rq: a #raqm_t. @@ -640,7 +743,7 @@ raqm_set_text_utf8 (raqm_t *rq, * text. * * Return value: - * %true if no errors happened, %false otherwise. + * `true` if no errors happened, `false` otherwise. * * Since: 0.1 */ @@ -673,7 +776,7 @@ raqm_set_par_direction (raqm_t *rq, * parts of the text. * * Return value: - * %true if no errors happened, %false otherwise. + * `true` if no errors happened, `false` otherwise. * * Stability: * Unstable @@ -687,7 +790,7 @@ raqm_set_language (raqm_t *rq, size_t len) { hb_language_t language; - size_t end = start + len; + size_t end; if (!rq) return false; @@ -695,11 +798,8 @@ raqm_set_language (raqm_t *rq, if (!rq->text_len) return true; - if (rq->text_utf8) - { - start = _raqm_u8_to_u32_index (rq, start); - end = _raqm_u8_to_u32_index (rq, end); - } + end = _raqm_encoding_to_u32_index (rq, start + len); + start = _raqm_encoding_to_u32_index (rq, start); if (start >= rq->text_len || end > rq->text_len) return false; @@ -716,11 +816,37 @@ raqm_set_language (raqm_t *rq, return true; } +static bool +_raqm_add_font_feature (raqm_t *rq, + hb_feature_t fea) +{ + void* new_features; + + if (!rq) + return false; + + new_features = realloc (rq->features, + sizeof (hb_feature_t) * (rq->features_len + 1)); + if (!new_features) + return false; + + if (fea.start != HB_FEATURE_GLOBAL_START) + fea.start = _raqm_encoding_to_u32_index (rq, fea.start); + if (fea.end != HB_FEATURE_GLOBAL_END) + fea.end = _raqm_encoding_to_u32_index (rq, fea.end); + + rq->features = new_features; + rq->features[rq->features_len] = fea; + rq->features_len++; + + return true; +} + /** * raqm_add_font_feature: * @rq: a #raqm_t. * @feature: (transfer none): a font feature string. - * @len: length of @feature, -1 for %NULL-terminated. + * @len: length of @feature, -1 for `NULL`-terminated. * * Adds a font feature to be used by the #raqm_t during text layout. This is * usually used to turn on optional font features that are not enabled by @@ -734,7 +860,7 @@ raqm_set_language (raqm_t *rq, * end of the features list and can potentially override previous features. * * Return value: - * %true if parsing @feature succeeded, %false otherwise. + * `true` if parsing @feature succeeded, `false` otherwise. * * Since: 0.1 */ @@ -751,16 +877,7 @@ raqm_add_font_feature (raqm_t *rq, ok = hb_feature_from_string (feature, len, &fea); if (ok) - { - void* new_features = realloc (rq->features, - sizeof (hb_feature_t) * (rq->features_len + 1)); - if (!new_features) - return false; - - rq->features = new_features; - rq->features[rq->features_len] = fea; - rq->features_len++; - } + _raqm_add_font_feature (rq, fea); return ok; } @@ -817,7 +934,7 @@ _raqm_set_freetype_face (raqm_t *rq, * See also raqm_set_freetype_face_range(). * * Return value: - * %true if no errors happened, %false otherwise. + * `true` if no errors happened, `false` otherwise. * * Since: 0.1 */ @@ -832,21 +949,23 @@ raqm_set_freetype_face (raqm_t *rq, * raqm_set_freetype_face_range: * @rq: a #raqm_t. * @face: an #FT_Face. - * @start: index of first character that should use @face. - * @len: number of characters using @face. + * @start: index of first character that should use @face from the input string. + * @len: number of elements using @face. * * Sets an #FT_Face to be used for @len-number of characters staring at @start. - * The @start and @len are input string array indices (i.e. counting bytes in - * UTF-8 and scaler values in UTF-32). + * The @start and @len are input string array indices, counting elements + * according to the underlying encoding. @start must always be aligned to the + * start of an encoded codepoint, and @len must always end at a codepoint's + * final element. * * This method can be used repeatedly to set different faces for different * parts of the text. It is the responsibility of the client to make sure that - * face ranges cover the whole text. + * face ranges cover the whole text, and is properly aligned. * * See also raqm_set_freetype_face(). * * Return value: - * %true if no errors happened, %false otherwise. + * `true` if no errors happened, `false` otherwise. * * Since: 0.1 */ @@ -856,7 +975,7 @@ raqm_set_freetype_face_range (raqm_t *rq, size_t start, size_t len) { - size_t end = start + len; + size_t end; if (!rq) return false; @@ -864,11 +983,8 @@ raqm_set_freetype_face_range (raqm_t *rq, if (!rq->text_len) return true; - if (rq->text_utf8) - { - start = _raqm_u8_to_u32_index (rq, start); - end = _raqm_u8_to_u32_index (rq, end); - } + end = _raqm_encoding_to_u32_index (rq, start + len); + start = _raqm_encoding_to_u32_index (rq, start); return _raqm_set_freetype_face (rq, face, start, end); } @@ -909,7 +1025,7 @@ _raqm_set_freetype_load_flags (raqm_t *rq, * older version the flags will be ignored. * * Return value: - * %true if no errors happened, %false otherwise. + * `true` if no errors happened, `false` otherwise. * * Since: 0.3 */ @@ -943,7 +1059,7 @@ raqm_set_freetype_load_flags (raqm_t *rq, * See also raqm_set_freetype_load_flags(). * * Return value: - * %true if no errors happened, %false otherwise. + * `true` if no errors happened, `false` otherwise. * * Since: 0.9 */ @@ -953,7 +1069,7 @@ raqm_set_freetype_load_flags_range (raqm_t *rq, size_t start, size_t len) { - size_t end = start + len; + size_t end; if (!rq) return false; @@ -961,15 +1077,161 @@ raqm_set_freetype_load_flags_range (raqm_t *rq, if (!rq->text_len) return true; - if (rq->text_utf8) - { - start = _raqm_u8_to_u32_index (rq, start); - end = _raqm_u8_to_u32_index (rq, end); - } + end = _raqm_encoding_to_u32_index (rq, start + len); + start = _raqm_encoding_to_u32_index (rq, start); return _raqm_set_freetype_load_flags (rq, flags, start, end); } +static bool +_raqm_set_spacing (raqm_t *rq, + int spacing, + bool word_spacing, + size_t start, + size_t end) +{ + if (!rq) + return false; + + if (!rq->text_len) + return true; + + if (start >= rq->text_len || end > rq->text_len) + return false; + + if (!rq->text_info) + return false; + + for (size_t i = start; i < end; i++) + { + bool set_spacing = i == 0; + if (!set_spacing) + set_spacing = _raqm_allowed_grapheme_boundary (rq->text[i-1], rq->text[i]); + + if (set_spacing) + { + if (word_spacing) + { + if (_raqm_allowed_grapheme_boundary (rq->text[i], rq->text[i+1])) + { + /* CSS word seperators, word spacing is only applied on these.*/ + if (rq->text[i] == 0x0020 || /* Space */ + rq->text[i] == 0x00A0 || /* No Break Space */ + rq->text[i] == 0x1361 || /* Ethiopic Word Space */ + rq->text[i] == 0x10100 || /* Aegean Word Seperator Line */ + rq->text[i] == 0x10101 || /* Aegean Word Seperator Dot */ + rq->text[i] == 0x1039F || /* Ugaric Word Divider */ + rq->text[i] == 0x1091F) /* Phoenician Word Separator */ + { + rq->text_info[i].spacing_after = spacing; + } + } + } + else + { + rq->text_info[i].spacing_after = spacing; + } + } + } + + return true; +} + +/** + * raqm_set_letter_spacing_range: + * @rq: a #raqm_t. + * @spacing: amount of spacing in Freetype Font Units (26.6 format). + * @start: index of first character that should use @spacing. + * @len: number of characters using @spacing. + * + * Set the letter spacing or tracking for a given range, the value + * will be added onto the advance and offset for RTL, and the advance for + * other directions. Letter spacing will be applied between characters, so + * the last character will not have spacing applied after it. + * Note that not all scripts have a letter-spacing tradition, + * for example, Arabic does not, while Devanagari does. + * + * This will also add “disable `liga`, `clig`, `hlig`, `dlig`, and `calt`” font + * features to the internal features list, so call this function after setting + * the font features for best spacing results. + * + * Return value: + * `true` if no errors happened, `false` otherwise. + * + * Since: 0.10 + */ +bool +raqm_set_letter_spacing_range(raqm_t *rq, + int spacing, + size_t start, + size_t len) +{ + size_t end; + + if (!rq) + return false; + + if (!rq->text_len) + return true; + + end = start + len - 1; + + if (spacing != 0) + { +#define NUM_TAGS 5 + static char *tags[NUM_TAGS] = { "clig", "liga", "hlig", "dlig", "calt" }; + for (size_t i = 0; i < NUM_TAGS; i++) + { + hb_feature_t fea = { hb_tag_from_string(tags[i], 5), 0, start, end }; + _raqm_add_font_feature (rq, fea); + } +#undef NUM_TAGS + } + + start = _raqm_encoding_to_u32_index (rq, start); + end = _raqm_encoding_to_u32_index (rq, end); + + return _raqm_set_spacing (rq, spacing, false, start, end); +} + +/** + * raqm_set_word_spacing_range: + * @rq: a #raqm_t. + * @spacing: amount of spacing in Freetype Font Units (26.6 format). + * @start: index of first character that should use @spacing. + * @len: number of characters using @spacing. + * + * Set the word spacing for a given range. Word spacing will only be applied to + * 'word separator' characters, such as 'space', 'no break space' and + * Ethiopic word separator'. + * The value will be added onto the advance and offset for RTL, and the advance + * for other directions. + * + * Return value: + * `true` if no errors happened, `false` otherwise. + * + * Since: 0.10 + */ +bool +raqm_set_word_spacing_range(raqm_t *rq, + int spacing, + size_t start, + size_t len) +{ + size_t end; + + if (!rq) + return false; + + if (!rq->text_len) + return true; + + end = _raqm_encoding_to_u32_index (rq, start + len); + start = _raqm_encoding_to_u32_index (rq, start); + + return _raqm_set_spacing (rq, spacing, true, start, end); +} + /** * raqm_set_invisible_glyph: * @rq: a #raqm_t. @@ -984,7 +1246,7 @@ raqm_set_freetype_load_flags_range (raqm_t *rq, * If @gid is a positive number, it will be used for invisible glyphs. * * Return value: - * %true if no errors happened, %false otherwise. + * `true` if no errors happened, `false` otherwise. * * Since: 0.6 */ @@ -1014,7 +1276,7 @@ _raqm_shape (raqm_t *rq); * text shaping, and any other part of the layout process. * * Return value: - * %true if the layout process was successful, %false otherwise. + * `true` if the layout process was successful, `false` otherwise. * * Since: 0.1 */ @@ -1048,7 +1310,9 @@ raqm_layout (raqm_t *rq) static uint32_t _raqm_u32_to_u8_index (raqm_t *rq, uint32_t index); - +static uint32_t +_raqm_u32_to_u16_index (raqm_t *rq, + uint32_t index); /** * raqm_get_glyphs: * @rq: a #raqm_t. @@ -1059,7 +1323,7 @@ _raqm_u32_to_u8_index (raqm_t *rq, * information. * * Return value: (transfer none): - * An array of #raqm_glyph_t, or %NULL in case of error. This is owned by @rq + * An array of #raqm_glyph_t, or `NULL` in case of error. This is owned by @rq * and must not be freed. * * Since: 0.1 @@ -1147,6 +1411,12 @@ raqm_get_glyphs (raqm_t *rq, RAQM_TEST ("\n"); #endif } + else if (rq->text_utf16) + { + for (size_t i = 0; i < count; i++) + rq->glyphs[i].cluster = _raqm_u32_to_u16_index (rq, + rq->glyphs[i].cluster); + } return rq->glyphs; } @@ -1162,7 +1432,7 @@ raqm_get_glyphs (raqm_t *rq, * * Since: 0.8 */ -RAQM_API raqm_direction_t +raqm_direction_t raqm_get_par_resolved_direction (raqm_t *rq) { if (!rq) @@ -1185,7 +1455,7 @@ raqm_get_par_resolved_direction (raqm_t *rq) * * Since: 0.8 */ -RAQM_API raqm_direction_t +raqm_direction_t raqm_get_direction_at_index (raqm_t *rq, size_t index) { @@ -1194,8 +1464,10 @@ raqm_get_direction_at_index (raqm_t *rq, for (raqm_run_t *run = rq->runs; run != NULL; run = run->next) { - if (run->pos <= index && index < run->pos + run->len) { - switch (run->direction) { + if (run->pos <= index && index < run->pos + run->len) + { + switch (run->direction) + { case HB_DIRECTION_LTR: return RAQM_DIRECTION_LTR; case HB_DIRECTION_RTL: @@ -1227,7 +1499,8 @@ _raqm_hb_dir (raqm_t *rq, _raqm_bidi_level_t level) return dir; } -typedef struct { +typedef struct +{ size_t pos; size_t len; _raqm_bidi_level_t level; @@ -1264,10 +1537,10 @@ _raqm_bidi_itemize (raqm_t *rq, size_t *run_count) line = SBParagraphCreateLine (par, 0, par_len); *run_count = SBLineGetRunCount (line); - if (SBParagraphGetBaseLevel (par) == 0) - rq->resolved_dir = RAQM_DIRECTION_LTR; - else + if (SBParagraphGetBaseLevel (par) == 1) rq->resolved_dir = RAQM_DIRECTION_RTL; + else + rq->resolved_dir = RAQM_DIRECTION_LTR; runs = malloc (sizeof (_raqm_bidi_run) * (*run_count)); if (runs) @@ -1418,10 +1691,10 @@ _raqm_bidi_itemize (raqm_t *rq, size_t *run_count) rq->text_len, &par_type, levels); - if (par_type == FRIBIDI_PAR_LTR) - rq->resolved_dir = RAQM_DIRECTION_LTR; - else + if (par_type == FRIBIDI_PAR_RTL) rq->resolved_dir = RAQM_DIRECTION_RTL; + else + rq->resolved_dir = RAQM_DIRECTION_LTR; if (max_level == 0) goto done; @@ -1447,22 +1720,15 @@ _raqm_itemize (raqm_t *rq) bool ok = true; #ifdef RAQM_TESTING - switch (rq->base_dir) - { - case RAQM_DIRECTION_RTL: - RAQM_TEST ("Direction is: RTL\n\n"); - break; - case RAQM_DIRECTION_LTR: - RAQM_TEST ("Direction is: LTR\n\n"); - break; - case RAQM_DIRECTION_TTB: - RAQM_TEST ("Direction is: TTB\n\n"); - break; - case RAQM_DIRECTION_DEFAULT: - default: - RAQM_TEST ("Direction is: DEFAULT\n\n"); - break; - } + static char *dir_names[] = { + "DEFAULT", + "RTL", + "LTR", + "TTB" + }; + + assert (rq->base_dir < sizeof (dir_names)); + RAQM_TEST ("Direction is: %s\n\n", dir_names[rq->base_dir]); #endif if (!_raqm_resolve_scripts (rq)) @@ -1483,9 +1749,9 @@ _raqm_itemize (raqm_t *rq) runs->len = rq->text_len; runs->level = 0; } - } else { - runs = _raqm_bidi_itemize (rq, &run_count); } + else + runs = _raqm_bidi_itemize (rq, &run_count); if (!runs) { @@ -1494,6 +1760,9 @@ _raqm_itemize (raqm_t *rq) } #ifdef RAQM_TESTING + assert (rq->resolved_dir < sizeof (dir_names)); + if (rq->base_dir == RAQM_DIRECTION_DEFAULT) + RAQM_TEST ("Resolved direction is: %s\n\n", dir_names[rq->resolved_dir]); RAQM_TEST ("Number of runs before script itemization: %zu\n\n", run_count); RAQM_TEST ("BiDi Runs:\n"); @@ -1617,7 +1886,8 @@ done: } /* Stack to handle script detection */ -typedef struct { +typedef struct +{ size_t capacity; size_t size; int *pair_index; @@ -1751,6 +2021,22 @@ _get_pair_index (const uint32_t ch) #define STACK_IS_EMPTY(script) ((script)->size <= 0) #define IS_OPEN(pair_index) (((pair_index) & 1) == 0) +static hb_script_t +_raqm_unicode_script (hb_codepoint_t u) +{ + static hb_unicode_funcs_t* unicode_funcs; + + unicode_funcs = hb_unicode_funcs_get_default (); + + /* Make combining marks inherit the script of their bases, regardless of + * their own script. + */ + if (hb_unicode_general_category (unicode_funcs, u) == HB_UNICODE_GENERAL_CATEGORY_NON_SPACING_MARK) + return HB_SCRIPT_INHERITED; + + return hb_unicode_script (unicode_funcs, u); +} + /* Resolve the script for each character in the input string, if the character * script is common or inherited it takes the script of the character before it * except paired characters which we try to make them use the same script. We @@ -1763,10 +2049,9 @@ _raqm_resolve_scripts (raqm_t *rq) int last_set_index = -1; hb_script_t last_script = HB_SCRIPT_INVALID; _raqm_stack_t *stack = NULL; - hb_unicode_funcs_t* unicode_funcs = hb_unicode_funcs_get_default (); for (size_t i = 0; i < rq->text_len; ++i) - rq->text_info[i].script = hb_unicode_script (unicode_funcs, rq->text[i]); + rq->text_info[i].script = _raqm_unicode_script (rq->text[i]); #ifdef RAQM_TESTING RAQM_TEST ("Before script detection:\n"); @@ -1910,15 +2195,47 @@ _raqm_shape (raqm_t *rq) { FT_Matrix matrix; + hb_glyph_info_t *info; hb_glyph_position_t *pos; unsigned int len; FT_Get_Transform (hb_ft_font_get_face (run->font), &matrix, NULL); pos = hb_buffer_get_glyph_positions (run->buffer, &len); + info = hb_buffer_get_glyph_infos (run->buffer, &len); + for (unsigned int i = 0; i < len; i++) { _raqm_ft_transform (&pos[i].x_advance, &pos[i].y_advance, matrix); _raqm_ft_transform (&pos[i].x_offset, &pos[i].y_offset, matrix); + + bool set_spacing = false; + if (run->direction == HB_DIRECTION_RTL) + { + set_spacing = i == 0; + if (!set_spacing) + set_spacing = info[i].cluster != info[i-1].cluster; + } + else + { + set_spacing = i == len - 1; + if (!set_spacing) + set_spacing = info[i].cluster != info[i+1].cluster; + } + + _raqm_text_info rq_info = rq->text_info[info[i].cluster]; + + if (rq_info.spacing_after != 0 && set_spacing) + { + if (run->direction == HB_DIRECTION_TTB) + pos[i].y_advance -= rq_info.spacing_after; + else if (run->direction == HB_DIRECTION_RTL) + { + pos[i].x_advance += rq_info.spacing_after; + pos[i].x_offset += rq_info.spacing_after; + } + else + pos[i].x_advance += rq_info.spacing_after; + } } } } @@ -1954,9 +2271,9 @@ _raqm_u32_to_u8_index (raqm_t *rq, } /* Convert index from UTF-8 to UTF-32 */ -static uint32_t +static size_t _raqm_u8_to_u32_index (raqm_t *rq, - uint32_t index) + size_t index) { const unsigned char *s = (const unsigned char *) rq->text_utf8; const unsigned char *t = s; @@ -1982,9 +2299,64 @@ _raqm_u8_to_u32_index (raqm_t *rq, return length; } -static bool -_raqm_allowed_grapheme_boundary (hb_codepoint_t l_char, - hb_codepoint_t r_char); +/* Count equivalent UTF-16 short in codepoint */ +static size_t +_raqm_count_codepoint_utf16_short (uint32_t chr) +{ + if (chr > 0x010000) + return 2; + else + return 1; +} + +/* Convert index from UTF-32 to UTF-16 */ +static uint32_t +_raqm_u32_to_u16_index (raqm_t *rq, + uint32_t index) +{ + size_t length = 0; + + for (uint32_t i = 0; i < index; ++i) + length += _raqm_count_codepoint_utf16_short (rq->text[i]); + + return length; +} + +/* Convert index from UTF-16 to UTF-32 */ +static size_t +_raqm_u16_to_u32_index (raqm_t *rq, + size_t index) +{ + const uint16_t *s = (const uint16_t *) rq->text_utf16; + const uint16_t *t = s; + size_t length = 0; + + while (((size_t) (s - t) < index) && ('\0' != *s)) + { + if (*s < 0xD800 || *s > 0xDBFF) + s += 1; + else + s += 2; + + length++; + } + + if ((size_t) (s-t) > index) + length--; + + return length; +} + +static inline size_t +_raqm_encoding_to_u32_index (raqm_t *rq, + size_t index) +{ + if (rq->text_utf8) + return _raqm_u8_to_u32_index (rq, index); + else if (rq->text_utf16) + return _raqm_u16_to_u32_index (rq, index); + return index; +} static bool _raqm_in_hangul_syllable (hb_codepoint_t ch); @@ -2001,7 +2373,7 @@ _raqm_in_hangul_syllable (hb_codepoint_t ch); * character is left-to-right, then the cursor will be at the right of it. * * Return value: - * %true if the process was successful, %false otherwise. + * `true` if the process was successful, `false` otherwise. * * Since: 0.2 */ @@ -2018,8 +2390,7 @@ raqm_index_to_position (raqm_t *rq, if (rq == NULL) return false; - if (rq->text_utf8) - *index = _raqm_u8_to_u32_index (rq, *index); + *index = _raqm_encoding_to_u32_index (rq, *index); if (*index >= rq->text_len) return false; @@ -2077,6 +2448,8 @@ raqm_index_to_position (raqm_t *rq, found: if (rq->text_utf8) *index = _raqm_u32_to_u8_index (rq, *index); + else if (rq->text_utf16) + *index = _raqm_u32_to_u16_index (rq, *index); RAQM_TEST ("The position is %d at index %zu\n",*x ,*index); return true; } @@ -2093,7 +2466,7 @@ found: * @index. * * Return value: - * %true if the process was successful, %false in case of error. + * `true` if the process was successful, `false` in case of error. * * Since: 0.2 */ @@ -2371,8 +2744,8 @@ raqm_version_string (void) * Checks if library version is less than or equal the specified version. * * Return value: - * %true if library version is less than or equal the specfied version, %false - * otherwise. + * `true` if library version is less than or equal the specified version, + * `false` otherwise. * * Since: 0.7 **/ @@ -2393,8 +2766,8 @@ raqm_version_atleast (unsigned int major, * Checks if library version is less than or equal the specified version. * * Return value: - * %true if library version is less than or equal the specfied version, %false - * otherwise. + * `true` if library version is less than or equal the specified version, + * `false` otherwise. * * Since: 0.7 **/ diff --git a/src/thirdparty/raqm/raqm.h b/src/thirdparty/raqm/raqm.h index bdb5a50d8..6fd6089c7 100644 --- a/src/thirdparty/raqm/raqm.h +++ b/src/thirdparty/raqm/raqm.h @@ -1,6 +1,6 @@ /* * Copyright © 2015 Information Technology Authority (ITA) - * Copyright © 2016-2022 Khaled Hosny + * Copyright © 2016-2023 Khaled Hosny * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to @@ -118,6 +118,10 @@ RAQM_API bool raqm_set_text_utf8 (raqm_t *rq, const char *text, size_t len); +RAQM_API bool +raqm_set_text_utf16 (raqm_t *rq, + const uint16_t *text, + size_t len); RAQM_API bool raqm_set_par_direction (raqm_t *rq, @@ -154,6 +158,17 @@ raqm_set_freetype_load_flags_range (raqm_t *rq, size_t start, size_t len); +RAQM_API bool +raqm_set_letter_spacing_range(raqm_t *rq, + int spacing, + size_t start, + size_t len); +RAQM_API bool +raqm_set_word_spacing_range(raqm_t *rq, + int spacing, + size_t start, + size_t len); + RAQM_API bool raqm_set_invisible_glyph (raqm_t *rq, int gid); diff --git a/tox.ini b/tox.ini index 9a41ca96b..034d89372 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,9 @@ [tox] -envlist = +requires = + tox>=4.2 +env_list = lint - py{py3, 311, 310, 39, 38, 37} -minversion = 1.9 + py{py3, 312, 311, 310, 39, 38} [testenv] deps = @@ -12,18 +13,27 @@ extras = tests commands = make clean - {envpython} -m pip install --global-option="build_ext" --global-option="--inplace" . + {envpython} -m pip install . {envpython} selftest.py {envpython} -m pytest -W always {posargs} -allowlist_externals = make +allowlist_externals = + make [testenv:lint] -passenv = - PRE_COMMIT_COLOR skip_install = true deps = check-manifest pre-commit +pass_env = + PRE_COMMIT_COLOR commands = pre-commit run --all-files --show-diff-on-failure check-manifest + +[testenv:mypy] +skip_install = true +deps = + mypy==1.7.1 + numpy +commands = + mypy src {posargs} diff --git a/wheels/README.md b/wheels/README.md new file mode 100644 index 000000000..8b412b7fe --- /dev/null +++ b/wheels/README.md @@ -0,0 +1,35 @@ +README +------ + +[cibuildwheel](https://github.com/pypa/cibuildwheel) is used to build macOS and Linux +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. + +Archives +-------- + +https://github.com/python-pillow/pillow-depends contains archives for libraries +that will be built as part of the Pillow build. + +In general, there is no need to put library archives there, because the +`multibuild` scripts will download them from their respective URLs. + +But, the build will look in that repository before downloading from the +URL, so if there is a library that often fails to download, or you think might +fail to download, then download it and add it to the Git repository. + +See `build` in `.github/workflows/wheels-dependencies.sh` and the `fetch_unpack` +routine in `multibuild/common_utils.sh` for the logic, and the build recipes in +`multibuild/library_builders.sh` for the filename to give to the downloaded +archive. + +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/dependency_licenses/BROTLI.txt b/wheels/dependency_licenses/BROTLI.txt new file mode 100644 index 000000000..33b7cdd2d --- /dev/null +++ b/wheels/dependency_licenses/BROTLI.txt @@ -0,0 +1,19 @@ +Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/wheels/dependency_licenses/BZIP2.txt b/wheels/dependency_licenses/BZIP2.txt new file mode 100644 index 000000000..d3edf477d --- /dev/null +++ b/wheels/dependency_licenses/BZIP2.txt @@ -0,0 +1,42 @@ + +-------------------------------------------------------------------------- + +This program, "bzip2", the associated library "libbzip2", and all +documentation, are copyright (C) 1996-2019 Julian R Seward. All +rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. The origin of this software must not be misrepresented; you must + not claim that you wrote the original software. If you use this + software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +3. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + +4. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Julian Seward, jseward@acm.org +bzip2/libbzip2 version 1.0.8 of 13 July 2019 + +-------------------------------------------------------------------------- diff --git a/wheels/dependency_licenses/FREETYPE2.txt b/wheels/dependency_licenses/FREETYPE2.txt new file mode 100644 index 000000000..93efc6126 --- /dev/null +++ b/wheels/dependency_licenses/FREETYPE2.txt @@ -0,0 +1,652 @@ +The FreeType 2 font engine is copyrighted work and cannot be used +legally without a software license. In order to make this project +usable to a vast majority of developers, we distribute it under two +mutually exclusive open-source licenses. + +This means that *you* must choose *one* of the two licenses described +below, then obey all its terms and conditions when using FreeType 2 in +any of your projects or products. + + - The FreeType License, found in the file `docs/FTL.TXT`, which is + similar to the original BSD license *with* an advertising clause + that forces you to explicitly cite the FreeType project in your + product's documentation. All details are in the license file. + This license is suited to products which don't use the GNU General + Public License. + + Note that this license is compatible to the GNU General Public + License version 3, but not version 2. + + - The GNU General Public License version 2, found in + `docs/GPLv2.TXT` (any later version can be used also), for + programs which already use the GPL. Note that the FTL is + incompatible with GPLv2 due to its advertisement clause. + +The contributed BDF and PCF drivers come with a license similar to +that of the X Window System. It is compatible to the above two +licenses (see files `src/bdf/README` and `src/pcf/README`). The same +holds for the source code files `src/base/fthash.c` and +`include/freetype/internal/fthash.h`; they were part of the BDF driver +in earlier FreeType versions. + +The gzip module uses the zlib license (see `src/gzip/zlib.h`) which +too is compatible to the above two licenses. + +The files `src/autofit/ft-hb.c` and `src/autofit/ft-hb.h` contain code +taken almost verbatim from the HarfBuzz file `hb-ft.cc`, which uses +the 'Old MIT' license, compatible to the above two licenses. + +The MD5 checksum support (only used for debugging in development +builds) is in the public domain. + +-------------------------------------------------------------------------- + + The FreeType Project LICENSE + ---------------------------- + + 2006-Jan-27 + + Copyright 1996-2002, 2006 by + David Turner, Robert Wilhelm, and Werner Lemberg + + + +Introduction +============ + + The FreeType Project is distributed in several archive packages; + some of them may contain, in addition to the FreeType font engine, + various tools and contributions which rely on, or relate to, the + FreeType Project. + + This license applies to all files found in such packages, and + which do not fall under their own explicit license. The license + affects thus the FreeType font engine, the test programs, + documentation and makefiles, at the very least. + + This license was inspired by the BSD, Artistic, and IJG + (Independent JPEG Group) licenses, which all encourage inclusion + and use of free software in commercial and freeware products + alike. As a consequence, its main points are that: + + o We don't promise that this software works. However, we will be + interested in any kind of bug reports. (`as is' distribution) + + o You can use this software for whatever you want, in parts or + full form, without having to pay us. (`royalty-free' usage) + + o You may not pretend that you wrote this software. If you use + it, or only parts of it, in a program, you must acknowledge + somewhere in your documentation that you have used the + FreeType code. (`credits') + + We specifically permit and encourage the inclusion of this + software, with or without modifications, in commercial products. + We disclaim all warranties covering The FreeType Project and + assume no liability related to The FreeType Project. + + + Finally, many people asked us for a preferred form for a + credit/disclaimer to use in compliance with this license. We thus + encourage you to use the following text: + + """ + Portions of this software are copyright © The FreeType + Project (www.freetype.org). All rights reserved. + """ + + Please replace with the value from the FreeType version you + actually use. + + +Legal Terms +=========== + +0. Definitions +-------------- + + Throughout this license, the terms `package', `FreeType Project', + and `FreeType archive' refer to the set of files originally + distributed by the authors (David Turner, Robert Wilhelm, and + Werner Lemberg) as the `FreeType Project', be they named as alpha, + beta or final release. + + `You' refers to the licensee, or person using the project, where + `using' is a generic term including compiling the project's source + code as well as linking it to form a `program' or `executable'. + This program is referred to as `a program using the FreeType + engine'. + + This license applies to all files distributed in the original + FreeType Project, including all source code, binaries and + documentation, unless otherwise stated in the file in its + original, unmodified form as distributed in the original archive. + If you are unsure whether or not a particular file is covered by + this license, you must contact us to verify this. + + The FreeType Project is copyright (C) 1996-2000 by David Turner, + Robert Wilhelm, and Werner Lemberg. All rights reserved except as + specified below. + +1. No Warranty +-------------- + + THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO + USE, OF THE FREETYPE PROJECT. + +2. Redistribution +----------------- + + This license grants a worldwide, royalty-free, perpetual and + irrevocable right and license to use, execute, perform, compile, + display, copy, create derivative works of, distribute and + sublicense the FreeType Project (in both source and object code + forms) and derivative works thereof for any purpose; and to + authorize others to exercise some or all of the rights granted + herein, subject to the following conditions: + + o Redistribution of source code must retain this license file + (`FTL.TXT') unaltered; any additions, deletions or changes to + the original files must be clearly indicated in accompanying + documentation. The copyright notices of the unaltered, + original files must be preserved in all copies of source + files. + + o Redistribution in binary form must provide a disclaimer that + states that the software is based in part of the work of the + FreeType Team, in the distribution documentation. We also + encourage you to put an URL to the FreeType web page in your + documentation, though this isn't mandatory. + + These conditions apply to any software derived from or based on + the FreeType Project, not just the unmodified files. If you use + our work, you must acknowledge us. However, no fee need be paid + to us. + +3. Advertising +-------------- + + Neither the FreeType authors and contributors nor you shall use + the name of the other for commercial, advertising, or promotional + purposes without specific prior written permission. + + We suggest, but do not require, that you use one or more of the + following phrases to refer to this software in your documentation + or advertising materials: `FreeType Project', `FreeType Engine', + `FreeType library', or `FreeType Distribution'. + + As you have not signed this license, you are not required to + accept it. However, as the FreeType Project is copyrighted + material, only this license, or another one contracted with the + authors, grants you the right to use, distribute, and modify it. + Therefore, by using, distributing, or modifying the FreeType + Project, you indicate that you understand and accept all the terms + of this license. + +4. Contacts +----------- + + There are two mailing lists related to FreeType: + + o freetype@nongnu.org + + Discusses general use and applications of FreeType, as well as + future and wanted additions to the library and distribution. + If you are looking for support, start in this list if you + haven't found anything to help you in the documentation. + + o freetype-devel@nongnu.org + + Discusses bugs, as well as engine internals, design issues, + specific licenses, porting, etc. + + Our home page can be found at + + https://www.freetype.org + + +--- end of FTL.TXT --- + +-------------------------------------------------------------------------- + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. + +-------------------------------------------------------------------------- + +The following license details are part of `src/bdf/README`: + +``` +License +******* + +Copyright (C) 2001-2002 by Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +*** Portions of the driver (that is, bdflib.c and bdf.h): + +Copyright 2000 Computing Research Labs, New Mexico State University +Copyright 2001-2002, 2011 Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE COMPUTING RESEARCH LAB OR NEW MEXICO STATE UNIVERSITY BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT +OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +Credits +******* + +This driver is based on excellent Mark Leisher's bdf library. If you +find something good in this driver you should probably thank him, not +me. +``` + +The following license details are part of `src/pcf/README`: + +``` +License +******* + +Copyright (C) 2000 by Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +Credits +******* + +Keith Packard wrote the pcf driver found in XFree86. His work is at +the same time the specification and the sample implementation of the +PCF format. Undoubtedly, this driver is inspired from his work. +``` diff --git a/wheels/dependency_licenses/HARFBUZZ.txt b/wheels/dependency_licenses/HARFBUZZ.txt new file mode 100644 index 000000000..1dd917e9f --- /dev/null +++ b/wheels/dependency_licenses/HARFBUZZ.txt @@ -0,0 +1,42 @@ +HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. +For parts of HarfBuzz that are licensed under different licenses see individual +files names COPYING in subdirectories where applicable. + +Copyright © 2010-2022 Google, Inc. +Copyright © 2015-2020 Ebrahim Byagowi +Copyright © 2019,2020 Facebook, Inc. +Copyright © 2012,2015 Mozilla Foundation +Copyright © 2011 Codethink Limited +Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies) +Copyright © 2009 Keith Stribley +Copyright © 2011 Martin Hosken and SIL International +Copyright © 2007 Chris Wilson +Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod +Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc. +Copyright © 1998-2005 David Turner and Werner Lemberg +Copyright © 2016 Igalia S.L. +Copyright © 2022 Matthias Clasen +Copyright © 2018,2021 Khaled Hosny +Copyright © 2018,2019,2020 Adobe, Inc +Copyright © 2013-2015 Alexei Podtelezhnikov + +For full copyright notices consult the individual files in the package. + + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. diff --git a/wheels/dependency_licenses/LCMS2.txt b/wheels/dependency_licenses/LCMS2.txt new file mode 100644 index 000000000..21ed6fb86 --- /dev/null +++ b/wheels/dependency_licenses/LCMS2.txt @@ -0,0 +1,8 @@ +Little CMS +Copyright (c) 1998-2020 Marti Maria Saguer + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/wheels/dependency_licenses/LIBJPEG.txt b/wheels/dependency_licenses/LIBJPEG.txt new file mode 100644 index 000000000..b5451f405 --- /dev/null +++ b/wheels/dependency_licenses/LIBJPEG.txt @@ -0,0 +1,43 @@ +1. We don't promise that this software works. (But if you find any bugs, + please let us know!) +2. You can use this software for whatever you want. You don't have to pay us. +3. You may not pretend that you wrote this software. If you use it in a + program, you must acknowledge somewhere in your documentation that + you've used the IJG code. + +In legalese: + +The authors make NO WARRANTY or representation, either express or implied, +with respect to this software, its quality, accuracy, merchantability, or +fitness for a particular purpose. This software is provided "AS IS", and you, +its user, assume the entire risk as to its quality and accuracy. + +This software is copyright (C) 1991-2020, Thomas G. Lane, Guido Vollbeding. +All Rights Reserved except as specified below. + +Permission is hereby granted to use, copy, modify, and distribute this +software (or portions thereof) for any purpose, without fee, subject to these +conditions: +(1) If any part of the source code for this software is distributed, then this +README file must be included, with this copyright and no-warranty notice +unaltered; and any additions, deletions, or changes to the original files +must be clearly indicated in accompanying documentation. +(2) If only executable code is distributed, then the accompanying +documentation must state that "this software is based in part on the work of +the Independent JPEG Group". +(3) Permission for use of this software is granted only if the user accepts +full responsibility for any undesirable consequences; the authors accept +NO LIABILITY for damages of any kind. + +These conditions apply to any software derived from or based on the IJG code, +not just to the unmodified library. If you use our work, you ought to +acknowledge us. + +Permission is NOT granted for the use of any IJG author's name or company name +in advertising or publicity relating to this software or products derived from +it. This software may be referred to only as "the Independent JPEG Group's +software". + +We specifically permit and encourage the use of this software as the basis of +commercial products, provided that all warranty or liability claims are +assumed by the product vendor. diff --git a/wheels/dependency_licenses/LIBLZMA.txt b/wheels/dependency_licenses/LIBLZMA.txt new file mode 100644 index 000000000..43c7a23ba --- /dev/null +++ b/wheels/dependency_licenses/LIBLZMA.txt @@ -0,0 +1,63 @@ +XZ Utils Licensing +================== + + Different licenses apply to different files in this package. Here + is a rough summary of which licenses apply to which parts of this + package (but check the individual files to be sure!): + + - liblzma is in the public domain. + + - xz, xzdec, and lzmadec command line tools are in the public + domain unless GNU getopt_long had to be compiled and linked + in from the lib directory. The getopt_long code is under + GNU LGPLv2.1+. + + - The scripts to grep, diff, and view compressed files have been + adapted from gzip. These scripts and their documentation are + under GNU GPLv2+. + + - All the documentation in the doc directory and most of the + XZ Utils specific documentation files in other directories + are in the public domain. + + - Translated messages are in the public domain. + + - The build system contains public domain files, and files that + are under GNU GPLv2+ or GNU GPLv3+. None of these files end up + in the binaries being built. + + - Test files and test code in the tests directory, and debugging + utilities in the debug directory are in the public domain. + + - The extra directory may contain public domain files, and files + that are under various free software licenses. + + You can do whatever you want with the files that have been put into + the public domain. If you find public domain legally problematic, + take the previous sentence as a license grant. If you still find + the lack of copyright legally problematic, you have too many + lawyers. + + As usual, this software is provided "as is", without any warranty. + + If you copy significant amounts of public domain code from XZ Utils + into your project, acknowledging this somewhere in your software is + polite (especially if it is proprietary, non-free software), but + naturally it is not legally required. Here is an example of a good + notice to put into "about box" or into documentation: + + This software includes code from XZ Utils . + + The following license texts are included in the following files: + - COPYING.LGPLv2.1: GNU Lesser General Public License version 2.1 + - COPYING.GPLv2: GNU General Public License version 2 + - COPYING.GPLv3: GNU General Public License version 3 + + Note that the toolchain (compiler, linker etc.) may add some code + pieces that are copyrighted. Thus, it is possible that e.g. liblzma + binary wouldn't actually be in the public domain in its entirety + even though it contains no copyrighted code from the XZ Utils source + package. + + If you have questions, don't hesitate to ask the author(s) for more + information. diff --git a/wheels/dependency_licenses/LIBPNG.txt b/wheels/dependency_licenses/LIBPNG.txt new file mode 100644 index 000000000..c8ad24eec --- /dev/null +++ b/wheels/dependency_licenses/LIBPNG.txt @@ -0,0 +1,134 @@ +COPYRIGHT NOTICE, DISCLAIMER, and LICENSE +========================================= + +PNG Reference Library License version 2 +--------------------------------------- + + * Copyright (c) 1995-2022 The PNG Reference Library Authors. + * Copyright (c) 2018-2022 Cosmin Truta. + * Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson. + * Copyright (c) 1996-1997 Andreas Dilger. + * Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +The software is supplied "as is", without warranty of any kind, +express or implied, including, without limitation, the warranties +of merchantability, fitness for a particular purpose, title, and +non-infringement. In no event shall the Copyright owners, or +anyone distributing the software, be liable for any damages or +other liability, whether in contract, tort or otherwise, arising +from, out of, or in connection with the software, or the use or +other dealings in the software, even if advised of the possibility +of such damage. + +Permission is hereby granted to use, copy, modify, and distribute +this software, or portions hereof, for any purpose, without fee, +subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you + must not claim that you wrote the original software. If you + use this software in a product, an acknowledgment in the product + documentation would be appreciated, but is not required. + + 2. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + + +PNG Reference Library License version 1 (for libpng 0.5 through 1.6.35) +----------------------------------------------------------------------- + +libpng versions 1.0.7, July 1, 2000, through 1.6.35, July 15, 2018 are +Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson, are +derived from libpng-1.0.6, and are distributed according to the same +disclaimer and license as libpng-1.0.6 with the following individuals +added to the list of Contributing Authors: + + Simon-Pierre Cadieux + Eric S. Raymond + Mans Rullgard + Cosmin Truta + Gilles Vollant + James Yu + Mandar Sahastrabuddhe + Google Inc. + Vadim Barkov + +and with the following additions to the disclaimer: + + There is no warranty against interference with your enjoyment of + the library or against infringement. There is no warranty that our + efforts or the library will fulfill any of your particular purposes + or needs. This library is provided with all faults, and the entire + risk of satisfactory quality, performance, accuracy, and effort is + with the user. + +Some files in the "contrib" directory and some configure-generated +files that are distributed with libpng have other copyright owners, and +are released under other open source licenses. + +libpng versions 0.97, January 1998, through 1.0.6, March 20, 2000, are +Copyright (c) 1998-2000 Glenn Randers-Pehrson, are derived from +libpng-0.96, and are distributed according to the same disclaimer and +license as libpng-0.96, with the following individuals added to the +list of Contributing Authors: + + Tom Lane + Glenn Randers-Pehrson + Willem van Schaik + +libpng versions 0.89, June 1996, through 0.96, May 1997, are +Copyright (c) 1996-1997 Andreas Dilger, are derived from libpng-0.88, +and are distributed according to the same disclaimer and license as +libpng-0.88, with the following individuals added to the list of +Contributing Authors: + + John Bowler + Kevin Bracey + Sam Bushell + Magnus Holmgren + Greg Roelofs + Tom Tanner + +Some files in the "scripts" directory have other copyright owners, +but are released under this license. + +libpng versions 0.5, May 1995, through 0.88, January 1996, are +Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +For the purposes of this copyright and license, "Contributing Authors" +is defined as the following set of individuals: + + Andreas Dilger + Dave Martindale + Guy Eric Schalnat + Paul Schmidt + Tim Wegner + +The PNG Reference Library is supplied "AS IS". The Contributing +Authors and Group 42, Inc. disclaim all warranties, expressed or +implied, including, without limitation, the warranties of +merchantability and of fitness for any purpose. The Contributing +Authors and Group 42, Inc. assume no liability for direct, indirect, +incidental, special, exemplary, or consequential damages, which may +result from the use of the PNG Reference Library, even if advised of +the possibility of such damage. + +Permission is hereby granted to use, copy, modify, and distribute this +source code, or portions hereof, for any purpose, without fee, subject +to the following restrictions: + + 1. The origin of this source code must not be misrepresented. + + 2. Altered versions must be plainly marked as such and must not + be misrepresented as being the original source. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + +The Contributing Authors and Group 42, Inc. specifically permit, +without fee, and encourage the use of this source code as a component +to supporting the PNG file format in commercial products. If you use +this source code in a product, acknowledgment is not required but would +be appreciated. diff --git a/wheels/dependency_licenses/LIBTIFF.txt b/wheels/dependency_licenses/LIBTIFF.txt new file mode 100644 index 000000000..dc255dec6 --- /dev/null +++ b/wheels/dependency_licenses/LIBTIFF.txt @@ -0,0 +1,21 @@ +Copyright (c) 1988-1997 Sam Leffler +Copyright (c) 1991-1997 Silicon Graphics, Inc. + +Permission to use, copy, modify, distribute, and sell this software and +its documentation for any purpose is hereby granted without fee, provided +that (i) the above copyright notices and this permission notice appear in +all copies of the software and related documentation, and (ii) the names of +Sam Leffler and Silicon Graphics may not be used in any advertising or +publicity relating to the software without the specific, prior written +permission of Sam Leffler and Silicon Graphics. + +THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, +EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY +WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +IN NO EVENT SHALL SAM LEFFLER OR SILICON GRAPHICS BE LIABLE FOR +ANY SPECIAL, INCIDENTAL, INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER OR NOT ADVISED OF THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF +LIABILITY, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +OF THIS SOFTWARE. diff --git a/wheels/dependency_licenses/LIBWEBP.txt b/wheels/dependency_licenses/LIBWEBP.txt new file mode 100644 index 000000000..83e4e6f6d --- /dev/null +++ b/wheels/dependency_licenses/LIBWEBP.txt @@ -0,0 +1,29 @@ +Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/wheels/dependency_licenses/OPENJPEG.txt b/wheels/dependency_licenses/OPENJPEG.txt new file mode 100644 index 000000000..c41fc21d8 --- /dev/null +++ b/wheels/dependency_licenses/OPENJPEG.txt @@ -0,0 +1,39 @@ +* + * The copyright in this software is being made available under the 2-clauses + * BSD License, included below. This software may be subject to other third + * party and contributor rights, including patent rights, and no such rights + * are granted under this license. + * + * Copyright (c) 2002-2014, Universite catholique de Louvain (UCL), Belgium + * Copyright (c) 2002-2014, Professor Benoit Macq + * Copyright (c) 2003-2014, Antonin Descampe + * Copyright (c) 2003-2009, Francois-Olivier Devaux + * Copyright (c) 2005, Herve Drolon, FreeImage Team + * Copyright (c) 2002-2003, Yannick Verschueren + * Copyright (c) 2001-2003, David Janssens + * Copyright (c) 2011-2012, Centre National d'Etudes Spatiales (CNES), France + * Copyright (c) 2012, CS Systemes d'Information, France + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS `AS IS' + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ diff --git a/wheels/dependency_licenses/RAQM.txt b/wheels/dependency_licenses/RAQM.txt new file mode 100644 index 000000000..196511ef6 --- /dev/null +++ b/wheels/dependency_licenses/RAQM.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright © 2015 Information Technology Authority (ITA) +Copyright © 2016 Khaled Hosny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/wheels/dependency_licenses/XAU.txt b/wheels/dependency_licenses/XAU.txt new file mode 100644 index 000000000..64492ad80 --- /dev/null +++ b/wheels/dependency_licenses/XAU.txt @@ -0,0 +1,21 @@ +Copyright 1988, 1993, 1994, 1998 The Open Group + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +OPEN GROUP BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of The Open Group shall not be +used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization from The Open Group. diff --git a/wheels/dependency_licenses/XCB.txt b/wheels/dependency_licenses/XCB.txt new file mode 100644 index 000000000..54bfbe5b0 --- /dev/null +++ b/wheels/dependency_licenses/XCB.txt @@ -0,0 +1,30 @@ +Copyright (C) 2001-2006 Bart Massey, Jamey Sharp, and Josh Triplett. +All Rights Reserved. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall +be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the names of the authors +or their institutions shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this +Software without prior written authorization from the +authors. diff --git a/wheels/dependency_licenses/XDMCP.txt b/wheels/dependency_licenses/XDMCP.txt new file mode 100644 index 000000000..5532d143c --- /dev/null +++ b/wheels/dependency_licenses/XDMCP.txt @@ -0,0 +1,23 @@ +Copyright 1989, 1998 The Open Group + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +OPEN GROUP BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of The Open Group shall not be +used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization from The Open Group. + +Author: Keith Packard, MIT X Consortium diff --git a/wheels/dependency_licenses/ZLIB.txt b/wheels/dependency_licenses/ZLIB.txt new file mode 100644 index 000000000..84def6dc6 --- /dev/null +++ b/wheels/dependency_licenses/ZLIB.txt @@ -0,0 +1,29 @@ + (C) 1995-2017 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu + +If you use the zlib library in a product, we would appreciate *not* receiving +lengthy legal documents to sign. The sources are provided for free but without +warranty of any kind. The library has been entirely written by Jean-loup +Gailly and Mark Adler; it does not include third-party code. + +If you redistribute modified sources, we would appreciate that you include in +the file ChangeLog history information documenting your changes. Please read +the FAQ for more information on the distribution of modified source versions. diff --git a/wheels/multibuild b/wheels/multibuild new file mode 160000 index 000000000..452dd2d17 --- /dev/null +++ b/wheels/multibuild @@ -0,0 +1 @@ +Subproject commit 452dd2d1705f6b2375369a6570c415beb3163f70 diff --git a/winbuild/README.md b/winbuild/README.md index d8538fbf3..7e81abcb0 100644 --- a/winbuild/README.md +++ b/winbuild/README.md @@ -10,7 +10,7 @@ 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.12 or newer (available as Visual Studio component). +* 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). @@ -18,12 +18,12 @@ The following is a simplified version of the script used on AppVeyor: ``` set PYTHON=C:\Python38\bin cd /D C:\Pillow\winbuild -C:\Python37\bin\python.exe build_prepare.py -v --depends=C:\pillow-depends +%PYTHON%\python.exe build_prepare.py -v --depends=C:\pillow-depends build\build_dep_all.cmd -build\build_pillow.cmd install cd .. +%PYTHON%\python.exe -m pip install -v -C raqm=vendor -C fribidi=vendor . path C:\Pillow\winbuild\build\bin;%PATH% %PYTHON%\python.exe selftest.py %PYTHON%\python.exe -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests -build\build_pillow.cmd bdist_wheel +%PYTHON%\python.exe -m pip wheel -v -C raqm=vendor -C fribidi=vendor . ``` diff --git a/winbuild/build.rst b/winbuild/build.rst index 716669771..a8e4ebaa6 100644 --- a/winbuild/build.rst +++ b/winbuild/build.rst @@ -21,10 +21,13 @@ Download and install: `_ (MSVC C++ build tools, and any Windows SDK version required) -* `CMake 3.12 or newer `_ +* `CMake 3.15 or newer `_ (also available as Visual Studio component C++ CMake tools for Windows) -* x86/x64: `NASM `_ +* `Ninja `_ + (optional, use ``--nmake`` if not available; bundled in Visual Studio CMake component) + +* x86/x64: `Netwide Assembler (NASM) `_ Any version of Visual Studio 2017 or newer should be supported, including Visual Studio 2017 Community, or Build Tools for Visual Studio 2019. @@ -35,53 +38,59 @@ Visual Studio is found automatically with ``vswhere.exe``. Build configuration ------------------- -The following environment variables, if set, will override the default -behaviour of ``build_prepare.py``: +Run ``build_prepare.py`` to configure the build:: -* ``PYTHON`` + ``EXECUTABLE`` point to the target version of Python. - If ``PYTHON`` is unset, the version of Python used to run - ``build_prepare.py`` will be used. If only ``PYTHON`` is set, - ``EXECUTABLE`` defaults to ``python.exe``. -* ``ARCHITECTURE`` is used to select a ``x86``, ``x64`` or ``ARM64`` build. - By default, uses same architecture as the version of Python used to run ``build_prepare.py``. -* ``PILLOW_BUILD`` can be used to override the ``winbuild\build`` directory - path, used to store generated build scripts and compiled libraries. - **Warning:** This directory is wiped when ``build_prepare.py`` is run. -* ``PILLOW_DEPS`` points to the directory used to store downloaded - dependencies. By default ``winbuild\depends`` is used. + usage: winbuild\build_prepare.py [-h] [-v] [-d PILLOW_BUILD] + [--depends PILLOW_DEPS] + [--architecture {x86,x64,ARM64}] [--nmake] + [--no-imagequant] [--no-fribidi] -``build_prepare.py`` also supports the following command line parameters: + Download and generate build scripts for Pillow dependencies. -* ``-v`` will print generated scripts. -* ``--no-imagequant`` will skip GPL-licensed ``libimagequant`` optional dependency -* ``--no-fribidi`` or ``--no-raqm`` will skip optional LGPL-licensed dependency FriBiDi - (required for Raqm text shaping). -* ``--python=`` and ``--executable=`` override ``PYTHON`` and ``EXECUTABLE``. -* ``--architecture=`` overrides ``ARCHITECTURE``. -* ``--dir=`` and ``--depends=`` override ``PILLOW_BUILD`` - and ``PILLOW_DEPS``. + options: + -h, --help show this help message and exit + -v, --verbose print generated scripts + -d PILLOW_BUILD, --dir PILLOW_BUILD, --build-dir PILLOW_BUILD + build directory (default: 'winbuild\build') + --depends PILLOW_DEPS + directory used to store cached dependencies (default: + 'winbuild\depends') + --architecture {x86,x64,ARM64} + build architecture (default: same as host Python) + --nmake build dependencies using NMake instead of Ninja + --no-imagequant skip GPL-licensed optional dependency libimagequant + --no-fribidi, --no-raqm + skip LGPL-licensed optional dependency FriBiDi + + Arguments can also be supplied using the environment variables PILLOW_BUILD, + PILLOW_DEPS, ARCHITECTURE. See winbuild\build.rst for more information. + +**Warning:** The build directory is wiped when ``build_prepare.py`` is run. Dependencies ------------ Dependencies will be automatically downloaded by ``build_prepare.py``. By default, downloaded dependencies are stored in ``winbuild\depends``; -set the ``PILLOW_DEPS`` environment variable to override this location. +use the ``--depends`` argument or ``PILLOW_DEPS`` environment variable +to override this location. To build all dependencies, run ``winbuild\build\build_dep_all.cmd``, -or run the individual scripts to build each dependency separately. +or run the individual scripts in order to build each dependency separately. Building Pillow --------------- -Once the dependencies are built, run -``winbuild\build\build_pillow.cmd install`` to build and install -Pillow for the selected version of Python. -``winbuild\build\build_pillow.cmd bdist_wheel`` will build wheels -instead of installing Pillow. +Once the dependencies are built, make sure the required environment variables +are set by running ``winbuild\build\build_env.cmd`` and install Pillow with pip:: -You can also use ``winbuild\build\build_pillow.cmd --inplace develop`` to build -and install Pillow in develop mode (instead of ``python3 -m pip install --editable``). + winbuild\build\build_env.cmd + python.exe -m pip install -v -C raqm=vendor -C fribidi=vendor . + +To build a wheel instead, run:: + + winbuild\build\build_env.cmd + python.exe -m pip wheel -v -C raqm=vendor -C fribidi=vendor . Testing Pillow -------------- @@ -96,17 +105,16 @@ directory. Example ------- -The following is a simplified version of the script used on AppVeyor: - -.. code-block:: +The following is a simplified version of the script used on AppVeyor:: set PYTHON=C:\Python38\bin cd /D C:\Pillow\winbuild - C:\Python37\bin\python.exe build_prepare.py -v --depends=C:\pillow-depends + %PYTHON%\python.exe build_prepare.py -v --depends C:\pillow-depends build\build_dep_all.cmd - build\build_pillow.cmd install + build\build_env.cmd cd .. + %PYTHON%\python.exe -m pip install -v -C raqm=vendor -C fribidi=vendor . path C:\Pillow\winbuild\build\bin;%PATH% %PYTHON%\python.exe selftest.py %PYTHON%\python.exe -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests - build\build_pillow.cmd bdist_wheel + %PYTHON%\python.exe -m pip wheel -v -C raqm=vendor -C fribidi=vendor . diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0b0c782a0..f7e145fb9 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -1,48 +1,49 @@ +from __future__ import annotations + +import argparse import os import platform import re import shutil import struct import subprocess -import sys -def cmd_cd(path): +def cmd_cd(path: str) -> str: return f"cd /D {path}" -def cmd_set(name, value): +def cmd_set(name: str, value: str) -> str: return f"set {name}={value}" -def cmd_append(name, value): +def cmd_append(name: str, value: str) -> str: op = "path " if name == "PATH" else f"set {name}=" return op + f"%{name}%;{value}" -def cmd_copy(src, tgt): +def cmd_copy(src: str, tgt: str) -> str: return f'copy /Y /B "{src}" "{tgt}"' -def cmd_xcopy(src, tgt): +def cmd_xcopy(src: str, tgt: str) -> str: return f'xcopy /Y /E "{src}" "{tgt}"' -def cmd_mkdir(path): +def cmd_mkdir(path: str) -> str: return f'mkdir "{path}"' -def cmd_rmdir(path): +def cmd_rmdir(path: str) -> str: return f'rmdir /S /Q "{path}"' -def cmd_nmake(makefile=None, target="", params=None): - if params is None: - params = "" - elif isinstance(params, list) or isinstance(params, tuple): - params = " ".join(params) - else: - params = str(params) +def cmd_nmake( + makefile: str | None = None, + target: str = "", + params: list[str] | None = None, +) -> str: + params = "" if params is None else " ".join(params) return " ".join( [ @@ -55,29 +56,36 @@ def cmd_nmake(makefile=None, target="", params=None): ) -def cmd_cmake(params=None, file="."): - if params is None: - params = "" - elif isinstance(params, list) or isinstance(params, tuple): - params = " ".join(params) - else: - params = str(params) - return " ".join( - [ - "{cmake}", - "-DCMAKE_VERBOSE_MAKEFILE=ON", - "-DCMAKE_RULE_MESSAGES:BOOL=OFF", - "-DCMAKE_BUILD_TYPE=Release", - f"{params}", - '-G "NMake Makefiles"', - f'"{file}"', - ] - ) +def cmds_cmake(target: str | tuple[str, ...] | list[str], *params) -> list[str]: + if not isinstance(target, str): + target = " ".join(target) + + return [ + " ".join( + [ + "{cmake}", + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_VERBOSE_MAKEFILE=ON", + "-DCMAKE_RULE_MESSAGES:BOOL=OFF", # for NMake + "-DCMAKE_C_COMPILER=cl.exe", # for Ninja + "-DCMAKE_CXX_COMPILER=cl.exe", # for Ninja + "-DCMAKE_C_FLAGS=-nologo", + "-DCMAKE_CXX_FLAGS=-nologo", + *params, + '-G "{cmake_generator}"', + ".", + ] + ), + f"{{cmake}} --build . --clean-first --parallel --target {target}", + ] def cmd_msbuild( - file, configuration="Release", target="Build", platform="{msbuild_arch}" -): + file: str, + configuration: str = "Release", + target: str = "Build", + platform: str = "{msbuild_arch}", +) -> str: return " ".join( [ "{msbuild}", @@ -92,45 +100,39 @@ def cmd_msbuild( SF_PROJECTS = "https://sourceforge.net/projects" -architectures = { +ARCHITECTURES = { "x86": {"vcvars_arch": "x86", "msbuild_arch": "Win32"}, "x64": {"vcvars_arch": "x86_amd64", "msbuild_arch": "x64"}, "ARM64": {"vcvars_arch": "x86_arm64", "msbuild_arch": "ARM64"}, } -header = [ - cmd_set("INCLUDE", "{inc_dir}"), - cmd_set("INCLIB", "{lib_dir}"), - cmd_set("LIB", "{lib_dir}"), - cmd_append("PATH", "{bin_dir}"), -] - # dependencies, listed in order of compilation -deps = { +DEPS = { "libjpeg": { "url": SF_PROJECTS - + "/libjpeg-turbo/files/2.1.4/libjpeg-turbo-2.1.4.tar.gz/download", - "filename": "libjpeg-turbo-2.1.4.tar.gz", - "dir": "libjpeg-turbo-2.1.4", + + "/libjpeg-turbo/files/3.0.1/libjpeg-turbo-3.0.1.tar.gz/download", + "filename": "libjpeg-turbo-3.0.1.tar.gz", + "dir": "libjpeg-turbo-3.0.1", "license": ["README.ijg", "LICENSE.md"], "license_pattern": ( "(LEGAL ISSUES\n============\n\n.+?)\n\nREFERENCES\n==========" ".+(libjpeg-turbo Licenses\n======================\n\n.+)$" ), + "patch": { + r"CMakeLists.txt": { + # libjpeg-turbo does not detect MSVC x86_arm64 cross-compiler correctly + 'if(MSVC_IDE AND CMAKE_GENERATOR_PLATFORM MATCHES "arm64")': "if({architecture} STREQUAL ARM64)", # noqa: E501 + }, + }, "build": [ - cmd_cmake( - [ - "-DENABLE_SHARED:BOOL=FALSE", - "-DWITH_JPEG8:BOOL=TRUE", - "-DWITH_CRT_DLL:BOOL=TRUE", - ] + *cmds_cmake( + ("jpeg-static", "cjpeg-static", "djpeg-static"), + "-DENABLE_SHARED:BOOL=FALSE", + "-DWITH_JPEG8:BOOL=TRUE", + "-DWITH_CRT_DLL:BOOL=TRUE", ), - cmd_nmake(target="clean"), - cmd_nmake(target="jpeg-static"), cmd_copy("jpeg-static.lib", "libjpeg.lib"), - cmd_nmake(target="cjpeg-static"), cmd_copy("cjpeg-static.exe", "cjpeg.exe"), - cmd_nmake(target="djpeg-static"), cmd_copy("djpeg-static.exe", "djpeg.exe"), ], "headers": ["j*.h"], @@ -138,9 +140,9 @@ deps = { "bins": ["cjpeg.exe", "djpeg.exe"], }, "zlib": { - "url": "https://zlib.net/zlib1213.zip", - "filename": "zlib1213.zip", - "dir": "zlib-1.2.13", + "url": "https://zlib.net/zlib13.zip", + "filename": "zlib13.zip", + "dir": "zlib-1.3", "license": "README", "license_pattern": "Copyright notice:\n\n(.+)$", "build": [ @@ -152,34 +154,22 @@ deps = { "libs": [r"*.lib"], }, "xz": { - "url": SF_PROJECTS + "/lzmautils/files/xz-5.4.0.tar.gz/download", - "filename": "xz-5.4.0.tar.gz", - "dir": "xz-5.4.0", + "url": SF_PROJECTS + "/lzmautils/files/xz-5.4.5.tar.gz/download", + "filename": "xz-5.4.5.tar.gz", + "dir": "xz-5.4.5", "license": "COPYING", - "patch": { - r"src\liblzma\api\lzma.h": { - "#ifndef LZMA_API_IMPORT": "#ifndef LZMA_API_IMPORT\n#define LZMA_API_STATIC", # noqa: E501 - }, - r"windows\vs2019\liblzma.vcxproj": { - # retarget to default toolset (selected by vcvarsall.bat) - "v142": "$(DefaultPlatformToolset)", # noqa: E501 - # retarget to latest (selected by vcvarsall.bat) - "10.0": "$(WindowsSDKVersion)", # noqa: E501 - }, - }, "build": [ - cmd_msbuild(r"windows\vs2019\liblzma.vcxproj", "Release", "Clean"), - cmd_msbuild(r"windows\vs2019\liblzma.vcxproj", "Release", "Build"), + *cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"), cmd_mkdir(r"{inc_dir}\lzma"), cmd_copy(r"src\liblzma\api\lzma\*.h", r"{inc_dir}\lzma"), ], "headers": [r"src\liblzma\api\lzma.h"], - "libs": [r"windows\vs2019\Release\{msbuild_arch}\liblzma\liblzma.lib"], + "libs": [r"liblzma.lib"], }, "libwebp": { - "url": "http://downloads.webmproject.org/releases/webp/libwebp-1.2.4.tar.gz", - "filename": "libwebp-1.2.4.tar.gz", - "dir": "libwebp-1.2.4", + "url": "http://downloads.webmproject.org/releases/webp/libwebp-1.3.2.tar.gz", + "filename": "libwebp-1.3.2.tar.gz", + "dir": "libwebp-1.3.2", "license": "COPYING", "build": [ cmd_rmdir(r"output\release-static"), # clean @@ -200,9 +190,9 @@ deps = { "libs": [r"output\release-static\{architecture}\lib\*.lib"], }, "libtiff": { - "url": "https://download.osgeo.org/libtiff/tiff-4.5.0.tar.gz", - "filename": "tiff-4.5.0.tar.gz", - "dir": "tiff-4.5.0", + "url": "https://download.osgeo.org/libtiff/tiff-4.6.0.tar.gz", + "filename": "tiff-4.6.0.tar.gz", + "dir": "tiff-4.6.0", "license": "LICENSE.md", "patch": { r"libtiff\tif_lzma.c": { @@ -213,15 +203,22 @@ deps = { # link against webp.lib "#ifdef WEBP_SUPPORT": '#ifdef WEBP_SUPPORT\n#pragma comment(lib, "webp.lib")', # noqa: E501 }, + r"test\CMakeLists.txt": { + "add_executable(test_write_read_tags ../placeholder.h)": "", + "target_sources(test_write_read_tags PRIVATE test_write_read_tags.c)": "", # noqa: E501 + "target_link_libraries(test_write_read_tags PRIVATE tiff)": "", + "list(APPEND simple_tests test_write_read_tags)": "", + }, }, "build": [ - cmd_cmake("-DBUILD_SHARED_LIBS:BOOL=OFF"), - cmd_nmake(target="clean"), - cmd_nmake(target="tiff"), + *cmds_cmake( + "tiff", + "-DBUILD_SHARED_LIBS:BOOL=OFF", + '-DCMAKE_C_FLAGS="-nologo -DLZMA_API_STATIC"', + ) ], "headers": [r"libtiff\tiff*.h"], "libs": [r"libtiff\*.lib"], - # "bins": [r"libtiff\*.dll"], }, "libpng": { "url": SF_PROJECTS + "/libpng/files/libpng16/1.6.39/lpng1639.zip/download", @@ -229,33 +226,27 @@ deps = { "dir": "lpng1639", "license": "LICENSE", "build": [ - # lint: do not inline - cmd_cmake(("-DPNG_SHARED:BOOL=OFF", "-DPNG_TESTS:BOOL=OFF")), - cmd_nmake(target="clean"), - cmd_nmake(), + *cmds_cmake("png_static", "-DPNG_SHARED:BOOL=OFF", "-DPNG_TESTS:BOOL=OFF"), cmd_copy("libpng16_static.lib", "libpng16.lib"), ], "headers": [r"png*.h"], "libs": [r"libpng16.lib"], }, "brotli": { - "url": "https://github.com/google/brotli/archive/refs/tags/v1.0.9.tar.gz", - "filename": "brotli-1.0.9.tar.gz", - "dir": "brotli-1.0.9", + "url": "https://github.com/google/brotli/archive/refs/tags/v1.1.0.tar.gz", + "filename": "brotli-1.1.0.tar.gz", + "dir": "brotli-1.1.0", "license": "LICENSE", "build": [ - cmd_cmake(), - cmd_nmake(target="clean"), - cmd_nmake(target="brotlicommon-static"), - cmd_nmake(target="brotlidec-static"), + *cmds_cmake(("brotlicommon", "brotlidec"), "-DBUILD_SHARED_LIBS:BOOL=OFF"), cmd_xcopy(r"c\include", "{inc_dir}"), ], "libs": ["*.lib"], }, "freetype": { - "url": "https://download.savannah.gnu.org/releases/freetype/freetype-2.12.1.tar.gz", # noqa: E501 - "filename": "freetype-2.12.1.tar.gz", - "dir": "freetype-2.12.1", + "url": "https://download.savannah.gnu.org/releases/freetype/freetype-2.13.2.tar.gz", + "filename": "freetype-2.13.2.tar.gz", + "dir": "freetype-2.13.2", "license": ["LICENSE.TXT", r"docs\FTL.TXT", r"docs\GPLv2.TXT"], "patch": { r"builds\windows\vc2010\freetype.vcxproj": { @@ -268,7 +259,7 @@ deps = { "": "FT_CONFIG_OPTION_SYSTEM_ZLIB;FT_CONFIG_OPTION_USE_PNG;FT_CONFIG_OPTION_USE_HARFBUZZ;FT_CONFIG_OPTION_USE_BROTLI", # noqa: E501 "": r"{dir_harfbuzz}\src;{inc_dir}", # noqa: E501 "": "{lib_dir}", # noqa: E501 - "": "zlib.lib;libpng16.lib;brotlicommon-static.lib;brotlidec-static.lib", # noqa: E501 + "": "zlib.lib;libpng16.lib;brotlicommon.lib;brotlidec.lib", # noqa: E501 }, r"src/autofit/afshaper.c": { # link against harfbuzz.lib @@ -286,13 +277,12 @@ deps = { cmd_xcopy("include", "{inc_dir}"), ], "libs": [r"objs\{msbuild_arch}\Release Static\freetype.lib"], - # "bins": [r"objs\{msbuild_arch}\Release\freetype.dll"], }, "lcms2": { - "url": SF_PROJECTS + "/lcms/files/lcms/2.13/lcms2-2.14.tar.gz/download", - "filename": "lcms2-2.14.tar.gz", - "dir": "lcms2-2.14", - "license": "COPYING", + "url": SF_PROJECTS + "/lcms/files/lcms/2.16/lcms2-2.16.tar.gz/download", + "filename": "lcms2-2.16.tar.gz", + "dir": "lcms2-2.16", + "license": "LICENSE", "patch": { r"Projects\VC2022\lcms2_static\lcms2_static.vcxproj": { # default is /MD for x86 and /MT for x64, we need /MD always @@ -319,10 +309,15 @@ deps = { "filename": "openjpeg-2.5.0.tar.gz", "dir": "openjpeg-2.5.0", "license": "LICENSE", + "patch": { + r"src\lib\openjp2\ht_dec.c": { + "#ifdef OPJ_COMPILER_MSVC\n return (OPJ_UINT32)__popcnt(val);": "#if defined(OPJ_COMPILER_MSVC) && (defined(_M_IX86) || defined(_M_AMD64))\n return (OPJ_UINT32)__popcnt(val);", # noqa: E501 + } + }, "build": [ - cmd_cmake(("-DBUILD_CODEC:BOOL=OFF", "-DBUILD_SHARED_LIBS:BOOL=OFF")), - cmd_nmake(target="clean"), - cmd_nmake(target="openjp2"), + *cmds_cmake( + "openjp2", "-DBUILD_CODEC:BOOL=OFF", "-DBUILD_SHARED_LIBS:BOOL=OFF" + ), cmd_mkdir(r"{inc_dir}\openjpeg-2.5.0"), cmd_copy(r"src\lib\openjp2\*.h", r"{inc_dir}\openjpeg-2.5.0"), ], @@ -330,7 +325,7 @@ deps = { }, "libimagequant": { # commit: Merge branch 'master' into msvc (matches 2.17.0 tag) - "url": "https://github.com/ImageOptim/libimagequant/archive/e4c1334be0eff290af5e2b4155057c2953a313ab.zip", # noqa: E501 + "url": "https://github.com/ImageOptim/libimagequant/archive/e4c1334be0eff290af5e2b4155057c2953a313ab.zip", "filename": "libimagequant-e4c1334be0eff290af5e2b4155057c2953a313ab.zip", "dir": "libimagequant-e4c1334be0eff290af5e2b4155057c2953a313ab", "license": "COPYRIGHT", @@ -338,43 +333,41 @@ deps = { "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": [ - # lint: do not inline - cmd_cmake(), - cmd_nmake(target="clean"), - cmd_nmake(target="imagequant_a"), + *cmds_cmake("imagequant_a"), cmd_copy("imagequant_a.lib", "imagequant.lib"), ], "headers": [r"*.h"], "libs": [r"imagequant.lib"], }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/6.0.0.zip", - "filename": "harfbuzz-6.0.0.zip", - "dir": "harfbuzz-6.0.0", + "url": "https://github.com/harfbuzz/harfbuzz/archive/8.3.0.zip", + "filename": "harfbuzz-8.3.0.zip", + "dir": "harfbuzz-8.3.0", "license": "COPYING", "build": [ - cmd_set("CXXFLAGS", "-d2FH4-"), - cmd_cmake("-DHB_HAVE_FREETYPE:BOOL=TRUE"), - cmd_nmake(target="clean"), - cmd_nmake(target="harfbuzz"), + *cmds_cmake( + "harfbuzz", + "-DHB_HAVE_FREETYPE:BOOL=TRUE", + '-DCMAKE_CXX_FLAGS="-nologo -d2FH4-"', + ), ], "headers": [r"src\*.h"], "libs": [r"*.lib"], }, "fribidi": { - "url": "https://github.com/fribidi/fribidi/archive/v1.0.12.zip", - "filename": "fribidi-1.0.12.zip", - "dir": "fribidi-1.0.12", + "url": "https://github.com/fribidi/fribidi/archive/v1.0.13.zip", + "filename": "fribidi-1.0.13.zip", + "dir": "fribidi-1.0.13", "license": "COPYING", "build": [ - cmd_copy(r"COPYING", r"{bin_dir}\fribidi-1.0.12-COPYING"), + cmd_copy(r"COPYING", r"{bin_dir}\fribidi-1.0.13-COPYING"), cmd_copy(r"{winbuild_dir}\fribidi.cmake", r"CMakeLists.txt"), - cmd_cmake(), - cmd_nmake(target="clean"), - cmd_nmake(target="fribidi"), + *cmds_cmake("fribidi"), ], "bins": [r"*.dll"], }, @@ -382,12 +375,17 @@ deps = { # based on distutils._msvccompiler from CPython 3.7.4 -def find_msvs(): +def find_msvs(architecture: str) -> dict[str, str] | None: root = os.environ.get("ProgramFiles(x86)") or os.environ.get("ProgramFiles") if not root: print("Program Files not found") return None + if architecture == "ARM64": + tools = "Microsoft.VisualStudio.Component.VC.Tools.ARM64" + else: + tools = "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" + try: vspath = ( subprocess.check_output( @@ -398,7 +396,7 @@ def find_msvs(): "-latest", "-prerelease", "-requires", - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + tools, "-property", "installationPath", "-products", @@ -416,23 +414,12 @@ def find_msvs(): print("Visual Studio seems to be missing C compiler") return None - vs = { - "header": [], - # nmake selected by vcvarsall - "nmake": "nmake.exe", - "vs_dir": vspath, - } - # vs2017 msbuild = os.path.join(vspath, "MSBuild", "15.0", "Bin", "MSBuild.exe") - if os.path.isfile(msbuild): - vs["msbuild"] = f'"{msbuild}"' - else: + if not os.path.isfile(msbuild): # vs2019 msbuild = os.path.join(vspath, "MSBuild", "Current", "Bin", "MSBuild.exe") - if os.path.isfile(msbuild): - vs["msbuild"] = f'"{msbuild}"' - else: + if not os.path.isfile(msbuild): print("Visual Studio MSBuild not found") return None @@ -440,30 +427,49 @@ def find_msvs(): if not os.path.isfile(vcvarsall): print("Visual Studio vcvarsall not found") return None - vs["header"].append(f'call "{vcvarsall}" {{vcvars_arch}}') - return vs + return { + "vs_dir": vspath, + "msbuild": f'"{msbuild}"', + "vcvarsall": f'"{vcvarsall}"', + "nmake": "nmake.exe", # nmake selected by vcvarsall + } -def extract_dep(url, filename): - import tarfile +def download_dep(url: str, file: str) -> None: import urllib.request + + ex = None + for i in range(3): + try: + print(f"Fetching {url} (attempt {i + 1})...") + content = urllib.request.urlopen(url).read() + with open(file, "wb") as f: + f.write(content) + break + except urllib.error.URLError as e: + ex = e + else: + raise RuntimeError(ex) + + +def extract_dep(url: str, filename: str) -> None: + import tarfile import zipfile - file = os.path.join(depends_dir, filename) + file = os.path.join(args.depends_dir, filename) if not os.path.exists(file): - ex = None - for i in range(3): - try: - print("Fetching %s (attempt %d)..." % (url, i + 1)) - content = urllib.request.urlopen(url).read() - with open(file, "wb") as f: - f.write(content) - break - except urllib.error.URLError as e: - ex = e - else: - raise RuntimeError(ex) + # First try our mirror + mirror_url = ( + f"https://raw.githubusercontent.com/" + f"python-pillow/pillow-depends/main/{filename}" + ) + try: + download_dep(mirror_url, file) + except RuntimeError as exc: + # Otherwise try upstream + print(exc) + download_dep(url, file) print("Extracting " + filename) sources_dir_abs = os.path.abspath(sources_dir) @@ -473,32 +479,35 @@ def extract_dep(url, filename): member_abspath = os.path.abspath(os.path.join(sources_dir, member)) member_prefix = os.path.commonpath([sources_dir_abs, member_abspath]) if sources_dir_abs != member_prefix: - raise RuntimeError("Attempted Path Traversal in Zip File") + msg = "Attempted Path Traversal in Zip File" + raise RuntimeError(msg) zf.extractall(sources_dir) - elif filename.endswith(".tar.gz") or filename.endswith(".tgz"): + elif filename.endswith((".tar.gz", ".tgz")): with tarfile.open(file, "r:gz") as tgz: for member in tgz.getnames(): member_abspath = os.path.abspath(os.path.join(sources_dir, member)) member_prefix = os.path.commonpath([sources_dir_abs, member_abspath]) if sources_dir_abs != member_prefix: - raise RuntimeError("Attempted Path Traversal in Tar File") + msg = "Attempted Path Traversal in Tar File" + raise RuntimeError(msg) tgz.extractall(sources_dir) else: - raise RuntimeError("Unknown archive type: " + filename) + msg = "Unknown archive type: " + filename + raise RuntimeError(msg) -def write_script(name, lines): - name = os.path.join(build_dir, name) +def write_script(name: str, lines: list[str]) -> None: + name = os.path.join(args.build_dir, name) lines = [line.format(**prefs) for line in lines] print("Writing " + name) with open(name, "w", newline="") as f: f.write(os.linesep.join(lines)) - if verbose: + if args.verbose: for line in lines: print(" " + line) -def get_footer(dep): +def get_footer(dep: dict) -> list[str]: lines = [] for out in dep.get("headers", []): lines.append(cmd_copy(out, "{inc_dir}")) @@ -509,8 +518,24 @@ def get_footer(dep): return lines -def build_dep(name): - dep = deps[name] +def build_env() -> None: + lines = [ + "if defined DISTUTILS_USE_SDK goto end", + cmd_set("INCLUDE", "{inc_dir}"), + cmd_set("INCLIB", "{lib_dir}"), + cmd_set("LIB", "{lib_dir}"), + cmd_append("PATH", "{bin_dir}"), + "call {vcvarsall} {vcvars_arch}", + cmd_set("DISTUTILS_USE_SDK", "1"), # use same compiler to build Pillow + cmd_set("py_vcruntime_redist", "true"), # always use /MD, never /MT + ":end", + "@echo on", + ] + write_script("build_env.cmd", lines) + + +def build_dep(name: str) -> str: + dep = DEPS[name] dir = dep["dir"] file = f"build_dep_{name}.cmd" @@ -546,11 +571,11 @@ def build_dep(name): banner = f"Building {name} ({dir})" lines = [ + r'call "{build_dir}\build_env.cmd"', "@echo " + ("=" * 70), f"@echo ==== {banner:<60} ====", "@echo " + ("=" * 70), - "cd /D %s" % os.path.join(sources_dir, dir), - *prefs["header"], + cmd_cd(os.path.join(sources_dir, dir)), *dep.get("build", []), *get_footer(dep), ] @@ -559,118 +584,143 @@ def build_dep(name): return file -def build_dep_all(): - lines = ["@echo on"] - for dep_name in deps: +def build_dep_all() -> None: + lines = [r'call "{build_dir}\build_env.cmd"'] + gha_groups = "GITHUB_ACTIONS" in os.environ + for dep_name in DEPS: + print() if dep_name in disabled: + print(f"Skipping disabled dependency {dep_name}") continue script = build_dep(dep_name) + if gha_groups: + lines.append(f"@echo ::group::Running {script}") lines.append(rf'cmd.exe /c "{{build_dir}}\{script}"') lines.append("if errorlevel 1 echo Build failed! && exit /B 1") + if gha_groups: + lines.append("@echo ::endgroup::") + print() lines.append("@echo All Pillow dependencies built successfully!") write_script("build_dep_all.cmd", lines) -def build_pillow(): - lines = [ - "@echo ---- Building Pillow (build_ext %*) ----", - cmd_cd("{pillow_dir}"), - *prefs["header"], - cmd_set("DISTUTILS_USE_SDK", "1"), # use same compiler to build Pillow - cmd_set("py_vcruntime_redist", "true"), # always use /MD, never /MT - r'"{python_dir}\{python_exe}" setup.py build_ext --vendor-raqm --vendor-fribidi %*', # noqa: E501 - ] - - write_script("build_pillow.cmd", lines) - - if __name__ == "__main__": - # winbuild directory winbuild_dir = os.path.dirname(os.path.realpath(__file__)) + pillow_dir = os.path.realpath(os.path.join(winbuild_dir, "..")) - verbose = False - disabled = [] - depends_dir = os.environ.get("PILLOW_DEPS", os.path.join(winbuild_dir, "depends")) - python_dir = os.environ.get("PYTHON") - python_exe = os.environ.get("EXECUTABLE", "python.exe") - architecture = os.environ.get( - "ARCHITECTURE", - "ARM64" - if platform.machine() == "ARM64" - else ("x86" if struct.calcsize("P") == 4 else "x64"), + parser = argparse.ArgumentParser( + prog="winbuild\\build_prepare.py", + description="Download and generate build scripts for Pillow dependencies.", + epilog="""Arguments can also be supplied using the environment variables + PILLOW_BUILD, PILLOW_DEPS, ARCHITECTURE. See winbuild\\build.rst + for more information.""", ) - build_dir = os.environ.get("PILLOW_BUILD", os.path.join(winbuild_dir, "build")) - sources_dir = "" - for arg in sys.argv[1:]: - if arg == "-v": - verbose = True - elif arg == "--no-imagequant": - disabled += ["libimagequant"] - elif arg == "--no-raqm" or arg == "--no-fribidi": - disabled += ["fribidi"] - elif arg.startswith("--depends="): - depends_dir = arg[10:] - elif arg.startswith("--python="): - python_dir = arg[9:] - elif arg.startswith("--executable="): - python_exe = arg[13:] - elif arg.startswith("--architecture="): - architecture = arg[15:] - elif arg.startswith("--dir="): - build_dir = arg[6:] - elif arg == "--srcdir": - sources_dir = os.path.sep + "src" - else: - raise ValueError("Unknown parameter: " + arg) + parser.add_argument( + "-v", "--verbose", action="store_true", help="print generated scripts" + ) + parser.add_argument( + "-d", + "--dir", + "--build-dir", + dest="build_dir", + metavar="PILLOW_BUILD", + default=os.environ.get("PILLOW_BUILD", os.path.join(winbuild_dir, "build")), + help="build directory (default: 'winbuild\\build')", + ) + parser.add_argument( + "--depends", + dest="depends_dir", + metavar="PILLOW_DEPS", + default=os.environ.get("PILLOW_DEPS", os.path.join(winbuild_dir, "depends")), + help="directory used to store cached dependencies " + "(default: 'winbuild\\depends')", + ) + parser.add_argument( + "--architecture", + choices=ARCHITECTURES, + default=os.environ.get( + "ARCHITECTURE", + ( + "ARM64" + if platform.machine() == "ARM64" + else ("x86" if struct.calcsize("P") == 4 else "x64") + ), + ), + help="build architecture (default: same as host Python)", + ) + parser.add_argument( + "--nmake", + dest="cmake_generator", + action="store_const", + const="NMake Makefiles", + default="Ninja", + help="build dependencies using NMake instead of Ninja", + ) + parser.add_argument( + "--no-imagequant", + action="store_true", + help="skip GPL-licensed optional dependency libimagequant", + ) + parser.add_argument( + "--no-fribidi", + "--no-raqm", + action="store_true", + help="skip LGPL-licensed optional dependency FriBiDi", + ) + args = parser.parse_args() - # dependency cache directory - os.makedirs(depends_dir, exist_ok=True) - print("Caching dependencies in:", depends_dir) + arch_prefs = ARCHITECTURES[args.architecture] + print("Target architecture:", args.architecture) - if python_dir is None: - python_dir = os.path.dirname(os.path.realpath(sys.executable)) - python_exe = os.path.basename(sys.executable) - print("Target Python:", os.path.join(python_dir, python_exe)) - - arch_prefs = architectures[architecture] - print("Target Architecture:", architecture) - - msvs = find_msvs() + msvs = find_msvs(args.architecture) if msvs is None: - raise RuntimeError( - "Visual Studio not found. Please install Visual Studio 2017 or newer." - ) + msg = "Visual Studio not found. Please install Visual Studio 2017 or newer." + raise RuntimeError(msg) print("Found Visual Studio at:", msvs["vs_dir"]) - print("Using output directory:", build_dir) + # dependency cache directory + args.depends_dir = os.path.abspath(args.depends_dir) + os.makedirs(args.depends_dir, exist_ok=True) + print("Caching dependencies in:", args.depends_dir) + + args.build_dir = os.path.abspath(args.build_dir) + print("Using output directory:", args.build_dir) # build directory for *.h files - inc_dir = os.path.join(build_dir, "inc") + inc_dir = os.path.join(args.build_dir, "inc") # build directory for *.lib files - lib_dir = os.path.join(build_dir, "lib") + lib_dir = os.path.join(args.build_dir, "lib") # build directory for *.bin files - bin_dir = os.path.join(build_dir, "bin") + bin_dir = os.path.join(args.build_dir, "bin") # directory for storing project files - sources_dir = build_dir + sources_dir + sources_dir = os.path.join(args.build_dir, "src") # copy dependency licenses to this directory - license_dir = os.path.join(build_dir, "license") + license_dir = os.path.join(args.build_dir, "license") - shutil.rmtree(build_dir, ignore_errors=True) - os.makedirs(build_dir, exist_ok=False) + shutil.rmtree(args.build_dir, ignore_errors=True) + os.makedirs(args.build_dir, exist_ok=False) for path in [inc_dir, lib_dir, bin_dir, sources_dir, license_dir]: os.makedirs(path, exist_ok=True) + disabled = [] + if args.no_imagequant: + disabled += ["libimagequant"] + if args.no_fribidi: + disabled += ["fribidi"] + elif args.architecture == "ARM64" and platform.machine() != "ARM64": + import warnings + + warnings.warn("Cross-compiling FriBiDi is currently not supported, disabling") + disabled += ["fribidi"] + prefs = { - # Python paths / preferences - "python_dir": python_dir, - "python_exe": python_exe, - "architecture": architecture, + "architecture": args.architecture, **arch_prefs, # Pillow paths - "pillow_dir": os.path.realpath(os.path.join(winbuild_dir, "..")), + "pillow_dir": pillow_dir, "winbuild_dir": winbuild_dir, # Build paths - "build_dir": build_dir, + "build_dir": args.build_dir, "inc_dir": inc_dir, "lib_dir": lib_dir, "bin_dir": bin_dir, @@ -679,16 +729,15 @@ if __name__ == "__main__": # Compilers / Tools **msvs, "cmake": "cmake.exe", # TODO find CMAKE automatically + "cmake_generator": args.cmake_generator, # TODO find NASM automatically - # script header - "header": sum([header, msvs["header"], ["@echo on"]], []), } - for k, v in deps.items(): + for k, v in DEPS.items(): prefs[f"dir_{k}"] = os.path.join(sources_dir, v["dir"]) print() write_script(".gitignore", ["*"]) + build_env() build_dep_all() - build_pillow()