diff --git a/.ci/install.sh b/.ci/install.sh index 62677005e..d065e7ab5 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -23,7 +23,7 @@ if [[ $(uname) != CYGWIN* ]]; then sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\ ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\ cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ - sway wl-clipboard libopenblas-dev + sway wl-clipboard libopenblas-dev nasm fi python3 -m pip install --upgrade pip @@ -36,6 +36,9 @@ python3 -m pip install -U pytest python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-timeout python3 -m pip install pyroma +# optional test dependency, only install if there's a binary package. +# fails on beta 3.14 and PyPy +python3 -m pip install --only-binary=:all: pyarrow || true if [[ $(uname) != CYGWIN* ]]; then python3 -m pip install numpy @@ -50,7 +53,7 @@ if [[ $(uname) != CYGWIN* ]]; then # Pyroma uses non-isolated build and fails with old setuptools if [[ $GHA_PYTHON_VERSION == 3.9 ]]; then # To match pyproject.toml - python3 -m pip install "setuptools>=67.8" + python3 -m pip install "setuptools>=77" fi # webp @@ -62,6 +65,9 @@ if [[ $(uname) != CYGWIN* ]]; then # raqm pushd depends && ./install_raqm.sh && popd + # libavif + pushd depends && CMAKE_POLICY_VERSION_MINIMUM=3.5 ./install_libavif.sh && popd + # extra test images pushd depends && ./install_extra_test_images.sh && popd else diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index f2109ed61..db5f89c9a 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.23.1 +cibuildwheel==2.23.2 diff --git a/.clang-format b/.clang-format index 143dde82c..1871d1f7a 100644 --- a/.clang-format +++ b/.clang-format @@ -1,5 +1,26 @@ # A clang-format style that approximates Python's PEP 7 # Useful for IDE integration +Language: C +BasedOnStyle: Google +AlwaysBreakAfterReturnType: All +AllowShortIfStatementsOnASingleLine: false +AlignAfterOpenBracket: BlockIndent +BinPackArguments: false +BinPackParameters: false +BreakBeforeBraces: Attach +ColumnLimit: 88 +DerivePointerAlignment: false +IndentGotoLabels: false +IndentWidth: 4 +PointerAlignment: Right +ReflowComments: true +SortIncludes: false +SpaceBeforeParens: ControlStatements +SpacesInParentheses: false +TabWidth: 4 +UseTab: Never +--- +Language: Cpp BasedOnStyle: Google AlwaysBreakAfterReturnType: All AllowShortIfStatementsOnASingleLine: false @@ -11,7 +32,6 @@ ColumnLimit: 88 DerivePointerAlignment: false IndentGotoLabels: false IndentWidth: 4 -Language: Cpp PointerAlignment: Right ReflowComments: true SortIncludes: false diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index 6aa59a4ac..94e3d5d08 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -6,6 +6,8 @@ if [[ "$ImageOS" == "macos13" ]]; then brew uninstall gradle maven fi brew install \ + aom \ + dav1d \ freetype \ ghostscript \ jpeg-turbo \ @@ -14,6 +16,8 @@ brew install \ libtiff \ little-cms2 \ openjpeg \ + rav1e \ + svt-av1 \ webp export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" @@ -26,6 +30,12 @@ python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-timeout python3 -m pip install pyroma python3 -m pip install numpy +# optional test dependency, only install if there's a binary package. +# fails on beta 3.14 and PyPy +python3 -m pip install --only-binary=:all: pyarrow || true + +# libavif +pushd depends && ./install_libavif.sh && popd # extra test images pushd depends && ./install_extra_test_images.sh && popd diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 25aef55fb..0b90732eb 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -47,8 +47,8 @@ jobs: centos-stream-10-amd64, debian-12-bookworm-x86, debian-12-bookworm-amd64, - fedora-40-amd64, fedora-41-amd64, + fedora-42-amd64, gentoo, ubuntu-22.04-jammy-amd64, ubuntu-24.04-noble-amd64, diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index bb6d7dc37..5a83c16c3 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -60,6 +60,7 @@ jobs: mingw-w64-x86_64-gcc \ mingw-w64-x86_64-ghostscript \ mingw-w64-x86_64-lcms2 \ + mingw-w64-x86_64-libavif \ mingw-w64-x86_64-libimagequant \ mingw-w64-x86_64-libjpeg-turbo \ mingw-w64-x86_64-libraqm \ diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index a780c7835..bf8ec2f2c 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -42,7 +42,7 @@ jobs: # Test the oldest Python on 32-bit - { python-version: "3.9", architecture: "x86", os: "windows-2019" } - timeout-minutes: 30 + timeout-minutes: 45 name: Python ${{ matrix.python-version }} (${{ matrix.architecture }}) @@ -88,6 +88,10 @@ jobs: run: | python3 -m pip install PyQt6 + - name: Install PyArrow dependency + run: | + python3 -m pip install --only-binary=:all: pyarrow || true + - name: Install dependencies id: install run: | @@ -145,6 +149,10 @@ jobs: if: steps.build-cache.outputs.cache-hit != 'true' run: "& winbuild\\build\\build_dep_libpng.cmd" + - name: Build dependencies / libavif + if: steps.build-cache.outputs.cache-hit != 'true' && matrix.architecture == 'x64' + run: "& winbuild\\build\\build_dep_libavif.cmd" + # for FreeType WOFF2 font support - name: Build dependencies / brotli if: steps.build-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4ad88be9..006d574f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,7 +70,7 @@ jobs: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: Quansight-Labs/setup-python@v5 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index e9c54536e..a4592871f 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -25,7 +25,7 @@ else MB_ML_LIBC=${AUDITWHEEL_POLICY::9} MB_ML_VER=${AUDITWHEEL_POLICY:9} fi -PLAT=$CIBW_ARCHS +PLAT="${CIBW_ARCHS:-$AUDITWHEEL_ARCH}" # Define custom utilities source wheels/multibuild/common_utils.sh @@ -38,13 +38,14 @@ ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds FREETYPE_VERSION=2.13.3 -HARFBUZZ_VERSION=10.4.0 +HARFBUZZ_VERSION=11.1.0 LIBPNG_VERSION=1.6.47 JPEGTURBO_VERSION=3.1.0 OPENJPEG_VERSION=2.5.3 -XZ_VERSION=5.6.4 +XZ_VERSION=5.8.1 TIFF_VERSION=4.7.0 LCMS2_VERSION=2.17 +ZLIB_VERSION=1.3.1 ZLIB_NG_VERSION=2.2.4 LIBWEBP_VERSION=1.5.0 BZIP2_VERSION=1.0.8 @@ -64,11 +65,7 @@ function build_pkg_config { function build_zlib_ng { if [ -e zlib-stamp ]; then return; fi - fetch_unpack https://github.com/zlib-ng/zlib-ng/archive/$ZLIB_NG_VERSION.tar.gz zlib-ng-$ZLIB_NG_VERSION.tar.gz - (cd zlib-ng-$ZLIB_NG_VERSION \ - && ./configure --prefix=$BUILD_PREFIX --zlib-compat \ - && make -j4 \ - && make install) + build_github zlib-ng/zlib-ng $ZLIB_NG_VERSION --zlib-compat if [ -n "$IS_MACOS" ]; then # Ensure that on macOS, the library name is an absolute path, not an @@ -95,7 +92,7 @@ function build_harfbuzz { local out_dir=$(fetch_unpack https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION/harfbuzz-$HARFBUZZ_VERSION.tar.xz harfbuzz-$HARFBUZZ_VERSION.tar.xz) (cd $out_dir \ - && meson setup build --prefix=$BUILD_PREFIX --libdir=$BUILD_PREFIX/lib --buildtype=release -Dfreetype=enabled -Dglib=disabled) + && meson setup build --prefix=$BUILD_PREFIX --libdir=$BUILD_PREFIX/lib --buildtype=minsize -Dfreetype=enabled -Dglib=disabled -Dtests=disabled) (cd $out_dir/build \ && meson install) touch harfbuzz-stamp @@ -106,7 +103,11 @@ function build { if [ -z "$IS_ALPINE" ] && [ -z "$SANITIZER" ] && [ -z "$IS_MACOS" ]; then yum remove -y zlib-devel fi - build_zlib_ng + if [[ -n "$IS_MACOS" ]] && [[ "$MACOSX_DEPLOYMENT_TARGET" == "10.10" || "$MACOSX_DEPLOYMENT_TARGET" == "10.13" ]]; then + build_new_zlib + else + build_zlib_ng + fi build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto if [ -n "$IS_MACOS" ]; then diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 1fe6badae..33e1976f0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -121,14 +121,17 @@ jobs: windows: if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' name: Windows ${{ matrix.cibw_arch }} - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - cibw_arch: x86 + os: windows-latest - cibw_arch: AMD64 + os: windows-latest - cibw_arch: ARM64 + os: windows-11-arm steps: - uses: actions/checkout@v4 with: @@ -157,7 +160,7 @@ jobs: # Install extra test images xcopy /S /Y Tests\test-images\* Tests\images - & python.exe winbuild\build_prepare.py -v --no-imagequant --architecture=${{ matrix.cibw_arch }} + & python.exe winbuild\build_prepare.py -v --no-imagequant --no-avif --architecture=${{ matrix.cibw_arch }} shell: pwsh - name: Build wheels @@ -240,7 +243,7 @@ jobs: path: dist merge-multiple: true - name: Upload wheels to scientific-python-nightly-wheels - uses: scientific-python/upload-nightly-action@82396a2ed4269ba06c6b2988bb4fd568ef3c3d6b # 0.6.1 + uses: scientific-python/upload-nightly-action@b36e8c0c10dbcfd2e05bf95f17ef8c14fd708dbf # 0.6.2 with: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ff947d41..140ce33be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.9 + rev: v0.11.4 hooks: - id: ruff args: [--exit-non-zero-on-fix] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v19.1.7 + rev: v20.1.0 hooks: - id: clang-format types: [c] @@ -44,20 +44,21 @@ repos: - id: check-json - id: check-toml - id: check-yaml + args: [--allow-multiple-documents] - id: end-of-file-fixer exclude: ^Tests/images/ - id: trailing-whitespace exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.31.2 + rev: 0.32.1 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate - repo: https://github.com/woodruffw/zizmor-pre-commit - rev: v1.4.1 + rev: v1.5.2 hooks: - id: zizmor @@ -72,7 +73,7 @@ repos: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.23 + rev: v0.24.1 hooks: - id: validate-pyproject additional_dependencies: [trove-classifiers>=2024.10.12] diff --git a/Makefile b/Makefile index 53164b08a..5a8152454 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,10 @@ doc html: htmlview: $(MAKE) -C docs htmlview +.PHONY: htmllive +htmllive: + $(MAKE) -C docs htmllive + .PHONY: doccheck doccheck: $(MAKE) doc @@ -43,6 +47,7 @@ help: @echo " docserve run an HTTP server on the docs directory" @echo " html make HTML docs" @echo " htmlview open the index page built by the html target in your browser" + @echo " htmllive rebuild and reload HTML files in your browser" @echo " install make and install" @echo " install-coverage make and install with C coverage" @echo " lint run the lint checks" diff --git a/Tests/check_wheel.py b/Tests/check_wheel.py index 563be0b74..8ba40ba3f 100644 --- a/Tests/check_wheel.py +++ b/Tests/check_wheel.py @@ -1,9 +1,12 @@ from __future__ import annotations +import platform import sys from PIL import features +from .helper import is_pypy + def test_wheel_modules() -> None: expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"} @@ -40,5 +43,7 @@ def test_wheel_features() -> None: if sys.platform == "win32": expected_features.remove("xcb") + elif sys.platform == "darwin" and not is_pypy() and platform.processor() != "arm": + expected_features.remove("zlib_ng") assert set(features.get_supported_features()) == expected_features diff --git a/Tests/images/avif/exif.avif b/Tests/images/avif/exif.avif new file mode 100644 index 000000000..07964487f Binary files /dev/null and b/Tests/images/avif/exif.avif differ diff --git a/Tests/images/avif/hopper-missing-pixi.avif b/Tests/images/avif/hopper-missing-pixi.avif new file mode 100644 index 000000000..c793ade1c Binary files /dev/null and b/Tests/images/avif/hopper-missing-pixi.avif differ diff --git a/Tests/images/avif/hopper.avif b/Tests/images/avif/hopper.avif new file mode 100644 index 000000000..87e4394f0 Binary files /dev/null and b/Tests/images/avif/hopper.avif differ diff --git a/Tests/images/avif/hopper.heif b/Tests/images/avif/hopper.heif new file mode 100644 index 000000000..f6dbd1b47 Binary files /dev/null and b/Tests/images/avif/hopper.heif differ diff --git a/Tests/images/avif/hopper_avif_write.png b/Tests/images/avif/hopper_avif_write.png new file mode 100644 index 000000000..a47a0562b Binary files /dev/null and b/Tests/images/avif/hopper_avif_write.png differ diff --git a/Tests/images/avif/icc_profile.avif b/Tests/images/avif/icc_profile.avif new file mode 100644 index 000000000..658cfec17 Binary files /dev/null and b/Tests/images/avif/icc_profile.avif differ diff --git a/Tests/images/avif/icc_profile_none.avif b/Tests/images/avif/icc_profile_none.avif new file mode 100644 index 000000000..c73e70a3a Binary files /dev/null and b/Tests/images/avif/icc_profile_none.avif differ diff --git a/Tests/images/avif/rot0mir0.avif b/Tests/images/avif/rot0mir0.avif new file mode 100644 index 000000000..f57203093 Binary files /dev/null and b/Tests/images/avif/rot0mir0.avif differ diff --git a/Tests/images/avif/rot0mir1.avif b/Tests/images/avif/rot0mir1.avif new file mode 100644 index 000000000..8a9fb5e54 Binary files /dev/null and b/Tests/images/avif/rot0mir1.avif differ diff --git a/Tests/images/avif/rot1mir0.avif b/Tests/images/avif/rot1mir0.avif new file mode 100644 index 000000000..0c7c7620f Binary files /dev/null and b/Tests/images/avif/rot1mir0.avif differ diff --git a/Tests/images/avif/rot1mir1.avif b/Tests/images/avif/rot1mir1.avif new file mode 100644 index 000000000..0181b088c Binary files /dev/null and b/Tests/images/avif/rot1mir1.avif differ diff --git a/Tests/images/avif/rot2mir0.avif b/Tests/images/avif/rot2mir0.avif new file mode 100644 index 000000000..ddaa02f3f Binary files /dev/null and b/Tests/images/avif/rot2mir0.avif differ diff --git a/Tests/images/avif/rot2mir1.avif b/Tests/images/avif/rot2mir1.avif new file mode 100644 index 000000000..63326b86c Binary files /dev/null and b/Tests/images/avif/rot2mir1.avif differ diff --git a/Tests/images/avif/rot3mir0.avif b/Tests/images/avif/rot3mir0.avif new file mode 100644 index 000000000..ed5dbe3d0 Binary files /dev/null and b/Tests/images/avif/rot3mir0.avif differ diff --git a/Tests/images/avif/rot3mir1.avif b/Tests/images/avif/rot3mir1.avif new file mode 100644 index 000000000..ccd8861d5 Binary files /dev/null and b/Tests/images/avif/rot3mir1.avif differ diff --git a/Tests/images/avif/star.avifs b/Tests/images/avif/star.avifs new file mode 100644 index 000000000..f2753395f Binary files /dev/null and b/Tests/images/avif/star.avifs differ diff --git a/Tests/images/avif/star.gif b/Tests/images/avif/star.gif new file mode 100644 index 000000000..52076cafd Binary files /dev/null and b/Tests/images/avif/star.gif differ diff --git a/Tests/images/avif/star.png b/Tests/images/avif/star.png new file mode 100644 index 000000000..468dcde00 Binary files /dev/null and b/Tests/images/avif/star.png differ diff --git a/Tests/images/avif/transparency.avif b/Tests/images/avif/transparency.avif new file mode 100644 index 000000000..f808357fc Binary files /dev/null and b/Tests/images/avif/transparency.avif differ diff --git a/Tests/images/avif/xmp_tags_orientation.avif b/Tests/images/avif/xmp_tags_orientation.avif new file mode 100644 index 000000000..41faa6050 Binary files /dev/null and b/Tests/images/avif/xmp_tags_orientation.avif differ diff --git a/Tests/images/drawing_emf_ref_72_144.png b/Tests/images/drawing_emf_ref_72_144.png new file mode 100644 index 000000000..000377b6c Binary files /dev/null and b/Tests/images/drawing_emf_ref_72_144.png differ diff --git a/Tests/images/full_gimp_palette.gpl b/Tests/images/full_gimp_palette.gpl new file mode 100644 index 000000000..004217210 --- /dev/null +++ b/Tests/images/full_gimp_palette.gpl @@ -0,0 +1,260 @@ +GIMP Palette +Name: fullpalette +Columns: 4 +# + 0 0 0 Index 0 + 1 1 1 Index 1 + 2 2 2 Index 2 + 3 3 3 Index 3 + 4 4 4 Index 4 + 5 5 5 Index 5 + 6 6 6 Index 6 + 7 7 7 Index 7 + 8 8 8 Index 8 + 9 9 9 Index 9 + 10 10 10 Index 10 + 11 11 11 Index 11 + 12 12 12 Index 12 + 13 13 13 Index 13 + 14 14 14 Index 14 + 15 15 15 Index 15 + 16 16 16 Index 16 + 17 17 17 Index 17 + 18 18 18 Index 18 + 19 19 19 Index 19 + 20 20 20 Index 20 + 21 21 21 Index 21 + 22 22 22 Index 22 + 23 23 23 Index 23 + 24 24 24 Index 24 + 25 25 25 Index 25 + 26 26 26 Index 26 + 27 27 27 Index 27 + 28 28 28 Index 28 + 29 29 29 Index 29 + 30 30 30 Index 30 + 31 31 31 Index 31 + 32 32 32 Index 32 + 33 33 33 Index 33 + 34 34 34 Index 34 + 35 35 35 Index 35 + 36 36 36 Index 36 + 37 37 37 Index 37 + 38 38 38 Index 38 + 39 39 39 Index 39 + 40 40 40 Index 40 + 41 41 41 Index 41 + 42 42 42 Index 42 + 43 43 43 Index 43 + 44 44 44 Index 44 + 45 45 45 Index 45 + 46 46 46 Index 46 + 47 47 47 Index 47 + 48 48 48 Index 48 + 49 49 49 Index 49 + 50 50 50 Index 50 + 51 51 51 Index 51 + 52 52 52 Index 52 + 53 53 53 Index 53 + 54 54 54 Index 54 + 55 55 55 Index 55 + 56 56 56 Index 56 + 57 57 57 Index 57 + 58 58 58 Index 58 + 59 59 59 Index 59 + 60 60 60 Index 60 + 61 61 61 Index 61 + 62 62 62 Index 62 + 63 63 63 Index 63 + 64 64 64 Index 64 + 65 65 65 Index 65 + 66 66 66 Index 66 + 67 67 67 Index 67 + 68 68 68 Index 68 + 69 69 69 Index 69 + 70 70 70 Index 70 + 71 71 71 Index 71 + 72 72 72 Index 72 + 73 73 73 Index 73 + 74 74 74 Index 74 + 75 75 75 Index 75 + 76 76 76 Index 76 + 77 77 77 Index 77 + 78 78 78 Index 78 + 79 79 79 Index 79 + 80 80 80 Index 80 + 81 81 81 Index 81 + 82 82 82 Index 82 + 83 83 83 Index 83 + 84 84 84 Index 84 + 85 85 85 Index 85 + 86 86 86 Index 86 + 87 87 87 Index 87 + 88 88 88 Index 88 + 89 89 89 Index 89 + 90 90 90 Index 90 + 91 91 91 Index 91 + 92 92 92 Index 92 + 93 93 93 Index 93 + 94 94 94 Index 94 + 95 95 95 Index 95 + 96 96 96 Index 96 + 97 97 97 Index 97 + 98 98 98 Index 98 + 99 99 99 Index 99 +100 100 100 Index 100 +101 101 101 Index 101 +102 102 102 Index 102 +103 103 103 Index 103 +104 104 104 Index 104 +105 105 105 Index 105 +106 106 106 Index 106 +107 107 107 Index 107 +108 108 108 Index 108 +109 109 109 Index 109 +110 110 110 Index 110 +111 111 111 Index 111 +112 112 112 Index 112 +113 113 113 Index 113 +114 114 114 Index 114 +115 115 115 Index 115 +116 116 116 Index 116 +117 117 117 Index 117 +118 118 118 Index 118 +119 119 119 Index 119 +120 120 120 Index 120 +121 121 121 Index 121 +122 122 122 Index 122 +123 123 123 Index 123 +124 124 124 Index 124 +125 125 125 Index 125 +126 126 126 Index 126 +127 127 127 Index 127 +128 128 128 Index 128 +129 129 129 Index 129 +130 130 130 Index 130 +131 131 131 Index 131 +132 132 132 Index 132 +133 133 133 Index 133 +134 134 134 Index 134 +135 135 135 Index 135 +136 136 136 Index 136 +137 137 137 Index 137 +138 138 138 Index 138 +139 139 139 Index 139 +140 140 140 Index 140 +141 141 141 Index 141 +142 142 142 Index 142 +143 143 143 Index 143 +144 144 144 Index 144 +145 145 145 Index 145 +146 146 146 Index 146 +147 147 147 Index 147 +148 148 148 Index 148 +149 149 149 Index 149 +150 150 150 Index 150 +151 151 151 Index 151 +152 152 152 Index 152 +153 153 153 Index 153 +154 154 154 Index 154 +155 155 155 Index 155 +156 156 156 Index 156 +157 157 157 Index 157 +158 158 158 Index 158 +159 159 159 Index 159 +160 160 160 Index 160 +161 161 161 Index 161 +162 162 162 Index 162 +163 163 163 Index 163 +164 164 164 Index 164 +165 165 165 Index 165 +166 166 166 Index 166 +167 167 167 Index 167 +168 168 168 Index 168 +169 169 169 Index 169 +170 170 170 Index 170 +171 171 171 Index 171 +172 172 172 Index 172 +173 173 173 Index 173 +174 174 174 Index 174 +175 175 175 Index 175 +176 176 176 Index 176 +177 177 177 Index 177 +178 178 178 Index 178 +179 179 179 Index 179 +180 180 180 Index 180 +181 181 181 Index 181 +182 182 182 Index 182 +183 183 183 Index 183 +184 184 184 Index 184 +185 185 185 Index 185 +186 186 186 Index 186 +187 187 187 Index 187 +188 188 188 Index 188 +189 189 189 Index 189 +190 190 190 Index 190 +191 191 191 Index 191 +192 192 192 Index 192 +193 193 193 Index 193 +194 194 194 Index 194 +195 195 195 Index 195 +196 196 196 Index 196 +197 197 197 Index 197 +198 198 198 Index 198 +199 199 199 Index 199 +200 200 200 Index 200 +201 201 201 Index 201 +202 202 202 Index 202 +203 203 203 Index 203 +204 204 204 Index 204 +205 205 205 Index 205 +206 206 206 Index 206 +207 207 207 Index 207 +208 208 208 Index 208 +209 209 209 Index 209 +210 210 210 Index 210 +211 211 211 Index 211 +212 212 212 Index 212 +213 213 213 Index 213 +214 214 214 Index 214 +215 215 215 Index 215 +216 216 216 Index 216 +217 217 217 Index 217 +218 218 218 Index 218 +219 219 219 Index 219 +220 220 220 Index 220 +221 221 221 Index 221 +222 222 222 Index 222 +223 223 223 Index 223 +224 224 224 Index 224 +225 225 225 Index 225 +226 226 226 Index 226 +227 227 227 Index 227 +228 228 228 Index 228 +229 229 229 Index 229 +230 230 230 Index 230 +231 231 231 Index 231 +232 232 232 Index 232 +233 233 233 Index 233 +234 234 234 Index 234 +235 235 235 Index 235 +236 236 236 Index 236 +237 237 237 Index 237 +238 238 238 Index 238 +239 239 239 Index 239 +240 240 240 Index 240 +241 241 241 Index 241 +242 242 242 Index 242 +243 243 243 Index 243 +244 244 244 Index 244 +245 245 245 Index 245 +246 246 246 Index 246 +247 247 247 Index 247 +248 248 248 Index 248 +249 249 249 Index 249 +250 250 250 Index 250 +251 251 251 Index 251 +252 252 252 Index 252 +253 253 253 Index 253 +254 254 254 Index 254 +255 255 255 Index 255 diff --git a/Tests/images/imagedraw/discontiguous_corners_polygon.png b/Tests/images/imagedraw/discontiguous_corners_polygon.png index 1b58889c8..8992a1657 100644 Binary files a/Tests/images/imagedraw/discontiguous_corners_polygon.png and b/Tests/images/imagedraw/discontiguous_corners_polygon.png differ diff --git a/Tests/test_arrow.py b/Tests/test_arrow.py new file mode 100644 index 000000000..b86c77b9a --- /dev/null +++ b/Tests/test_arrow.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import pytest + +from PIL import Image + +from .helper import hopper + + +@pytest.mark.parametrize( + "mode, dest_modes", + ( + ("L", ["I", "F", "LA", "RGB", "RGBA", "RGBX", "CMYK", "YCbCr", "HSV"]), + ("I", ["L", "F"]), # Technically I;32 can work for any 4x8bit storage. + ("F", ["I", "L", "LA", "RGB", "RGBA", "RGBX", "CMYK", "YCbCr", "HSV"]), + ("LA", ["L", "F"]), + ("RGB", ["L", "F"]), + ("RGBA", ["L", "F"]), + ("RGBX", ["L", "F"]), + ("CMYK", ["L", "F"]), + ("YCbCr", ["L", "F"]), + ("HSV", ["L", "F"]), + ), +) +def test_invalid_array_type(mode: str, dest_modes: list[str]) -> None: + img = hopper(mode) + for dest_mode in dest_modes: + with pytest.raises(ValueError): + Image.fromarrow(img, dest_mode, img.size) + + +def test_invalid_array_size() -> None: + img = hopper("RGB") + + assert img.size != (10, 10) + with pytest.raises(ValueError): + Image.fromarrow(img, "RGB", (10, 10)) + + +def test_release_schema() -> None: + # these should not error out, valgrind should be clean + img = hopper("L") + schema = img.__arrow_c_schema__() + del schema + + +def test_release_array() -> None: + # these should not error out, valgrind should be clean + img = hopper("L") + array, schema = img.__arrow_c_array__() + del array + del schema + + +def test_readonly() -> None: + img = hopper("L") + reloaded = Image.fromarrow(img, img.mode, img.size) + assert reloaded.readonly == 1 + reloaded._readonly = 0 + assert reloaded.readonly == 1 + + +def test_multiblock_l_image() -> None: + block_size = Image.core.get_block_size() + + # check a 2 block image in single channel mode + size = (4096, 2 * block_size // 4096) + img = Image.new("L", size, 128) + + with pytest.raises(ValueError): + (schema, arr) = img.__arrow_c_array__() + + +def test_multiblock_rgba_image() -> None: + block_size = Image.core.get_block_size() + + # check a 2 block image in 4 channel mode + size = (4096, (block_size // 4096) // 2) + img = Image.new("RGBA", size, (128, 127, 126, 125)) + + with pytest.raises(ValueError): + (schema, arr) = img.__arrow_c_array__() + + +def test_multiblock_l_schema() -> None: + block_size = Image.core.get_block_size() + + # check a 2 block image in single channel mode + size = (4096, 2 * block_size // 4096) + img = Image.new("L", size, 128) + + with pytest.raises(ValueError): + img.__arrow_c_schema__() + + +def test_multiblock_rgba_schema() -> None: + block_size = Image.core.get_block_size() + + # check a 2 block image in 4 channel mode + size = (4096, (block_size // 4096) // 2) + img = Image.new("RGBA", size, (128, 127, 126, 125)) + + with pytest.raises(ValueError): + img.__arrow_c_schema__() + + +def test_singleblock_l_image() -> None: + Image.core.set_use_block_allocator(1) + + block_size = Image.core.get_block_size() + + # check a 2 block image in 4 channel mode + size = (4096, 2 * (block_size // 4096)) + img = Image.new("L", size, 128) + assert img.im.isblock() + + (schema, arr) = img.__arrow_c_array__() + assert schema + assert arr + + Image.core.set_use_block_allocator(0) + + +def test_singleblock_rgba_image() -> None: + Image.core.set_use_block_allocator(1) + block_size = Image.core.get_block_size() + + # check a 2 block image in 4 channel mode + size = (4096, (block_size // 4096) // 2) + img = Image.new("RGBA", size, (128, 127, 126, 125)) + assert img.im.isblock() + + (schema, arr) = img.__arrow_c_array__() + assert schema + assert arr + Image.core.set_use_block_allocator(0) + + +def test_singleblock_l_schema() -> None: + Image.core.set_use_block_allocator(1) + block_size = Image.core.get_block_size() + + # check a 2 block image in single channel mode + size = (4096, 2 * block_size // 4096) + img = Image.new("L", size, 128) + assert img.im.isblock() + + schema = img.__arrow_c_schema__() + assert schema + Image.core.set_use_block_allocator(0) + + +def test_singleblock_rgba_schema() -> None: + Image.core.set_use_block_allocator(1) + block_size = Image.core.get_block_size() + + # check a 2 block image in 4 channel mode + size = (4096, (block_size // 4096) // 2) + img = Image.new("RGBA", size, (128, 127, 126, 125)) + assert img.im.isblock() + + schema = img.__arrow_c_schema__() + assert schema + Image.core.set_use_block_allocator(0) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 4bb6c1da6..60849e74e 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -13,6 +13,7 @@ from PIL import Image, ImageSequence, PngImagePlugin # (referenced from https://wiki.mozilla.org/APNG_Specification) def test_apng_basic() -> None: with Image.open("Tests/images/apng/single_frame.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated assert im.n_frames == 1 assert im.get_format_mimetype() == "image/apng" @@ -21,6 +22,7 @@ def test_apng_basic() -> None: assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/single_frame_default.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.is_animated assert im.n_frames == 2 assert im.get_format_mimetype() == "image/apng" @@ -53,6 +55,7 @@ def test_apng_basic() -> None: ) def test_apng_fdat(filename: str) -> None: with Image.open(filename) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -60,31 +63,37 @@ def test_apng_fdat(filename: str) -> None: def test_apng_dispose() -> None: with Image.open("Tests/images/apng/dispose_op_none.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_background.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0) with Image.open("Tests/images/apng/dispose_op_background_final.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_previous.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_previous_final.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_previous_first.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0) @@ -92,21 +101,25 @@ def test_apng_dispose() -> None: def test_apng_dispose_region() -> None: with Image.open("Tests/images/apng/dispose_op_none_region.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/dispose_op_background_before_region.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0) with Image.open("Tests/images/apng/dispose_op_background_region.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 255, 255) assert im.getpixel((64, 32)) == (0, 0, 0, 0) with Image.open("Tests/images/apng/dispose_op_previous_region.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -133,6 +146,7 @@ def test_apng_dispose_op_previous_frame() -> None: # ], # ) with Image.open("Tests/images/apng/dispose_op_previous_frame.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (255, 0, 0, 255) @@ -146,26 +160,31 @@ def test_apng_dispose_op_background_p_mode() -> None: def test_apng_blend() -> None: with Image.open("Tests/images/apng/blend_op_source_solid.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/blend_op_source_transparent.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 0, 0) assert im.getpixel((64, 32)) == (0, 0, 0, 0) with Image.open("Tests/images/apng/blend_op_source_near_transparent.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 2) assert im.getpixel((64, 32)) == (0, 255, 0, 2) with Image.open("Tests/images/apng/blend_op_over.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/blend_op_over_near_transparent.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 97) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -179,6 +198,7 @@ def test_apng_blend_transparency() -> None: def test_apng_chunk_order() -> None: with Image.open("Tests/images/apng/fctl_actl.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) @@ -234,24 +254,28 @@ def test_apng_num_plays() -> None: def test_apng_mode() -> None: with Image.open("Tests/images/apng/mode_16bit.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "RGBA" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 0, 128, 191) assert im.getpixel((64, 32)) == (0, 0, 128, 191) with Image.open("Tests/images/apng/mode_grayscale.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "L" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == 128 assert im.getpixel((64, 32)) == 255 with Image.open("Tests/images/apng/mode_grayscale_alpha.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "LA" im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (128, 191) assert im.getpixel((64, 32)) == (128, 191) with Image.open("Tests/images/apng/mode_palette.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) im = im.convert("RGB") @@ -259,6 +283,7 @@ def test_apng_mode() -> None: assert im.getpixel((64, 32)) == (0, 255, 0) with Image.open("Tests/images/apng/mode_palette_alpha.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) im = im.convert("RGBA") @@ -266,6 +291,7 @@ def test_apng_mode() -> None: assert im.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) im = im.convert("RGBA") @@ -275,25 +301,31 @@ def test_apng_mode() -> None: def test_apng_chunk_errors() -> None: with Image.open("Tests/images/apng/chunk_no_actl.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated with pytest.warns(UserWarning): with Image.open("Tests/images/apng/chunk_multi_actl.png") as im: im.load() + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated with Image.open("Tests/images/apng/chunk_actl_after_idat.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated with Image.open("Tests/images/apng/chunk_no_fctl.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) with pytest.raises(SyntaxError): im.seek(im.n_frames - 1) with Image.open("Tests/images/apng/chunk_repeat_fctl.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) with pytest.raises(SyntaxError): im.seek(im.n_frames - 1) with Image.open("Tests/images/apng/chunk_no_fdat.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) with pytest.raises(SyntaxError): im.seek(im.n_frames - 1) @@ -301,26 +333,31 @@ def test_apng_chunk_errors() -> None: def test_apng_syntax_errors() -> None: with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated with pytest.raises(OSError): im.load() with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated im.load() # we can handle this case gracefully with Image.open("Tests/images/apng/syntax_num_frames_low.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) with pytest.raises(OSError): with Image.open("Tests/images/apng/syntax_num_frames_high.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) im.load() with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_invalid.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert not im.is_animated im.load() @@ -340,6 +377,7 @@ def test_apng_syntax_errors() -> None: def test_apng_sequence_errors(test_file: str) -> None: with pytest.raises(SyntaxError): with Image.open(f"Tests/images/apng/{test_file}") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) im.load() @@ -350,6 +388,7 @@ def test_apng_save(tmp_path: Path) -> None: im.save(test_file, save_all=True) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.load() assert not im.is_animated assert im.n_frames == 1 @@ -365,6 +404,7 @@ def test_apng_save(tmp_path: Path) -> None: ) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.load() assert im.is_animated assert im.n_frames == 2 @@ -404,6 +444,7 @@ def test_apng_save_split_fdat(tmp_path: Path) -> None: append_images=frames, ) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) im.seek(im.n_frames - 1) im.load() @@ -446,6 +487,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None: test_file, save_all=True, append_images=[frame, frame], duration=[500, 100, 150] ) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.n_frames == 1 assert "duration" not in im.info @@ -457,6 +499,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None: duration=[500, 100, 150], ) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.n_frames == 2 assert im.info["duration"] == 600 @@ -467,6 +510,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None: frame.info["duration"] = 300 frame.save(test_file, save_all=True, append_images=[frame, different_frame]) with Image.open(test_file) as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.n_frames == 2 assert im.info["duration"] == 600 diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py new file mode 100644 index 000000000..bd87947c0 --- /dev/null +++ b/Tests/test_file_avif.py @@ -0,0 +1,779 @@ +from __future__ import annotations + +import gc +import os +import re +import warnings +from collections.abc import Generator, Sequence +from contextlib import contextmanager +from io import BytesIO +from pathlib import Path +from typing import Any + +import pytest + +from PIL import ( + AvifImagePlugin, + Image, + ImageDraw, + ImageFile, + UnidentifiedImageError, + features, +) + +from .helper import ( + PillowLeakTestCase, + assert_image, + assert_image_similar, + assert_image_similar_tofile, + hopper, + skip_unless_feature, +) + +try: + from PIL import _avif + + HAVE_AVIF = True +except ImportError: + HAVE_AVIF = False + + +TEST_AVIF_FILE = "Tests/images/avif/hopper.avif" + + +def assert_xmp_orientation(xmp: bytes, expected: int) -> None: + assert int(xmp.split(b'tiff:Orientation="')[1].split(b'"')[0]) == expected + + +def roundtrip(im: ImageFile.ImageFile, **options: Any) -> ImageFile.ImageFile: + out = BytesIO() + im.save(out, "AVIF", **options) + return Image.open(out) + + +def skip_unless_avif_decoder(codec_name: str) -> pytest.MarkDecorator: + reason = f"{codec_name} decode not available" + return pytest.mark.skipif( + not HAVE_AVIF or not _avif.decoder_codec_available(codec_name), reason=reason + ) + + +def skip_unless_avif_encoder(codec_name: str) -> pytest.MarkDecorator: + reason = f"{codec_name} encode not available" + return pytest.mark.skipif( + not HAVE_AVIF or not _avif.encoder_codec_available(codec_name), reason=reason + ) + + +def is_docker_qemu() -> bool: + try: + init_proc_exe = os.readlink("/proc/1/exe") + except (FileNotFoundError, PermissionError): + return False + return "qemu" in init_proc_exe + + +class TestUnsupportedAvif: + def test_unsupported(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(AvifImagePlugin, "SUPPORTED", False) + + with pytest.warns(UserWarning): + with pytest.raises(UnidentifiedImageError): + with Image.open(TEST_AVIF_FILE): + pass + + def test_unsupported_open(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(AvifImagePlugin, "SUPPORTED", False) + + with pytest.raises(SyntaxError): + AvifImagePlugin.AvifImageFile(TEST_AVIF_FILE) + + +@skip_unless_feature("avif") +class TestFileAvif: + def test_version(self) -> None: + version = features.version_module("avif") + assert version is not None + assert re.search(r"^\d+\.\d+\.\d+$", version) + + def test_codec_version(self) -> None: + assert AvifImagePlugin.get_codec_version("unknown") is None + + for codec_name in ("aom", "dav1d", "rav1e", "svt"): + codec_version = AvifImagePlugin.get_codec_version(codec_name) + if _avif.decoder_codec_available( + codec_name + ) or _avif.encoder_codec_available(codec_name): + assert codec_version is not None + assert re.search(r"^v?\d+\.\d+\.\d+(-([a-z\d])+)*$", codec_version) + else: + assert codec_version is None + + def test_read(self) -> None: + """ + Can we read an AVIF file without error? + Does it have the bits we expect? + """ + + with Image.open(TEST_AVIF_FILE) as image: + assert image.mode == "RGB" + assert image.size == (128, 128) + assert image.format == "AVIF" + assert image.get_format_mimetype() == "image/avif" + image.getdata() + + # generated with: + # avifdec hopper.avif hopper_avif_write.png + assert_image_similar_tofile( + image, "Tests/images/avif/hopper_avif_write.png", 11.5 + ) + + def test_write_rgb(self, tmp_path: Path) -> None: + """ + Can we write a RGB mode file to avif without error? + Does it have the bits we expect? + """ + + temp_file = tmp_path / "temp.avif" + + im = hopper() + im.save(temp_file) + with Image.open(temp_file) as reloaded: + assert reloaded.mode == "RGB" + assert reloaded.size == (128, 128) + assert reloaded.format == "AVIF" + reloaded.getdata() + + # avifdec hopper.avif avif/hopper_avif_write.png + assert_image_similar_tofile( + reloaded, "Tests/images/avif/hopper_avif_write.png", 6.02 + ) + + # This test asserts that the images are similar. If the average pixel + # difference between the two images is less than the epsilon value, + # then we're going to accept that it's a reasonable lossy version of + # the image. + assert_image_similar(reloaded, im, 8.62) + + def test_AvifEncoder_with_invalid_args(self) -> None: + """ + Calling encoder functions with no arguments should result in an error. + """ + with pytest.raises(TypeError): + _avif.AvifEncoder() + + def test_AvifDecoder_with_invalid_args(self) -> None: + """ + Calling decoder functions with no arguments should result in an error. + """ + with pytest.raises(TypeError): + _avif.AvifDecoder() + + def test_invalid_dimensions(self, tmp_path: Path) -> None: + test_file = tmp_path / "temp.avif" + im = Image.new("RGB", (0, 0)) + with pytest.raises(ValueError): + im.save(test_file) + + def test_encoder_finish_none_error( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Save should raise an OSError if AvifEncoder.finish returns None""" + + class _mock_avif: + class AvifEncoder: + def __init__(self, *args: Any) -> None: + pass + + def add(self, *args: Any) -> None: + pass + + def finish(self) -> None: + return None + + monkeypatch.setattr(AvifImagePlugin, "_avif", _mock_avif) + + im = Image.new("RGB", (150, 150)) + test_file = tmp_path / "temp.avif" + with pytest.raises(OSError): + im.save(test_file) + + def test_no_resource_warning(self, tmp_path: Path) -> None: + with Image.open(TEST_AVIF_FILE) as im: + with warnings.catch_warnings(): + warnings.simplefilter("error") + + im.save(tmp_path / "temp.avif") + + @pytest.mark.parametrize("major_brand", [b"avif", b"avis", b"mif1", b"msf1"]) + def test_accept_ftyp_brands(self, major_brand: bytes) -> None: + data = b"\x00\x00\x00\x1cftyp%s\x00\x00\x00\x00" % major_brand + assert AvifImagePlugin._accept(data) is True + + def test_file_pointer_could_be_reused(self) -> None: + with open(TEST_AVIF_FILE, "rb") as blob: + with Image.open(blob) as im: + im.load() + with Image.open(blob) as im: + im.load() + + def test_background_from_gif(self, tmp_path: Path) -> None: + with Image.open("Tests/images/chi.gif") as im: + original_value = im.convert("RGB").getpixel((1, 1)) + + # Save as AVIF + out_avif = tmp_path / "temp.avif" + im.save(out_avif, save_all=True) + + # Save as GIF + out_gif = tmp_path / "temp.gif" + with Image.open(out_avif) as im: + im.save(out_gif) + + with Image.open(out_gif) as reread: + reread_value = reread.convert("RGB").getpixel((1, 1)) + difference = sum([abs(original_value[i] - reread_value[i]) for i in range(3)]) + assert difference <= 3 + + def test_save_single_frame(self, tmp_path: Path) -> None: + temp_file = tmp_path / "temp.avif" + with Image.open("Tests/images/chi.gif") as im: + im.save(temp_file) + with Image.open(temp_file) as im: + assert im.n_frames == 1 + + def test_invalid_file(self) -> None: + invalid_file = "Tests/images/flower.jpg" + + with pytest.raises(SyntaxError): + AvifImagePlugin.AvifImageFile(invalid_file) + + def test_load_transparent_rgb(self) -> None: + test_file = "Tests/images/avif/transparency.avif" + with Image.open(test_file) as im: + assert_image(im, "RGBA", (64, 64)) + + # image has 876 transparent pixels + assert im.getchannel("A").getcolors()[0] == (876, 0) + + def test_save_transparent(self, tmp_path: Path) -> None: + im = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) + assert im.getcolors() == [(100, (0, 0, 0, 0))] + + test_file = tmp_path / "temp.avif" + im.save(test_file) + + # check if saved image contains the same transparency + with Image.open(test_file) as im: + assert_image(im, "RGBA", (10, 10)) + assert im.getcolors() == [(100, (0, 0, 0, 0))] + + def test_save_icc_profile(self) -> None: + with Image.open("Tests/images/avif/icc_profile_none.avif") as im: + assert "icc_profile" not in im.info + + with Image.open("Tests/images/avif/icc_profile.avif") as with_icc: + expected_icc = with_icc.info["icc_profile"] + assert expected_icc is not None + + im = roundtrip(im, icc_profile=expected_icc) + assert im.info["icc_profile"] == expected_icc + + def test_discard_icc_profile(self) -> None: + with Image.open("Tests/images/avif/icc_profile.avif") as im: + im = roundtrip(im, icc_profile=None) + assert "icc_profile" not in im.info + + def test_roundtrip_icc_profile(self) -> None: + with Image.open("Tests/images/avif/icc_profile.avif") as im: + expected_icc = im.info["icc_profile"] + + im = roundtrip(im) + assert im.info["icc_profile"] == expected_icc + + def test_roundtrip_no_icc_profile(self) -> None: + with Image.open("Tests/images/avif/icc_profile_none.avif") as im: + assert "icc_profile" not in im.info + + im = roundtrip(im) + assert "icc_profile" not in im.info + + def test_exif(self) -> None: + # With an EXIF chunk + with Image.open("Tests/images/avif/exif.avif") as im: + exif = im.getexif() + assert exif[274] == 1 + + with Image.open("Tests/images/avif/xmp_tags_orientation.avif") as im: + exif = im.getexif() + assert exif[274] == 3 + + @pytest.mark.parametrize("use_bytes", [True, False]) + @pytest.mark.parametrize("orientation", [1, 2, 3, 4, 5, 6, 7, 8]) + def test_exif_save( + self, + tmp_path: Path, + use_bytes: bool, + orientation: int, + ) -> None: + exif = Image.Exif() + exif[274] = orientation + exif_data = exif.tobytes() + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + im.save(test_file, exif=exif_data if use_bytes else exif) + + with Image.open(test_file) as reloaded: + if orientation == 1: + assert "exif" not in reloaded.info + else: + assert reloaded.getexif()[274] == orientation + assert reloaded.info["exif"] == exif_data + + def test_exif_without_orientation(self, tmp_path: Path) -> None: + exif = Image.Exif() + exif[272] = b"test" + exif_data = exif.tobytes() + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + im.save(test_file, exif=exif) + + with Image.open(test_file) as reloaded: + assert reloaded.info["exif"] == exif_data + + def test_exif_invalid(self, tmp_path: Path) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + with pytest.raises(SyntaxError): + im.save(test_file, exif=b"invalid") + + @pytest.mark.parametrize( + "rot, mir, exif_orientation", + [ + (0, 0, 4), + (0, 1, 2), + (1, 0, 5), + (1, 1, 7), + (2, 0, 2), + (2, 1, 4), + (3, 0, 7), + (3, 1, 5), + ], + ) + def test_rot_mir_exif( + self, rot: int, mir: int, exif_orientation: int, tmp_path: Path + ) -> None: + with Image.open(f"Tests/images/avif/rot{rot}mir{mir}.avif") as im: + exif = im.getexif() + assert exif[274] == exif_orientation + + test_file = tmp_path / "temp.avif" + im.save(test_file, exif=exif) + with Image.open(test_file) as reloaded: + assert reloaded.getexif()[274] == exif_orientation + + def test_xmp(self) -> None: + with Image.open("Tests/images/avif/xmp_tags_orientation.avif") as im: + xmp = im.info["xmp"] + assert_xmp_orientation(xmp, 3) + + def test_xmp_save(self, tmp_path: Path) -> None: + xmp_arg = "\n".join( + [ + '', + '', + ' ', + ' ', + " ", + "", + '', + ] + ) + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + im.save(test_file, xmp=xmp_arg) + + with Image.open(test_file) as reloaded: + xmp = reloaded.info["xmp"] + assert_xmp_orientation(xmp, 1) + + def test_tell(self) -> None: + with Image.open(TEST_AVIF_FILE) as im: + assert im.tell() == 0 + + def test_seek(self) -> None: + with Image.open(TEST_AVIF_FILE) as im: + im.seek(0) + + with pytest.raises(EOFError): + im.seek(1) + + @pytest.mark.parametrize("subsampling", ["4:4:4", "4:2:2", "4:2:0", "4:0:0"]) + def test_encoder_subsampling(self, tmp_path: Path, subsampling: str) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + im.save(test_file, subsampling=subsampling) + + def test_encoder_subsampling_invalid(self, tmp_path: Path) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + with pytest.raises(ValueError): + im.save(test_file, subsampling="foo") + + @pytest.mark.parametrize("value", ["full", "limited"]) + def test_encoder_range(self, tmp_path: Path, value: str) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + im.save(test_file, range=value) + + def test_encoder_range_invalid(self, tmp_path: Path) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + with pytest.raises(ValueError): + im.save(test_file, range="foo") + + @skip_unless_avif_encoder("aom") + def test_encoder_codec_param(self, tmp_path: Path) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + im.save(test_file, codec="aom") + + def test_encoder_codec_invalid(self, tmp_path: Path) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + with pytest.raises(ValueError): + im.save(test_file, codec="foo") + + @skip_unless_avif_decoder("dav1d") + def test_decoder_codec_cannot_encode(self, tmp_path: Path) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + with pytest.raises(ValueError): + im.save(test_file, codec="dav1d") + + @skip_unless_avif_encoder("aom") + @pytest.mark.parametrize( + "advanced", + [ + { + "aq-mode": "1", + "enable-chroma-deltaq": "1", + }, + (("aq-mode", "1"), ("enable-chroma-deltaq", "1")), + [("aq-mode", "1"), ("enable-chroma-deltaq", "1")], + ], + ) + def test_encoder_advanced_codec_options( + self, advanced: dict[str, str] | Sequence[tuple[str, str]] + ) -> None: + with Image.open(TEST_AVIF_FILE) as im: + ctrl_buf = BytesIO() + im.save(ctrl_buf, "AVIF", codec="aom") + test_buf = BytesIO() + im.save( + test_buf, + "AVIF", + codec="aom", + advanced=advanced, + ) + assert ctrl_buf.getvalue() != test_buf.getvalue() + + @skip_unless_avif_encoder("aom") + @pytest.mark.parametrize("advanced", [{"foo": "bar"}, {"foo": 1234}, 1234]) + def test_encoder_advanced_codec_options_invalid( + self, tmp_path: Path, advanced: dict[str, str] | int + ) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + with pytest.raises(ValueError): + im.save(test_file, codec="aom", advanced=advanced) + + @skip_unless_avif_decoder("aom") + def test_decoder_codec_param(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(AvifImagePlugin, "DECODE_CODEC_CHOICE", "aom") + + with Image.open(TEST_AVIF_FILE) as im: + assert im.size == (128, 128) + + @skip_unless_avif_encoder("rav1e") + def test_encoder_codec_cannot_decode( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setattr(AvifImagePlugin, "DECODE_CODEC_CHOICE", "rav1e") + + with pytest.raises(ValueError): + with Image.open(TEST_AVIF_FILE): + pass + + def test_decoder_codec_invalid(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(AvifImagePlugin, "DECODE_CODEC_CHOICE", "foo") + + with pytest.raises(ValueError): + with Image.open(TEST_AVIF_FILE): + pass + + @skip_unless_avif_encoder("aom") + def test_encoder_codec_available(self) -> None: + assert _avif.encoder_codec_available("aom") is True + + def test_encoder_codec_available_bad_params(self) -> None: + with pytest.raises(TypeError): + _avif.encoder_codec_available() + + @skip_unless_avif_decoder("dav1d") + def test_encoder_codec_available_cannot_decode(self) -> None: + assert _avif.encoder_codec_available("dav1d") is False + + def test_encoder_codec_available_invalid(self) -> None: + assert _avif.encoder_codec_available("foo") is False + + def test_encoder_quality_valueerror(self, tmp_path: Path) -> None: + with Image.open(TEST_AVIF_FILE) as im: + test_file = tmp_path / "temp.avif" + with pytest.raises(ValueError): + im.save(test_file, quality="invalid") + + @skip_unless_avif_decoder("aom") + def test_decoder_codec_available(self) -> None: + assert _avif.decoder_codec_available("aom") is True + + def test_decoder_codec_available_bad_params(self) -> None: + with pytest.raises(TypeError): + _avif.decoder_codec_available() + + @skip_unless_avif_encoder("rav1e") + def test_decoder_codec_available_cannot_decode(self) -> None: + assert _avif.decoder_codec_available("rav1e") is False + + def test_decoder_codec_available_invalid(self) -> None: + assert _avif.decoder_codec_available("foo") is False + + def test_p_mode_transparency(self, tmp_path: Path) -> None: + im = Image.new("P", size=(64, 64)) + draw = ImageDraw.Draw(im) + draw.rectangle(xy=[(0, 0), (32, 32)], fill=255) + draw.rectangle(xy=[(32, 32), (64, 64)], fill=255) + + out_png = tmp_path / "temp.png" + im.save(out_png, transparency=0) + with Image.open(out_png) as im_png: + out_avif = tmp_path / "temp.avif" + im_png.save(out_avif, quality=100) + + with Image.open(out_avif) as expected: + assert_image_similar(im_png.convert("RGBA"), expected, 0.17) + + def test_decoder_strict_flags(self) -> None: + # This would fail if full avif strictFlags were enabled + with Image.open("Tests/images/avif/hopper-missing-pixi.avif") as im: + assert im.size == (128, 128) + + @skip_unless_avif_encoder("aom") + @pytest.mark.parametrize("speed", [-1, 1, 11]) + def test_aom_optimizations(self, tmp_path: Path, speed: int) -> None: + test_file = tmp_path / "temp.avif" + hopper().save(test_file, codec="aom", speed=speed) + + @skip_unless_avif_encoder("svt") + def test_svt_optimizations(self, tmp_path: Path) -> None: + test_file = tmp_path / "temp.avif" + hopper().save(test_file, codec="svt", speed=1) + + +@skip_unless_feature("avif") +class TestAvifAnimation: + @contextmanager + def star_frames(self) -> Generator[list[Image.Image], None, None]: + with Image.open("Tests/images/avif/star.png") as f: + yield [f, f.rotate(90), f.rotate(180), f.rotate(270)] + + def test_n_frames(self) -> None: + """ + Ensure that AVIF format sets n_frames and is_animated attributes + correctly. + """ + + with Image.open(TEST_AVIF_FILE) as im: + assert im.n_frames == 1 + assert not im.is_animated + + with Image.open("Tests/images/avif/star.avifs") as im: + assert im.n_frames == 5 + assert im.is_animated + + def test_write_animation_P(self, tmp_path: Path) -> None: + """ + Convert an animated GIF to animated AVIF, then compare the frame + count, and ensure the frames are visually similar to the originals. + """ + + with Image.open("Tests/images/avif/star.gif") as original: + assert original.n_frames > 1 + + temp_file = tmp_path / "temp.avif" + original.save(temp_file, save_all=True) + with Image.open(temp_file) as im: + assert im.n_frames == original.n_frames + + # Compare first frame in P mode to frame from original GIF + assert_image_similar(im, original.convert("RGBA"), 2) + + # Compare later frames in RGBA mode to frames from original GIF + for frame in range(1, original.n_frames): + original.seek(frame) + im.seek(frame) + assert_image_similar(im, original, 2.54) + + def test_write_animation_RGBA(self, tmp_path: Path) -> None: + """ + Write an animated AVIF from RGBA frames, and ensure the frames + are visually similar to the originals. + """ + + def check(temp_file: Path) -> None: + with Image.open(temp_file) as im: + assert im.n_frames == 4 + + # Compare first frame to original + assert_image_similar(im, frame1, 2.7) + + # Compare second frame to original + im.seek(1) + assert_image_similar(im, frame2, 4.1) + + with self.star_frames() as frames: + frame1 = frames[0] + frame2 = frames[1] + temp_file1 = tmp_path / "temp.avif" + frames[0].copy().save(temp_file1, save_all=True, append_images=frames[1:]) + check(temp_file1) + + # Test appending using a generator + def imGenerator( + ims: list[Image.Image], + ) -> Generator[Image.Image, None, None]: + yield from ims + + temp_file2 = tmp_path / "temp_generator.avif" + frames[0].copy().save( + temp_file2, + save_all=True, + append_images=imGenerator(frames[1:]), + ) + check(temp_file2) + + def test_sequence_dimension_mismatch_check(self, tmp_path: Path) -> None: + temp_file = tmp_path / "temp.avif" + frame1 = Image.new("RGB", (100, 100)) + frame2 = Image.new("RGB", (150, 150)) + with pytest.raises(ValueError): + frame1.save(temp_file, save_all=True, append_images=[frame2]) + + def test_heif_raises_unidentified_image_error(self) -> None: + with pytest.raises(UnidentifiedImageError): + with Image.open("Tests/images/avif/hopper.heif"): + pass + + @pytest.mark.parametrize("alpha_premultiplied", [False, True]) + def test_alpha_premultiplied( + self, tmp_path: Path, alpha_premultiplied: bool + ) -> None: + temp_file = tmp_path / "temp.avif" + color = (200, 200, 200, 1) + im = Image.new("RGBA", (1, 1), color) + im.save(temp_file, alpha_premultiplied=alpha_premultiplied) + + expected = (255, 255, 255, 1) if alpha_premultiplied else color + with Image.open(temp_file) as reloaded: + assert reloaded.getpixel((0, 0)) == expected + + def test_timestamp_and_duration(self, tmp_path: Path) -> None: + """ + Try passing a list of durations, and make sure the encoded + timestamps and durations are correct. + """ + + durations = [1, 10, 20, 30, 40] + temp_file = tmp_path / "temp.avif" + with self.star_frames() as frames: + frames[0].save( + temp_file, + save_all=True, + append_images=(frames[1:] + [frames[0]]), + duration=durations, + ) + + with Image.open(temp_file) as im: + assert im.n_frames == 5 + assert im.is_animated + + # Check that timestamps and durations match original values specified + timestamp = 0 + for frame in range(im.n_frames): + im.seek(frame) + im.load() + assert im.info["duration"] == durations[frame] + assert im.info["timestamp"] == timestamp + timestamp += durations[frame] + + def test_seeking(self, tmp_path: Path) -> None: + """ + Create an animated AVIF file, and then try seeking through frames in + reverse-order, verifying the timestamps and durations are correct. + """ + + duration = 33 + temp_file = tmp_path / "temp.avif" + with self.star_frames() as frames: + frames[0].save( + temp_file, + save_all=True, + append_images=(frames[1:] + [frames[0]]), + duration=duration, + ) + + with Image.open(temp_file) as im: + assert im.n_frames == 5 + assert im.is_animated + + # Traverse frames in reverse, checking timestamps and durations + timestamp = duration * (im.n_frames - 1) + for frame in reversed(range(im.n_frames)): + im.seek(frame) + im.load() + assert im.info["duration"] == duration + assert im.info["timestamp"] == timestamp + timestamp -= duration + + def test_seek_errors(self) -> None: + with Image.open("Tests/images/avif/star.avifs") as im: + with pytest.raises(EOFError): + im.seek(-1) + + with pytest.raises(EOFError): + im.seek(42) + + +MAX_THREADS = os.cpu_count() or 1 + + +@skip_unless_feature("avif") +class TestAvifLeaks(PillowLeakTestCase): + mem_limit = MAX_THREADS * 3 * 1024 + iterations = 100 + + @pytest.mark.skipif( + is_docker_qemu(), reason="Skipping on cross-architecture containers" + ) + def test_leak_load(self) -> None: + with open(TEST_AVIF_FILE, "rb") as f: + im_data = f.read() + + def core() -> None: + with Image.open(BytesIO(im_data)) as im: + im.load() + gc.collect() + + self._test_leak(core) diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 64d2acaf5..746b2e180 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -15,25 +15,19 @@ from .helper import ( ) -def test_sanity(tmp_path: Path) -> None: - def roundtrip(im: Image.Image) -> None: - outfile = tmp_path / "temp.bmp" +@pytest.mark.parametrize("mode", ("1", "L", "P", "RGB")) +def test_sanity(mode: str, tmp_path: Path) -> None: + outfile = tmp_path / "temp.bmp" - im.save(outfile, "BMP") + im = hopper(mode) + im.save(outfile, "BMP") - with Image.open(outfile) as reloaded: - reloaded.load() - assert im.mode == reloaded.mode - assert im.size == reloaded.size - assert reloaded.format == "BMP" - assert reloaded.get_format_mimetype() == "image/bmp" - - roundtrip(hopper()) - - roundtrip(hopper("1")) - roundtrip(hopper("L")) - roundtrip(hopper("P")) - roundtrip(hopper("RGB")) + with Image.open(outfile) as reloaded: + reloaded.load() + assert im.mode == reloaded.mode + assert im.size == reloaded.size + assert reloaded.format == "BMP" + assert reloaded.get_format_mimetype() == "image/bmp" def test_invalid_file() -> None: @@ -196,9 +190,9 @@ def test_rle8() -> None: # Signal end of bitmap before the image is finished with open("Tests/images/bmp/g/pal8rle.bmp", "rb") as fp: data = fp.read(1063) + b"\x01" - with Image.open(io.BytesIO(data)) as im: - with pytest.raises(ValueError): - im.load() + with Image.open(io.BytesIO(data)) as im: + with pytest.raises(ValueError): + im.load() def test_rle4() -> None: @@ -220,9 +214,9 @@ def test_rle4() -> None: def test_rle8_eof(file_name: str, length: int) -> None: with open(file_name, "rb") as fp: data = fp.read(length) - with Image.open(io.BytesIO(data)) as im: - with pytest.raises(ValueError): - im.load() + with Image.open(io.BytesIO(data)) as im: + with pytest.raises(ValueError): + im.load() def test_offset() -> None: @@ -230,3 +224,13 @@ def test_offset() -> None: # to exclude the palette size from the pixel data offset with Image.open("Tests/images/pal8_offset.bmp") as im: assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp") + + +def test_use_raw_alpha(monkeypatch: pytest.MonkeyPatch) -> None: + with Image.open("Tests/images/bmp/g/rgb32.bmp") as im: + assert im.info["compression"] == BmpImagePlugin.BmpImageFile.COMPRESSIONS["RAW"] + assert im.mode == "RGB" + + monkeypatch.setattr(BmpImagePlugin, "USE_RAW_ALPHA", True) + with Image.open("Tests/images/bmp/g/rgb32.bmp") as im: + assert im.mode == "RGBA" diff --git a/Tests/test_file_dcx.py b/Tests/test_file_dcx.py index ab6b9f983..e9d88dd39 100644 --- a/Tests/test_file_dcx.py +++ b/Tests/test_file_dcx.py @@ -69,12 +69,14 @@ def test_tell() -> None: def test_n_frames() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, DcxImagePlugin.DcxImageFile) assert im.n_frames == 1 assert not im.is_animated def test_eoferror() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, DcxImagePlugin.DcxImageFile) n_frames = im.n_frames # Test seeking past the last frame diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index f5acc532c..b484a8cfa 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -86,6 +86,8 @@ simple_eps_file_with_long_binary_data = ( def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: expected_size = tuple(s * scale for s in size) with Image.open(filename) as image: + assert isinstance(image, EpsImagePlugin.EpsImageFile) + image.load(scale=scale) assert image.mode == "RGB" assert image.size == expected_size @@ -227,6 +229,8 @@ def test_showpage() -> None: @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_transparency() -> None: with Image.open("Tests/images/eps/reqd_showpage.eps") as plot_image: + assert isinstance(plot_image, EpsImagePlugin.EpsImageFile) + plot_image.load(transparency=True) assert plot_image.mode == "RGBA" @@ -308,6 +312,7 @@ def test_render_scale2() -> None: # Zero bounding box with Image.open(FILE1) as image1_scale2: + assert isinstance(image1_scale2, EpsImagePlugin.EpsImageFile) image1_scale2.load(scale=2) with Image.open(FILE1_COMPARE_SCALE2) as image1_scale2_compare: image1_scale2_compare = image1_scale2_compare.convert("RGB") @@ -316,6 +321,7 @@ def test_render_scale2() -> None: # Non-zero bounding box with Image.open(FILE2) as image2_scale2: + assert isinstance(image2_scale2, EpsImagePlugin.EpsImageFile) image2_scale2.load(scale=2) with Image.open(FILE2_COMPARE_SCALE2) as image2_scale2_compare: image2_scale2_compare = image2_scale2_compare.convert("RGB") diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index 2f39adc69..81df1ab0b 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -22,6 +22,8 @@ animated_test_file_with_prefix_chunk = "Tests/images/2422.flc" def test_sanity() -> None: with Image.open(static_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) + im.load() assert im.mode == "P" assert im.size == (128, 128) @@ -29,6 +31,8 @@ def test_sanity() -> None: assert not im.is_animated with Image.open(animated_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) + assert im.mode == "P" assert im.size == (320, 200) assert im.format == "FLI" @@ -112,16 +116,19 @@ def test_palette_chunk_second() -> None: def test_n_frames() -> None: with Image.open(static_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) assert im.n_frames == 1 assert not im.is_animated with Image.open(animated_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) assert im.n_frames == 384 assert im.is_animated def test_eoferror() -> None: with Image.open(animated_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) n_frames = im.n_frames # Test seeking past the last frame @@ -166,6 +173,7 @@ def test_seek_tell() -> None: def test_seek() -> None: with Image.open(animated_test_file) as im: + assert isinstance(im, FliImagePlugin.FliImageFile) im.seek(50) assert_image_equal_tofile(im, "Tests/images/a_fli.png") diff --git a/Tests/test_file_fpx.py b/Tests/test_file_fpx.py index e32f30a01..8d8064692 100644 --- a/Tests/test_file_fpx.py +++ b/Tests/test_file_fpx.py @@ -22,10 +22,11 @@ def test_sanity() -> None: def test_close() -> None: with Image.open("Tests/images/input_bw_one_band.fpx") as im: - pass + assert isinstance(im, FpxImagePlugin.FpxImageFile) assert im.ole.fp.closed im = Image.open("Tests/images/input_bw_one_band.fpx") + assert isinstance(im, FpxImagePlugin.FpxImageFile) im.close() assert im.ole.fp.closed diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 1ff98cfe2..be1a2231b 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -450,6 +450,7 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None: def test_seek() -> None: with Image.open("Tests/images/dispose_none.gif") as img: + assert isinstance(img, GifImagePlugin.GifImageFile) frame_count = 0 try: while True: @@ -494,10 +495,12 @@ def test_seek_rewind() -> None: def test_n_frames(path: str, n_frames: int) -> None: # Test is_animated before n_frames with Image.open(path) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.is_animated == (n_frames != 1) # Test is_animated after n_frames with Image.open(path) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.n_frames == n_frames assert im.is_animated == (n_frames != 1) @@ -507,6 +510,7 @@ def test_no_change() -> None: with Image.open("Tests/images/dispose_bgnd.gif") as im: im.seek(1) expected = im.copy() + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.n_frames == 5 assert_image_equal(im, expected) @@ -514,17 +518,20 @@ def test_no_change() -> None: with Image.open("Tests/images/dispose_bgnd.gif") as im: im.seek(3) expected = im.copy() + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.is_animated assert_image_equal(im, expected) with Image.open("Tests/images/comment_after_only_frame.gif") as im: expected = Image.new("P", (1, 1)) + assert isinstance(im, GifImagePlugin.GifImageFile) assert not im.is_animated assert_image_equal(im, expected) def test_eoferror() -> None: with Image.open(TEST_GIF) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) n_frames = im.n_frames # Test seeking past the last frame @@ -543,6 +550,7 @@ def test_first_frame_transparency() -> None: def test_dispose_none() -> None: with Image.open("Tests/images/dispose_none.gif") as img: + assert isinstance(img, GifImagePlugin.GifImageFile) try: while True: img.seek(img.tell() + 1) @@ -566,6 +574,7 @@ def test_dispose_none_load_end() -> None: def test_dispose_background() -> None: with Image.open("Tests/images/dispose_bgnd.gif") as img: + assert isinstance(img, GifImagePlugin.GifImageFile) try: while True: img.seek(img.tell() + 1) @@ -619,6 +628,7 @@ def test_transparent_dispose( def test_dispose_previous() -> None: with Image.open("Tests/images/dispose_prev.gif") as img: + assert isinstance(img, GifImagePlugin.GifImageFile) try: while True: img.seek(img.tell() + 1) @@ -656,6 +666,7 @@ def test_save_dispose(tmp_path: Path) -> None: for method in range(4): im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=method) with Image.open(out) as img: + assert isinstance(img, GifImagePlugin.GifImageFile) for _ in range(2): img.seek(img.tell() + 1) assert img.disposal_method == method @@ -669,6 +680,7 @@ def test_save_dispose(tmp_path: Path) -> None: ) with Image.open(out) as img: + assert isinstance(img, GifImagePlugin.GifImageFile) for i in range(2): img.seek(img.tell() + 1) assert img.disposal_method == i + 1 @@ -791,6 +803,7 @@ def test_dispose2_background_frame(tmp_path: Path) -> None: im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=2) with Image.open(out) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.n_frames == 3 @@ -972,6 +985,8 @@ def test_identical_frames(tmp_path: Path) -> None: out, save_all=True, append_images=im_list[1:], duration=duration_list ) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) + # Assert that the first three frames were combined assert reread.n_frames == 2 @@ -1001,6 +1016,8 @@ def test_identical_frames_to_single_frame( im_list[0].save(out, save_all=True, append_images=im_list[1:], duration=duration) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) + # Assert that all frames were combined assert reread.n_frames == 1 @@ -1187,6 +1204,14 @@ def test_append_images(tmp_path: Path) -> None: im.copy().save(out, save_all=True, append_images=ims) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) + assert reread.n_frames == 3 + + # Test append_images without save_all + im.copy().save(out, append_images=ims) + + with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 3 # Tests appending using a generator @@ -1196,6 +1221,7 @@ def test_append_images(tmp_path: Path) -> None: im.save(out, save_all=True, append_images=im_generator(ims)) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 3 # Tests appending single and multiple frame images @@ -1204,6 +1230,7 @@ def test_append_images(tmp_path: Path) -> None: im.save(out, save_all=True, append_images=[im2]) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 10 @@ -1304,6 +1331,7 @@ def test_bbox(tmp_path: Path) -> None: im.save(out, save_all=True, append_images=ims) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 2 @@ -1316,6 +1344,7 @@ def test_bbox_alpha(tmp_path: Path) -> None: im.save(out, save_all=True, append_images=[im2]) with Image.open(out) as reread: + assert isinstance(reread, GifImagePlugin.GifImageFile) assert reread.n_frames == 2 @@ -1467,6 +1496,7 @@ def test_extents( ) -> None: monkeypatch.setattr(GifImagePlugin, "LOADING_STRATEGY", loading_strategy) with Image.open("Tests/images/" + test_file) as im: + assert isinstance(im, GifImagePlugin.GifImageFile) assert im.size == (100, 100) # Check that n_frames does not change the size @@ -1514,4 +1544,5 @@ def test_p_rgba(tmp_path: Path, params: dict[str, Any]) -> None: im1.save(out, save_all=True, append_images=[im2], **params) with Image.open(out) as reloaded: + assert isinstance(reloaded, GifImagePlugin.GifImageFile) assert reloaded.n_frames == 2 diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index ff9cc91c5..08862113b 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -1,5 +1,7 @@ from __future__ import annotations +from io import BytesIO + import pytest from PIL.GimpPaletteFile import GimpPaletteFile @@ -14,17 +16,20 @@ def test_sanity() -> None: GimpPaletteFile(fp) with open("Tests/images/bad_palette_file.gpl", "rb") as fp: - with pytest.raises(SyntaxError): + with pytest.raises(SyntaxError, match="bad palette file"): GimpPaletteFile(fp) with open("Tests/images/bad_palette_entry.gpl", "rb") as fp: - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="bad palette entry"): GimpPaletteFile(fp) -def test_get_palette() -> None: +@pytest.mark.parametrize( + "filename, size", (("custom_gimp_palette.gpl", 8), ("full_gimp_palette.gpl", 256)) +) +def test_get_palette(filename: str, size: int) -> None: # Arrange - with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp: + with open("Tests/images/" + filename, "rb") as fp: palette_file = GimpPaletteFile(fp) # Act @@ -32,4 +37,36 @@ def test_get_palette() -> None: # Assert assert mode == "RGB" - assert len(palette) / 3 == 8 + assert len(palette) / 3 == size + + +def test_frombytes() -> None: + # Test that __init__ stops reading after 260 lines + with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp: + custom_data = fp.read() + custom_data += b"#\n" * 300 + b" 0 0 0 Index 12" + b = BytesIO(custom_data) + palette = GimpPaletteFile(b) + assert len(palette.palette) / 3 == 8 + + # Test that __init__ only reads 256 entries + with open("Tests/images/full_gimp_palette.gpl", "rb") as fp: + full_data = fp.read() + data = full_data.replace(b"#\n", b"") + b" 0 0 0 Index 256" + b = BytesIO(data) + palette = GimpPaletteFile(b) + assert len(palette.palette) / 3 == 256 + + # Test that frombytes() can read beyond that + palette = GimpPaletteFile.frombytes(data) + assert len(palette.palette) / 3 == 257 + + # Test that __init__ raises an error if a comment is too long + data = full_data[:-1] + b"a" * 100 + b = BytesIO(data) + with pytest.raises(SyntaxError, match="bad palette file"): + palette = GimpPaletteFile(b) + + # Test that frombytes() can read the data regardless + palette = GimpPaletteFile.frombytes(data) + assert len(palette.palette) / 3 == 256 diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index 50864009f..e4f09a09c 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -43,7 +43,7 @@ def test_save() -> None: # Arrange with Image.open(TEST_FILE) as im: dummy_fp = BytesIO() - dummy_filename = "dummy.filename" + dummy_filename = "dummy.h5" # Act / Assert: stub cannot save without an implemented handler with pytest.raises(OSError): diff --git a/Tests/test_file_icns.py b/Tests/test_file_icns.py index b6dc4bc19..2dabfd2f3 100644 --- a/Tests/test_file_icns.py +++ b/Tests/test_file_icns.py @@ -69,6 +69,7 @@ def test_save_append_images(tmp_path: Path) -> None: assert_image_similar_tofile(im, temp_file, 1) with Image.open(temp_file) as reread: + assert isinstance(reread, IcnsImagePlugin.IcnsImageFile) reread.size = (16, 16) reread.load(2) assert_image_equal(reread, provided_im) @@ -90,6 +91,7 @@ def test_sizes() -> None: # Check that we can load all of the sizes, and that the final pixel # dimensions are as expected with Image.open(TEST_FILE) as im: + assert isinstance(im, IcnsImagePlugin.IcnsImageFile) for w, h, r in im.info["sizes"]: wr = w * r hr = h * r @@ -118,6 +120,7 @@ def test_older_icon() -> None: wr = w * r hr = h * r with Image.open("Tests/images/pillow2.icns") as im2: + assert isinstance(im2, IcnsImagePlugin.IcnsImageFile) im2.size = (w, h) im2.load(r) assert im2.mode == "RGBA" @@ -135,6 +138,7 @@ def test_jp2_icon() -> None: wr = w * r hr = h * r with Image.open("Tests/images/pillow3.icns") as im2: + assert isinstance(im2, IcnsImagePlugin.IcnsImageFile) im2.size = (w, h) im2.load(r) assert im2.mode == "RGBA" diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 37bfd3f1f..5d2ace35e 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -77,6 +77,7 @@ def test_save_to_bytes() -> None: # The other one output.seek(0) with Image.open(output) as reloaded: + assert isinstance(reloaded, IcoImagePlugin.IcoImageFile) reloaded.size = (32, 32) assert im.mode == reloaded.mode @@ -94,6 +95,7 @@ def test_getpixel(tmp_path: Path) -> None: im.save(temp_file, "ico", sizes=[(32, 32), (64, 64)]) with Image.open(temp_file) as reloaded: + assert isinstance(reloaded, IcoImagePlugin.IcoImageFile) reloaded.load() reloaded.size = (32, 32) @@ -167,6 +169,7 @@ def test_save_to_bytes_bmp(mode: str) -> None: # The other one output.seek(0) with Image.open(output) as reloaded: + assert isinstance(reloaded, IcoImagePlugin.IcoImageFile) reloaded.size = (32, 32) assert "RGBA" == reloaded.mode @@ -178,6 +181,7 @@ def test_save_to_bytes_bmp(mode: str) -> None: def test_incorrect_size() -> None: with Image.open(TEST_ICO_FILE) as im: + assert isinstance(im, IcoImagePlugin.IcoImageFile) with pytest.raises(ValueError): im.size = (1, 1) @@ -219,6 +223,7 @@ def test_save_append_images(tmp_path: Path) -> None: im.save(outfile, sizes=[(32, 32), (128, 128)], append_images=[provided_im]) with Image.open(outfile) as reread: + assert isinstance(reread, IcoImagePlugin.IcoImageFile) assert_image_equal(reread, hopper("RGBA")) reread.size = (32, 32) diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index 235914a2b..55c6b7305 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -68,12 +68,14 @@ def test_tell() -> None: def test_n_frames() -> None: with Image.open(TEST_IM) as im: + assert isinstance(im, ImImagePlugin.ImImageFile) assert im.n_frames == 1 assert not im.is_animated def test_eoferror() -> None: with Image.open(TEST_IM) as im: + assert isinstance(im, ImImagePlugin.ImImageFile) n_frames = im.n_frames # Test seeking past the last frame diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 8ab853b85..79f0ec1a8 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -91,6 +91,7 @@ class TestFileJpeg: def test_app(self) -> None: # Test APP/COM reader (@PIL135) with Image.open(TEST_FILE) as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert im.applist[0] == ("APP0", b"JFIF\x00\x01\x01\x01\x00`\x00`\x00\x00") assert im.applist[1] == ( "COM", @@ -316,6 +317,8 @@ class TestFileJpeg: def test_exif_typeerror(self) -> None: with Image.open("Tests/images/exif_typeerror.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) + # Should not raise a TypeError im._getexif() @@ -500,6 +503,7 @@ class TestFileJpeg: def test_mp(self) -> None: with Image.open("Tests/images/pil_sample_rgb.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert im._getmp() is None def test_quality_keep(self, tmp_path: Path) -> None: @@ -558,12 +562,14 @@ class TestFileJpeg: with Image.open(test_file) as im: im.save(b, "JPEG", qtables=[[n] * 64] * n) with Image.open(b) as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert len(im.quantization) == n reloaded = self.roundtrip(im, qtables="keep") assert im.quantization == reloaded.quantization assert max(reloaded.quantization[0]) <= 255 with Image.open("Tests/images/hopper.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) qtables = im.quantization reloaded = self.roundtrip(im, qtables=qtables, subsampling=0) assert im.quantization == reloaded.quantization @@ -663,6 +669,7 @@ class TestFileJpeg: def test_load_16bit_qtables(self) -> None: with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert len(im.quantization) == 2 assert len(im.quantization[0]) == 64 assert max(im.quantization[0]) > 255 @@ -705,6 +712,7 @@ class TestFileJpeg: @pytest.mark.skipif(not djpeg_available(), reason="djpeg not available") def test_load_djpeg(self) -> None: with Image.open(TEST_FILE) as img: + assert isinstance(img, JpegImagePlugin.JpegImageFile) img.load_djpeg() assert_image_similar_tofile(img, TEST_FILE, 5) @@ -909,6 +917,7 @@ class TestFileJpeg: def test_photoshop_malformed_and_multiple(self) -> None: with Image.open("Tests/images/app13-multiple.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) assert "photoshop" in im.info assert 24 == len(im.info["photoshop"]) apps_13_lengths = [len(v) for k, v in im.applist if k == "APP13"] @@ -1084,6 +1093,7 @@ class TestFileJpeg: def test_deprecation(self) -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) with pytest.warns(DeprecationWarning): assert im.huffman_ac == {} with pytest.warns(DeprecationWarning): diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 916df2586..a5365a90d 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -228,12 +228,14 @@ def test_layers(card: ImageFile.ImageFile) -> None: out.seek(0) with Image.open(out) as im: + assert isinstance(im, Jpeg2KImagePlugin.Jpeg2KImageFile) im.layers = 1 im.load() assert_image_similar(im, card, 13) out.seek(0) with Image.open(out) as im: + assert isinstance(im, Jpeg2KImagePlugin.Jpeg2KImageFile) im.layers = 3 im.load() assert_image_similar(im, card, 0.4) @@ -455,8 +457,8 @@ def test_comment() -> None: # 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 + with Image.open(b) as im: + pass def test_save_comment(card: ImageFile.ImageFile) -> None: diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 25d1f5712..1ec39eba5 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -36,6 +36,7 @@ class LibTiffTestCase: im.load() im.getdata() + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im._compression == "group4" # can we write it back out, in a different form. @@ -80,7 +81,7 @@ class TestFileLibTiff(LibTiffTestCase): s = io.BytesIO() with open(test_file, "rb") as f: s.write(f.read()) - s.seek(0) + s.seek(0) with Image.open(s) as im: assert im.size == (500, 500) self._assert_noerr(tmp_path, im) @@ -153,6 +154,7 @@ class TestFileLibTiff(LibTiffTestCase): """Test metadata writing through libtiff""" f = tmp_path / "temp.tiff" with Image.open("Tests/images/hopper_g4.tif") as img: + assert isinstance(img, TiffImagePlugin.TiffImageFile) img.save(f, tiffinfo=img.tag) if legacy_api: @@ -170,6 +172,7 @@ class TestFileLibTiff(LibTiffTestCase): ] with Image.open(f) as loaded: + assert isinstance(loaded, TiffImagePlugin.TiffImageFile) if legacy_api: reloaded = loaded.tag.named() else: @@ -212,6 +215,7 @@ class TestFileLibTiff(LibTiffTestCase): # Exclude ones that have special meaning # that we're already testing them with Image.open("Tests/images/hopper_g4.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) for tag in im.tag_v2: try: del core_items[tag] @@ -317,6 +321,7 @@ class TestFileLibTiff(LibTiffTestCase): im.save(out, tiffinfo=tiffinfo) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) for tag, value in tiffinfo.items(): reloaded_value = reloaded.tag_v2[tag] if ( @@ -349,12 +354,14 @@ class TestFileLibTiff(LibTiffTestCase): def test_osubfiletype(self, tmp_path: Path) -> None: outfile = tmp_path / "temp.tif" with Image.open("Tests/images/g4_orientation_6.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.tag_v2[OSUBFILETYPE] = 1 im.save(outfile) def test_subifd(self, tmp_path: Path) -> None: outfile = tmp_path / "temp.tif" with Image.open("Tests/images/g4_orientation_6.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.tag_v2[SUBIFD] = 10000 # Should not segfault @@ -369,6 +376,7 @@ class TestFileLibTiff(LibTiffTestCase): hopper().save(out, tiffinfo={700: b"xmlpacket tag"}) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) if 700 in reloaded.tag_v2: assert reloaded.tag_v2[700] == b"xmlpacket tag" @@ -430,12 +438,15 @@ class TestFileLibTiff(LibTiffTestCase): """Tests String data in info directory""" test_file = "Tests/images/hopper_g4_500.tif" with Image.open(test_file) as orig: + assert isinstance(orig, TiffImagePlugin.TiffImageFile) + out = tmp_path / "temp.tif" orig.tag[269] = "temp.tif" orig.save(out) with Image.open(out) as reread: + assert isinstance(reread, TiffImagePlugin.TiffImageFile) assert "temp.tif" == reread.tag_v2[269] assert "temp.tif" == reread.tag[269][0] @@ -541,6 +552,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open(out) as reloaded: # colormap/palette tag + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert len(reloaded.tag_v2[320]) == 768 @pytest.mark.parametrize("compression", ("tiff_ccitt", "group3", "group4")) @@ -572,6 +584,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/multipage.tiff") as im: # file is a multipage tiff, 10x10 green, 10x10 red, 20x20 blue + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.seek(0) assert im.size == (10, 10) assert im.convert("RGB").getpixel((0, 0)) == (0, 128, 0) @@ -591,6 +604,7 @@ class TestFileLibTiff(LibTiffTestCase): # issue #862 monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True) with Image.open("Tests/images/multipage.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) frames = im.n_frames assert frames == 3 for _ in range(frames): @@ -610,6 +624,7 @@ class TestFileLibTiff(LibTiffTestCase): def test__next(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True) with Image.open("Tests/images/hopper.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert not im.tag.next im.load() assert not im.tag.next @@ -690,21 +705,25 @@ class TestFileLibTiff(LibTiffTestCase): im.save(outfile, compression="jpeg") with Image.open(outfile) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[530] == (1, 1) assert reloaded.tag_v2[532] == (0, 255, 128, 255, 128, 255) def test_exif_ifd(self) -> None: out = io.BytesIO() with Image.open("Tests/images/tiff_adobe_deflate.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2[34665] == 125456 im.save(out, "TIFF") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert 34665 not in reloaded.tag_v2 im.save(out, "TIFF", tiffinfo={34665: 125456}) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) if Image.core.libtiff_support_custom_tags: assert reloaded.tag_v2[34665] == 125456 @@ -786,6 +805,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_multipage_compression(self) -> None: with Image.open("Tests/images/compression.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.seek(0) assert im._compression == "tiff_ccitt" assert im.size == (10, 10) @@ -1026,6 +1046,17 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/old-style-jpeg-compression.tif") as im: assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + def test_old_style_jpeg_orientation(self) -> None: + with open("Tests/images/old-style-jpeg-compression.tif", "rb") as fp: + data = fp.read() + + # Set EXIF Orientation to 2 + data = data[:102] + b"\x02" + data[103:] + + with Image.open(io.BytesIO(data)) as im: + im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + def test_open_missing_samplesperpixel(self) -> None: with Image.open( "Tests/images/old-style-jpeg-compression-no-samplesperpixel.tif" @@ -1079,6 +1110,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/g4_orientation_1.tif") as base_im: for i in range(2, 9): with Image.open("Tests/images/g4_orientation_" + str(i) + ".tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert 274 in im.tag_v2 im.load() diff --git a/Tests/test_file_libtiff_small.py b/Tests/test_file_libtiff_small.py index 617e1e89c..65ba80c20 100644 --- a/Tests/test_file_libtiff_small.py +++ b/Tests/test_file_libtiff_small.py @@ -32,7 +32,7 @@ class TestFileLibTiffSmall(LibTiffTestCase): s = BytesIO() with open(test_file, "rb") as f: s.write(f.read()) - s.seek(0) + s.seek(0) with Image.open(s) as im: assert im.size == (128, 128) self._assert_noerr(tmp_path, im) diff --git a/Tests/test_file_mic.py b/Tests/test_file_mic.py index 9a6f13ea3..9aeb306e4 100644 --- a/Tests/test_file_mic.py +++ b/Tests/test_file_mic.py @@ -30,11 +30,13 @@ def test_sanity() -> None: def test_n_frames() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, MicImagePlugin.MicImageFile) assert im.n_frames == 1 def test_is_animated() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, MicImagePlugin.MicImageFile) assert not im.is_animated @@ -55,10 +57,11 @@ def test_seek() -> None: def test_close() -> None: with Image.open(TEST_FILE) as im: - pass + assert isinstance(im, MicImagePlugin.MicImageFile) assert im.ole.fp.closed im = Image.open(TEST_FILE) + assert isinstance(im, MicImagePlugin.MicImageFile) im.close() assert im.ole.fp.closed diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 34b0f4afa..c8139d160 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -6,7 +6,7 @@ from typing import Any import pytest -from PIL import Image, ImageFile, MpoImagePlugin +from PIL import Image, ImageFile, JpegImagePlugin, MpoImagePlugin from .helper import ( assert_image_equal, @@ -80,6 +80,7 @@ def test_context_manager() -> None: def test_app(test_file: str) -> None: # Test APP/COM reader (@PIL135) with Image.open(test_file) as im: + assert isinstance(im, MpoImagePlugin.MpoImageFile) assert im.applist[0][0] == "APP1" assert im.applist[1][0] == "APP2" assert im.applist[1][1].startswith( @@ -220,12 +221,14 @@ def test_seek(test_file: str) -> None: def test_n_frames() -> None: with Image.open("Tests/images/sugarshack.mpo") as im: + assert isinstance(im, MpoImagePlugin.MpoImageFile) assert im.n_frames == 2 assert im.is_animated def test_eoferror() -> None: with Image.open("Tests/images/sugarshack.mpo") as im: + assert isinstance(im, MpoImagePlugin.MpoImageFile) n_frames = im.n_frames # Test seeking past the last frame @@ -239,6 +242,8 @@ def test_eoferror() -> None: def test_adopt_jpeg() -> None: with Image.open("Tests/images/hopper.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) + with pytest.raises(ValueError): MpoImagePlugin.MpoImageFile.adopt(im) diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index a1859bc33..58208ba99 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -43,6 +43,11 @@ def roundtrip(tmp_path: Path, mode: str) -> None: im.save(outfile) converted = open_with_magick(magick, tmp_path, outfile) + if mode == "P": + assert converted.mode == "P" + + im = im.convert("RGB") + converted = converted.convert("RGB") assert_image_equal(converted, im) @@ -55,7 +60,6 @@ def test_monochrome(tmp_path: Path) -> None: roundtrip(tmp_path, mode) -@pytest.mark.xfail(reason="Palm P image is wrong") def test_p_mode(tmp_path: Path) -> None: # Arrange mode = "P" diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index c969bd502..0f0886ab8 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -576,6 +576,7 @@ class TestFilePng: def test_read_private_chunks(self) -> None: with Image.open("Tests/images/exif.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.private_chunks == [(b"orNT", b"\x01")] def test_roundtrip_private_chunk(self) -> None: @@ -598,6 +599,7 @@ class TestFilePng: def test_textual_chunks_after_idat(self, monkeypatch: pytest.MonkeyPatch) -> None: with Image.open("Tests/images/hopper.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert "comment" in im.text for k, v in { "date:create": "2014-09-04T09:37:08+03:00", @@ -607,15 +609,19 @@ class TestFilePng: # Raises a SyntaxError in load_end with Image.open("Tests/images/broken_data_stream.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) with pytest.raises(OSError): assert isinstance(im.text, dict) # Raises an EOFError in load_end with Image.open("Tests/images/hopper_idat_after_image_end.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) assert im.text == {"TXT": "VALUE", "ZIP": "VALUE"} # Raises a UnicodeDecodeError in load_end with Image.open("Tests/images/truncated_image.png") as im: + assert isinstance(im, PngImagePlugin.PngImageFile) + # The file is truncated with pytest.raises(OSError): im.text @@ -726,6 +732,7 @@ class TestFilePng: im.save(test_file) with Image.open(test_file) as reloaded: + assert isinstance(reloaded, PngImagePlugin.PngImageFile) assert reloaded._getexif() is None # Test passing in exif diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 1793c269d..38a88cd17 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -59,17 +59,21 @@ def test_invalid_file() -> None: def test_n_frames() -> None: with Image.open("Tests/images/hopper_merged.psd") as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) assert im.n_frames == 1 assert not im.is_animated for path in [test_file, "Tests/images/negative_layer_count.psd"]: with Image.open(path) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) assert im.n_frames == 2 assert im.is_animated def test_eoferror() -> None: with Image.open(test_file) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) + # PSD seek index starts at 1 rather than 0 n_frames = im.n_frames + 1 @@ -119,11 +123,13 @@ def test_rgba() -> None: def test_negative_top_left_layer() -> None: with Image.open("Tests/images/negative_top_left_layer.psd") as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) assert im.layers[0][2] == (-50, -50, 50, 50) def test_layer_skip() -> None: with Image.open("Tests/images/five_channels.psd") as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) assert im.n_frames == 1 @@ -175,5 +181,6 @@ def test_crashes(test_file: str, raises: type[Exception]) -> None: def test_layer_crashes(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) with pytest.raises(SyntaxError): im.layers diff --git a/Tests/test_file_sgi.py b/Tests/test_file_sgi.py index 7d34fa4b5..da0965fa1 100644 --- a/Tests/test_file_sgi.py +++ b/Tests/test_file_sgi.py @@ -71,24 +71,26 @@ def test_invalid_file() -> None: SgiImagePlugin.SgiImageFile(invalid_file) -def test_write(tmp_path: Path) -> None: - def roundtrip(img: Image.Image) -> None: - out = tmp_path / "temp.sgi" - img.save(out, format="sgi") +def roundtrip(img: Image.Image, tmp_path: Path) -> None: + out = tmp_path / "temp.sgi" + img.save(out, format="sgi") + assert_image_equal_tofile(img, out) + + out = tmp_path / "fp.sgi" + with open(out, "wb") as fp: + img.save(fp) assert_image_equal_tofile(img, out) - out = tmp_path / "fp.sgi" - with open(out, "wb") as fp: - img.save(fp) - assert_image_equal_tofile(img, out) + assert not fp.closed - assert not fp.closed - for mode in ("L", "RGB", "RGBA"): - roundtrip(hopper(mode)) +@pytest.mark.parametrize("mode", ("L", "RGB", "RGBA")) +def test_write(mode: str, tmp_path: Path) -> None: + roundtrip(hopper(mode), tmp_path) - # Test 1 dimension for an L mode image - roundtrip(Image.new("L", (10, 1))) + +def test_write_L_mode_1_dimension(tmp_path: Path) -> None: + roundtrip(Image.new("L", (10, 1)), tmp_path) def test_write16(tmp_path: Path) -> None: diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index b64a629f5..3b3c3b4a5 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -96,6 +96,7 @@ def test_tell() -> None: def test_n_frames() -> None: with Image.open(TEST_FILE) as im: + assert isinstance(im, SpiderImagePlugin.SpiderImageFile) assert im.n_frames == 1 assert not im.is_animated diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index a10d4d7ab..8b6ed3ed2 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -1,8 +1,6 @@ from __future__ import annotations import os -from glob import glob -from itertools import product from pathlib import Path import pytest @@ -15,14 +13,27 @@ _TGA_DIR = os.path.join("Tests", "images", "tga") _TGA_DIR_COMMON = os.path.join(_TGA_DIR, "common") -_MODES = ("L", "LA", "P", "RGB", "RGBA") _ORIGINS = ("tl", "bl") _ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1} -@pytest.mark.parametrize("mode", _MODES) -def test_sanity(mode: str, tmp_path: Path) -> None: +@pytest.mark.parametrize( + "size_mode", + ( + ("1x1", "L"), + ("200x32", "L"), + ("200x32", "LA"), + ("200x32", "P"), + ("200x32", "RGB"), + ("200x32", "RGBA"), + ), +) +@pytest.mark.parametrize("origin", _ORIGINS) +@pytest.mark.parametrize("rle", (True, False)) +def test_sanity( + size_mode: tuple[str, str], origin: str, rle: str, tmp_path: Path +) -> None: def roundtrip(original_im: Image.Image) -> None: out = tmp_path / "temp.tga" @@ -36,33 +47,26 @@ def test_sanity(mode: str, tmp_path: Path) -> None: assert_image_equal(saved_im, original_im) - png_paths = glob(os.path.join(_TGA_DIR_COMMON, f"*x*_{mode.lower()}.png")) + size, mode = size_mode + png_path = os.path.join(_TGA_DIR_COMMON, size + "_" + mode.lower() + ".png") + with Image.open(png_path) as reference_im: + assert reference_im.mode == mode - for png_path in png_paths: - with Image.open(png_path) as reference_im: - assert reference_im.mode == mode + path_no_ext = os.path.splitext(png_path)[0] + tga_path = "{}_{}_{}.tga".format(path_no_ext, origin, "rle" if rle else "raw") - path_no_ext = os.path.splitext(png_path)[0] - for origin, rle in product(_ORIGINS, (True, False)): - tga_path = "{}_{}_{}.tga".format( - path_no_ext, origin, "rle" if rle else "raw" - ) + with Image.open(tga_path) as original_im: + assert original_im.format == "TGA" + assert original_im.get_format_mimetype() == "image/x-tga" + if rle: + assert original_im.info["compression"] == "tga_rle" + assert original_im.info["orientation"] == _ORIGIN_TO_ORIENTATION[origin] + if mode == "P": + assert original_im.getpalette() == reference_im.getpalette() - with Image.open(tga_path) as original_im: - assert original_im.format == "TGA" - assert original_im.get_format_mimetype() == "image/x-tga" - if rle: - assert original_im.info["compression"] == "tga_rle" - assert ( - original_im.info["orientation"] - == _ORIGIN_TO_ORIENTATION[origin] - ) - if mode == "P": - assert original_im.getpalette() == reference_im.getpalette() + assert_image_equal(original_im, reference_im) - assert_image_equal(original_im, reference_im) - - roundtrip(original_im) + roundtrip(original_im) def test_palette_depth_8() -> None: diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index bd1222316..3a96f7553 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -9,7 +9,13 @@ from types import ModuleType import pytest -from PIL import Image, ImageFile, TiffImagePlugin, UnidentifiedImageError +from PIL import ( + Image, + ImageFile, + JpegImagePlugin, + TiffImagePlugin, + UnidentifiedImageError, +) from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION from .helper import ( @@ -113,6 +119,7 @@ class TestFileTiff: with Image.open("Tests/images/hopper_bigtiff.tif") as im: outfile = tmp_path / "temp.tif" + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2) def test_bigtiff_save(self, tmp_path: Path) -> None: @@ -121,11 +128,13 @@ class TestFileTiff: im.save(outfile, big_tiff=True) with Image.open(outfile) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2._bigtiff is True im.save(outfile, save_all=True, append_images=[im], big_tiff=True) with Image.open(outfile) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2._bigtiff is True def test_seek_too_large(self) -> None: @@ -140,6 +149,8 @@ class TestFileTiff: def test_xyres_tiff(self) -> None: filename = "Tests/images/pil168.tif" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # legacy api assert isinstance(im.tag[X_RESOLUTION][0], tuple) assert isinstance(im.tag[Y_RESOLUTION][0], tuple) @@ -153,6 +164,8 @@ class TestFileTiff: def test_xyres_fallback_tiff(self) -> None: filename = "Tests/images/compression.tif" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # v2 api assert isinstance(im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational) assert isinstance(im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational) @@ -167,6 +180,8 @@ class TestFileTiff: def test_int_resolution(self) -> None: filename = "Tests/images/pil168.tif" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # Try to read a file where X,Y_RESOLUTION are ints im.tag_v2[X_RESOLUTION] = 71 im.tag_v2[Y_RESOLUTION] = 71 @@ -181,6 +196,7 @@ class TestFileTiff: with Image.open( "Tests/images/hopper_float_dpi_" + str(resolution_unit) + ".tif" ) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2.get(RESOLUTION_UNIT) == resolution_unit assert im.info["dpi"] == (dpi, dpi) @@ -198,6 +214,7 @@ class TestFileTiff: with Image.open("Tests/images/10ct_32bit_128.tiff") as im: im.save(b, format="tiff", resolution=123.45) with Image.open(b) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2[X_RESOLUTION] == 123.45 assert im.tag_v2[Y_RESOLUTION] == 123.45 @@ -213,10 +230,12 @@ class TestFileTiff: TiffImagePlugin.PREFIXES.pop() def test_bad_exif(self) -> None: - with Image.open("Tests/images/hopper_bad_exif.jpg") as i: + with Image.open("Tests/images/hopper_bad_exif.jpg") as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) + # Should not raise struct.error. with pytest.warns(UserWarning): - i._getexif() + im._getexif() def test_save_rgba(self, tmp_path: Path) -> None: im = hopper("RGBA") @@ -307,11 +326,13 @@ class TestFileTiff: ) def test_n_frames(self, path: str, n_frames: int) -> None: with Image.open(path) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == n_frames assert im.is_animated == (n_frames != 1) def test_eoferror(self) -> None: with Image.open("Tests/images/multipage-lastframe.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) n_frames = im.n_frames # Test seeking past the last frame @@ -355,19 +376,24 @@ class TestFileTiff: def test_frame_order(self) -> None: # A frame can't progress to itself after reading with Image.open("Tests/images/multipage_single_frame_loop.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == 1 # A frame can't progress to a frame that has already been read with Image.open("Tests/images/multipage_multiple_frame_loop.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == 2 # Frames don't have to be in sequence with Image.open("Tests/images/multipage_out_of_order.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == 3 def test___str__(self) -> None: filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # Act ret = str(im.ifd) @@ -378,6 +404,8 @@ class TestFileTiff: # Arrange filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # v2 interface v2_tags = { 256: 55, @@ -417,6 +445,7 @@ class TestFileTiff: def test__delitem__(self) -> None: filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) len_before = len(dict(im.ifd)) del im.ifd[256] len_after = len(dict(im.ifd)) @@ -449,6 +478,7 @@ class TestFileTiff: def test_ifd_tag_type(self) -> None: with Image.open("Tests/images/ifd_tag_type.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert 0x8825 in im.tag_v2 def test_exif(self, tmp_path: Path) -> None: @@ -537,6 +567,7 @@ class TestFileTiff: im = hopper(mode) im.save(filename, tiffinfo={262: 0}) with Image.open(filename) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[262] == 0 assert_image_equal(im, reloaded) @@ -615,6 +646,8 @@ class TestFileTiff: filename = tmp_path / "temp.tif" hopper("RGB").save(filename, "TIFF", **kwargs) with Image.open(filename) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + # legacy interface assert im.tag[X_RESOLUTION][0][0] == 72 assert im.tag[Y_RESOLUTION][0][0] == 36 @@ -701,6 +734,7 @@ class TestFileTiff: def test_planar_configuration_save(self, tmp_path: Path) -> None: infile = "Tests/images/tiff_tiled_planar_raw.tif" with Image.open(infile) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im._planar_configuration == 2 outfile = tmp_path / "temp.tif" @@ -733,6 +767,7 @@ class TestFileTiff: mp.seek(0, os.SEEK_SET) with Image.open(mp) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.n_frames == 3 # Test appending images @@ -743,6 +778,7 @@ class TestFileTiff: mp.seek(0, os.SEEK_SET) with Image.open(mp) as reread: + assert isinstance(reread, TiffImagePlugin.TiffImageFile) assert reread.n_frames == 3 # Test appending using a generator @@ -754,6 +790,7 @@ class TestFileTiff: mp.seek(0, os.SEEK_SET) with Image.open(mp) as reread: + assert isinstance(reread, TiffImagePlugin.TiffImageFile) assert reread.n_frames == 3 def test_save_all_progress(self) -> None: @@ -915,6 +952,7 @@ class TestFileTiff: def test_get_photoshop_blocks(self) -> None: with Image.open("Tests/images/lab.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert list(im.get_photoshop_blocks().keys()) == [ 1061, 1002, diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index 0734d1db1..884868345 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -61,6 +61,7 @@ def test_rt_metadata(tmp_path: Path) -> None: img.save(f, tiffinfo=info) with Image.open(f) as loaded: + assert isinstance(loaded, TiffImagePlugin.TiffImageFile) assert loaded.tag[ImageJMetaDataByteCounts] == (len(bin_data),) assert loaded.tag_v2[ImageJMetaDataByteCounts] == (len(bin_data),) @@ -80,12 +81,14 @@ def test_rt_metadata(tmp_path: Path) -> None: info[ImageJMetaDataByteCounts] = (8, len(bin_data) - 8) img.save(f, tiffinfo=info) with Image.open(f) as loaded: + assert isinstance(loaded, TiffImagePlugin.TiffImageFile) assert loaded.tag[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8) assert loaded.tag_v2[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8) def test_read_metadata() -> None: with Image.open("Tests/images/hopper_g4.tif") as img: + assert isinstance(img, TiffImagePlugin.TiffImageFile) assert { "YResolution": IFDRational(4294967295, 113653537), "PlanarConfiguration": 1, @@ -128,6 +131,7 @@ def test_read_metadata() -> None: def test_write_metadata(tmp_path: Path) -> None: """Test metadata writing through the python code""" with Image.open("Tests/images/hopper.tif") as img: + assert isinstance(img, TiffImagePlugin.TiffImageFile) f = tmp_path / "temp.tiff" del img.tag[278] img.save(f, tiffinfo=img.tag) @@ -135,6 +139,7 @@ def test_write_metadata(tmp_path: Path) -> None: original = img.tag_v2.named() with Image.open(f) as loaded: + assert isinstance(loaded, TiffImagePlugin.TiffImageFile) reloaded = loaded.tag_v2.named() ignored = ["StripByteCounts", "RowsPerStrip", "PageNumber", "StripOffsets"] @@ -165,6 +170,7 @@ def test_write_metadata(tmp_path: Path) -> None: def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None: out = tmp_path / "temp.tiff" with Image.open("Tests/images/hopper.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) info = im.tag_v2 del info[278] @@ -178,6 +184,7 @@ def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None: im.save(out, tiffinfo=info) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] == TiffTags.LONG @@ -231,6 +238,7 @@ def test_writing_other_types_to_ascii( im.save(out, tiffinfo=info) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[271] == expected @@ -248,6 +256,7 @@ def test_writing_other_types_to_bytes(value: int | IFDRational, tmp_path: Path) im.save(out, tiffinfo=info) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[700] == b"\x01" @@ -267,6 +276,7 @@ def test_writing_other_types_to_undefined( im.save(out, tiffinfo=info) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[33723] == b"1" @@ -311,6 +321,7 @@ def test_iccprofile_binary() -> None: # but probably won't be able to save it. with Image.open("Tests/images/hopper.iccprofile_binary.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2.tagtype[34675] == 1 assert im.info["icc_profile"] @@ -336,6 +347,7 @@ def test_exif_div_zero(tmp_path: Path) -> None: im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert 0 == reloaded.tag_v2[41988].numerator assert 0 == reloaded.tag_v2[41988].denominator @@ -355,6 +367,7 @@ def test_ifd_unsigned_rational(tmp_path: Path) -> None: im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert max_long == reloaded.tag_v2[41493].numerator assert 1 == reloaded.tag_v2[41493].denominator @@ -367,6 +380,7 @@ def test_ifd_unsigned_rational(tmp_path: Path) -> None: im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert max_long == reloaded.tag_v2[41493].numerator assert 1 == reloaded.tag_v2[41493].denominator @@ -385,6 +399,7 @@ def test_ifd_signed_rational(tmp_path: Path) -> None: im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert numerator == reloaded.tag_v2[37380].numerator assert denominator == reloaded.tag_v2[37380].denominator @@ -397,6 +412,7 @@ def test_ifd_signed_rational(tmp_path: Path) -> None: im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert numerator == reloaded.tag_v2[37380].numerator assert denominator == reloaded.tag_v2[37380].denominator @@ -410,6 +426,7 @@ def test_ifd_signed_rational(tmp_path: Path) -> None: im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert 2**31 - 1 == reloaded.tag_v2[37380].numerator assert -1 == reloaded.tag_v2[37380].denominator @@ -424,6 +441,7 @@ def test_ifd_signed_long(tmp_path: Path) -> None: im.save(out, tiffinfo=info, compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[37000] == -60000 @@ -444,11 +462,13 @@ def test_empty_values() -> None: def test_photoshop_info(tmp_path: Path) -> None: with Image.open("Tests/images/issue_2278.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert len(im.tag_v2[34377]) == 70 assert isinstance(im.tag_v2[34377], bytes) out = tmp_path / "temp.tiff" im.save(out) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert len(reloaded.tag_v2[34377]) == 70 assert isinstance(reloaded.tag_v2[34377], bytes) diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index d4b1fda97..503761374 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest from packaging.version import parse as parse_version -from PIL import Image, features +from PIL import GifImagePlugin, Image, WebPImagePlugin, features from .helper import ( assert_image_equal, @@ -22,10 +22,12 @@ def test_n_frames() -> None: """Ensure that WebP format sets n_frames and is_animated attributes correctly.""" with Image.open("Tests/images/hopper.webp") as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 1 assert not im.is_animated with Image.open("Tests/images/iss634.webp") as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 42 assert im.is_animated @@ -37,11 +39,13 @@ def test_write_animation_L(tmp_path: Path) -> None: """ with Image.open("Tests/images/iss634.gif") as orig: + assert isinstance(orig, GifImagePlugin.GifImageFile) assert orig.n_frames > 1 temp_file = tmp_path / "temp.webp" orig.save(temp_file, save_all=True) with Image.open(temp_file) as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == orig.n_frames # Compare first and last frames to the original animated GIF @@ -69,6 +73,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None: def check(temp_file: Path) -> None: with Image.open(temp_file) as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 2 # Compare first frame to original @@ -127,6 +132,7 @@ def test_timestamp_and_duration(tmp_path: Path) -> None: ) with Image.open(temp_file) as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 5 assert im.is_animated @@ -170,6 +176,7 @@ def test_seeking(tmp_path: Path) -> None: ) with Image.open(temp_file) as im: + assert isinstance(im, WebPImagePlugin.WebPImageFile) assert im.n_frames == 5 assert im.is_animated diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index d1d3421ec..7543d22da 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -6,7 +6,7 @@ from types import ModuleType import pytest -from PIL import Image +from PIL import Image, WebPImagePlugin from .helper import mark_if_feature_version, skip_unless_feature @@ -110,6 +110,7 @@ def test_read_no_exif() -> None: test_buffer.seek(0) with Image.open(test_buffer) as webp_image: + assert isinstance(webp_image, WebPImagePlugin.WebPImageFile) assert not webp_image._getexif() diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 07c945848..dcf5f000f 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -8,7 +8,7 @@ import pytest from PIL import Image, ImageFile, WmfImagePlugin -from .helper import assert_image_similar_tofile, hopper +from .helper import assert_image_equal_tofile, assert_image_similar_tofile, hopper def test_load_raw() -> None: @@ -44,6 +44,15 @@ def test_load_zero_inch() -> None: pass +def test_render() -> None: + with open("Tests/images/drawing.emf", "rb") as fp: + data = fp.read() + b = BytesIO(data[:808] + b"\x00" + data[809:]) + with Image.open(b) as im: + if hasattr(Image.core, "drawwmf"): + assert_image_equal_tofile(im, "Tests/images/drawing.emf") + + def test_register_handler(tmp_path: Path) -> None: class TestHandler(ImageFile.StubHandler): methodCalled = False @@ -80,6 +89,7 @@ def test_load_float_dpi() -> None: def test_load_set_dpi() -> None: with Image.open("Tests/images/drawing.wmf") as im: + assert isinstance(im, WmfImagePlugin.WmfStubImageFile) assert im.size == (82, 82) if hasattr(Image.core, "drawwmf"): @@ -88,6 +98,22 @@ def test_load_set_dpi() -> None: assert_image_similar_tofile(im, "Tests/images/drawing_wmf_ref_144.png", 2.1) + with Image.open("Tests/images/drawing.emf") as im: + assert im.size == (1625, 1625) + + if not hasattr(Image.core, "drawwmf"): + return + assert isinstance(im, WmfImagePlugin.WmfStubImageFile) + im.load(im.info["dpi"]) + assert im.size == (1625, 1625) + + with Image.open("Tests/images/drawing.emf") as im: + assert isinstance(im, WmfImagePlugin.WmfStubImageFile) + im.load((72, 144)) + assert im.size == (82, 164) + + assert_image_equal_tofile(im, "Tests/images/drawing_emf_ref_72_144.png") + @pytest.mark.parametrize("ext", (".wmf", ".emf")) def test_save(ext: str, tmp_path: Path) -> None: diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index 26afe93f4..73c62a44d 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -30,6 +30,7 @@ def test_invalid_file() -> None: def test_load_read() -> None: # Arrange with Image.open(TEST_FILE) as im: + assert isinstance(im, XpmImagePlugin.XpmImageFile) dummy_bytes = 1 # Act diff --git a/Tests/test_image.py b/Tests/test_image.py index f18d8489c..7e6118d52 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -230,10 +230,10 @@ class TestImage: assert_image_similar(im, reloaded, 20) def test_unknown_extension(self, tmp_path: Path) -> None: - im = hopper() temp_file = tmp_path / "temp.unknown" - with pytest.raises(ValueError): - im.save(temp_file) + with hopper() as im: + with pytest.raises(ValueError): + im.save(temp_file) def test_internals(self) -> None: im = Image.new("L", (100, 100)) @@ -258,6 +258,15 @@ class TestImage: assert im.readonly im.save(temp_file) + def test_save_without_changing_readonly(self, tmp_path: Path) -> None: + temp_file = tmp_path / "temp.bmp" + + with Image.open("Tests/images/rgb32bf-rgba.bmp") as im: + assert im.readonly + + im.save(temp_file) + assert im.readonly + def test_dump(self, tmp_path: Path) -> None: im = Image.new("L", (10, 10)) im._dump(str(tmp_path / "temp_L.ppm")) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 2767418ea..ffe9c0979 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1704,7 +1704,7 @@ def test_discontiguous_corners_polygon() -> None: BLACK, ) expected = os.path.join(IMAGES_PATH, "discontiguous_corners_polygon.png") - assert_image_similar_tofile(img, expected, 1) + assert_image_equal_tofile(img, expected) def test_polygon2() -> None: diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index c60a475a3..7622eea99 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -131,6 +131,26 @@ class TestImageFile: assert_image_equal(im1, im2) + def test_tile_size(self) -> None: + with open("Tests/images/hopper.tif", "rb") as im_fp: + data = im_fp.read() + + reads = [] + + class FP(BytesIO): + def read(self, size: int | None = None) -> bytes: + reads.append(size) + return super().read(size) + + fp = FP(data) + with Image.open(fp) as im: + assert len(im.tile) == 7 + + im.load() + + # Despite multiple tiles, assert only one tile caused a read of maxblock size + assert reads.count(im.decodermaxblock) == 1 + def test_raise_oserror(self) -> None: with pytest.warns(DeprecationWarning): with pytest.raises(OSError): diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 5cd510751..01fa090dc 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -40,8 +40,11 @@ class TestImageGrab: @pytest.mark.skipif(Image.core.HAVE_XCB, reason="tests missing XCB") def test_grab_no_xcb(self) -> None: - if sys.platform not in ("win32", "darwin") and not shutil.which( - "gnome-screenshot" + if ( + sys.platform not in ("win32", "darwin") + and not shutil.which("gnome-screenshot") + and not shutil.which("grim") + and not shutil.which("spectacle") ): with pytest.raises(OSError) as e: ImageGrab.grab() @@ -57,6 +60,13 @@ class TestImageGrab: ImageGrab.grab(xdisplay="error.test:0.0") assert str(e.value).startswith("X connection failed") + @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") + def test_grab_invalid_handle(self) -> None: + with pytest.raises(OSError, match="unable to get device context for handle"): + ImageGrab.grab(window=-1) + with pytest.raises(OSError, match="screen grab failed"): + ImageGrab.grab(window=0) + def test_grabclipboard(self) -> None: if sys.platform == "darwin": subprocess.call(["screencapture", "-cx"]) diff --git a/Tests/test_imagesequence.py b/Tests/test_imagesequence.py index da9e71692..7b9ac80bc 100644 --- a/Tests/test_imagesequence.py +++ b/Tests/test_imagesequence.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from PIL import Image, ImageSequence, TiffImagePlugin +from PIL import Image, ImageSequence, PsdImagePlugin, TiffImagePlugin from .helper import assert_image_equal, hopper, skip_unless_feature @@ -31,6 +31,7 @@ def test_sanity(tmp_path: Path) -> None: def test_iterator() -> None: with Image.open("Tests/images/multipage.tiff") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) i = ImageSequence.Iterator(im) for index in range(im.n_frames): assert i[index] == next(i) @@ -42,6 +43,7 @@ def test_iterator() -> None: def test_iterator_min_frame() -> None: with Image.open("Tests/images/hopper.psd") as im: + assert isinstance(im, PsdImagePlugin.PsdImageFile) i = ImageSequence.Iterator(im) for index in range(1, im.n_frames): assert i[index] == next(i) diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index 70661ecc1..1c48cb743 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -81,6 +81,7 @@ def test_pickle_jpeg() -> None: unpickled_image = pickle.loads(pickle.dumps(image)) # Assert + assert unpickled_image.filename == "Tests/images/hopper.jpg" assert len(unpickled_image.layer) == 3 assert unpickled_image.layers == 3 diff --git a/Tests/test_pyarrow.py b/Tests/test_pyarrow.py new file mode 100644 index 000000000..ece9f8f26 --- /dev/null +++ b/Tests/test_pyarrow.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import Any # undone + +import pytest + +from PIL import Image + +from .helper import ( + assert_deep_equal, + assert_image_equal, + hopper, +) + +pyarrow = pytest.importorskip("pyarrow", reason="PyArrow not installed") + +TEST_IMAGE_SIZE = (10, 10) + + +def _test_img_equals_pyarray( + img: Image.Image, arr: Any, mask: list[int] | None +) -> None: + assert img.height * img.width == len(arr) + px = img.load() + assert px is not None + for x in range(0, img.size[0], int(img.size[0] / 10)): + for y in range(0, img.size[1], int(img.size[1] / 10)): + if mask: + for ix, elt in enumerate(mask): + pixel = px[x, y] + assert isinstance(pixel, tuple) + assert pixel[ix] == arr[y * img.width + x].as_py()[elt] + else: + assert_deep_equal(px[x, y], arr[y * img.width + x].as_py()) + + +# really hard to get a non-nullable list type +fl_uint8_4_type = pyarrow.field( + "_", pyarrow.list_(pyarrow.field("_", pyarrow.uint8()).with_nullable(False), 4) +).type + + +@pytest.mark.parametrize( + "mode, dtype, mask", + ( + ("L", pyarrow.uint8(), None), + ("I", pyarrow.int32(), None), + ("F", pyarrow.float32(), None), + ("LA", fl_uint8_4_type, [0, 3]), + ("RGB", fl_uint8_4_type, [0, 1, 2]), + ("RGBA", fl_uint8_4_type, None), + ("RGBX", fl_uint8_4_type, None), + ("CMYK", fl_uint8_4_type, None), + ("YCbCr", fl_uint8_4_type, [0, 1, 2]), + ("HSV", fl_uint8_4_type, [0, 1, 2]), + ), +) +def test_to_array(mode: str, dtype: Any, mask: list[int] | None) -> None: + img = hopper(mode) + + # Resize to non-square + img = img.crop((3, 0, 124, 127)) + assert img.size == (121, 127) + + arr = pyarrow.array(img) + _test_img_equals_pyarray(img, arr, mask) + assert arr.type == dtype + + reloaded = Image.fromarrow(arr, mode, img.size) + + assert reloaded + + assert_image_equal(img, reloaded) + + +def test_lifetime() -> None: + # valgrind shouldn't error out here. + # arrays should be accessible after the image is deleted. + + img = hopper("L") + + arr_1 = pyarrow.array(img) + arr_2 = pyarrow.array(img) + + del img + + assert arr_1.sum().as_py() > 0 + del arr_1 + + assert arr_2.sum().as_py() > 0 + del arr_2 + + +def test_lifetime2() -> None: + # valgrind shouldn't error out here. + # img should remain after the arrays are collected. + + img = hopper("L") + + arr_1 = pyarrow.array(img) + arr_2 = pyarrow.array(img) + + assert arr_1.sum().as_py() > 0 + del arr_1 + + assert arr_2.sum().as_py() > 0 + del arr_2 + + img2 = img.copy() + px = img2.load() + assert px # make mypy happy + assert isinstance(px[0, 0], int) diff --git a/Tests/test_pyroma.py b/Tests/test_pyroma.py index c2f7fe22e..8235daf32 100644 --- a/Tests/test_pyroma.py +++ b/Tests/test_pyroma.py @@ -23,5 +23,11 @@ def test_pyroma() -> None: ) else: - # Should have a perfect score - assert rating == (10, []) + # Should have a perfect score, but pyroma does not support PEP 639 yet. + assert rating == ( + 9, + [ + "Your package does neither have a license field " + "nor any license classifiers." + ], + ) diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index 4fd3aab5d..03e92b5b9 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -39,6 +39,7 @@ class TestShellInjection: shutil.copy(TEST_JPG, src_file) with Image.open(src_file) as im: + assert isinstance(im, JpegImagePlugin.JpegImageFile) im.load_djpeg() @pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available") diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index 30dc73654..42d06b896 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -72,4 +72,5 @@ def test_ifd_rational_save( im.save(out, dpi=(res, res), compression="raw") with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert float(IFDRational(301, 1)) == float(reloaded.tag_v2[282]) diff --git a/depends/install_libavif.sh b/depends/install_libavif.sh new file mode 100755 index 000000000..fc10d3e54 --- /dev/null +++ b/depends/install_libavif.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -eo pipefail + +version=1.2.1 + +./download-and-extract.sh libavif-$version https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$version.tar.gz + +pushd libavif-$version + +if [ $(uname) == "Darwin" ] && [ -x "$(command -v brew)" ]; then + PREFIX=$(brew --prefix) +else + PREFIX=/usr +fi + +PKGCONFIG=${PKGCONFIG:-pkg-config} + +LIBAVIF_CMAKE_FLAGS=() +HAS_DECODER=0 +HAS_ENCODER=0 + +if $PKGCONFIG --exists aom; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=SYSTEM) + HAS_ENCODER=1 + HAS_DECODER=1 +fi + +if $PKGCONFIG --exists dav1d; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_DAV1D=SYSTEM) + HAS_DECODER=1 +fi + +if $PKGCONFIG --exists libgav1; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_LIBGAV1=SYSTEM) + HAS_DECODER=1 +fi + +if $PKGCONFIG --exists rav1e; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_RAV1E=SYSTEM) + HAS_ENCODER=1 +fi + +if $PKGCONFIG --exists SvtAv1Enc; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_SVT=SYSTEM) + HAS_ENCODER=1 +fi + +if [ "$HAS_ENCODER" != 1 ] || [ "$HAS_DECODER" != 1 ]; then + LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=LOCAL) +fi + +cmake \ + -DCMAKE_INSTALL_PREFIX=$PREFIX \ + -DCMAKE_INSTALL_NAME_DIR=$PREFIX/lib \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_MACOSX_RPATH=OFF \ + -DAVIF_LIBSHARPYUV=LOCAL \ + -DAVIF_LIBYUV=LOCAL \ + "${LIBAVIF_CMAKE_FLAGS[@]}" \ + . + +sudo make install + +popd diff --git a/docs/Guardfile b/docs/Guardfile deleted file mode 100755 index 16a891a73..000000000 --- a/docs/Guardfile +++ /dev/null @@ -1,10 +0,0 @@ -#!/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")) diff --git a/docs/Makefile b/docs/Makefile index e90af0519..4412fc806 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -20,8 +20,8 @@ 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 " htmllive to rebuild and reload HTML files 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" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @@ -201,9 +201,10 @@ doctest: 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 +.PHONY: htmllive +htmllive: SPHINXBUILD = $(PYTHON) -m sphinx_autobuild +htmllive: SPHINXOPTS = --open-browser --delay 0 +htmllive: html .PHONY: serve serve: diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 634cee689..7f8e76bcc 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -186,7 +186,7 @@ ExifTags.IFD.Makernote Image.Image.get_child_images() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. deprecated:: 11.2.0 +.. deprecated:: 11.2.1 ``Image.Image.get_child_images()`` has been deprecated. and will be removed in Pillow 13 (2026-10-15). It will be moved to ``ImageFile.ImageFile.get_child_images()``. The diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst index 7da1078c1..fe874a740 100644 --- a/docs/handbook/concepts.rst +++ b/docs/handbook/concepts.rst @@ -30,35 +30,35 @@ image. Each pixel uses the full range of the bit depth. So a 1-bit pixel has a r INT32 and a 32-bit floating point pixel has the range of FLOAT32. The current release supports the following standard modes: - * ``1`` (1-bit pixels, black and white, stored with one pixel per byte) - * ``L`` (8-bit pixels, grayscale) - * ``P`` (8-bit pixels, mapped to any other mode using a color palette) - * ``RGB`` (3x8-bit pixels, true color) - * ``RGBA`` (4x8-bit pixels, true color with transparency mask) - * ``CMYK`` (4x8-bit pixels, color separation) - * ``YCbCr`` (3x8-bit pixels, color video format) +* ``1`` (1-bit pixels, black and white, stored with one pixel per byte) +* ``L`` (8-bit pixels, grayscale) +* ``P`` (8-bit pixels, mapped to any other mode using a color palette) +* ``RGB`` (3x8-bit pixels, true color) +* ``RGBA`` (4x8-bit pixels, true color with transparency mask) +* ``CMYK`` (4x8-bit pixels, color separation) +* ``YCbCr`` (3x8-bit pixels, color video format) - * Note that this refers to the JPEG, and not the ITU-R BT.2020, standard + * Note that this refers to the JPEG, and not the ITU-R BT.2020, standard - * ``LAB`` (3x8-bit pixels, the L*a*b color space) - * ``HSV`` (3x8-bit pixels, Hue, Saturation, Value color space) +* ``LAB`` (3x8-bit pixels, the L*a*b color space) +* ``HSV`` (3x8-bit pixels, Hue, Saturation, Value color space) - * Hue's range of 0-255 is a scaled version of 0 degrees <= Hue < 360 degrees + * Hue's range of 0-255 is a scaled version of 0 degrees <= Hue < 360 degrees - * ``I`` (32-bit signed integer pixels) - * ``F`` (32-bit floating point pixels) +* ``I`` (32-bit signed integer pixels) +* ``F`` (32-bit floating point pixels) Pillow also provides limited support for a few additional modes, including: - * ``LA`` (L with alpha) - * ``PA`` (P with alpha) - * ``RGBX`` (true color with padding) - * ``RGBa`` (true color with premultiplied alpha) - * ``La`` (L with premultiplied alpha) - * ``I;16`` (16-bit unsigned integer pixels) - * ``I;16L`` (16-bit little endian unsigned integer pixels) - * ``I;16B`` (16-bit big endian unsigned integer pixels) - * ``I;16N`` (16-bit native endian unsigned integer pixels) +* ``LA`` (L with alpha) +* ``PA`` (P with alpha) +* ``RGBX`` (true color with padding) +* ``RGBa`` (true color with premultiplied alpha) +* ``La`` (L with premultiplied alpha) +* ``I;16`` (16-bit unsigned integer pixels) +* ``I;16L`` (16-bit little endian unsigned integer pixels) +* ``I;16B`` (16-bit big endian unsigned integer pixels) +* ``I;16N`` (16-bit native endian unsigned integer pixels) Premultiplied alpha is where the values for each other channel have been multiplied by the alpha. For example, an RGBA pixel of ``(10, 20, 30, 127)`` diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 97599ace5..46fe8b630 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -24,6 +24,83 @@ present, and the :py:attr:`~PIL.Image.Image.format` attribute will be ``None``. Fully supported formats ----------------------- +AVIF +^^^^ + +Pillow reads and writes AVIF files, including AVIF sequence images. +It is only possible to save 8-bit AVIF images, and all AVIF images are decoded +as 8-bit RGB(A). + +The :py:meth:`~PIL.Image.Image.save` method supports the following options: + +**quality** + Integer, 0-100, defaults to 75. 0 gives the smallest size and poorest + quality, 100 the largest size and best quality. + +**subsampling** + If present, sets the subsampling for the encoder. Defaults to ``4:2:0``. + Options include: + + * ``4:0:0`` + * ``4:2:0`` + * ``4:2:2`` + * ``4:4:4`` + +**speed** + Quality/speed trade-off (0=slower/better, 10=fastest). Defaults to 6. + +**max_threads** + Limit the number of active threads used. By default, there is no limit. If the aom + codec is used, there is a maximum of 64. + +**range** + YUV range, either "full" or "limited". Defaults to "full". + +**codec** + AV1 codec to use for encoding. Specific values are "aom", "rav1e", and + "svt", presuming the chosen codec is available. Defaults to "auto", which + will choose the first available codec in the order of the preceding list. + +**tile_rows** / **tile_cols** + For tile encoding, the (log 2) number of tile rows and columns to use. + Valid values are 0-6, default 0. Ignored if "autotiling" is set to true. + +**autotiling** + Split the image up to allow parallelization. Enabled automatically if "tile_rows" + and "tile_cols" both have their default values of zero. + +**alpha_premultiplied** + Encode the image with premultiplied alpha. Defaults to ``False``. + +**advanced** + Codec specific options. + +**icc_profile** + The ICC Profile to include in the saved file. + +**exif** + The exif data to include in the saved file. + +**xmp** + The XMP data to include in the saved file. + +Saving sequences +~~~~~~~~~~~~~~~~ + +When calling :py:meth:`~PIL.Image.Image.save` to write an AVIF file, by default +only the first frame of a multiframe image will be saved. If the ``save_all`` +argument is present and true, then all frames will be saved, and the following +options will also be available. + +**append_images** + A list of images to append as additional frames. Each of the + images in the list can be single or multiframe images. + +**duration** + The display duration of each frame, in milliseconds. Pass a single + integer for a constant duration, or a list or tuple to set the + duration for each frame separately. + BLP ^^^ @@ -93,7 +170,7 @@ DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode. in ``P`` mode. -.. versionadded:: 11.2.0 +.. versionadded:: 11.2.1 DXT1, DXT3, DXT5, BC2, BC3 and BC5 pixel formats can be saved:: im.save(out, pixel_format="DXT1") @@ -235,13 +312,14 @@ following options are available:: im.save(out, save_all=True, append_images=[im1, im2, ...]) **save_all** - If present and true, all frames of the image will be saved. If - not, then only the first frame of a multiframe image will be saved. + If present and true, or if ``append_images`` is not empty, all frames of + the image will be saved. Otherwise, only the first frame of a multiframe + image will be saved. **append_images** A list of images to append as additional frames. Each of the images in the list can be single or multiframe images. - This is currently supported for GIF, PDF, PNG, TIFF, and WebP. + This is supported for AVIF, GIF, PDF, PNG, TIFF and WebP. It is also supported for ICO and ICNS. If images are passed in of relevant sizes, they will be used instead of scaling down the main image. @@ -723,8 +801,8 @@ Saving When calling :py:meth:`~PIL.Image.Image.save` to write an MPO file, by default only the first frame of a multiframe image will be saved. If the ``save_all`` -argument is present and true, then all frames will be saved, and the following -option will also be available. +argument is present and true, or if ``append_images`` is not empty, all frames +will be saved. **append_images** A list of images to append as additional pictures. Each of the @@ -934,7 +1012,8 @@ Saving When calling :py:meth:`~PIL.Image.Image.save`, by default only a single frame PNG file will be saved. To save an APNG file (including a single frame APNG), the ``save_all`` -parameter must be set to ``True``. The following parameters can also be set: +parameter should be set to ``True`` or ``append_images`` should not be empty. The +following parameters can also be set: **default_image** Boolean value, specifying whether or not the base image is a default image. @@ -1163,7 +1242,8 @@ Saving The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: **save_all** - If true, Pillow will save all frames of the image to a multiframe tiff document. + If true, or if ``append_images`` is not empty, Pillow will save all frames of the + image to a multiframe tiff document. .. versionadded:: 3.4.0 @@ -1313,8 +1393,8 @@ Saving sequences When calling :py:meth:`~PIL.Image.Image.save` to write a WebP file, by default only the first frame of a multiframe image will be saved. If the ``save_all`` -argument is present and true, then all frames will be saved, and the following -options will also be available. +argument is present and true, or if ``append_images`` is not empty, all frames +will be saved, and the following options will also be available. **append_images** A list of images to append as additional frames. Each of the @@ -1584,6 +1664,11 @@ The :py:meth:`~PIL.Image.open` method sets the following Transparency color index. This key is omitted if the image is not transparent. +XV Thumbnails +^^^^^^^^^^^^^ + +Pillow can read XV thumbnail files. + Write-only formats ------------------ @@ -1616,15 +1701,14 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum **save_all** If a multiframe image is used, by default, only the first image will be saved. To save all frames, each frame to a separate page of the PDF, the ``save_all`` - parameter must be present and set to ``True``. + parameter should be present and set to ``True`` or ``append_images`` should not be + empty. .. versionadded:: 3.0.0 **append_images** A list of :py:class:`PIL.Image.Image` objects to append as additional pages. Each - of the images in the list can be single or multiframe images. The ``save_all`` - parameter must be present and set to ``True`` in conjunction with - ``append_images``. + of the images in the list can be single or multiframe images. .. versionadded:: 4.2.0 @@ -1690,11 +1774,6 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum .. versionadded:: 5.3.0 -XV Thumbnails -^^^^^^^^^^^^^ - -Pillow can read XV thumbnail files. - Identify-only formats --------------------- diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst index f771ae7ae..f1a2849b8 100644 --- a/docs/handbook/tutorial.rst +++ b/docs/handbook/tutorial.rst @@ -534,7 +534,6 @@ You can create animated GIFs with Pillow, e.g. # Save the images as an animated GIF images[0].save( "animated_hopper.gif", - save_all=True, append_images=images[1:], duration=500, # duration of each frame in milliseconds loop=0, # loop forever diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 2790bc2e6..9ba389b66 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -89,6 +89,14 @@ Many of Pillow's features require external libraries: * **libxcb** provides X11 screengrab support. +* **libavif** provides support for the AVIF format. + + * Pillow requires libavif version **1.0.0** or greater. + * libavif is merely an API that wraps AVIF codecs. If you are compiling + libavif from source, you will also need to install both an AVIF encoder + and decoder, such as rav1e and dav1d, or libaom, which both encodes and + decodes AVIF images. + .. tab:: Linux If you didn't build Python from source, make sure you have Python's @@ -117,6 +125,12 @@ Many of Pillow's features require external libraries: To install libraqm, ``sudo apt-get install meson`` and then see ``depends/install_raqm.sh``. + Build prerequisites for libavif on Ubuntu are installed with:: + + sudo apt-get install cmake ninja-build nasm + + Then see ``depends/install_libavif.sh`` to build and install libavif. + Prerequisites are installed on recent **Red Hat**, **CentOS** or **Fedora** with:: sudo dnf install libtiff-devel libjpeg-devel openjpeg2-devel zlib-devel \ @@ -148,7 +162,15 @@ Many of Pillow's features require external libraries: The easiest way to install external libraries is via `Homebrew `_. After you install Homebrew, run:: - brew install libjpeg libraqm libtiff little-cms2 openjpeg webp + brew install libavif libjpeg libraqm libtiff little-cms2 openjpeg webp + + If you would like to use libavif with more codecs than just aom, then + instead of installing libavif through Homebrew directly, you can use + Homebrew to install libavif's build dependencies:: + + brew install aom dav1d rav1e svt-av1 + + Then see ``depends/install_libavif.sh`` to install libavif. .. tab:: Windows @@ -187,7 +209,8 @@ Many of Pillow's features require external libraries: mingw-w64-x86_64-libwebp \ mingw-w64-x86_64-openjpeg2 \ mingw-w64-x86_64-libimagequant \ - mingw-w64-x86_64-libraqm + mingw-w64-x86_64-libraqm \ + mingw-w64-x86_64-libavif .. tab:: FreeBSD @@ -199,7 +222,7 @@ Many of Pillow's features require external libraries: Prerequisites are installed on **FreeBSD 10 or 11** with:: - sudo pkg install jpeg-turbo tiff webp lcms2 freetype2 openjpeg harfbuzz fribidi libxcb + sudo pkg install jpeg-turbo tiff webp lcms2 freetype2 openjpeg harfbuzz fribidi libxcb libavif Then see ``depends/install_raqm_cmake.sh`` to install libraqm. @@ -261,14 +284,16 @@ Build Options * Config settings: ``-C zlib=disable``, ``-C jpeg=disable``, ``-C tiff=disable``, ``-C freetype=disable``, ``-C raqm=disable``, ``-C lcms=disable``, ``-C webp=disable``, - ``-C jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``. + ``-C jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``, + ``-C avif=disable``. Disable building the corresponding feature even if the development libraries are present on the building machine. * Config settings: ``-C zlib=enable``, ``-C jpeg=enable``, ``-C tiff=enable``, ``-C freetype=enable``, ``-C raqm=enable``, ``-C lcms=enable``, ``-C webp=enable``, - ``-C jpeg2000=enable``, ``-C imagequant=enable``, ``-C xcb=enable``. + ``-C jpeg2000=enable``, ``-C imagequant=enable``, ``-C xcb=enable``, + ``-C avif=enable``. Require that the corresponding feature is built. The build will raise an exception if the libraries are not found. Tcl and Tk must be used together. diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 9eafad3c4..d751620fd 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -23,7 +23,7 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Amazon Linux 2023 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Arch | 3.12 | x86-64 | +| Arch | 3.13 | x86-64 | +----------------------------------+----------------------------+---------------------+ | CentOS Stream 9 | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -31,10 +31,10 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Debian 12 Bookworm | 3.11 | x86, x86-64 | +----------------------------------+----------------------------+---------------------+ -| Fedora 40 | 3.12 | x86-64 | -+----------------------------------+----------------------------+---------------------+ | Fedora 41 | 3.13 | x86-64 | +----------------------------------+----------------------------+---------------------+ +| Fedora 42 | 3.13 | x86-64 | ++----------------------------------+----------------------------+---------------------+ | Gentoo | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ | macOS 13 Ventura | 3.9 | x86-64 | @@ -73,7 +73,7 @@ These platforms have been reported to work at the versions mentioned. | Operating system | | Tested Python | | Latest tested | | Tested | | | | versions | | Pillow version | | processors | +==================================+============================+==================+==============+ -| macOS 15 Sequoia | 3.9, 3.10, 3.11, 3.12, 3.13| 11.1.0 |arm | +| macOS 15 Sequoia | 3.9, 3.10, 3.11, 3.12, 3.13| 11.2.1 |arm | | +----------------------------+------------------+ | | | 3.8 | 10.4.0 | | +----------------------------------+----------------------------+------------------+--------------+ diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index bc3758218..a3ba8cfd8 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -79,6 +79,7 @@ Constructing images .. autofunction:: new .. autofunction:: fromarray +.. autofunction:: fromarrow .. autofunction:: frombytes .. autofunction:: frombuffer @@ -370,6 +371,8 @@ Protocols .. autoclass:: SupportsArrayInterface :show-inheritance: +.. autoclass:: SupportsArrowArrayInterface + :show-inheritance: .. autoclass:: SupportsGetData :show-inheritance: diff --git a/docs/reference/ImageCms.rst b/docs/reference/ImageCms.rst index 96bd14dd3..4a1f5a3ee 100644 --- a/docs/reference/ImageCms.rst +++ b/docs/reference/ImageCms.rst @@ -286,6 +286,14 @@ can be easily displayed in a chromaticity diagram, for example). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. + .. py:attribute:: media_white_point + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None + + This tag specifies the media white point and is used for + generating absolute colorimetry. + + The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. + .. py:attribute:: media_white_point_temperature :type: float | None diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index b2f1bdc93..bd6f6b048 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -391,7 +391,7 @@ Methods the relative alignment of lines. Use the ``anchor`` parameter to specify the alignment to ``xy``. - .. versionadded:: 11.2.0 ``"justify"`` + .. versionadded:: 11.2.1 ``"justify"`` :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. @@ -462,7 +462,7 @@ Methods the relative alignment of lines. Use the ``anchor`` parameter to specify the alignment to ``xy``. - .. versionadded:: 11.2.0 ``"justify"`` + .. versionadded:: 11.2.1 ``"justify"`` :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. @@ -609,7 +609,7 @@ Methods the relative alignment of lines. Use the ``anchor`` parameter to specify the alignment to ``xy``. - .. versionadded:: 11.2.0 ``"justify"`` + .. versionadded:: 11.2.1 ``"justify"`` :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. @@ -663,7 +663,7 @@ Methods the relative alignment of lines. Use the ``anchor`` parameter to specify the alignment to ``xy``. - .. versionadded:: 11.2.0 ``"justify"`` + .. versionadded:: 11.2.1 ``"justify"`` :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst index db2987eb0..0fd8f68df 100644 --- a/docs/reference/ImageGrab.rst +++ b/docs/reference/ImageGrab.rst @@ -9,15 +9,16 @@ or the clipboard to a PIL image memory. .. versionadded:: 1.1.3 -.. py:function:: grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None) +.. py:function:: grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None, window=None) Take a snapshot of the screen. The pixels inside the bounding box are returned as an "RGBA" on macOS, or an "RGB" image otherwise. If the bounding box is omitted, the entire screen is copied, and on macOS, it will be at 2x if on a Retina screen. 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. + a snapshot of the screen, ``gnome-screenshot``, ``grim`` or ``spectacle`` will be + used as a fallback if they are installed. To disable this behaviour, pass + ``xdisplay=""`` instead. .. versionadded:: 1.1.3 (Windows), 3.0.0 (macOS), 7.1.0 (Linux) @@ -39,6 +40,11 @@ or the clipboard to a PIL image memory. You can check X11 support using :py:func:`PIL.features.check_feature` with ``feature="xcb"``. .. versionadded:: 7.1.0 + + :param window: + HWND, to capture a single window. Windows only. + + .. versionadded:: 11.2.1 :return: An image .. py:function:: grabclipboard() diff --git a/docs/reference/arrow_support.rst b/docs/reference/arrow_support.rst new file mode 100644 index 000000000..4a5c45e86 --- /dev/null +++ b/docs/reference/arrow_support.rst @@ -0,0 +1,88 @@ +.. _arrow-support: + +============= +Arrow Support +============= + +`Arrow `__ +is an in-memory data exchange format that is the spiritual +successor to the NumPy array interface. It provides for zero-copy +access to columnar data, which in our case is ``Image`` data. + +The goal with Arrow is to provide native zero-copy interoperability +with any Arrow provider or consumer in the Python ecosystem. + +.. warning:: Zero-copy does not mean zero allocation -- the internal + memory layout of Pillow images contains an allocation for row + pointers, so there is a non-zero, but significantly smaller than a + full-copy memory cost to reading an Arrow image. + + +Data Formats +============ + +Pillow currently supports exporting Arrow images in all modes +**except** for ``BGR;15``, ``BGR;16`` and ``BGR;24``. This is due to +line-length packing in these modes making for non-continuous memory. + +For single-band images, the exported array is width*height elements, +with each pixel corresponding to the appropriate Arrow type. + +For multiband images, the exported array is width*height fixed-length +four-element arrays of uint8. This is memory compatible with the raw +image storage of four bytes per pixel. + +Mode ``1`` images are exported as one uint8 byte/pixel, as this is +consistent with the internal storage. + +Pillow will accept, but not produce, one other format. For any +multichannel image with 32-bit storage per pixel, Pillow will accept +an array of width*height int32 elements, which will then be +interpreted using the mode-specific interpretation of the bytes. + +The image mode must match the Arrow band format when reading single +channel images. + +Memory Allocator +================ + +Pillow's default memory allocator, the :ref:`block_allocator`, +allocates up to a 16 MB block for images by default. Larger images +overflow into additional blocks. Arrow requires a single continuous +memory allocation, so images allocated in multiple blocks cannot be +exported in the Arrow format. + +To enable the single block allocator:: + + from PIL import Image + Image.core.set_use_block_allocator(1) + +Note that this is a global setting, not a per-image setting. + +Unsupported Features +==================== + +* Table/dataframe protocol. We support a single array. +* Null markers, producing or consuming. Null values are inferred from + the mode, e.g. RGB images are stored in the first three bytes of + each 32-bit pixel, and the last byte is an implied null. +* Schema negotiation. There is an optional schema for the requested + datatype in the Arrow source interface. We ignore that + parameter. +* Array metadata. + +Internal Details +================ + +Python Arrow C interface: +https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html + +The memory that is exported from the Arrow interface is shared -- not +copied, so the lifetime of the memory allocation is no longer strictly +tied to the life of the Python object. + +The core imaging struct now has a refcount associated with it, and the +lifetime of the core image struct is now divorced from the Python +image object. Creating an arrow reference to the image increments the +refcount, and the imaging struct is only released when the refcount +reaches zero. diff --git a/docs/reference/block_allocator.rst b/docs/reference/block_allocator.rst index 1abe5280f..c6be5b7e6 100644 --- a/docs/reference/block_allocator.rst +++ b/docs/reference/block_allocator.rst @@ -1,3 +1,6 @@ + +.. _block_allocator: + Block Allocator =============== @@ -34,14 +37,14 @@ fresh allocation. This caching of free blocks is currently disabled by default, but can be enabled and tweaked using three environment variables: - * ``PILLOW_ALIGNMENT``, in bytes. Specifies the alignment of memory - allocations. Valid values are powers of 2 between 1 and - 128, inclusive. Defaults to 1. +* ``PILLOW_ALIGNMENT``, in bytes. Specifies the alignment of memory + allocations. Valid values are powers of 2 between 1 and + 128, inclusive. Defaults to 1. - * ``PILLOW_BLOCK_SIZE``, in bytes, K, or M. Specifies the maximum - block size for ``ImagingAllocateArray``. Valid values are - integers, with an optional ``k`` or ``m`` suffix. Defaults to 16M. +* ``PILLOW_BLOCK_SIZE``, in bytes, K, or M. Specifies the maximum + block size for ``ImagingAllocateArray``. Valid values are + integers, with an optional ``k`` or ``m`` suffix. Defaults to 16M. - * ``PILLOW_BLOCKS_MAX`` Specifies the number of freed blocks to - retain to fill future memory requests. Any freed blocks over this - threshold will be returned to the OS immediately. Defaults to 0. +* ``PILLOW_BLOCKS_MAX`` Specifies the number of freed blocks to + retain to fill future memory requests. Any freed blocks over this + threshold will be returned to the OS immediately. Defaults to 0. diff --git a/docs/reference/features.rst b/docs/reference/features.rst index 0e173fe87..c5d89b838 100644 --- a/docs/reference/features.rst +++ b/docs/reference/features.rst @@ -21,6 +21,7 @@ Support for the following modules can be checked: * ``freetype2``: FreeType font support via :py:func:`PIL.ImageFont.truetype`. * ``littlecms2``: LittleCMS 2 support via :py:mod:`PIL.ImageCms`. * ``webp``: WebP image support. +* ``avif``: AVIF image support. .. autofunction:: PIL.features.check_module .. autofunction:: PIL.features.version_module diff --git a/docs/reference/internal_design.rst b/docs/reference/internal_design.rst index 99a18e9ea..041177953 100644 --- a/docs/reference/internal_design.rst +++ b/docs/reference/internal_design.rst @@ -9,3 +9,4 @@ Internal Reference block_allocator internal_modules c_extension_debugging + arrow_support diff --git a/docs/reference/plugins.rst b/docs/reference/plugins.rst index 454b94d8c..c789f5757 100644 --- a/docs/reference/plugins.rst +++ b/docs/reference/plugins.rst @@ -1,6 +1,14 @@ Plugin reference ================ +:mod:`~PIL.AvifImagePlugin` Module +---------------------------------- + +.. automodule:: PIL.AvifImagePlugin + :members: + :undoc-members: + :show-inheritance: + :mod:`~PIL.BmpImagePlugin` Module --------------------------------- diff --git a/docs/releasenotes/11.2.0.rst b/docs/releasenotes/11.2.0.rst deleted file mode 100644 index 3e977221e..000000000 --- a/docs/releasenotes/11.2.0.rst +++ /dev/null @@ -1,83 +0,0 @@ -11.2.0 ------- - -Security -======== - -TODO -^^^^ - -TODO - -:cve:`YYYY-XXXXX`: TODO -^^^^^^^^^^^^^^^^^^^^^^^ - -TODO - -Backwards Incompatible Changes -============================== - -TODO -^^^^ - -Deprecations -============ - -Image.Image.get_child_images() -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. deprecated:: 11.2.0 - -``Image.Image.get_child_images()`` has been deprecated. and will be removed in Pillow -13 (2026-10-15). It will be moved to ``ImageFile.ImageFile.get_child_images()``. The -method uses an image's file pointer, and so child images could only be retrieved from -an :py:class:`PIL.ImageFile.ImageFile` instance. - -API Changes -=========== - -TODO -^^^^ - -TODO - -API Additions -============= - -``"justify"`` multiline text alignment -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -In addition to ``"left"``, ``"center"`` and ``"right"``, multiline text can also be -aligned using ``"justify"`` in :py:mod:`~PIL.ImageDraw`:: - - from PIL import Image, ImageDraw - im = Image.new("RGB", (50, 25)) - draw = ImageDraw.Draw(im) - draw.multiline_text((0, 0), "Multiline\ntext 1", align="justify") - draw.multiline_textbbox((0, 0), "Multiline\ntext 2", align="justify") - -Check for MozJPEG -^^^^^^^^^^^^^^^^^ - -You can check if Pillow has been built against the MozJPEG version of the -libjpeg library, and what version of MozJPEG is being used:: - - from PIL import features - features.check_feature("mozjpeg") # True or False - features.version_feature("mozjpeg") # "4.1.1" for example, or None - -Saving compressed DDS images -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Compressed DDS images can now be saved using a ``pixel_format`` argument. DXT1, DXT3, -DXT5, BC2, BC3 and BC5 are supported:: - - im.save("out.dds", pixel_format="DXT1") - -Other Changes -============= - -TODO -^^^^ - -TODO diff --git a/docs/releasenotes/11.2.1.rst b/docs/releasenotes/11.2.1.rst new file mode 100644 index 000000000..5c6d40d9d --- /dev/null +++ b/docs/releasenotes/11.2.1.rst @@ -0,0 +1,118 @@ +11.2.1 +------ + +.. warning:: + + The release of Pillow *11.2.0* was halted prematurely, due to hitting PyPI's + project size limit and concern over the size of Pillow wheels containing libavif. + The PyPI limit has now been increased and Pillow *11.2.1* has been released + instead, without libavif included in the wheels. + To avoid confusion, the incomplete 11.2.0 release has been removed from PyPI. + +Security +======== + +Undefined shift when loading compressed DDS images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When loading some compressed DDS formats, an integer was bitshifted by 24 places to +generate the 32 bits of the lookup table. This was undefined behaviour, and has been +present since Pillow 3.4.0. + +Deprecations +============ + +Image.Image.get_child_images() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.2.1 + +``Image.Image.get_child_images()`` has been deprecated. and will be removed in Pillow +13 (2026-10-15). It will be moved to ``ImageFile.ImageFile.get_child_images()``. The +method uses an image's file pointer, and so child images could only be retrieved from +an :py:class:`PIL.ImageFile.ImageFile` instance. + +API Changes +=========== + +``append_images`` no longer requires ``save_all`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Previously, ``save_all`` was required to in order to use ``append_images``. Now, +``save_all`` will default to ``True`` if ``append_images`` is not empty and the format +supports saving multiple frames:: + + im.save("out.gif", append_images=ims) + +API Additions +============= + +``"justify"`` multiline text alignment +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In addition to ``"left"``, ``"center"`` and ``"right"``, multiline text can also be +aligned using ``"justify"`` in :py:mod:`~PIL.ImageDraw`:: + + from PIL import Image, ImageDraw + im = Image.new("RGB", (50, 25)) + draw = ImageDraw.Draw(im) + draw.multiline_text((0, 0), "Multiline\ntext 1", align="justify") + draw.multiline_textbbox((0, 0), "Multiline\ntext 2", align="justify") + +Specify window in ImageGrab on Windows +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When using :py:meth:`~PIL.ImageGrab.grab`, a specific window can be selected using the +HWND:: + + from PIL import ImageGrab + ImageGrab.grab(window=hwnd) + +Check for MozJPEG +^^^^^^^^^^^^^^^^^ + +You can check if Pillow has been built against the MozJPEG version of the +libjpeg library, and what version of MozJPEG is being used:: + + from PIL import features + features.check_feature("mozjpeg") # True or False + features.version_feature("mozjpeg") # "4.1.1" for example, or None + +Saving compressed DDS images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Compressed DDS images can now be saved using a ``pixel_format`` argument. DXT1, DXT3, +DXT5, BC2, BC3 and BC5 are supported:: + + im.save("out.dds", pixel_format="DXT1") + +Other Changes +============= + +Arrow support +^^^^^^^^^^^^^ + +`Arrow `__ is an in-memory data exchange format that is the +spiritual successor to the NumPy array interface. It provides for zero-copy access to +columnar data, which in our case is ``Image`` data. + +To create an image with zero-copy shared memory from an object exporting the +arrow_c_array interface protocol:: + + from PIL import Image + import pyarrow as pa + arr = pa.array([0]*(5*5*4), type=pa.uint8()) + im = Image.fromarrow(arr, 'RGBA', (5, 5)) + +Pillow images can also be converted to Arrow objects:: + + from PIL import Image + import pyarrow as pa + im = Image.open('hopper.jpg') + arr = pa.array(im) + +Reading and writing AVIF images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Pillow can now read and write AVIF images when built from source with libavif 1.0.0 +or later. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index be9f623d0..a116ef056 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -14,7 +14,7 @@ expected to be backported to earlier versions. .. toctree:: :maxdepth: 2 - 11.2.0 + 11.2.1 11.1.0 11.0.0 10.4.0 diff --git a/pyproject.toml b/pyproject.toml index 780a938a3..a3ff9723b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] build-backend = "backend" requires = [ - "setuptools>=67.8", + "setuptools>=77", ] backend-path = [ "_custom_build", @@ -14,14 +14,14 @@ readme = "README.md" keywords = [ "Imaging", ] -license = { text = "MIT-CMU" } +license = "MIT-CMU" +license-files = [ "LICENSE" ] authors = [ { name = "Jeffrey A. Clark", email = "aclark@aclark.net" }, ] requires-python = ">=3.9" classifiers = [ "Development Status :: 6 - Mature", - "License :: OSI Approved :: CMU License (MIT-CMU)", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -44,6 +44,7 @@ optional-dependencies.docs = [ "furo", "olefile", "sphinx>=8.2", + "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph", @@ -54,6 +55,10 @@ optional-dependencies.fpx = [ optional-dependencies.mic = [ "olefile", ] +optional-dependencies.test-arrow = [ + "pyarrow", +] + optional-dependencies.tests = [ "check-manifest", "coverage>=7.4.2", @@ -67,6 +72,7 @@ optional-dependencies.tests = [ "pytest-timeout", "trove-classifiers>=2024.10.12", ] + optional-dependencies.typing = [ "typing-extensions; python_version<'3.10'", ] diff --git a/setup.py b/setup.py index 9fac993b1..5ecd6b816 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ configuration: dict[str, list[str]] = {} PILLOW_VERSION = get_version() +AVIF_ROOT = None FREETYPE_ROOT = None HARFBUZZ_ROOT = None FRIBIDI_ROOT = None @@ -64,6 +65,7 @@ _IMAGING = ("decode", "encode", "map", "display", "outline", "path") _LIB_IMAGING = ( "Access", "AlphaComposite", + "Arrow", "Resample", "Reduce", "Bands", @@ -306,6 +308,7 @@ class pil_build_ext(build_ext): "jpeg2000", "imagequant", "xcb", + "avif", ] required = {"jpeg", "zlib"} @@ -481,6 +484,7 @@ class pil_build_ext(build_ext): # # add configured kits for root_name, lib_name in { + "AVIF_ROOT": "avif", "JPEG_ROOT": "libjpeg", "JPEG2K_ROOT": "libopenjp2", "TIFF_ROOT": ("libtiff-5", "libtiff-4"), @@ -846,6 +850,12 @@ class pil_build_ext(build_ext): if _find_library_file(self, "xcb"): feature.set("xcb", "xcb") + if feature.want("avif"): + _dbg("Looking for avif") + if _find_include_file(self, "avif/avif.h"): + if _find_library_file(self, "avif"): + feature.set("avif", "avif") + for f in feature: if not feature.get(f) and feature.require(f): if f in ("jpeg", "zlib"): @@ -934,6 +944,14 @@ class pil_build_ext(build_ext): else: self._remove_extension("PIL._webp") + if feature.get("avif"): + libs = [feature.get("avif")] + if sys.platform == "win32": + libs.extend(["ntdll", "userenv", "ws2_32", "bcrypt"]) + self._update_extension("PIL._avif", libs) + else: + self._remove_extension("PIL._avif") + tk_libs = ["psapi"] if sys.platform in ("win32", "cygwin") else [] self._update_extension("PIL._imagingtk", tk_libs) @@ -976,6 +994,7 @@ class pil_build_ext(build_ext): (feature.get("lcms"), "LITTLECMS2"), (feature.get("webp"), "WEBP"), (feature.get("xcb"), "XCB (X protocol)"), + (feature.get("avif"), "LIBAVIF"), ] all = 1 @@ -1018,6 +1037,7 @@ ext_modules = [ Extension("PIL._imagingft", ["src/_imagingft.c"]), Extension("PIL._imagingcms", ["src/_imagingcms.c"]), Extension("PIL._webp", ["src/_webp.c"]), + Extension("PIL._avif", ["src/_avif.c"]), Extension("PIL._imagingtk", ["src/_imagingtk.c", "src/Tk/tkImaging.c"]), Extension("PIL._imagingmath", ["src/_imagingmath.c"]), Extension("PIL._imagingmorph", ["src/_imagingmorph.c"]), diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py new file mode 100644 index 000000000..b2c5ab15d --- /dev/null +++ b/src/PIL/AvifImagePlugin.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import os +from io import BytesIO +from typing import IO + +from . import ExifTags, Image, ImageFile + +try: + from . import _avif + + SUPPORTED = True +except ImportError: + SUPPORTED = False + +# Decoder options as module globals, until there is a way to pass parameters +# to Image.open (see https://github.com/python-pillow/Pillow/issues/569) +DECODE_CODEC_CHOICE = "auto" +# Decoding is only affected by this for libavif **0.8.4** or greater. +DEFAULT_MAX_THREADS = 0 + + +def get_codec_version(codec_name: str) -> str | None: + versions = _avif.codec_versions() + for version in versions.split(", "): + if version.split(" [")[0] == codec_name: + return version.split(":")[-1].split(" ")[0] + return None + + +def _accept(prefix: bytes) -> bool | str: + if prefix[4:8] != b"ftyp": + return False + major_brand = prefix[8:12] + if major_brand in ( + # coding brands + b"avif", + b"avis", + # We accept files with AVIF container brands; we can't yet know if + # the ftyp box has the correct compatible brands, but if it doesn't + # then the plugin will raise a SyntaxError which Pillow will catch + # before moving on to the next plugin that accepts the file. + # + # Also, because this file might not actually be an AVIF file, we + # don't raise an error if AVIF support isn't properly compiled. + b"mif1", + b"msf1", + ): + if not SUPPORTED: + return ( + "image file could not be identified because AVIF support not installed" + ) + return True + return False + + +def _get_default_max_threads() -> int: + if DEFAULT_MAX_THREADS: + return DEFAULT_MAX_THREADS + if hasattr(os, "sched_getaffinity"): + return len(os.sched_getaffinity(0)) + else: + return os.cpu_count() or 1 + + +class AvifImageFile(ImageFile.ImageFile): + format = "AVIF" + format_description = "AVIF image" + __frame = -1 + + def _open(self) -> None: + if not SUPPORTED: + msg = "image file could not be opened because AVIF support not installed" + raise SyntaxError(msg) + + if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available( + DECODE_CODEC_CHOICE + ): + msg = "Invalid opening codec" + raise ValueError(msg) + self._decoder = _avif.AvifDecoder( + self.fp.read(), + DECODE_CODEC_CHOICE, + _get_default_max_threads(), + ) + + # Get info from decoder + self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = ( + self._decoder.get_info() + ) + self.is_animated = self.n_frames > 1 + + if icc: + self.info["icc_profile"] = icc + if xmp: + self.info["xmp"] = xmp + + if exif_orientation != 1 or exif: + exif_data = Image.Exif() + if exif: + exif_data.load(exif) + original_orientation = exif_data.get(ExifTags.Base.Orientation, 1) + else: + original_orientation = 1 + if exif_orientation != original_orientation: + exif_data[ExifTags.Base.Orientation] = exif_orientation + exif = exif_data.tobytes() + if exif: + self.info["exif"] = exif + self.seek(0) + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + + # Set tile + self.__frame = frame + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)] + + def load(self) -> Image.core.PixelAccess | None: + if self.tile: + # We need to load the image data for this frame + data, timescale, pts_in_timescales, duration_in_timescales = ( + self._decoder.get_frame(self.__frame) + ) + self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale)) + self.info["duration"] = round(1000 * (duration_in_timescales / timescale)) + + if self.fp and self._exclusive_fp: + self.fp.close() + self.fp = BytesIO(data) + + return super().load() + + def load_seek(self, pos: int) -> None: + pass + + def tell(self) -> int: + return self.__frame + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + _save(im, fp, filename, save_all=True) + + +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: + info = im.encoderinfo.copy() + if save_all: + append_images = list(info.get("append_images", [])) + else: + append_images = [] + + total = 0 + for ims in [im] + append_images: + total += getattr(ims, "n_frames", 1) + + quality = info.get("quality", 75) + if not isinstance(quality, int) or quality < 0 or quality > 100: + msg = "Invalid quality setting" + raise ValueError(msg) + + duration = info.get("duration", 0) + subsampling = info.get("subsampling", "4:2:0") + speed = info.get("speed", 6) + max_threads = info.get("max_threads", _get_default_max_threads()) + codec = info.get("codec", "auto") + if codec != "auto" and not _avif.encoder_codec_available(codec): + msg = "Invalid saving codec" + raise ValueError(msg) + range_ = info.get("range", "full") + tile_rows_log2 = info.get("tile_rows", 0) + tile_cols_log2 = info.get("tile_cols", 0) + alpha_premultiplied = bool(info.get("alpha_premultiplied", False)) + autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0)) + + icc_profile = info.get("icc_profile", im.info.get("icc_profile")) + exif_orientation = 1 + if exif := info.get("exif"): + if isinstance(exif, Image.Exif): + exif_data = exif + else: + exif_data = Image.Exif() + exif_data.load(exif) + if ExifTags.Base.Orientation in exif_data: + exif_orientation = exif_data.pop(ExifTags.Base.Orientation) + exif = exif_data.tobytes() if exif_data else b"" + elif isinstance(exif, Image.Exif): + exif = exif_data.tobytes() + + xmp = info.get("xmp") + + if isinstance(xmp, str): + xmp = xmp.encode("utf-8") + + advanced = info.get("advanced") + if advanced is not None: + if isinstance(advanced, dict): + advanced = advanced.items() + try: + advanced = tuple(advanced) + except TypeError: + invalid = True + else: + invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced) + if invalid: + msg = ( + "advanced codec options must be a dict of key-value string " + "pairs or a series of key-value two-tuples" + ) + raise ValueError(msg) + + # Setup the AVIF encoder + enc = _avif.AvifEncoder( + im.size, + subsampling, + quality, + speed, + max_threads, + codec, + range_, + tile_rows_log2, + tile_cols_log2, + alpha_premultiplied, + autotiling, + icc_profile or b"", + exif or b"", + exif_orientation, + xmp or b"", + advanced, + ) + + # Add each frame + frame_idx = 0 + frame_duration = 0 + cur_idx = im.tell() + is_single_frame = total == 1 + try: + for ims in [im] + append_images: + # Get number of frames in this image + nfr = getattr(ims, "n_frames", 1) + + for idx in range(nfr): + ims.seek(idx) + + # Make sure image mode is supported + frame = ims + rawmode = ims.mode + if ims.mode not in {"RGB", "RGBA"}: + rawmode = "RGBA" if ims.has_transparency_data else "RGB" + frame = ims.convert(rawmode) + + # Update frame duration + if isinstance(duration, (list, tuple)): + frame_duration = duration[frame_idx] + else: + frame_duration = duration + + # Append the frame to the animation encoder + enc.add( + frame.tobytes("raw", rawmode), + frame_duration, + frame.size, + rawmode, + is_single_frame, + ) + + # Update frame index + frame_idx += 1 + + if not save_all: + break + + finally: + im.seek(cur_idx) + + # Get the final output from the encoder + data = enc.finish() + if data is None: + msg = "cannot write file as AVIF (encoder returned None)" + raise OSError(msg) + + fp.write(data) + + +Image.register_open(AvifImageFile.format, AvifImageFile, _accept) +if SUPPORTED: + Image.register_save(AvifImageFile.format, _save) + Image.register_save_all(AvifImageFile.format, _save_all) + Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"]) + Image.register_mime(AvifImageFile.format, "image/avif") diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index d60ea591a..43131cfe2 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -48,6 +48,8 @@ BIT2MODE = { 32: ("RGB", "BGRX"), } +USE_RAW_ALPHA = False + def _accept(prefix: bytes) -> bool: return prefix.startswith(b"BM") @@ -242,7 +244,9 @@ class BmpImageFile(ImageFile.ImageFile): msg = "Unsupported BMP bitfields layout" raise OSError(msg) elif file_info["compression"] == self.COMPRESSIONS["RAW"]: - if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset + if file_info["bits"] == 32 and ( + header == 22 or USE_RAW_ALPHA # 32-bit .cur offset + ): raw_mode, self._mode = "BGRA", "RGBA" elif file_info["compression"] in ( self.COMPRESSIONS["RLE8"], diff --git a/src/PIL/DcxImagePlugin.py b/src/PIL/DcxImagePlugin.py index f67f27d73..aea661b9c 100644 --- a/src/PIL/DcxImagePlugin.py +++ b/src/PIL/DcxImagePlugin.py @@ -24,6 +24,7 @@ from __future__ import annotations from . import Image from ._binary import i32le as i32 +from ._util import DeferredError from .PcxImagePlugin import PcxImageFile MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then? @@ -66,6 +67,8 @@ class DcxImageFile(PcxImageFile): def seek(self, frame: int) -> None: if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.frame = frame self.fp = self._fp self.fp.seek(self._offset[frame]) diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index b534b30ab..7c5bfeefa 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -22,6 +22,7 @@ from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 from ._binary import i32le as i32 from ._binary import o8 +from ._util import DeferredError # # decoder @@ -134,6 +135,8 @@ class FliImageFile(ImageFile.ImageFile): self._seek(f) def _seek(self, frame: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex if frame == 0: self.__frame = -1 self._fp.seek(self.__rewind) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 94d14d774..d55c87d58 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -30,7 +30,7 @@ import os import subprocess from enum import IntEnum from functools import cached_property -from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, Union +from typing import IO, Any, Literal, NamedTuple, Union from . import ( Image, @@ -44,7 +44,9 @@ from . import ( from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 +from ._util import DeferredError +TYPE_CHECKING = False if TYPE_CHECKING: from . import _imaging from ._typing import Buffer @@ -166,6 +168,8 @@ class GifImageFile(ImageFile.ImageFile): raise EOFError(msg) from e def _seek(self, frame: int, update_image: bool = True) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex if frame == 0: # rewind self.__offset = 0 diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index 0f079f457..379ffd739 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -16,6 +16,7 @@ from __future__ import annotations import re +from io import BytesIO from typing import IO @@ -24,13 +25,18 @@ class GimpPaletteFile: rawmode = "RGB" - def __init__(self, fp: IO[bytes]) -> None: + def _read(self, fp: IO[bytes], limit: bool = True) -> None: if not fp.readline().startswith(b"GIMP Palette"): msg = "not a GIMP palette file" raise SyntaxError(msg) palette: list[int] = [] - for _ in range(256): + i = 0 + while True: + if limit and i == 256 + 3: + break + + i += 1 s = fp.readline() if not s: break @@ -38,7 +44,7 @@ class GimpPaletteFile: # skip fields and comment lines if re.match(rb"\w+:|#", s): continue - if len(s) > 100: + if limit and len(s) > 100: msg = "bad palette file" raise SyntaxError(msg) @@ -48,8 +54,19 @@ class GimpPaletteFile: raise ValueError(msg) palette += (int(v[i]) for i in range(3)) + if limit and len(palette) == 768: + break self.palette = bytes(palette) + def __init__(self, fp: IO[bytes]) -> None: + self._read(fp) + + @classmethod + def frombytes(cls, data: bytes) -> GimpPaletteFile: + self = cls.__new__(cls) + self._read(BytesIO(data), False) + return self + def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 9f20b30f8..71b999678 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -31,6 +31,7 @@ import re from typing import IO, Any from . import Image, ImageFile, ImagePalette +from ._util import DeferredError # -------------------------------------------------------------------- # Standard tags @@ -290,6 +291,8 @@ class ImImageFile(ImageFile.ImageFile): def seek(self, frame: int) -> None: if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.frame = frame diff --git a/src/PIL/Image.py b/src/PIL/Image.py index c86dcf7a7..07bb140e4 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -41,14 +41,7 @@ import warnings from collections.abc import Callable, Iterator, MutableMapping, Sequence from enum import IntEnum from types import ModuleType -from typing import ( - IO, - TYPE_CHECKING, - Any, - Literal, - Protocol, - cast, -) +from typing import IO, Any, Literal, Protocol, cast # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -218,6 +211,7 @@ if hasattr(core, "DEFAULT_STRATEGY"): # -------------------------------------------------------------------- # Registries +TYPE_CHECKING = False if TYPE_CHECKING: import mmap from xml.etree.ElementTree import Element @@ -548,7 +542,6 @@ class Image: def __init__(self) -> None: # FIXME: take "new" parameters / other image? - # FIXME: turn mode and size into delegating properties? self._im: core.ImagingCore | DeferredError | None = None self._mode = "" self._size = (0, 0) @@ -584,6 +577,14 @@ class Image: def mode(self) -> str: return self._mode + @property + def readonly(self) -> int: + return (self._im and self._im.readonly) or self._readonly + + @readonly.setter + def readonly(self, readonly: int) -> None: + self._readonly = readonly + def _new(self, im: core.ImagingCore) -> Image: new = Image() new.im = im @@ -622,6 +623,8 @@ class Image: more information. """ if getattr(self, "map", None): + if sys.platform == "win32" and hasattr(sys, "pypy_version_info"): + self.map.close() self.map: mmap.mmap | None = None # Instead of simply setting to None, we're setting up a @@ -733,6 +736,16 @@ class Image: new["shape"], new["typestr"] = _conv_type_shape(self) return new + def __arrow_c_schema__(self) -> object: + self.load() + return self.im.__arrow_c_schema__() + + def __arrow_c_array__( + self, requested_schema: object | None = None + ) -> tuple[object, object]: + self.load() + return (self.im.__arrow_c_schema__(), self.im.__arrow_c_array__()) + def __getstate__(self) -> list[Any]: im_data = self.tobytes() # load image first return [self.info, self.mode, self.size, self.getpalette(), im_data] @@ -1525,6 +1538,8 @@ class Image: # XMP tags if ExifTags.Base.Orientation not in self._exif: xmp_tags = self.info.get("XML:com.adobe.xmp") + if not xmp_tags and (xmp_tags := self.info.get("xmp")): + xmp_tags = xmp_tags.decode("utf-8") if xmp_tags: match = re.search(r'tiff:Orientation(="|>)([0-9])', xmp_tags) if match: @@ -2511,13 +2526,6 @@ class Image: # only set the name for metadata purposes filename = os.fspath(fp.name) - # may mutate self! - self._ensure_mutable() - - save_all = params.pop("save_all", False) - self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params} - self.encoderconfig: tuple[Any, ...] = () - preinit() filename_ext = os.path.splitext(filename)[1].lower() @@ -2532,9 +2540,27 @@ class Image: msg = f"unknown file extension: {ext}" raise ValueError(msg) from e + from . import ImageFile + + # may mutate self! + if isinstance(self, ImageFile.ImageFile) and os.path.abspath( + filename + ) == os.path.abspath(self.filename): + self._ensure_mutable() + else: + self.load() + + save_all = params.pop("save_all", None) + self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params} + self.encoderconfig: tuple[Any, ...] = () + if format.upper() not in SAVE: init() - if save_all: + if save_all or ( + save_all is None + and params.get("append_images") + and format.upper() in SAVE_ALL + ): save_handler = SAVE_ALL[format.upper()] else: save_handler = SAVE[format.upper()] @@ -3220,6 +3246,18 @@ class SupportsArrayInterface(Protocol): raise NotImplementedError() +class SupportsArrowArrayInterface(Protocol): + """ + An object that has an ``__arrow_c_array__`` method corresponding to the arrow c + data interface. + """ + + def __arrow_c_array__( + self, requested_schema: "PyCapsule" = None # type: ignore[name-defined] # noqa: F821, UP037 + ) -> tuple["PyCapsule", "PyCapsule"]: # type: ignore[name-defined] # noqa: F821, UP037 + raise NotImplementedError() + + def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: """ Creates an image memory from an object exporting the array interface @@ -3308,6 +3346,58 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) +def fromarrow( + obj: SupportsArrowArrayInterface, mode: str, size: tuple[int, int] +) -> Image: + """Creates an image with zero-copy shared memory from an object exporting + the arrow_c_array interface protocol:: + + from PIL import Image + import pyarrow as pa + arr = pa.array([0]*(5*5*4), type=pa.uint8()) + im = Image.fromarrow(arr, 'RGBA', (5, 5)) + + If the data representation of the ``obj`` is not compatible with + Pillow internal storage, a ValueError is raised. + + Pillow images can also be converted to Arrow objects:: + + from PIL import Image + import pyarrow as pa + im = Image.open('hopper.jpg') + arr = pa.array(im) + + As with array support, when converting Pillow images to arrays, + only pixel values are transferred. This means that P and PA mode + images will lose their palette. + + :param obj: Object with an arrow_c_array interface + :param mode: Image mode. + :param size: Image size. This must match the storage of the arrow object. + :returns: An Image object + + Note that according to the Arrow spec, both the producer and the + consumer should consider the exported array to be immutable, as + unsynchronized updates will potentially cause inconsistent data. + + See: :ref:`arrow-support` for more detailed information + + .. versionadded:: 11.2.1 + + """ + if not hasattr(obj, "__arrow_c_array__"): + msg = "arrow_c_array interface not found" + raise ValueError(msg) + + (schema_capsule, array_capsule) = obj.__arrow_c_array__() + _im = core.new_arrow(mode, size, schema_capsule, array_capsule) + if _im: + return Image()._new(_im) + + msg = "new_arrow returned None without an exception" + raise ValueError(msg) + + def fromqimage(im: ImageQt.QImage) -> ImageFile.ImageFile: """Creates an image instance from a QImage image""" from . import ImageQt diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index c2ed9034d..e6c7b0298 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -35,7 +35,7 @@ import math import struct from collections.abc import Sequence from types import ModuleType -from typing import TYPE_CHECKING, Any, AnyStr, Callable, Union, cast +from typing import Any, AnyStr, Callable, Union, cast from . import Image, ImageColor from ._deprecate import deprecate @@ -44,6 +44,7 @@ from ._typing import Coords # experimental access to the outline API Outline: Callable[[], Image.core._Outline] = Image.core.outline +TYPE_CHECKING = False if TYPE_CHECKING: from . import ImageDraw2, ImageFont diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 1bf8a7e5f..bcb7d462e 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -34,13 +34,13 @@ import itertools import logging import os import struct -import sys -from typing import IO, TYPE_CHECKING, Any, NamedTuple, cast +from typing import IO, Any, NamedTuple, cast from . import ExifTags, Image from ._deprecate import deprecate from ._util import DeferredError, is_path +TYPE_CHECKING = False if TYPE_CHECKING: from ._typing import StrOrBytesPath @@ -167,7 +167,7 @@ class ImageFile(Image.Image): pass def _close_fp(self): - if getattr(self, "_fp", False): + if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError): if self._fp != self.fp: self._fp.close() self._fp = DeferredError(ValueError("Operation on closed image")) @@ -252,8 +252,12 @@ class ImageFile(Image.Image): return Image.MIME.get(self.format.upper()) return None + def __getstate__(self) -> list[Any]: + return super().__getstate__() + [self.filename] + def __setstate__(self, state: list[Any]) -> None: self.tile = [] + self.filename = state[5] super().__setstate__(state) def verify(self) -> None: @@ -278,8 +282,6 @@ class ImageFile(Image.Image): self.map: mmap.mmap | None = None use_mmap = self.filename and len(self.tile) == 1 - # As of pypy 2.1.0, memory mapping was failing here. - use_mmap = use_mmap and not hasattr(sys, "pypy_version_info") readonly = 0 @@ -345,7 +347,7 @@ class ImageFile(Image.Image): self.tile, lambda tile: (tile[0], tile[1], tile[3]) ) ] - for decoder_name, extents, offset, args in self.tile: + for i, (decoder_name, extents, offset, args) in enumerate(self.tile): seek(offset) decoder = Image._getdecoder( self.mode, decoder_name, args, self.decoderconfig @@ -358,8 +360,13 @@ class ImageFile(Image.Image): else: b = prefix while True: + read_bytes = self.decodermaxblock + if i + 1 < len(self.tile): + next_offset = self.tile[i + 1].offset + if next_offset > offset: + read_bytes = next_offset - offset try: - s = read(self.decodermaxblock) + s = read(read_bytes) except (IndexError, struct.error) as e: # truncated png/gif if LOAD_TRUNCATED_IMAGES: diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 05829d0c6..b9ed54ab2 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -20,8 +20,9 @@ import abc import functools from collections.abc import Sequence from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable, cast +from typing import Any, Callable, cast +TYPE_CHECKING = False if TYPE_CHECKING: from . import _imaging from ._typing import NumpyArray diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index c8f05fbb7..ebe510ba9 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -34,12 +34,13 @@ import warnings from enum import IntEnum from io import BytesIO from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict, cast +from typing import IO, Any, BinaryIO, TypedDict, cast from . import Image, features from ._typing import StrOrBytesPath from ._util import DeferredError, is_path +TYPE_CHECKING = False if TYPE_CHECKING: from . import ImageFile from ._imaging import ImagingFont diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index fe27bfaeb..c29350b7a 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -25,12 +25,17 @@ import tempfile from . import Image +TYPE_CHECKING = False +if TYPE_CHECKING: + from . import ImageWin + def grab( bbox: tuple[int, int, int, int] | None = None, include_layered_windows: bool = False, all_screens: bool = False, xdisplay: str | None = None, + window: int | ImageWin.HWND | None = None, ) -> Image.Image: im: Image.Image if xdisplay is None: @@ -51,8 +56,12 @@ def grab( return im_resized return im elif sys.platform == "win32": + if window is not None: + all_screens = -1 offset, size, data = Image.core.grabscreen_win32( - include_layered_windows, all_screens + include_layered_windows, + all_screens, + int(window) if window is not None else 0, ) im = Image.frombytes( "RGB", @@ -77,14 +86,18 @@ def grab( raise OSError(msg) size, data = Image.core.grabscreen_x11(display_name) except OSError: - if ( - display_name is None - and sys.platform not in ("darwin", "win32") - and shutil.which("gnome-screenshot") - ): + if display_name is None and sys.platform not in ("darwin", "win32"): + if shutil.which("gnome-screenshot"): + args = ["gnome-screenshot", "-f"] + elif shutil.which("grim"): + args = ["grim"] + elif shutil.which("spectacle"): + args = ["spectacle", "-n", "-b", "-f", "-o"] + else: + raise fh, filepath = tempfile.mkstemp(".png") os.close(fh) - subprocess.call(["gnome-screenshot", "-f", filepath]) + subprocess.call(args + [filepath]) im = Image.open(filepath) im.load() os.unlink(filepath) diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 183f85526..103697117 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -19,10 +19,11 @@ from __future__ import annotations import array from collections.abc import Sequence -from typing import IO, TYPE_CHECKING +from typing import IO from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile +TYPE_CHECKING = False if TYPE_CHECKING: from . import Image diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index 2cc40f855..df7a57b65 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -19,11 +19,12 @@ from __future__ import annotations import sys from io import BytesIO -from typing import TYPE_CHECKING, Any, Callable, Union +from typing import Any, Callable, Union from . import Image from ._util import is_path +TYPE_CHECKING = False if TYPE_CHECKING: import PyQt6 import PySide6 diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index e6a9d8eea..3a4cb81e9 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -28,10 +28,11 @@ from __future__ import annotations import tkinter from io import BytesIO -from typing import TYPE_CHECKING, Any +from typing import Any from . import Image, ImageFile +TYPE_CHECKING = False if TYPE_CHECKING: from ._typing import CapsuleType diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 9465d8e2d..969528841 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -42,7 +42,7 @@ import subprocess import sys import tempfile import warnings -from typing import IO, TYPE_CHECKING, Any +from typing import IO, Any from . import Image, ImageFile from ._binary import i16be as i16 @@ -52,6 +52,7 @@ from ._binary import o16be as o16 from ._deprecate import deprecate from .JpegPresets import presets +TYPE_CHECKING = False if TYPE_CHECKING: from .MpoImagePlugin import MpoImageFile @@ -402,8 +403,8 @@ class JpegImageFile(ImageFile.ImageFile): return super().__getstate__() + [self.layers, self.layer] def __setstate__(self, state: list[Any]) -> None: + self.layers, self.layer = state[6:] super().__setstate__(state) - self.layers, self.layer = state[5:] def load_read(self, read_bytes: int) -> bytes: """ diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index ab6fcb466..0e2b6f387 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -31,6 +31,7 @@ from . import ( TiffImagePlugin, ) from ._binary import o32le +from ._util import DeferredError def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: @@ -135,11 +136,15 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): self.readonly = 1 def load_seek(self, pos: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex self._fp.seek(pos) def seek(self, frame: int) -> None: if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.fp = self._fp self.offset = self.__mpoffsets[frame] diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index 02939d26b..7fd4c5c94 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -17,10 +17,13 @@ from __future__ import annotations import sys -from typing import IO, TYPE_CHECKING +from typing import IO from . import EpsImagePlugin +TYPE_CHECKING = False + + ## # Simple PostScript graphics interface. diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index b33245376..15f712908 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -116,9 +116,6 @@ _COMPRESSION_TYPES = {"none": 0xFF, "rle": 0x01, "scanline": 0x00} def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "P": - # we assume this is a color Palm image with the standard colormap, - # unless the "info" dict has a "custom-colormap" field - rawmode = "P" bpp = 8 version = 1 @@ -172,12 +169,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: compression_type = _COMPRESSION_TYPES["none"] flags = 0 - if im.mode == "P" and "custom-colormap" in im.info: - assert im.palette is not None - flags = flags & _FLAGS["custom-colormap"] - colormapsize = 4 * 256 + 2 - colormapmode = im.palette.mode - colormap = im.getdata().getpalette() + if im.mode == "P": + flags |= _FLAGS["custom-colormap"] + colormap = im.im.getpalette() + colors = len(colormap) // 3 + colormapsize = 4 * colors + 2 else: colormapsize = 0 @@ -196,22 +192,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # now write colormap if necessary - if colormapsize > 0: - fp.write(o16b(256)) - for i in range(256): + if colormapsize: + fp.write(o16b(colors)) + for i in range(colors): fp.write(o8(i)) - if colormapmode == "RGB": - fp.write( - o8(colormap[3 * i]) - + o8(colormap[3 * i + 1]) - + o8(colormap[3 * i + 2]) - ) - elif colormapmode == "RGBA": - fp.write( - o8(colormap[4 * i]) - + o8(colormap[4 * i + 1]) - + o8(colormap[4 * i + 2]) - ) + fp.write(colormap[3 * i : 3 * i + 3]) # now convert data to raw form ImageFile._save( diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 41b38ebbf..73d8c21c0 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -8,7 +8,7 @@ import os import re import time import zlib -from typing import IO, TYPE_CHECKING, Any, NamedTuple, Union +from typing import IO, Any, NamedTuple, Union # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set @@ -251,6 +251,7 @@ class PdfArray(list[Any]): return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]" +TYPE_CHECKING = False if TYPE_CHECKING: _DictBase = collections.UserDict[Union[str, bytes], Any] else: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 88817a573..0885da5c9 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -40,7 +40,7 @@ import warnings import zlib from collections.abc import Callable from enum import IntEnum -from typing import IO, TYPE_CHECKING, Any, NamedTuple, NoReturn, cast +from typing import IO, Any, NamedTuple, NoReturn, cast from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from ._binary import i16be as i16 @@ -48,7 +48,9 @@ from ._binary import i32be as i32 from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 +from ._util import DeferredError +TYPE_CHECKING = False if TYPE_CHECKING: from . import _imaging @@ -869,6 +871,8 @@ class PngImageFile(ImageFile.ImageFile): def _seek(self, frame: int, rewind: bool = False) -> None: assert self.png is not None + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.dispose: _imaging.ImagingCore | None dispose_extent = None diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 0aada8a06..f49aaeeb1 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -27,6 +27,7 @@ from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import si16be as si16 from ._binary import si32be as si32 +from ._util import DeferredError MODES = { # (photoshop mode, bits) -> (pil mode, required channels) @@ -148,6 +149,8 @@ class PsdImageFile(ImageFile.ImageFile): ) -> list[tuple[str, str, tuple[int, int, int, int], list[ImageFile._Tile]]]: layers = [] if self._layers_position is not None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex self._fp.seek(self._layers_position) _layer_data = io.BytesIO(ImageFile._safe_read(self._fp, self._layers_size)) layers = _layerinfo(_layer_data, self._layers_size) @@ -167,6 +170,8 @@ class PsdImageFile(ImageFile.ImageFile): def seek(self, layer: int) -> None: if not self._seek_check(layer): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex # seek to given layer (1..max) _, mode, _, tile = self.layers[layer - 1] diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index b26e1a996..868019e80 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -37,9 +37,12 @@ from __future__ import annotations import os import struct import sys -from typing import IO, TYPE_CHECKING, Any, cast +from typing import IO, Any, cast from . import Image, ImageFile +from ._util import DeferredError + +TYPE_CHECKING = False def isInt(f: Any) -> int: @@ -178,6 +181,8 @@ class SpiderImageFile(ImageFile.ImageFile): raise EOFError(msg) if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.stkoffset = self.hdrlen + frame * (self.hdrlen + self.imgbytes) self.fp = self._fp self.fp.seek(self.stkoffset) diff --git a/src/PIL/TarIO.py b/src/PIL/TarIO.py index 779288b1c..86490a496 100644 --- a/src/PIL/TarIO.py +++ b/src/PIL/TarIO.py @@ -35,12 +35,16 @@ class TarIO(ContainerIO.ContainerIO[bytes]): while True: s = self.fh.read(512) if len(s) != 512: + self.fh.close() + msg = "unexpected end of tar file" raise OSError(msg) name = s[:100].decode("utf-8") i = name.find("\0") if i == 0: + self.fh.close() + msg = "cannot find subfile" raise OSError(msg) if i > 0: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index b9ee1c4c4..ed3333982 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -50,7 +50,7 @@ import warnings from collections.abc import Iterator, MutableMapping from fractions import Fraction from numbers import Number, Rational -from typing import IO, TYPE_CHECKING, Any, Callable, NoReturn, cast +from typing import IO, Any, Callable, NoReturn, cast from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 @@ -58,9 +58,10 @@ from ._binary import i32be as i32 from ._binary import o8 from ._deprecate import deprecate from ._typing import StrOrBytesPath -from ._util import is_path +from ._util import DeferredError, is_path from .TiffTags import TYPES +TYPE_CHECKING = False if TYPE_CHECKING: from ._typing import Buffer, IntegralLike @@ -1222,6 +1223,8 @@ class TiffImageFile(ImageFile.ImageFile): self._im = None def _seek(self, frame: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.fp = self._fp while len(self._frame_pos) <= frame: diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index 04abd52f0..f709d026b 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -80,8 +80,6 @@ class WmfStubImageFile(ImageFile.StubImageFile): format_description = "Windows Metafile" def _open(self) -> None: - self._inch = None - # check placable header s = self.fp.read(80) @@ -89,10 +87,11 @@ class WmfStubImageFile(ImageFile.StubImageFile): # placeable windows metafile # get units per inch - self._inch = word(s, 14) - if self._inch == 0: + inch = word(s, 14) + if inch == 0: msg = "Invalid inch" raise ValueError(msg) + self._inch: tuple[float, float] = inch, inch # get bounding box x0 = short(s, 6) @@ -103,8 +102,8 @@ class WmfStubImageFile(ImageFile.StubImageFile): # normalize size to 72 dots per inch self.info["dpi"] = 72 size = ( - (x1 - x0) * self.info["dpi"] // self._inch, - (y1 - y0) * self.info["dpi"] // self._inch, + (x1 - x0) * self.info["dpi"] // inch, + (y1 - y0) * self.info["dpi"] // inch, ) self.info["wmf_bbox"] = x0, y0, x1, y1 @@ -138,6 +137,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): self.info["dpi"] = xdpi else: self.info["dpi"] = xdpi, ydpi + self._inch = xdpi, ydpi else: msg = "Unsupported file format" @@ -153,13 +153,17 @@ class WmfStubImageFile(ImageFile.StubImageFile): def _load(self) -> ImageFile.StubHandler | None: return _handler - def load(self, dpi: int | None = None) -> Image.core.PixelAccess | None: - if dpi is not None and self._inch is not None: + def load( + self, dpi: float | tuple[float, float] | None = None + ) -> Image.core.PixelAccess | None: + if dpi is not None: self.info["dpi"] = dpi x0, y0, x1, y1 = self.info["wmf_bbox"] + if not isinstance(dpi, tuple): + dpi = dpi, dpi self._size = ( - (x1 - x0) * self.info["dpi"] // self._inch, - (y1 - y0) * self.info["dpi"] // self._inch, + int((x1 - x0) * dpi[0] / self._inch[0]), + int((y1 - y0) * dpi[1] / self._inch[1]), ) return super().load() diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index 09546fe63..6e4c23f89 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -25,6 +25,7 @@ del _version _plugins = [ + "AvifImagePlugin", "BlpImagePlugin", "BmpImagePlugin", "BufrStubImagePlugin", diff --git a/src/PIL/_avif.pyi b/src/PIL/_avif.pyi new file mode 100644 index 000000000..e27843e53 --- /dev/null +++ b/src/PIL/_avif.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 34a9a81e1..373938e71 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -3,8 +3,9 @@ from __future__ import annotations import os import sys from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Protocol, TypeVar, Union +from typing import Any, Protocol, TypeVar, Union +TYPE_CHECKING = False if TYPE_CHECKING: from numbers import _IntegralLike as IntegralLike diff --git a/src/PIL/_version.py b/src/PIL/_version.py index e93c7887b..ac678c7d2 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "11.2.0.dev0" +__version__ = "11.3.0.dev0" diff --git a/src/PIL/features.py b/src/PIL/features.py index ae7ea4255..573f1d412 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -17,6 +17,7 @@ modules = { "freetype2": ("PIL._imagingft", "freetype2_version"), "littlecms2": ("PIL._imagingcms", "littlecms_version"), "webp": ("PIL._webp", "webpdecoder_version"), + "avif": ("PIL._avif", "libavif_version"), } @@ -288,6 +289,7 @@ def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: ("freetype2", "FREETYPE2"), ("littlecms2", "LITTLECMS2"), ("webp", "WEBP"), + ("avif", "AVIF"), ("jpg", "JPEG"), ("jpg_2000", "OPENJPEG (JPEG2000)"), ("zlib", "ZLIB (PNG/ZIP)"), diff --git a/src/_avif.c b/src/_avif.c new file mode 100644 index 000000000..7e7bee703 --- /dev/null +++ b/src/_avif.c @@ -0,0 +1,906 @@ +#define PY_SSIZE_T_CLEAN + +#include +#include "avif/avif.h" + +// Encoder type +typedef struct { + PyObject_HEAD avifEncoder *encoder; + avifImage *image; + int first_frame; +} AvifEncoderObject; + +static PyTypeObject AvifEncoder_Type; + +// Decoder type +typedef struct { + PyObject_HEAD avifDecoder *decoder; + Py_buffer buffer; +} AvifDecoderObject; + +static PyTypeObject AvifDecoder_Type; + +static int +normalize_tiles_log2(int value) { + if (value < 0) { + return 0; + } else if (value > 6) { + return 6; + } else { + return value; + } +} + +static PyObject * +exc_type_for_avif_result(avifResult result) { + switch (result) { + case AVIF_RESULT_INVALID_EXIF_PAYLOAD: + case AVIF_RESULT_INVALID_CODEC_SPECIFIC_OPTION: + return PyExc_ValueError; + case AVIF_RESULT_INVALID_FTYP: + case AVIF_RESULT_BMFF_PARSE_FAILED: + case AVIF_RESULT_TRUNCATED_DATA: + case AVIF_RESULT_NO_CONTENT: + return PyExc_SyntaxError; + default: + return PyExc_RuntimeError; + } +} + +static uint8_t +irot_imir_to_exif_orientation(const avifImage *image) { + uint8_t axis = image->imir.axis; + int imir = image->transformFlags & AVIF_TRANSFORM_IMIR; + int irot = image->transformFlags & AVIF_TRANSFORM_IROT; + if (irot) { + uint8_t angle = image->irot.angle; + if (angle == 1) { + if (imir) { + return axis ? 7 // 90 degrees anti-clockwise then swap left and right. + : 5; // 90 degrees anti-clockwise then swap top and bottom. + } + return 8; // 90 degrees anti-clockwise. + } + if (angle == 2) { + if (imir) { + return axis + ? 4 // 180 degrees anti-clockwise then swap left and right. + : 2; // 180 degrees anti-clockwise then swap top and bottom. + } + return 3; // 180 degrees anti-clockwise. + } + if (angle == 3) { + if (imir) { + return axis + ? 5 // 270 degrees anti-clockwise then swap left and right. + : 7; // 270 degrees anti-clockwise then swap top and bottom. + } + return 6; // 270 degrees anti-clockwise. + } + } + if (imir) { + return axis ? 2 // Swap left and right. + : 4; // Swap top and bottom. + } + return 1; // Default orientation ("top-left", no-op). +} + +static void +exif_orientation_to_irot_imir(avifImage *image, int orientation) { + // Mapping from Exif orientation as defined in JEITA CP-3451C section 4.6.4.A + // Orientation to irot and imir boxes as defined in HEIF ISO/IEC 28002-12:2021 + // sections 6.5.10 and 6.5.12. + switch (orientation) { + case 2: // The 0th row is at the visual top of the image, and the 0th column is + // the visual right-hand side. + image->transformFlags |= AVIF_TRANSFORM_IMIR; + image->imir.axis = 1; + break; + case 3: // The 0th row is at the visual bottom of the image, and the 0th column + // is the visual right-hand side. + image->transformFlags |= AVIF_TRANSFORM_IROT; + image->irot.angle = 2; + break; + case 4: // The 0th row is at the visual bottom of the image, and the 0th column + // is the visual left-hand side. + image->transformFlags |= AVIF_TRANSFORM_IMIR; + break; + case 5: // The 0th row is the visual left-hand side of the image, and the 0th + // column is the visual top. + image->transformFlags |= AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR; + image->irot.angle = 1; // applied before imir according to MIAF spec + // ISO/IEC 28002-12:2021 - section 7.3.6.7 + break; + case 6: // The 0th row is the visual right-hand side of the image, and the 0th + // column is the visual top. + image->transformFlags |= AVIF_TRANSFORM_IROT; + image->irot.angle = 3; + break; + case 7: // The 0th row is the visual right-hand side of the image, and the 0th + // column is the visual bottom. + image->transformFlags |= AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR; + image->irot.angle = 3; // applied before imir according to MIAF spec + // ISO/IEC 28002-12:2021 - section 7.3.6.7 + break; + case 8: // The 0th row is the visual left-hand side of the image, and the 0th + // column is the visual bottom. + image->transformFlags |= AVIF_TRANSFORM_IROT; + image->irot.angle = 1; + break; + } +} + +static int +_codec_available(const char *name, avifCodecFlags flags) { + avifCodecChoice codec = avifCodecChoiceFromName(name); + if (codec == AVIF_CODEC_CHOICE_AUTO) { + return 0; + } + const char *codec_name = avifCodecName(codec, flags); + return (codec_name == NULL) ? 0 : 1; +} + +PyObject * +_decoder_codec_available(PyObject *self, PyObject *args) { + char *codec_name; + if (!PyArg_ParseTuple(args, "s", &codec_name)) { + return NULL; + } + int is_available = _codec_available(codec_name, AVIF_CODEC_FLAG_CAN_DECODE); + return PyBool_FromLong(is_available); +} + +PyObject * +_encoder_codec_available(PyObject *self, PyObject *args) { + char *codec_name; + if (!PyArg_ParseTuple(args, "s", &codec_name)) { + return NULL; + } + int is_available = _codec_available(codec_name, AVIF_CODEC_FLAG_CAN_ENCODE); + return PyBool_FromLong(is_available); +} + +PyObject * +_codec_versions(PyObject *self, PyObject *args) { + char buffer[256]; + avifCodecVersions(buffer); + return PyUnicode_FromString(buffer); +} + +static int +_add_codec_specific_options(avifEncoder *encoder, PyObject *opts) { + Py_ssize_t i, size; + PyObject *keyval, *py_key, *py_val; + if (!PyTuple_Check(opts)) { + PyErr_SetString(PyExc_ValueError, "Invalid advanced codec options"); + return 1; + } + size = PyTuple_GET_SIZE(opts); + + for (i = 0; i < size; i++) { + keyval = PyTuple_GetItem(opts, i); + if (!PyTuple_Check(keyval) || PyTuple_GET_SIZE(keyval) != 2) { + PyErr_SetString(PyExc_ValueError, "Invalid advanced codec options"); + return 1; + } + py_key = PyTuple_GetItem(keyval, 0); + py_val = PyTuple_GetItem(keyval, 1); + if (!PyUnicode_Check(py_key) || !PyUnicode_Check(py_val)) { + PyErr_SetString(PyExc_ValueError, "Invalid advanced codec options"); + return 1; + } + const char *key = PyUnicode_AsUTF8(py_key); + const char *val = PyUnicode_AsUTF8(py_val); + if (key == NULL || val == NULL) { + PyErr_SetString(PyExc_ValueError, "Invalid advanced codec options"); + return 1; + } + + avifResult result = avifEncoderSetCodecSpecificOption(encoder, key, val); + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Setting advanced codec options failed: %s", + avifResultToString(result) + ); + return 1; + } + } + return 0; +} + +// Encoder functions +PyObject * +AvifEncoderNew(PyObject *self_, PyObject *args) { + unsigned int width, height; + AvifEncoderObject *self = NULL; + avifEncoder *encoder = NULL; + + char *subsampling; + int quality; + int speed; + int exif_orientation; + int max_threads; + Py_buffer icc_buffer; + Py_buffer exif_buffer; + Py_buffer xmp_buffer; + int alpha_premultiplied; + int autotiling; + int tile_rows_log2; + int tile_cols_log2; + + char *codec; + char *range; + + PyObject *advanced; + int error = 0; + + if (!PyArg_ParseTuple( + args, + "(II)siiissiippy*y*iy*O", + &width, + &height, + &subsampling, + &quality, + &speed, + &max_threads, + &codec, + &range, + &tile_rows_log2, + &tile_cols_log2, + &alpha_premultiplied, + &autotiling, + &icc_buffer, + &exif_buffer, + &exif_orientation, + &xmp_buffer, + &advanced + )) { + return NULL; + } + + // Create a new animation encoder and picture frame + avifImage *image = avifImageCreateEmpty(); + if (image == NULL) { + PyErr_SetString(PyExc_ValueError, "Image creation failed"); + error = 1; + goto end; + } + + // Set these in advance so any upcoming RGB -> YUV use the proper coefficients + if (strcmp(range, "full") == 0) { + image->yuvRange = AVIF_RANGE_FULL; + } else if (strcmp(range, "limited") == 0) { + image->yuvRange = AVIF_RANGE_LIMITED; + } else { + PyErr_SetString(PyExc_ValueError, "Invalid range"); + error = 1; + goto end; + } + if (strcmp(subsampling, "4:0:0") == 0) { + image->yuvFormat = AVIF_PIXEL_FORMAT_YUV400; + } else if (strcmp(subsampling, "4:2:0") == 0) { + image->yuvFormat = AVIF_PIXEL_FORMAT_YUV420; + } else if (strcmp(subsampling, "4:2:2") == 0) { + image->yuvFormat = AVIF_PIXEL_FORMAT_YUV422; + } else if (strcmp(subsampling, "4:4:4") == 0) { + image->yuvFormat = AVIF_PIXEL_FORMAT_YUV444; + } else { + PyErr_Format(PyExc_ValueError, "Invalid subsampling: %s", subsampling); + error = 1; + goto end; + } + + // Validate canvas dimensions + if (width == 0 || height == 0) { + PyErr_SetString(PyExc_ValueError, "invalid canvas dimensions"); + error = 1; + goto end; + } + image->width = width; + image->height = height; + + image->depth = 8; + image->alphaPremultiplied = alpha_premultiplied ? AVIF_TRUE : AVIF_FALSE; + + encoder = avifEncoderCreate(); + if (!encoder) { + PyErr_SetString(PyExc_MemoryError, "Can't allocate encoder"); + error = 1; + goto end; + } + + int is_aom_encode = strcmp(codec, "aom") == 0 || + (strcmp(codec, "auto") == 0 && + _codec_available("aom", AVIF_CODEC_FLAG_CAN_ENCODE)); + encoder->maxThreads = is_aom_encode && max_threads > 64 ? 64 : max_threads; + + encoder->quality = quality; + + if (strcmp(codec, "auto") == 0) { + encoder->codecChoice = AVIF_CODEC_CHOICE_AUTO; + } else { + encoder->codecChoice = avifCodecChoiceFromName(codec); + } + if (speed < AVIF_SPEED_SLOWEST) { + speed = AVIF_SPEED_SLOWEST; + } else if (speed > AVIF_SPEED_FASTEST) { + speed = AVIF_SPEED_FASTEST; + } + encoder->speed = speed; + encoder->timescale = (uint64_t)1000; + + encoder->autoTiling = autotiling ? AVIF_TRUE : AVIF_FALSE; + if (!autotiling) { + encoder->tileRowsLog2 = normalize_tiles_log2(tile_rows_log2); + encoder->tileColsLog2 = normalize_tiles_log2(tile_cols_log2); + } + + if (advanced != Py_None && _add_codec_specific_options(encoder, advanced)) { + error = 1; + goto end; + } + + self = PyObject_New(AvifEncoderObject, &AvifEncoder_Type); + if (!self) { + PyErr_SetString(PyExc_RuntimeError, "could not create encoder object"); + error = 1; + goto end; + } + self->first_frame = 1; + + avifResult result; + if (icc_buffer.len) { + result = avifImageSetProfileICC(image, icc_buffer.buf, icc_buffer.len); + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Setting ICC profile failed: %s", + avifResultToString(result) + ); + error = 1; + goto end; + } + // colorPrimaries and transferCharacteristics are ignored when an ICC + // profile is present, so set them to UNSPECIFIED. + image->colorPrimaries = AVIF_COLOR_PRIMARIES_UNSPECIFIED; + image->transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_UNSPECIFIED; + } else { + image->colorPrimaries = AVIF_COLOR_PRIMARIES_BT709; + image->transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_SRGB; + } + image->matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT601; + + if (exif_buffer.len) { + result = avifImageSetMetadataExif(image, exif_buffer.buf, exif_buffer.len); + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Setting EXIF data failed: %s", + avifResultToString(result) + ); + error = 1; + goto end; + } + } + + if (xmp_buffer.len) { + result = avifImageSetMetadataXMP(image, xmp_buffer.buf, xmp_buffer.len); + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Setting XMP data failed: %s", + avifResultToString(result) + ); + error = 1; + goto end; + } + } + + if (exif_orientation > 1) { + exif_orientation_to_irot_imir(image, exif_orientation); + } + + self->image = image; + self->encoder = encoder; + +end: + PyBuffer_Release(&icc_buffer); + PyBuffer_Release(&exif_buffer); + PyBuffer_Release(&xmp_buffer); + + if (error) { + if (image) { + avifImageDestroy(image); + } + if (encoder) { + avifEncoderDestroy(encoder); + } + if (self) { + PyObject_Del(self); + } + return NULL; + } + + return (PyObject *)self; +} + +PyObject * +_encoder_dealloc(AvifEncoderObject *self) { + if (self->encoder) { + avifEncoderDestroy(self->encoder); + } + if (self->image) { + avifImageDestroy(self->image); + } + Py_RETURN_NONE; +} + +PyObject * +_encoder_add(AvifEncoderObject *self, PyObject *args) { + uint8_t *rgb_bytes; + Py_ssize_t size; + unsigned int duration; + unsigned int width; + unsigned int height; + char *mode; + unsigned int is_single_frame; + int error = 0; + + avifRGBImage rgb; + avifResult result; + + avifEncoder *encoder = self->encoder; + avifImage *image = self->image; + avifImage *frame = NULL; + + if (!PyArg_ParseTuple( + args, + "y#I(II)sp", + (char **)&rgb_bytes, + &size, + &duration, + &width, + &height, + &mode, + &is_single_frame + )) { + return NULL; + } + + if (image->width != width || image->height != height) { + PyErr_Format( + PyExc_ValueError, + "Image sequence dimensions mismatch, %ux%u != %ux%u", + image->width, + image->height, + width, + height + ); + return NULL; + } + + if (self->first_frame) { + // If we don't have an image populated with yuv planes, this is the first frame + frame = image; + } else { + frame = avifImageCreateEmpty(); + if (image == NULL) { + PyErr_SetString(PyExc_ValueError, "Image creation failed"); + return NULL; + } + + frame->width = width; + frame->height = height; + frame->colorPrimaries = image->colorPrimaries; + frame->transferCharacteristics = image->transferCharacteristics; + frame->matrixCoefficients = image->matrixCoefficients; + frame->yuvRange = image->yuvRange; + frame->yuvFormat = image->yuvFormat; + frame->depth = image->depth; + frame->alphaPremultiplied = image->alphaPremultiplied; + } + + avifRGBImageSetDefaults(&rgb, frame); + + if (strcmp(mode, "RGBA") == 0) { + rgb.format = AVIF_RGB_FORMAT_RGBA; + } else { + rgb.format = AVIF_RGB_FORMAT_RGB; + } + + result = avifRGBImageAllocatePixels(&rgb); + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Pixel allocation failed: %s", + avifResultToString(result) + ); + error = 1; + goto end; + } + + if (rgb.rowBytes * rgb.height != size) { + PyErr_Format( + PyExc_RuntimeError, + "rgb data has incorrect size: %u * %u (%u) != %u", + rgb.rowBytes, + rgb.height, + rgb.rowBytes * rgb.height, + size + ); + error = 1; + goto end; + } + + // rgb.pixels is safe for writes + memcpy(rgb.pixels, rgb_bytes, size); + + Py_BEGIN_ALLOW_THREADS; + result = avifImageRGBToYUV(frame, &rgb); + Py_END_ALLOW_THREADS; + + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Conversion to YUV failed: %s", + avifResultToString(result) + ); + error = 1; + goto end; + } + + uint32_t addImageFlags = + is_single_frame ? AVIF_ADD_IMAGE_FLAG_SINGLE : AVIF_ADD_IMAGE_FLAG_NONE; + + Py_BEGIN_ALLOW_THREADS; + result = avifEncoderAddImage(encoder, frame, duration, addImageFlags); + Py_END_ALLOW_THREADS; + + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Failed to encode image: %s", + avifResultToString(result) + ); + error = 1; + goto end; + } + +end: + avifRGBImageFreePixels(&rgb); + if (!self->first_frame) { + avifImageDestroy(frame); + } + + if (error) { + return NULL; + } + self->first_frame = 0; + Py_RETURN_NONE; +} + +PyObject * +_encoder_finish(AvifEncoderObject *self) { + avifEncoder *encoder = self->encoder; + + avifRWData raw = AVIF_DATA_EMPTY; + avifResult result; + PyObject *ret = NULL; + + Py_BEGIN_ALLOW_THREADS; + result = avifEncoderFinish(encoder, &raw); + Py_END_ALLOW_THREADS; + + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Failed to finish encoding: %s", + avifResultToString(result) + ); + avifRWDataFree(&raw); + return NULL; + } + + ret = PyBytes_FromStringAndSize((char *)raw.data, raw.size); + + avifRWDataFree(&raw); + + return ret; +} + +// Decoder functions +PyObject * +AvifDecoderNew(PyObject *self_, PyObject *args) { + Py_buffer buffer; + AvifDecoderObject *self = NULL; + avifDecoder *decoder; + + char *codec_str; + avifCodecChoice codec; + int max_threads; + + avifResult result; + + if (!PyArg_ParseTuple(args, "y*si", &buffer, &codec_str, &max_threads)) { + return NULL; + } + + if (strcmp(codec_str, "auto") == 0) { + codec = AVIF_CODEC_CHOICE_AUTO; + } else { + codec = avifCodecChoiceFromName(codec_str); + } + + self = PyObject_New(AvifDecoderObject, &AvifDecoder_Type); + if (!self) { + PyErr_SetString(PyExc_RuntimeError, "could not create decoder object"); + PyBuffer_Release(&buffer); + return NULL; + } + + decoder = avifDecoderCreate(); + if (!decoder) { + PyErr_SetString(PyExc_MemoryError, "Can't allocate decoder"); + PyBuffer_Release(&buffer); + PyObject_Del(self); + return NULL; + } + decoder->maxThreads = max_threads; + // Turn off libavif's 'clap' (clean aperture) property validation. + decoder->strictFlags &= ~AVIF_STRICT_CLAP_VALID; + // Allow the PixelInformationProperty ('pixi') to be missing in AV1 image + // items. libheif v1.11.0 and older does not add the 'pixi' item property to + // AV1 image items. + decoder->strictFlags &= ~AVIF_STRICT_PIXI_REQUIRED; + decoder->codecChoice = codec; + + result = avifDecoderSetIOMemory(decoder, buffer.buf, buffer.len); + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Setting IO memory failed: %s", + avifResultToString(result) + ); + avifDecoderDestroy(decoder); + PyBuffer_Release(&buffer); + PyObject_Del(self); + return NULL; + } + + result = avifDecoderParse(decoder); + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Failed to decode image: %s", + avifResultToString(result) + ); + avifDecoderDestroy(decoder); + PyBuffer_Release(&buffer); + PyObject_Del(self); + return NULL; + } + + self->decoder = decoder; + self->buffer = buffer; + + return (PyObject *)self; +} + +PyObject * +_decoder_dealloc(AvifDecoderObject *self) { + if (self->decoder) { + avifDecoderDestroy(self->decoder); + } + PyBuffer_Release(&self->buffer); + Py_RETURN_NONE; +} + +PyObject * +_decoder_get_info(AvifDecoderObject *self) { + avifDecoder *decoder = self->decoder; + avifImage *image = decoder->image; + + PyObject *icc = NULL; + PyObject *exif = NULL; + PyObject *xmp = NULL; + PyObject *ret = NULL; + + if (image->xmp.size) { + xmp = PyBytes_FromStringAndSize((const char *)image->xmp.data, image->xmp.size); + } + + if (image->exif.size) { + exif = + PyBytes_FromStringAndSize((const char *)image->exif.data, image->exif.size); + } + + if (image->icc.size) { + icc = PyBytes_FromStringAndSize((const char *)image->icc.data, image->icc.size); + } + + ret = Py_BuildValue( + "(II)IsSSIS", + image->width, + image->height, + decoder->imageCount, + decoder->alphaPresent ? "RGBA" : "RGB", + NULL == icc ? Py_None : icc, + NULL == exif ? Py_None : exif, + irot_imir_to_exif_orientation(image), + NULL == xmp ? Py_None : xmp + ); + + Py_XDECREF(xmp); + Py_XDECREF(exif); + Py_XDECREF(icc); + + return ret; +} + +PyObject * +_decoder_get_frame(AvifDecoderObject *self, PyObject *args) { + PyObject *bytes; + PyObject *ret; + Py_ssize_t size; + avifResult result; + avifRGBImage rgb; + avifDecoder *decoder; + avifImage *image; + uint32_t frame_index; + + decoder = self->decoder; + + if (!PyArg_ParseTuple(args, "I", &frame_index)) { + return NULL; + } + + result = avifDecoderNthImage(decoder, frame_index); + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Failed to decode frame %u: %s", + frame_index, + avifResultToString(result) + ); + return NULL; + } + + image = decoder->image; + + avifRGBImageSetDefaults(&rgb, image); + + rgb.depth = 8; + rgb.format = decoder->alphaPresent ? AVIF_RGB_FORMAT_RGBA : AVIF_RGB_FORMAT_RGB; + + result = avifRGBImageAllocatePixels(&rgb); + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Pixel allocation failed: %s", + avifResultToString(result) + ); + return NULL; + } + + Py_BEGIN_ALLOW_THREADS; + result = avifImageYUVToRGB(image, &rgb); + Py_END_ALLOW_THREADS; + + if (result != AVIF_RESULT_OK) { + PyErr_Format( + exc_type_for_avif_result(result), + "Conversion from YUV failed: %s", + avifResultToString(result) + ); + avifRGBImageFreePixels(&rgb); + return NULL; + } + + if (rgb.height > PY_SSIZE_T_MAX / rgb.rowBytes) { + PyErr_SetString(PyExc_MemoryError, "Integer overflow in pixel size"); + return NULL; + } + + size = rgb.rowBytes * rgb.height; + + bytes = PyBytes_FromStringAndSize((char *)rgb.pixels, size); + avifRGBImageFreePixels(&rgb); + + ret = Py_BuildValue( + "SKKK", + bytes, + decoder->timescale, + decoder->imageTiming.ptsInTimescales, + decoder->imageTiming.durationInTimescales + ); + + Py_DECREF(bytes); + + return ret; +} + +/* -------------------------------------------------------------------- */ +/* Type Definitions */ +/* -------------------------------------------------------------------- */ + +// AvifEncoder methods +static struct PyMethodDef _encoder_methods[] = { + {"add", (PyCFunction)_encoder_add, METH_VARARGS}, + {"finish", (PyCFunction)_encoder_finish, METH_NOARGS}, + {NULL, NULL} /* sentinel */ +}; + +// AvifEncoder type definition +static PyTypeObject AvifEncoder_Type = { + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "AvifEncoder", + .tp_basicsize = sizeof(AvifEncoderObject), + .tp_dealloc = (destructor)_encoder_dealloc, + .tp_methods = _encoder_methods, +}; + +// AvifDecoder methods +static struct PyMethodDef _decoder_methods[] = { + {"get_info", (PyCFunction)_decoder_get_info, METH_NOARGS}, + {"get_frame", (PyCFunction)_decoder_get_frame, METH_VARARGS}, + {NULL, NULL} /* sentinel */ +}; + +// AvifDecoder type definition +static PyTypeObject AvifDecoder_Type = { + PyVarObject_HEAD_INIT(NULL, 0).tp_name = "AvifDecoder", + .tp_basicsize = sizeof(AvifDecoderObject), + .tp_dealloc = (destructor)_decoder_dealloc, + .tp_methods = _decoder_methods, +}; + +/* -------------------------------------------------------------------- */ +/* Module Setup */ +/* -------------------------------------------------------------------- */ + +static PyMethodDef avifMethods[] = { + {"AvifDecoder", AvifDecoderNew, METH_VARARGS}, + {"AvifEncoder", AvifEncoderNew, METH_VARARGS}, + {"decoder_codec_available", _decoder_codec_available, METH_VARARGS}, + {"encoder_codec_available", _encoder_codec_available, METH_VARARGS}, + {"codec_versions", _codec_versions, METH_NOARGS}, + {NULL, NULL} +}; + +static int +setup_module(PyObject *m) { + if (PyType_Ready(&AvifDecoder_Type) < 0 || PyType_Ready(&AvifEncoder_Type) < 0) { + return -1; + } + + PyObject *d = PyModule_GetDict(m); + PyObject *v = PyUnicode_FromString(avifVersion()); + PyDict_SetItemString(d, "libavif_version", v ? v : Py_None); + Py_XDECREF(v); + + return 0; +} + +PyMODINIT_FUNC +PyInit__avif(void) { + PyObject *m; + + static PyModuleDef module_def = { + PyModuleDef_HEAD_INIT, + .m_name = "_avif", + .m_size = -1, + .m_methods = avifMethods, + }; + + m = PyModule_Create(&module_def); + if (setup_module(m) < 0) { + Py_DECREF(m); + return NULL; + } + +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + + return m; +} diff --git a/src/_imaging.c b/src/_imaging.c index 330a7eef4..72f122143 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -230,6 +230,93 @@ PyImaging_GetBuffer(PyObject *buffer, Py_buffer *view) { return PyObject_GetBuffer(buffer, view, PyBUF_SIMPLE); } +/* -------------------------------------------------------------------- */ +/* Arrow HANDLING */ +/* -------------------------------------------------------------------- */ + +PyObject * +ArrowError(int err) { + if (err == IMAGING_CODEC_MEMORY) { + return ImagingError_MemoryError(); + } + if (err == IMAGING_ARROW_INCOMPATIBLE_MODE) { + return ImagingError_ValueError("Incompatible Pillow mode for Arrow array"); + } + if (err == IMAGING_ARROW_MEMORY_LAYOUT) { + return ImagingError_ValueError( + "Image is in multiple array blocks, use imaging_new_block for zero copy" + ); + } + return ImagingError_ValueError("Unknown error"); +} + +void +ReleaseArrowSchemaPyCapsule(PyObject *capsule) { + struct ArrowSchema *schema = + (struct ArrowSchema *)PyCapsule_GetPointer(capsule, "arrow_schema"); + if (schema->release != NULL) { + schema->release(schema); + } + free(schema); +} + +PyObject * +ExportArrowSchemaPyCapsule(ImagingObject *self) { + struct ArrowSchema *schema = + (struct ArrowSchema *)calloc(1, sizeof(struct ArrowSchema)); + int err = export_imaging_schema(self->image, schema); + if (err == 0) { + return PyCapsule_New(schema, "arrow_schema", ReleaseArrowSchemaPyCapsule); + } + free(schema); + return ArrowError(err); +} + +void +ReleaseArrowArrayPyCapsule(PyObject *capsule) { + struct ArrowArray *array = + (struct ArrowArray *)PyCapsule_GetPointer(capsule, "arrow_array"); + if (array->release != NULL) { + array->release(array); + } + free(array); +} + +PyObject * +ExportArrowArrayPyCapsule(ImagingObject *self) { + struct ArrowArray *array = + (struct ArrowArray *)calloc(1, sizeof(struct ArrowArray)); + int err = export_imaging_array(self->image, array); + if (err == 0) { + return PyCapsule_New(array, "arrow_array", ReleaseArrowArrayPyCapsule); + } + free(array); + return ArrowError(err); +} + +static PyObject * +_new_arrow(PyObject *self, PyObject *args) { + char *mode; + int xsize, ysize; + PyObject *schema_capsule, *array_capsule; + PyObject *ret; + + if (!PyArg_ParseTuple( + args, "s(ii)OO", &mode, &xsize, &ysize, &schema_capsule, &array_capsule + )) { + return NULL; + } + + // ImagingBorrowArrow is responsible for retaining the array_capsule + ret = + PyImagingNew(ImagingNewArrow(mode, xsize, ysize, schema_capsule, array_capsule) + ); + if (!ret) { + return ImagingError_ValueError("Invalid Arrow array mode or size mismatch"); + } + return ret; +} + /* -------------------------------------------------------------------- */ /* EXCEPTION REROUTING */ /* -------------------------------------------------------------------- */ @@ -3655,6 +3742,10 @@ static struct PyMethodDef methods[] = { /* Misc. */ {"save_ppm", (PyCFunction)_save_ppm, METH_VARARGS}, + /* arrow */ + {"__arrow_c_schema__", (PyCFunction)ExportArrowSchemaPyCapsule, METH_VARARGS}, + {"__arrow_c_array__", (PyCFunction)ExportArrowArrayPyCapsule, METH_VARARGS}, + {NULL, NULL} /* sentinel */ }; @@ -3722,6 +3813,11 @@ _getattr_unsafe_ptrs(ImagingObject *self, void *closure) { ); } +static PyObject * +_getattr_readonly(ImagingObject *self, void *closure) { + return PyLong_FromLong(self->image->read_only); +} + static struct PyGetSetDef getsetters[] = { {"mode", (getter)_getattr_mode}, {"size", (getter)_getattr_size}, @@ -3729,6 +3825,7 @@ static struct PyGetSetDef getsetters[] = { {"id", (getter)_getattr_id}, {"ptr", (getter)_getattr_ptr}, {"unsafe_ptrs", (getter)_getattr_unsafe_ptrs}, + {"readonly", (getter)_getattr_readonly}, {NULL} }; @@ -3983,6 +4080,21 @@ _set_blocks_max(PyObject *self, PyObject *args) { Py_RETURN_NONE; } +static PyObject * +_set_use_block_allocator(PyObject *self, PyObject *args) { + int use_block_allocator; + if (!PyArg_ParseTuple(args, "i:set_use_block_allocator", &use_block_allocator)) { + return NULL; + } + ImagingMemorySetBlockAllocator(&ImagingDefaultArena, use_block_allocator); + Py_RETURN_NONE; +} + +static PyObject * +_get_use_block_allocator(PyObject *self, PyObject *args) { + return PyLong_FromLong(ImagingDefaultArena.use_block_allocator); +} + static PyObject * _clear_cache(PyObject *self, PyObject *args) { int i = 0; @@ -4104,6 +4216,7 @@ static PyMethodDef functions[] = { {"fill", (PyCFunction)_fill, METH_VARARGS}, {"new", (PyCFunction)_new, METH_VARARGS}, {"new_block", (PyCFunction)_new_block, METH_VARARGS}, + {"new_arrow", (PyCFunction)_new_arrow, METH_VARARGS}, {"merge", (PyCFunction)_merge, METH_VARARGS}, /* Functions */ @@ -4190,9 +4303,11 @@ static PyMethodDef functions[] = { {"get_alignment", (PyCFunction)_get_alignment, METH_VARARGS}, {"get_block_size", (PyCFunction)_get_block_size, METH_VARARGS}, {"get_blocks_max", (PyCFunction)_get_blocks_max, METH_VARARGS}, + {"get_use_block_allocator", (PyCFunction)_get_use_block_allocator, METH_VARARGS}, {"set_alignment", (PyCFunction)_set_alignment, METH_VARARGS}, {"set_block_size", (PyCFunction)_set_block_size, METH_VARARGS}, {"set_blocks_max", (PyCFunction)_set_blocks_max, METH_VARARGS}, + {"set_use_block_allocator", (PyCFunction)_set_use_block_allocator, METH_VARARGS}, {"clear_cache", (PyCFunction)_clear_cache, METH_VARARGS}, {NULL, NULL} /* sentinel */ diff --git a/src/_imagingcms.c b/src/_imagingcms.c index ea2f70186..f93c1613b 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -1402,8 +1402,8 @@ static struct PyGetSetDef cms_profile_getsetters[] = { {"colorant_table_out", (getter)cms_profile_getattr_colorant_table_out}, {"intent_supported", (getter)cms_profile_getattr_is_intent_supported}, {"clut", (getter)cms_profile_getattr_is_clut}, - {"icc_measurement_condition", (getter)cms_profile_getattr_icc_measurement_condition - }, + {"icc_measurement_condition", + (getter)cms_profile_getattr_icc_measurement_condition}, {"icc_viewing_condition", (getter)cms_profile_getattr_icc_viewing_condition}, {NULL} diff --git a/src/display.c b/src/display.c index a05387504..11742a895 100644 --- a/src/display.c +++ b/src/display.c @@ -286,29 +286,42 @@ PyImaging_DisplayModeWin32(PyObject *self, PyObject *args) { /* -------------------------------------------------------------------- */ /* Windows screen grabber */ +typedef HANDLE(__stdcall *Func_GetWindowDpiAwarenessContext)(HANDLE); typedef HANDLE(__stdcall *Func_SetThreadDpiAwarenessContext)(HANDLE); PyObject * PyImaging_GrabScreenWin32(PyObject *self, PyObject *args) { - int x = 0, y = 0, width, height; - int includeLayeredWindows = 0, all_screens = 0; + int x = 0, y = 0, width = -1, height; + int includeLayeredWindows = 0, screens = 0; HBITMAP bitmap; BITMAPCOREHEADER core; HDC screen, screen_copy; + HWND wnd; DWORD rop; PyObject *buffer; - HANDLE dpiAwareness; + HANDLE dpiAwareness = NULL; HMODULE user32; + Func_GetWindowDpiAwarenessContext GetWindowDpiAwarenessContext_function; Func_SetThreadDpiAwarenessContext SetThreadDpiAwarenessContext_function; - if (!PyArg_ParseTuple(args, "|ii", &includeLayeredWindows, &all_screens)) { + if (!PyArg_ParseTuple( + args, "|ii" F_HANDLE, &includeLayeredWindows, &screens, &wnd + )) { return NULL; } /* step 1: create a memory DC large enough to hold the entire screen */ - screen = CreateDC("DISPLAY", NULL, NULL, NULL); + if (screens == -1) { + screen = GetDC(wnd); + if (screen == NULL) { + PyErr_SetString(PyExc_OSError, "unable to get device context for handle"); + return NULL; + } + } else { + screen = CreateDC("DISPLAY", NULL, NULL, NULL); + } screen_copy = CreateCompatibleDC(screen); // added in Windows 10 (1607) @@ -317,15 +330,28 @@ PyImaging_GrabScreenWin32(PyObject *self, PyObject *args) { SetThreadDpiAwarenessContext_function = (Func_SetThreadDpiAwarenessContext )GetProcAddress(user32, "SetThreadDpiAwarenessContext"); if (SetThreadDpiAwarenessContext_function != NULL) { + GetWindowDpiAwarenessContext_function = (Func_GetWindowDpiAwarenessContext + )GetProcAddress(user32, "GetWindowDpiAwarenessContext"); + if (screens == -1 && GetWindowDpiAwarenessContext_function != NULL) { + dpiAwareness = GetWindowDpiAwarenessContext_function(wnd); + } // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = ((DPI_CONTEXT_HANDLE)-3) - dpiAwareness = SetThreadDpiAwarenessContext_function((HANDLE)-3); + dpiAwareness = SetThreadDpiAwarenessContext_function( + dpiAwareness == NULL ? (HANDLE)-3 : dpiAwareness + ); } - if (all_screens) { + if (screens == 1) { x = GetSystemMetrics(SM_XVIRTUALSCREEN); y = GetSystemMetrics(SM_YVIRTUALSCREEN); width = GetSystemMetrics(SM_CXVIRTUALSCREEN); height = GetSystemMetrics(SM_CYVIRTUALSCREEN); + } else if (screens == -1) { + RECT rect; + if (GetClientRect(wnd, &rect)) { + width = rect.right; + height = rect.bottom; + } } else { width = GetDeviceCaps(screen, HORZRES); height = GetDeviceCaps(screen, VERTRES); @@ -337,6 +363,10 @@ PyImaging_GrabScreenWin32(PyObject *self, PyObject *args) { FreeLibrary(user32); + if (width == -1) { + goto error; + } + bitmap = CreateCompatibleBitmap(screen, width, height); if (!bitmap) { goto error; @@ -382,7 +412,11 @@ PyImaging_GrabScreenWin32(PyObject *self, PyObject *args) { DeleteObject(bitmap); DeleteDC(screen_copy); - DeleteDC(screen); + if (screens == -1) { + ReleaseDC(wnd, screen); + } else { + DeleteDC(screen); + } return Py_BuildValue("(ii)(ii)N", x, y, width, height, buffer); @@ -390,7 +424,11 @@ error: PyErr_SetString(PyExc_OSError, "screen grab failed"); DeleteDC(screen_copy); - DeleteDC(screen); + if (screens == -1) { + ReleaseDC(wnd, screen); + } else { + DeleteDC(screen); + } return NULL; } @@ -687,6 +725,14 @@ PyImaging_EventLoopWin32(PyObject *self, PyObject *args) { #define GET32(p, o) ((DWORD *)(p + o))[0] +static int CALLBACK +enhMetaFileProc( + HDC hdc, HANDLETABLE *lpht, const ENHMETARECORD *lpmr, int nHandles, LPARAM data +) { + PlayEnhMetaFileRecord(hdc, lpht, lpmr, nHandles); + return 1; +} + PyObject * PyImaging_DrawWmf(PyObject *self, PyObject *args) { HBITMAP bitmap; @@ -767,10 +813,7 @@ PyImaging_DrawWmf(PyObject *self, PyObject *args) { /* FIXME: make background transparent? configurable? */ FillRect(dc, &rect, GetStockObject(WHITE_BRUSH)); - if (!PlayEnhMetaFile(dc, meta, &rect)) { - PyErr_SetString(PyExc_OSError, "cannot render metafile"); - goto error; - } + EnumEnhMetaFile(dc, meta, enhMetaFileProc, NULL, &rect); /* step 4: extract bits from bitmap */ diff --git a/src/libImaging/Arrow.c b/src/libImaging/Arrow.c new file mode 100644 index 000000000..33ff2ce77 --- /dev/null +++ b/src/libImaging/Arrow.c @@ -0,0 +1,299 @@ + +#include "Arrow.h" +#include "Imaging.h" +#include + +/* struct ArrowSchema* */ +/* _arrow_schema_channel(char* channel, char* format) { */ + +/* } */ + +static void +ReleaseExportedSchema(struct ArrowSchema *array) { + // This should not be called on already released array + // assert(array->release != NULL); + + if (!array->release) { + return; + } + if (array->format) { + free((void *)array->format); + array->format = NULL; + } + if (array->name) { + free((void *)array->name); + array->name = NULL; + } + if (array->metadata) { + free((void *)array->metadata); + array->metadata = NULL; + } + + // Release children + for (int64_t i = 0; i < array->n_children; ++i) { + struct ArrowSchema *child = array->children[i]; + if (child->release != NULL) { + child->release(child); + child->release = NULL; + } + // UNDONE -- should I be releasing the children? + } + + // Release dictionary + struct ArrowSchema *dict = array->dictionary; + if (dict != NULL && dict->release != NULL) { + dict->release(dict); + dict->release = NULL; + } + + // TODO here: release and/or deallocate all data directly owned by + // the ArrowArray struct, such as the private_data. + + // Mark array released + array->release = NULL; +} + +int +export_named_type(struct ArrowSchema *schema, char *format, char *name) { + char *formatp; + char *namep; + size_t format_len = strlen(format) + 1; + size_t name_len = strlen(name) + 1; + + formatp = calloc(format_len, 1); + + if (!formatp) { + return IMAGING_CODEC_MEMORY; + } + + namep = calloc(name_len, 1); + if (!namep) { + free(formatp); + return IMAGING_CODEC_MEMORY; + } + + strncpy(formatp, format, format_len); + strncpy(namep, name, name_len); + + *schema = (struct ArrowSchema){// Type description + .format = formatp, + .name = namep, + .metadata = NULL, + .flags = 0, + .n_children = 0, + .children = NULL, + .dictionary = NULL, + // Bookkeeping + .release = &ReleaseExportedSchema + }; + return 0; +} + +int +export_imaging_schema(Imaging im, struct ArrowSchema *schema) { + int retval = 0; + + if (strcmp(im->arrow_band_format, "") == 0) { + return IMAGING_ARROW_INCOMPATIBLE_MODE; + } + + /* for now, single block images */ + if (!(im->blocks_count == 0 || im->blocks_count == 1)) { + return IMAGING_ARROW_MEMORY_LAYOUT; + } + + if (im->bands == 1) { + return export_named_type(schema, im->arrow_band_format, im->band_names[0]); + } + + retval = export_named_type(schema, "+w:4", ""); + if (retval != 0) { + return retval; + } + // if it's not 1 band, it's an int32 at the moment. 4 uint8 bands. + schema->n_children = 1; + schema->children = calloc(1, sizeof(struct ArrowSchema *)); + schema->children[0] = (struct ArrowSchema *)calloc(1, sizeof(struct ArrowSchema)); + retval = export_named_type(schema->children[0], im->arrow_band_format, "pixel"); + if (retval != 0) { + free(schema->children[0]); + schema->release(schema); + return retval; + } + return 0; +} + +static void +release_const_array(struct ArrowArray *array) { + Imaging im = (Imaging)array->private_data; + + if (array->n_children == 0) { + ImagingDelete(im); + } + + // Free the buffers and the buffers array + if (array->buffers) { + free(array->buffers); + array->buffers = NULL; + } + if (array->children) { + // undone -- does arrow release all the children recursively? + for (int i = 0; i < array->n_children; i++) { + if (array->children[i]->release) { + array->children[i]->release(array->children[i]); + array->children[i]->release = NULL; + free(array->children[i]); + } + } + free(array->children); + array->children = NULL; + } + // Mark released + array->release = NULL; +} + +int +export_single_channel_array(Imaging im, struct ArrowArray *array) { + int length = im->xsize * im->ysize; + + /* for now, single block images */ + if (!(im->blocks_count == 0 || im->blocks_count == 1)) { + return IMAGING_ARROW_MEMORY_LAYOUT; + } + + if (im->lines_per_block && im->lines_per_block < im->ysize) { + length = im->xsize * im->lines_per_block; + } + + MUTEX_LOCK(&im->mutex); + im->refcount++; + MUTEX_UNLOCK(&im->mutex); + // Initialize primitive fields + *array = (struct ArrowArray){// Data description + .length = length, + .offset = 0, + .null_count = 0, + .n_buffers = 2, + .n_children = 0, + .children = NULL, + .dictionary = NULL, + // Bookkeeping + .release = &release_const_array, + .private_data = im + }; + + // Allocate list of buffers + array->buffers = (const void **)malloc(sizeof(void *) * array->n_buffers); + // assert(array->buffers != NULL); + array->buffers[0] = NULL; // no nulls, null bitmap can be omitted + + if (im->block) { + array->buffers[1] = im->block; + } else { + array->buffers[1] = im->blocks[0].ptr; + } + return 0; +} + +int +export_fixed_pixel_array(Imaging im, struct ArrowArray *array) { + int length = im->xsize * im->ysize; + + /* for now, single block images */ + if (!(im->blocks_count == 0 || im->blocks_count == 1)) { + return IMAGING_ARROW_MEMORY_LAYOUT; + } + + if (im->lines_per_block && im->lines_per_block < im->ysize) { + length = im->xsize * im->lines_per_block; + } + + MUTEX_LOCK(&im->mutex); + im->refcount++; + MUTEX_UNLOCK(&im->mutex); + // Initialize primitive fields + // Fixed length arrays are 1 buffer of validity, and the length in pixels. + // Data is in a child array. + *array = (struct ArrowArray){// Data description + .length = length, + .offset = 0, + .null_count = 0, + .n_buffers = 1, + .n_children = 1, + .children = NULL, + .dictionary = NULL, + // Bookkeeping + .release = &release_const_array, + .private_data = im + }; + + // Allocate list of buffers + array->buffers = (const void **)calloc(1, sizeof(void *) * array->n_buffers); + if (!array->buffers) { + goto err; + } + // assert(array->buffers != NULL); + array->buffers[0] = NULL; // no nulls, null bitmap can be omitted + + // if it's not 1 band, it's an int32 at the moment. 4 uint8 bands. + array->n_children = 1; + array->children = calloc(1, sizeof(struct ArrowArray *)); + if (!array->children) { + goto err; + } + array->children[0] = (struct ArrowArray *)calloc(1, sizeof(struct ArrowArray)); + if (!array->children[0]) { + goto err; + } + + MUTEX_LOCK(&im->mutex); + im->refcount++; + MUTEX_UNLOCK(&im->mutex); + *array->children[0] = (struct ArrowArray){// Data description + .length = length * 4, + .offset = 0, + .null_count = 0, + .n_buffers = 2, + .n_children = 0, + .children = NULL, + .dictionary = NULL, + // Bookkeeping + .release = &release_const_array, + .private_data = im + }; + + array->children[0]->buffers = + (const void **)calloc(2, sizeof(void *) * array->n_buffers); + + if (im->block) { + array->children[0]->buffers[1] = im->block; + } else { + array->children[0]->buffers[1] = im->blocks[0].ptr; + } + return 0; + +err: + if (array->children[0]) { + free(array->children[0]); + } + if (array->children) { + free(array->children); + } + if (array->buffers) { + free(array->buffers); + } + return IMAGING_CODEC_MEMORY; +} + +int +export_imaging_array(Imaging im, struct ArrowArray *array) { + if (strcmp(im->arrow_band_format, "") == 0) { + return IMAGING_ARROW_INCOMPATIBLE_MODE; + } + + if (im->bands == 1) { + return export_single_channel_array(im, array); + } + + return export_fixed_pixel_array(im, array); +} diff --git a/src/libImaging/Arrow.h b/src/libImaging/Arrow.h new file mode 100644 index 000000000..0b285fe80 --- /dev/null +++ b/src/libImaging/Arrow.h @@ -0,0 +1,48 @@ +#include +#include + +// Apache License 2.0. +// Source apache arrow project +// https://arrow.apache.org/docs/format/CDataInterface.html + +#ifndef ARROW_C_DATA_INTERFACE +#define ARROW_C_DATA_INTERFACE + +#define ARROW_FLAG_DICTIONARY_ORDERED 1 +#define ARROW_FLAG_NULLABLE 2 +#define ARROW_FLAG_MAP_KEYS_SORTED 4 + +struct ArrowSchema { + // Array type description + const char *format; + const char *name; + const char *metadata; + int64_t flags; + int64_t n_children; + struct ArrowSchema **children; + struct ArrowSchema *dictionary; + + // Release callback + void (*release)(struct ArrowSchema *); + // Opaque producer-specific data + void *private_data; +}; + +struct ArrowArray { + // Array data description + int64_t length; + int64_t null_count; + int64_t offset; + int64_t n_buffers; + int64_t n_children; + const void **buffers; + struct ArrowArray **children; + struct ArrowArray *dictionary; + + // Release callback + void (*release)(struct ArrowArray *); + // Opaque producer-specific data + void *private_data; +}; + +#endif // ARROW_C_DATA_INTERFACE diff --git a/src/libImaging/BcnDecode.c b/src/libImaging/BcnDecode.c index 9a41febc7..7b3d8f908 100644 --- a/src/libImaging/BcnDecode.c +++ b/src/libImaging/BcnDecode.c @@ -25,7 +25,6 @@ typedef struct { typedef struct { UINT16 c0, c1; - UINT32 lut; } bc1_color; typedef struct { @@ -40,13 +39,10 @@ typedef struct { #define LOAD16(p) (p)[0] | ((p)[1] << 8) -#define LOAD32(p) (p)[0] | ((p)[1] << 8) | ((p)[2] << 16) | ((p)[3] << 24) - static void bc1_color_load(bc1_color *dst, const UINT8 *src) { dst->c0 = LOAD16(src); dst->c1 = LOAD16(src + 2); - dst->lut = LOAD32(src + 4); } static rgba @@ -70,7 +66,7 @@ static void decode_bc1_color(rgba *dst, const UINT8 *src, int separate_alpha) { bc1_color col; rgba p[4]; - int n, cw; + int n, o, cw; UINT16 r0, g0, b0, r1, g1, b1; bc1_color_load(&col, src); @@ -103,9 +99,11 @@ decode_bc1_color(rgba *dst, const UINT8 *src, int separate_alpha) { p[3].b = 0; p[3].a = 0; } - for (n = 0; n < 16; n++) { - cw = 3 & (col.lut >> (2 * n)); - dst[n] = p[cw]; + for (n = 0; n < 4; n++) { + for (o = 0; o < 4; o++) { + cw = 3 & ((src + 4)[n] >> (2 * o)); + dst[n * 4 + o] = p[cw]; + } } } diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index ea6f8805e..d5aff8709 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -501,55 +501,49 @@ polygon_generic( // Needed to draw consistent polygons xx[j] = xx[j - 1]; j++; - } else if (current->dx != 0 && j % 2 == 1 && - roundf(xx[j - 1]) == xx[j - 1]) { + } else if ((ymin == current->ymin || ymin == current->ymax) && + current->dx != 0) { // Connect discontiguous corners for (k = 0; k < i; k++) { Edge *other_edge = edge_table[k]; - if ((current->dx > 0 && other_edge->dx <= 0) || - (current->dx < 0 && other_edge->dx >= 0)) { + if ((ymin != other_edge->ymin && ymin != other_edge->ymax) || + other_edge->dx == 0) { continue; } // Check if the two edges join to make a corner - if (xx[j - 1] == - (ymin - other_edge->y0) * other_edge->dx + other_edge->x0) { + if (roundf(xx[j - 1]) == + roundf( + (ymin - other_edge->y0) * other_edge->dx + + other_edge->x0 + )) { // Determine points from the edges on the next row // Or if this is the last row, check the previous row - int offset = ymin == ymax ? -1 : 1; + int offset = ymin == current->ymax ? -1 : 1; adjacent_line_x = (ymin + offset - current->y0) * current->dx + current->x0; - adjacent_line_x_other_edge = - (ymin + offset - other_edge->y0) * other_edge->dx + - other_edge->x0; - if (ymin == current->ymax) { - if (current->dx > 0) { - xx[k] = - fmax( + if (ymin + offset >= other_edge->ymin && + ymin + offset <= other_edge->ymax) { + adjacent_line_x_other_edge = + (ymin + offset - other_edge->y0) * other_edge->dx + + other_edge->x0; + if (xx[j - 1] > adjacent_line_x + 1 && + xx[j - 1] > adjacent_line_x_other_edge + 1) { + xx[j - 1] = + roundf(fmax( adjacent_line_x, adjacent_line_x_other_edge - ) + + )) + 1; - } else { - xx[k] = - fmin( + } else if (xx[j - 1] < adjacent_line_x - 1 && + xx[j - 1] < adjacent_line_x_other_edge - 1) { + xx[j - 1] = + roundf(fmin( adjacent_line_x, adjacent_line_x_other_edge - ) - - 1; - } - } else { - if (current->dx > 0) { - xx[k] = fmin( - adjacent_line_x, adjacent_line_x_other_edge - ); - } else { - xx[k] = - fmax( - adjacent_line_x, adjacent_line_x_other_edge - ) + + )) - 1; } + break; } - break; } } } diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 0fc191d15..234f9943c 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -20,6 +20,8 @@ extern "C" { #define M_PI 3.1415926535897932384626433832795 #endif +#include "Arrow.h" + /* -------------------------------------------------------------------- */ /* @@ -104,6 +106,21 @@ struct ImagingMemoryInstance { /* Virtual methods */ void (*destroy)(Imaging im); + + /* arrow */ + int refcount; /* Number of arrow arrays that have been allocated */ + char band_names[4][3]; /* names of bands, max 2 char + null terminator */ + char arrow_band_format[2]; /* single character + null terminator */ + + int read_only; /* flag for read-only. set for arrow borrowed arrays */ + PyObject *arrow_array_capsule; /* upstream arrow array source */ + + int blocks_count; /* Number of blocks that have been allocated */ + int lines_per_block; /* Number of lines in a block have been allocated */ + +#ifdef Py_GIL_DISABLED + PyMutex mutex; +#endif }; #define IMAGING_PIXEL_1(im, x, y) ((im)->image8[(y)][(x)]) @@ -161,6 +178,7 @@ typedef struct ImagingMemoryArena { int stats_reallocated_blocks; /* Number of blocks which were actually reallocated after retrieving */ int stats_freed_blocks; /* Number of freed blocks */ + int use_block_allocator; /* don't use arena, use block allocator */ #ifdef Py_GIL_DISABLED PyMutex mutex; #endif @@ -174,6 +192,8 @@ extern int ImagingMemorySetBlocksMax(ImagingMemoryArena arena, int blocks_max); extern void ImagingMemoryClearCache(ImagingMemoryArena arena, int new_size); +extern void +ImagingMemorySetBlockAllocator(ImagingMemoryArena arena, int use_block_allocator); extern Imaging ImagingNew(const char *mode, int xsize, int ysize); @@ -187,6 +207,15 @@ ImagingDelete(Imaging im); extern Imaging ImagingNewBlock(const char *mode, int xsize, int ysize); +extern Imaging +ImagingNewArrow( + const char *mode, + int xsize, + int ysize, + PyObject *schema_capsule, + PyObject *array_capsule +); + extern Imaging ImagingNewPrologue(const char *mode, int xsize, int ysize); extern Imaging @@ -700,6 +729,13 @@ _imaging_seek_pyFd(PyObject *fd, Py_ssize_t offset, int whence); extern Py_ssize_t _imaging_tell_pyFd(PyObject *fd); +/* Arrow */ + +extern int +export_imaging_array(Imaging im, struct ArrowArray *array); +extern int +export_imaging_schema(Imaging im, struct ArrowSchema *schema); + /* Errcodes */ #define IMAGING_CODEC_END 1 #define IMAGING_CODEC_OVERRUN -1 @@ -707,6 +743,8 @@ _imaging_tell_pyFd(PyObject *fd); #define IMAGING_CODEC_UNKNOWN -3 #define IMAGING_CODEC_CONFIG -8 #define IMAGING_CODEC_MEMORY -9 +#define IMAGING_ARROW_INCOMPATIBLE_MODE -10 +#define IMAGING_ARROW_MEMORY_LAYOUT -11 #include "ImagingUtils.h" extern UINT8 *clip8_lookups; diff --git a/src/libImaging/QuantHash.h b/src/libImaging/QuantHash.h index 0462cfd49..d75d55ce0 100644 --- a/src/libImaging/QuantHash.h +++ b/src/libImaging/QuantHash.h @@ -20,8 +20,12 @@ typedef uint32_t HashVal_t; typedef uint32_t (*HashFunc)(const HashTable *, const HashKey_t); typedef int (*HashCmpFunc)(const HashTable *, const HashKey_t, const HashKey_t); -typedef void (*IteratorFunc)(const HashTable *, const HashKey_t, const HashVal_t, void *); -typedef void (*IteratorUpdateFunc)(const HashTable *, const HashKey_t, HashVal_t *, void *); +typedef void (*IteratorFunc)( + const HashTable *, const HashKey_t, const HashVal_t, void * +); +typedef void (*IteratorUpdateFunc)( + const HashTable *, const HashKey_t, HashVal_t *, void * +); typedef void (*ComputeFunc)(const HashTable *, const HashKey_t, HashVal_t *); typedef void (*CollisionFunc)( const HashTable *, HashKey_t *, HashVal_t *, HashKey_t, HashVal_t diff --git a/src/libImaging/Storage.c b/src/libImaging/Storage.c index 522e9f375..4fa4ecd1c 100644 --- a/src/libImaging/Storage.c +++ b/src/libImaging/Storage.c @@ -58,19 +58,22 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { /* Setup image descriptor */ im->xsize = xsize; im->ysize = ysize; - + im->refcount = 1; im->type = IMAGING_TYPE_UINT8; + strcpy(im->arrow_band_format, "C"); if (strcmp(mode, "1") == 0) { /* 1-bit images */ im->bands = im->pixelsize = 1; im->linesize = xsize; + strcpy(im->band_names[0], "1"); } else if (strcmp(mode, "P") == 0) { /* 8-bit palette mapped images */ im->bands = im->pixelsize = 1; im->linesize = xsize; im->palette = ImagingPaletteNew("RGB"); + strcpy(im->band_names[0], "P"); } else if (strcmp(mode, "PA") == 0) { /* 8-bit palette with alpha */ @@ -78,23 +81,36 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->pixelsize = 4; /* store in image32 memory */ im->linesize = xsize * 4; im->palette = ImagingPaletteNew("RGB"); + strcpy(im->band_names[0], "P"); + strcpy(im->band_names[1], "X"); + strcpy(im->band_names[2], "X"); + strcpy(im->band_names[3], "A"); } else if (strcmp(mode, "L") == 0) { /* 8-bit grayscale (luminance) images */ im->bands = im->pixelsize = 1; im->linesize = xsize; + strcpy(im->band_names[0], "L"); } else if (strcmp(mode, "LA") == 0) { /* 8-bit grayscale (luminance) with alpha */ im->bands = 2; im->pixelsize = 4; /* store in image32 memory */ im->linesize = xsize * 4; + strcpy(im->band_names[0], "L"); + strcpy(im->band_names[1], "X"); + strcpy(im->band_names[2], "X"); + strcpy(im->band_names[3], "A"); } else if (strcmp(mode, "La") == 0) { /* 8-bit grayscale (luminance) with premultiplied alpha */ im->bands = 2; im->pixelsize = 4; /* store in image32 memory */ im->linesize = xsize * 4; + strcpy(im->band_names[0], "L"); + strcpy(im->band_names[1], "X"); + strcpy(im->band_names[2], "X"); + strcpy(im->band_names[3], "a"); } else if (strcmp(mode, "F") == 0) { /* 32-bit floating point images */ @@ -102,6 +118,8 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->pixelsize = 4; im->linesize = xsize * 4; im->type = IMAGING_TYPE_FLOAT32; + strcpy(im->arrow_band_format, "f"); + strcpy(im->band_names[0], "F"); } else if (strcmp(mode, "I") == 0) { /* 32-bit integer images */ @@ -109,6 +127,8 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->pixelsize = 4; im->linesize = xsize * 4; im->type = IMAGING_TYPE_INT32; + strcpy(im->arrow_band_format, "i"); + strcpy(im->band_names[0], "I"); } else if (strcmp(mode, "I;16") == 0 || strcmp(mode, "I;16L") == 0 || strcmp(mode, "I;16B") == 0 || strcmp(mode, "I;16N") == 0) { @@ -118,12 +138,18 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->pixelsize = 2; im->linesize = xsize * 2; im->type = IMAGING_TYPE_SPECIAL; + strcpy(im->arrow_band_format, "s"); + strcpy(im->band_names[0], "I"); } else if (strcmp(mode, "RGB") == 0) { /* 24-bit true colour images */ im->bands = 3; im->pixelsize = 4; im->linesize = xsize * 4; + strcpy(im->band_names[0], "R"); + strcpy(im->band_names[1], "G"); + strcpy(im->band_names[2], "B"); + strcpy(im->band_names[3], "X"); } else if (strcmp(mode, "BGR;15") == 0) { /* EXPERIMENTAL */ @@ -132,6 +158,8 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->pixelsize = 2; im->linesize = (xsize * 2 + 3) & -4; im->type = IMAGING_TYPE_SPECIAL; + /* not allowing arrow due to line length packing */ + strcpy(im->arrow_band_format, ""); } else if (strcmp(mode, "BGR;16") == 0) { /* EXPERIMENTAL */ @@ -140,6 +168,8 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->pixelsize = 2; im->linesize = (xsize * 2 + 3) & -4; im->type = IMAGING_TYPE_SPECIAL; + /* not allowing arrow due to line length packing */ + strcpy(im->arrow_band_format, ""); } else if (strcmp(mode, "BGR;24") == 0) { /* EXPERIMENTAL */ @@ -148,32 +178,54 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->pixelsize = 3; im->linesize = (xsize * 3 + 3) & -4; im->type = IMAGING_TYPE_SPECIAL; + /* not allowing arrow due to line length packing */ + strcpy(im->arrow_band_format, ""); } else if (strcmp(mode, "RGBX") == 0) { /* 32-bit true colour images with padding */ im->bands = im->pixelsize = 4; im->linesize = xsize * 4; + strcpy(im->band_names[0], "R"); + strcpy(im->band_names[1], "G"); + strcpy(im->band_names[2], "B"); + strcpy(im->band_names[3], "X"); } else if (strcmp(mode, "RGBA") == 0) { /* 32-bit true colour images with alpha */ im->bands = im->pixelsize = 4; im->linesize = xsize * 4; + strcpy(im->band_names[0], "R"); + strcpy(im->band_names[1], "G"); + strcpy(im->band_names[2], "B"); + strcpy(im->band_names[3], "A"); } else if (strcmp(mode, "RGBa") == 0) { /* 32-bit true colour images with premultiplied alpha */ im->bands = im->pixelsize = 4; im->linesize = xsize * 4; + strcpy(im->band_names[0], "R"); + strcpy(im->band_names[1], "G"); + strcpy(im->band_names[2], "B"); + strcpy(im->band_names[3], "a"); } else if (strcmp(mode, "CMYK") == 0) { /* 32-bit colour separation */ im->bands = im->pixelsize = 4; im->linesize = xsize * 4; + strcpy(im->band_names[0], "C"); + strcpy(im->band_names[1], "M"); + strcpy(im->band_names[2], "Y"); + strcpy(im->band_names[3], "K"); } else if (strcmp(mode, "YCbCr") == 0) { /* 24-bit video format */ im->bands = 3; im->pixelsize = 4; im->linesize = xsize * 4; + strcpy(im->band_names[0], "Y"); + strcpy(im->band_names[1], "Cb"); + strcpy(im->band_names[2], "Cr"); + strcpy(im->band_names[3], "X"); } else if (strcmp(mode, "LAB") == 0) { /* 24-bit color, luminance, + 2 color channels */ @@ -181,6 +233,10 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->bands = 3; im->pixelsize = 4; im->linesize = xsize * 4; + strcpy(im->band_names[0], "L"); + strcpy(im->band_names[1], "a"); + strcpy(im->band_names[2], "b"); + strcpy(im->band_names[3], "X"); } else if (strcmp(mode, "HSV") == 0) { /* 24-bit color, luminance, + 2 color channels */ @@ -188,6 +244,10 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { im->bands = 3; im->pixelsize = 4; im->linesize = xsize * 4; + strcpy(im->band_names[0], "H"); + strcpy(im->band_names[1], "S"); + strcpy(im->band_names[2], "V"); + strcpy(im->band_names[3], "X"); } else { free(im); @@ -218,6 +278,7 @@ ImagingNewPrologueSubtype(const char *mode, int xsize, int ysize, int size) { break; } + // UNDONE -- not accurate for arrow MUTEX_LOCK(&ImagingDefaultArena.mutex); ImagingDefaultArena.stats_new_count += 1; MUTEX_UNLOCK(&ImagingDefaultArena.mutex); @@ -238,8 +299,18 @@ ImagingDelete(Imaging im) { return; } + MUTEX_LOCK(&im->mutex); + im->refcount--; + + if (im->refcount > 0) { + MUTEX_UNLOCK(&im->mutex); + return; + } + MUTEX_UNLOCK(&im->mutex); + if (im->palette) { ImagingPaletteDelete(im->palette); + im->palette = NULL; } if (im->destroy) { @@ -270,6 +341,7 @@ struct ImagingMemoryArena ImagingDefaultArena = { 0, 0, 0, // Stats + 0, // use_block_allocator #ifdef Py_GIL_DISABLED {0}, #endif @@ -302,6 +374,11 @@ ImagingMemorySetBlocksMax(ImagingMemoryArena arena, int blocks_max) { return 1; } +void +ImagingMemorySetBlockAllocator(ImagingMemoryArena arena, int use_block_allocator) { + arena->use_block_allocator = use_block_allocator; +} + void ImagingMemoryClearCache(ImagingMemoryArena arena, int new_size) { while (arena->blocks_cached > new_size) { @@ -396,11 +473,13 @@ ImagingAllocateArray(Imaging im, ImagingMemoryArena arena, int dirty, int block_ if (lines_per_block == 0) { lines_per_block = 1; } + im->lines_per_block = lines_per_block; blocks_count = (im->ysize + lines_per_block - 1) / lines_per_block; // printf("NEW size: %dx%d, ls: %d, lpb: %d, blocks: %d\n", // im->xsize, im->ysize, aligned_linesize, lines_per_block, blocks_count); /* One extra pointer is always NULL */ + im->blocks_count = blocks_count; im->blocks = calloc(sizeof(*im->blocks), blocks_count + 1); if (!im->blocks) { return (Imaging)ImagingError_MemoryError(); @@ -487,6 +566,58 @@ ImagingAllocateBlock(Imaging im) { return im; } +/* Borrowed Arrow Storage Type */ +/* --------------------------- */ +/* Don't allocate the image. */ + +static void +ImagingDestroyArrow(Imaging im) { + // Rely on the internal Python destructor for the array capsule. + if (im->arrow_array_capsule) { + Py_DECREF(im->arrow_array_capsule); + im->arrow_array_capsule = NULL; + } +} + +Imaging +ImagingBorrowArrow( + Imaging im, + struct ArrowArray *external_array, + int offset_width, + PyObject *arrow_capsule +) { + // offset_width is the number of char* for a single offset from arrow + Py_ssize_t y, i; + + char *borrowed_buffer = NULL; + struct ArrowArray *arr = external_array; + + if (arr->n_children == 1) { + arr = arr->children[0]; + } + if (arr->n_buffers == 2) { + // buffer 0 is the null list + // buffer 1 is the data + borrowed_buffer = (char *)arr->buffers[1] + (offset_width * arr->offset); + } + + if (!borrowed_buffer) { + return (Imaging + )ImagingError_ValueError("Arrow Array, exactly 2 buffers required"); + } + + for (y = i = 0; y < im->ysize; y++) { + im->image[y] = borrowed_buffer + i; + i += im->linesize; + } + im->read_only = 1; + Py_INCREF(arrow_capsule); + im->arrow_array_capsule = arrow_capsule; + im->destroy = ImagingDestroyArrow; + + return im; +} + /* -------------------------------------------------------------------- * Create a new, internally allocated, image. */ @@ -529,11 +660,17 @@ ImagingNewInternal(const char *mode, int xsize, int ysize, int dirty) { Imaging ImagingNew(const char *mode, int xsize, int ysize) { + if (ImagingDefaultArena.use_block_allocator) { + return ImagingNewBlock(mode, xsize, ysize); + } return ImagingNewInternal(mode, xsize, ysize, 0); } Imaging ImagingNewDirty(const char *mode, int xsize, int ysize) { + if (ImagingDefaultArena.use_block_allocator) { + return ImagingNewBlock(mode, xsize, ysize); + } return ImagingNewInternal(mode, xsize, ysize, 1); } @@ -558,6 +695,66 @@ ImagingNewBlock(const char *mode, int xsize, int ysize) { return NULL; } +Imaging +ImagingNewArrow( + const char *mode, + int xsize, + int ysize, + PyObject *schema_capsule, + PyObject *array_capsule +) { + /* A borrowed arrow array */ + Imaging im; + struct ArrowSchema *schema = + (struct ArrowSchema *)PyCapsule_GetPointer(schema_capsule, "arrow_schema"); + + struct ArrowArray *external_array = + (struct ArrowArray *)PyCapsule_GetPointer(array_capsule, "arrow_array"); + + if (xsize < 0 || ysize < 0) { + return (Imaging)ImagingError_ValueError("bad image size"); + } + + im = ImagingNewPrologue(mode, xsize, ysize); + if (!im) { + return NULL; + } + + int64_t pixels = (int64_t)xsize * (int64_t)ysize; + + // fmt:off // don't reformat this + if (((strcmp(schema->format, "I") == 0 // int32 + && im->pixelsize == 4 // 4xchar* storage + && im->bands >= 2) // INT32 into any INT32 Storage mode + || // (()||()) && + (strcmp(schema->format, im->arrow_band_format) == 0 // same mode + && im->bands == 1)) // Single band match + && pixels == external_array->length) { + // one arrow element per, and it matches a pixelsize*char + if (ImagingBorrowArrow(im, external_array, im->pixelsize, array_capsule)) { + return im; + } + } + if (strcmp(schema->format, "+w:4") == 0 // 4 up array + && im->pixelsize == 4 // storage as 32 bpc + && schema->n_children > 0 // make sure schema is well formed. + && schema->children // make sure schema is well formed + && strcmp(schema->children[0]->format, "C") == 0 // Expected format + && strcmp(im->arrow_band_format, "C") == 0 // Expected Format + && pixels == external_array->length // expected length + && external_array->n_children == 1 // array is well formed + && external_array->children // array is well formed + && 4 * pixels == external_array->children[0]->length) { + // 4 up element of char into pixelsize == 4 + if (ImagingBorrowArrow(im, external_array, 1, array_capsule)) { + return im; + } + } + // fmt: on + ImagingDelete(im); + return NULL; +} + Imaging ImagingNew2Dirty(const char *mode, Imaging imOut, Imaging imIn) { /* allocate or validate output image */ diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index e4da9162d..9a2db95b4 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -299,6 +299,7 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) { return -1; } + img.orientation = ORIENTATION_TOPLEFT; img.req_orientation = ORIENTATION_TOPLEFT; img.col_offset = 0; diff --git a/wheels/dependency_licenses/FREETYPE2.txt b/wheels/dependency_licenses/FREETYPE2.txt index 93efc6126..8f2345992 100644 --- a/wheels/dependency_licenses/FREETYPE2.txt +++ b/wheels/dependency_licenses/FREETYPE2.txt @@ -211,351 +211,6 @@ Legal Terms --- 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`: ``` diff --git a/winbuild/build.rst b/winbuild/build.rst index aae78ce12..3c20c7d17 100644 --- a/winbuild/build.rst +++ b/winbuild/build.rst @@ -61,6 +61,7 @@ Run ``build_prepare.py`` to configure the build:: --no-imagequant skip GPL-licensed optional dependency libimagequant --no-fribidi, --no-raqm skip LGPL-licensed optional dependency FriBiDi + --no-avif skip optional dependency libavif Arguments can also be supplied using the environment variables PILLOW_BUILD, PILLOW_DEPS, ARCHITECTURE. See winbuild\build.rst for more information. diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index ea3d99394..17fc37572 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -113,15 +113,16 @@ V = { "BROTLI": "1.1.0", "FREETYPE": "2.13.3", "FRIBIDI": "1.0.16", - "HARFBUZZ": "10.4.0", + "HARFBUZZ": "11.1.0", "JPEGTURBO": "3.1.0", "LCMS2": "2.17", + "LIBAVIF": "1.2.1", "LIBIMAGEQUANT": "4.3.4", "LIBPNG": "1.6.47", "LIBWEBP": "1.5.0", "OPENJPEG": "2.5.3", "TIFF": "4.7.0", - "XZ": "5.6.4", + "XZ": "5.8.1", "ZLIBNG": "2.2.4", } V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) @@ -180,7 +181,11 @@ DEPS: dict[str, dict[str, Any]] = { "filename": f"xz-{V['XZ']}.tar.gz", "license": "COPYING", "build": [ - *cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"), + *cmds_cmake( + "liblzma", + "-DBUILD_SHARED_LIBS:BOOL=OFF" + + (" -DXZ_CLMUL_CRC:BOOL=OFF" if struct.calcsize("P") == 4 else ""), + ), cmd_mkdir(r"{inc_dir}\lzma"), cmd_copy(r"src\liblzma\api\lzma\*.h", r"{inc_dir}\lzma"), ], @@ -234,6 +239,7 @@ DEPS: dict[str, dict[str, Any]] = { "-DBUILD_SHARED_LIBS:BOOL=OFF", "-DWebP_LIBRARY=libwebp", '-DCMAKE_C_FLAGS="-nologo -DLZMA_API_STATIC"', + "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", ) ], "headers": [r"libtiff\tiff*.h"], @@ -347,8 +353,8 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"..\target\release\imagequant_sys.lib"], }, "harfbuzz": { - "url": f"https://github.com/harfbuzz/harfbuzz/archive/{V['HARFBUZZ']}.zip", - "filename": f"harfbuzz-{V['HARFBUZZ']}.zip", + "url": f"https://github.com/harfbuzz/harfbuzz/releases/download/{V['HARFBUZZ']}/FILENAME", + "filename": f"harfbuzz-{V['HARFBUZZ']}.tar.xz", "license": "COPYING", "build": [ *cmds_cmake( @@ -378,6 +384,27 @@ DEPS: dict[str, dict[str, Any]] = { ], "bins": [r"*.dll"], }, + "libavif": { + "url": f"https://github.com/AOMediaCodec/libavif/archive/v{V['LIBAVIF']}.zip", + "filename": f"libavif-{V['LIBAVIF']}.zip", + "license": "LICENSE", + "build": [ + f"{sys.executable} -m pip install meson", + *cmds_cmake( + "avif_static", + "-DBUILD_SHARED_LIBS=OFF", + "-DAVIF_LIBSHARPYUV=LOCAL", + "-DAVIF_LIBYUV=LOCAL", + "-DAVIF_CODEC_AOM=LOCAL", + "-DAVIF_CODEC_DAV1D=LOCAL", + "-DAVIF_CODEC_RAV1E=LOCAL", + "-DAVIF_CODEC_SVT=LOCAL", + "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", + ), + cmd_xcopy("include", "{inc_dir}"), + ], + "libs": ["avif.lib"], + }, } @@ -491,8 +518,8 @@ def extract_dep(url: str, filename: str, prefs: dict[str, str]) -> None: msg = "Attempted Path Traversal in Zip File" raise RuntimeError(msg) zf.extractall(sources_dir) - elif filename.endswith((".tar.gz", ".tgz")): - with tarfile.open(file, "r:gz") as tgz: + elif filename.endswith((".tar.gz", ".tar.xz")): + with tarfile.open(file, "r:xz" if filename.endswith(".xz") else "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]) @@ -683,6 +710,11 @@ def main() -> None: action="store_true", help="skip LGPL-licensed optional dependency FriBiDi", ) + parser.add_argument( + "--no-avif", + action="store_true", + help="skip optional dependency libavif", + ) args = parser.parse_args() arch_prefs = ARCHITECTURES[args.architecture] @@ -723,6 +755,8 @@ def main() -> None: disabled += ["libimagequant"] if args.no_fribidi: disabled += ["fribidi"] + if args.no_avif or args.architecture != "AMD64": + disabled += ["libavif"] prefs = { "architecture": args.architecture, @@ -746,7 +780,7 @@ def main() -> None: for k, v in DEPS.items(): if "dir" not in v: - v["dir"] = re.sub(r"\.(tar\.gz|zip)", "", v["filename"]) + v["dir"] = re.sub(r"\.(tar\.gz|tar\.xz|zip)", "", v["filename"]) prefs[f"dir_{k}"] = os.path.join(sources_dir, v["dir"]) print()