diff --git a/.ci/install.sh b/.ci/install.sh
index 622a36dab..ba32eab04 100755
--- a/.ci/install.sh
+++ b/.ci/install.sh
@@ -20,10 +20,10 @@ fi
set -e
if [[ $(uname) != CYGWIN* ]]; then
- sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\
+ 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
@@ -65,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 833aca23d..db5f89c9a 100644
--- a/.ci/requirements-cibw.txt
+++ b/.ci/requirements-cibw.txt
@@ -1 +1 @@
-cibuildwheel==2.22.0
+cibuildwheel==2.23.2
diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt
index 10e59b885..2e3610478 100644
--- a/.ci/requirements-mypy.txt
+++ b/.ci/requirements-mypy.txt
@@ -1,4 +1,4 @@
-mypy==1.14.1
+mypy==1.15.0
IceSpringPySideStubs-PyQt6
IceSpringPySideStubs-PySide6
ipython
diff --git a/.github/renovate.json b/.github/renovate.json
index f48b670ec..91fa0426f 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -16,6 +16,6 @@
}
],
"schedule": [
- "on the 3rd day of the month"
+ "* * 3 * *"
]
}
diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh
index 5ba2aaba3..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"
@@ -30,5 +34,8 @@ python3 -m pip install numpy
# 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 da5e191da..25aef55fb 100644
--- a/.github/workflows/test-docker.yml
+++ b/.github/workflows/test-docker.yml
@@ -35,6 +35,10 @@ jobs:
matrix:
os: ["ubuntu-latest"]
docker: [
+ # Run slower jobs first to give them a headstart and reduce waiting time
+ ubuntu-24.04-noble-ppc64le,
+ ubuntu-24.04-noble-s390x,
+ # Then run the remainder
alpine,
amazon-2-amd64,
amazon-2023-amd64,
@@ -52,13 +56,9 @@ jobs:
dockerTag: [main]
include:
- docker: "ubuntu-24.04-noble-ppc64le"
- os: "ubuntu-22.04"
qemu-arch: "ppc64le"
- dockerTag: main
- docker: "ubuntu-24.04-noble-s390x"
- os: "ubuntu-22.04"
qemu-arch: "s390x"
- dockerTag: main
- docker: "ubuntu-24.04-noble-arm64v8"
os: "ubuntu-24.04-arm"
dockerTag: main
@@ -75,8 +75,9 @@ jobs:
- name: Set up QEMU
if: "matrix.qemu-arch"
- run: |
- docker run --rm --privileged aptman/qus -s -- -p ${{ matrix.qemu-arch }}
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: ${{ matrix.qemu-arch }}
- name: Docker pull
run: |
diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml
index 045926482..5a83c16c3 100644
--- a/.github/workflows/test-mingw.yml
+++ b/.github/workflows/test-mingw.yml
@@ -60,6 +60,8 @@ 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 \
mingw-w64-x86_64-libtiff \
diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml
index 0f4ef8257..bf8ec2f2c 100644
--- a/.github/workflows/test-windows.yml
+++ b/.github/workflows/test-windows.yml
@@ -41,7 +41,8 @@ jobs:
include:
# 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 }})
@@ -97,8 +98,8 @@ jobs:
choco install nasm --no-progress
echo "C:\Program Files\NASM" >> $env:GITHUB_PATH
- choco install ghostscript --version=10.4.0 --no-progress
- echo "C:\Program Files\gs\gs10.04.0\bin" >> $env:GITHUB_PATH
+ choco install ghostscript --version=10.5.0 --no-progress
+ echo "C:\Program Files\gs\gs10.05.0\bin" >> $env:GITHUB_PATH
# Install extra test images
xcopy /S /Y Tests\test-images\* Tests\images
@@ -148,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 f0c96d160..2f2e75b6c 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,18 +38,34 @@ ARCHIVE_SDIR=pillow-depends-main
# Package versions for fresh source builds
FREETYPE_VERSION=2.13.3
-HARFBUZZ_VERSION=10.2.0
-LIBPNG_VERSION=1.6.46
+HARFBUZZ_VERSION=11.0.0
+LIBPNG_VERSION=1.6.47
JPEGTURBO_VERSION=3.1.0
OPENJPEG_VERSION=2.5.3
-XZ_VERSION=5.6.4
-TIFF_VERSION=4.6.0
-LCMS2_VERSION=2.16
+XZ_VERSION=5.8.0
+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
LIBXCB_VERSION=1.17.0
BROTLI_VERSION=1.1.0
+LIBAVIF_VERSION=1.2.1
+
+if [[ $MB_ML_VER == 2014 ]]; then
+ function build_xz {
+ if [ -e xz-stamp ]; then return; fi
+ yum install -y gettext-devel
+ fetch_unpack https://tukaani.org/xz/xz-$XZ_VERSION.tar.gz
+ (cd xz-$XZ_VERSION \
+ && ./autogen.sh --no-po4a \
+ && ./configure --prefix=$BUILD_PREFIX \
+ && make -j4 \
+ && make install)
+ touch xz-stamp
+ }
+fi
function build_pkg_config {
if [ -e pkg-config-stamp ]; then return; fi
@@ -101,12 +117,55 @@ function build_harfbuzz {
touch harfbuzz-stamp
}
+function build_libavif {
+ if [ -e libavif-stamp ]; then return; fi
+
+ python3 -m pip install meson ninja
+
+ if [[ "$PLAT" == "x86_64" ]] || [ -n "$SANITIZER" ]; then
+ build_simple nasm 2.16.03 https://www.nasm.us/pub/nasm/releasebuilds/2.16.03
+ fi
+
+ # For rav1e
+ curl https://sh.rustup.rs -sSf | sh -s -- -y
+ . "$HOME/.cargo/env"
+ if [ -z "$IS_ALPINE" ] && [ -z "$SANITIZER" ] && [ -z "$IS_MACOS" ]; then
+ yum install -y perl
+ if [[ "$MB_ML_VER" == 2014 ]]; then
+ yum install -y perl-IPC-Cmd
+ fi
+ fi
+
+ local out_dir=$(fetch_unpack https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$LIBAVIF_VERSION.tar.gz libavif-$LIBAVIF_VERSION.tar.gz)
+ (cd $out_dir \
+ && CMAKE_POLICY_VERSION_MINIMUM=3.5 cmake \
+ -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX \
+ -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib \
+ -DCMAKE_BUILD_TYPE=Release \
+ -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 \
+ -DENABLE_NASM=ON \
+ -DCMAKE_MODULE_PATH=/tmp/cmake/Modules \
+ . \
+ && make install)
+ touch libavif-stamp
+}
+
function build {
build_xz
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
@@ -131,6 +190,7 @@ function build {
build_tiff
fi
+ build_libavif
build_libpng
build_lcms2
build_openjpeg
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index db8e4d58b..2a8594f49 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -63,7 +63,7 @@ jobs:
- name: "macOS 10.15 x86_64"
os: macos-13
cibw_arch: x86_64
- build: "pp310*"
+ build: "pp3*"
macosx_deployment_target: "10.15"
- name: "macOS arm64"
os: macos-latest
@@ -160,6 +160,11 @@ jobs:
& python.exe winbuild\build_prepare.py -v --no-imagequant --architecture=${{ matrix.cibw_arch }}
shell: pwsh
+ - name: Update rust
+ if: matrix.cibw_arch == 'AMD64'
+ run: |
+ rustup update
+
- name: Build wheels
run: |
setlocal EnableDelayedExpansion
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a8c8cee15..5ff947d41 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.4
+ rev: v0.9.9
hooks:
- id: ruff
args: [--exit-non-zero-on-fix]
@@ -11,7 +11,7 @@ repos:
- id: black
- repo: https://github.com/PyCQA/bandit
- rev: 1.8.2
+ rev: 1.8.3
hooks:
- id: bandit
args: [--severity-level=high]
@@ -50,14 +50,14 @@ repos:
exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/
- repo: https://github.com/python-jsonschema/check-jsonschema
- rev: 0.31.1
+ rev: 0.31.2
hooks:
- id: check-github-workflows
- id: check-readthedocs
- id: check-renovate
- repo: https://github.com/woodruffw/zizmor-pre-commit
- rev: v1.3.0
+ rev: v1.4.1
hooks:
- id: zizmor
@@ -67,7 +67,7 @@ repos:
- id: sphinx-lint
- repo: https://github.com/tox-dev/pyproject-fmt
- rev: v2.5.0
+ rev: v2.5.1
hooks:
- id: pyproject-fmt
diff --git a/Tests/check_j2k_overflow.py b/Tests/check_j2k_overflow.py
index dbdd5a4f5..58566c4b2 100644
--- a/Tests/check_j2k_overflow.py
+++ b/Tests/check_j2k_overflow.py
@@ -9,6 +9,6 @@ from PIL import Image
def test_j2k_overflow(tmp_path: Path) -> None:
im = Image.new("RGBA", (1024, 131584))
- target = str(tmp_path / "temp.jpc")
+ target = tmp_path / "temp.jpc"
with pytest.raises(OSError):
im.save(target)
diff --git a/Tests/check_large_memory.py b/Tests/check_large_memory.py
index a9ce79e57..c9feda3b1 100644
--- a/Tests/check_large_memory.py
+++ b/Tests/check_large_memory.py
@@ -32,7 +32,7 @@ pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit sy
def _write_png(tmp_path: Path, xdim: int, ydim: int) -> None:
- f = str(tmp_path / "temp.png")
+ f = tmp_path / "temp.png"
im = Image.new("L", (xdim, ydim), 0)
im.save(f)
diff --git a/Tests/check_large_memory_numpy.py b/Tests/check_large_memory_numpy.py
index f4ca8d0aa..458b0ab72 100644
--- a/Tests/check_large_memory_numpy.py
+++ b/Tests/check_large_memory_numpy.py
@@ -28,7 +28,7 @@ pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit sy
def _write_png(tmp_path: Path, xdim: int, ydim: int) -> None:
dtype = np.uint8
a = np.zeros((xdim, ydim), dtype=dtype)
- f = str(tmp_path / "temp.png")
+ f = tmp_path / "temp.png"
im = Image.fromarray(a, "L")
im.save(f)
diff --git a/Tests/check_wheel.py b/Tests/check_wheel.py
index 563be0b74..582fc92c2 100644
--- a/Tests/check_wheel.py
+++ b/Tests/check_wheel.py
@@ -1,12 +1,16 @@
from __future__ import annotations
+import platform
+import struct
import sys
from PIL import features
+from .helper import is_pypy
+
def test_wheel_modules() -> None:
- expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"}
+ expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp", "avif"}
# tkinter is not available in cibuildwheel installed CPython on Windows
try:
@@ -16,6 +20,11 @@ def test_wheel_modules() -> None:
except ImportError:
expected_modules.remove("tkinter")
+ # libavif is not available on Windows for x86 and ARM64 architectures
+ if sys.platform == "win32":
+ if platform.machine() == "ARM64" or struct.calcsize("P") == 4:
+ expected_modules.remove("avif")
+
assert set(features.get_supported_modules()) == expected_modules
@@ -40,5 +49,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/helper.py b/Tests/helper.py
index 764935f87..909fff879 100644
--- a/Tests/helper.py
+++ b/Tests/helper.py
@@ -13,6 +13,7 @@ import tempfile
from collections.abc import Sequence
from functools import lru_cache
from io import BytesIO
+from pathlib import Path
from typing import Any, Callable
import pytest
@@ -95,7 +96,10 @@ def assert_image_equal(a: Image.Image, b: Image.Image, msg: str | None = None) -
def assert_image_equal_tofile(
- a: Image.Image, filename: str, msg: str | None = None, mode: str | None = None
+ a: Image.Image,
+ filename: str | Path,
+ msg: str | None = None,
+ mode: str | None = None,
) -> None:
with Image.open(filename) as img:
if mode:
@@ -136,7 +140,7 @@ def assert_image_similar(
def assert_image_similar_tofile(
a: Image.Image,
- filename: str,
+ filename: str | Path,
epsilon: float,
msg: str | None = None,
) -> None:
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_file_apng.py b/Tests/test_file_apng.py
index 9d5154fca..a5734c202 100644
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -12,6 +12,7 @@ from PIL import Image, ImageSequence, PngImagePlugin
# (referenced from https://wiki.mozilla.org/APNG_Specification)
def test_apng_basic() -> None:
with Image.open("Tests/images/apng/single_frame.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
assert im.n_frames == 1
assert im.get_format_mimetype() == "image/apng"
@@ -20,6 +21,7 @@ def test_apng_basic() -> None:
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/single_frame_default.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.is_animated
assert im.n_frames == 2
assert im.get_format_mimetype() == "image/apng"
@@ -34,8 +36,11 @@ def test_apng_basic() -> None:
with pytest.raises(EOFError):
im.seek(2)
- # test rewind support
im.seek(0)
+ with pytest.raises(ValueError, match="cannot seek to frame 2"):
+ im._seek(2)
+
+ # test rewind support
assert im.getpixel((0, 0)) == (255, 0, 0, 255)
assert im.getpixel((64, 32)) == (255, 0, 0, 255)
im.seek(1)
@@ -49,6 +54,7 @@ def test_apng_basic() -> None:
)
def test_apng_fdat(filename: str) -> None:
with Image.open(filename) as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
@@ -56,31 +62,37 @@ def test_apng_fdat(filename: str) -> None:
def test_apng_dispose() -> None:
with Image.open("Tests/images/apng/dispose_op_none.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_background.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 0, 0)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
with Image.open("Tests/images/apng/dispose_op_background_final.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_previous.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_previous_final.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_previous_first.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 0, 0)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
@@ -88,21 +100,25 @@ def test_apng_dispose() -> None:
def test_apng_dispose_region() -> None:
with Image.open("Tests/images/apng/dispose_op_none_region.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_background_before_region.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 0, 0)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
with Image.open("Tests/images/apng/dispose_op_background_region.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 255, 255)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
with Image.open("Tests/images/apng/dispose_op_previous_region.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
@@ -129,6 +145,7 @@ def test_apng_dispose_op_previous_frame() -> None:
# ],
# )
with Image.open("Tests/images/apng/dispose_op_previous_frame.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (255, 0, 0, 255)
@@ -142,26 +159,31 @@ def test_apng_dispose_op_background_p_mode() -> None:
def test_apng_blend() -> None:
with Image.open("Tests/images/apng/blend_op_source_solid.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/blend_op_source_transparent.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 0, 0)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
with Image.open("Tests/images/apng/blend_op_source_near_transparent.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 2)
assert im.getpixel((64, 32)) == (0, 255, 0, 2)
with Image.open("Tests/images/apng/blend_op_over.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/blend_op_over_near_transparent.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 97)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
@@ -175,6 +197,7 @@ def test_apng_blend_transparency() -> None:
def test_apng_chunk_order() -> None:
with Image.open("Tests/images/apng/fctl_actl.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
@@ -230,24 +253,28 @@ def test_apng_num_plays() -> None:
def test_apng_mode() -> None:
with Image.open("Tests/images/apng/mode_16bit.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "RGBA"
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 128, 191)
assert im.getpixel((64, 32)) == (0, 0, 128, 191)
with Image.open("Tests/images/apng/mode_grayscale.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "L"
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == 128
assert im.getpixel((64, 32)) == 255
with Image.open("Tests/images/apng/mode_grayscale_alpha.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "LA"
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (128, 191)
assert im.getpixel((64, 32)) == (128, 191)
with Image.open("Tests/images/apng/mode_palette.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "P"
im.seek(im.n_frames - 1)
im = im.convert("RGB")
@@ -255,6 +282,7 @@ def test_apng_mode() -> None:
assert im.getpixel((64, 32)) == (0, 255, 0)
with Image.open("Tests/images/apng/mode_palette_alpha.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "P"
im.seek(im.n_frames - 1)
im = im.convert("RGBA")
@@ -262,6 +290,7 @@ def test_apng_mode() -> None:
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "P"
im.seek(im.n_frames - 1)
im = im.convert("RGBA")
@@ -271,25 +300,31 @@ def test_apng_mode() -> None:
def test_apng_chunk_errors() -> None:
with Image.open("Tests/images/apng/chunk_no_actl.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/chunk_multi_actl.png") as im:
im.load()
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
with Image.open("Tests/images/apng/chunk_actl_after_idat.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
with Image.open("Tests/images/apng/chunk_no_fctl.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
with pytest.raises(SyntaxError):
im.seek(im.n_frames - 1)
with Image.open("Tests/images/apng/chunk_repeat_fctl.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
with pytest.raises(SyntaxError):
im.seek(im.n_frames - 1)
with Image.open("Tests/images/apng/chunk_no_fdat.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
with pytest.raises(SyntaxError):
im.seek(im.n_frames - 1)
@@ -297,26 +332,31 @@ def test_apng_chunk_errors() -> None:
def test_apng_syntax_errors() -> None:
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
with pytest.raises(OSError):
im.load()
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
im.load()
# we can handle this case gracefully
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()
@@ -336,16 +376,18 @@ def test_apng_syntax_errors() -> None:
def test_apng_sequence_errors(test_file: str) -> None:
with pytest.raises(SyntaxError):
with Image.open(f"Tests/images/apng/{test_file}") as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
im.load()
def test_apng_save(tmp_path: Path) -> None:
with Image.open("Tests/images/apng/single_frame.png") as im:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im.save(test_file, save_all=True)
with Image.open(test_file) as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.load()
assert not im.is_animated
assert im.n_frames == 1
@@ -361,6 +403,7 @@ def test_apng_save(tmp_path: Path) -> None:
)
with Image.open(test_file) as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
im.load()
assert im.is_animated
assert im.n_frames == 2
@@ -372,7 +415,7 @@ def test_apng_save(tmp_path: Path) -> None:
def test_apng_save_alpha(tmp_path: Path) -> None:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im = Image.new("RGBA", (1, 1), (255, 0, 0, 255))
im2 = Image.new("RGBA", (1, 1), (255, 0, 0, 127))
@@ -390,7 +433,7 @@ def test_apng_save_split_fdat(tmp_path: Path) -> None:
# frames with image data spanning multiple fdAT chunks (in this case
# both the default image and first animation frame will span multiple
# data chunks)
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
with Image.open("Tests/images/old-style-jpeg-compression.png") as im:
frames = [im.copy(), Image.new("RGBA", im.size, (255, 0, 0, 255))]
im.save(
@@ -400,12 +443,13 @@ 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()
def test_apng_save_duration_loop(tmp_path: Path) -> None:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
with Image.open("Tests/images/apng/delay.png") as im:
frames = []
durations = []
@@ -442,6 +486,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None:
test_file, save_all=True, append_images=[frame, frame], duration=[500, 100, 150]
)
with Image.open(test_file) as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.n_frames == 1
assert "duration" not in im.info
@@ -453,6 +498,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None:
duration=[500, 100, 150],
)
with Image.open(test_file) as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.n_frames == 2
assert im.info["duration"] == 600
@@ -463,12 +509,13 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None:
frame.info["duration"] = 300
frame.save(test_file, save_all=True, append_images=[frame, different_frame])
with Image.open(test_file) as im:
+ assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.n_frames == 2
assert im.info["duration"] == 600
def test_apng_save_disposal(tmp_path: Path) -> None:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
size = (128, 64)
red = Image.new("RGBA", size, (255, 0, 0, 255))
green = Image.new("RGBA", size, (0, 255, 0, 255))
@@ -569,7 +616,7 @@ def test_apng_save_disposal(tmp_path: Path) -> None:
def test_apng_save_disposal_previous(tmp_path: Path) -> None:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
size = (128, 64)
blue = Image.new("RGBA", size, (0, 0, 255, 255))
red = Image.new("RGBA", size, (255, 0, 0, 255))
@@ -591,7 +638,7 @@ def test_apng_save_disposal_previous(tmp_path: Path) -> None:
def test_apng_save_blend(tmp_path: Path) -> None:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
size = (128, 64)
red = Image.new("RGBA", size, (255, 0, 0, 255))
green = Image.new("RGBA", size, (0, 255, 0, 255))
@@ -659,7 +706,7 @@ def test_apng_save_blend(tmp_path: Path) -> None:
def test_apng_save_size(tmp_path: Path) -> None:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im = Image.new("L", (100, 100))
im.save(test_file, save_all=True, append_images=[Image.new("L", (200, 200))])
@@ -683,7 +730,7 @@ def test_seek_after_close() -> None:
def test_different_modes_in_later_frames(
mode: str, default_image: bool, duplicate: bool, tmp_path: Path
) -> None:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im = Image.new("L", (1, 1))
im.save(
@@ -697,7 +744,7 @@ def test_different_modes_in_later_frames(
def test_different_durations(tmp_path: Path) -> None:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
with Image.open("Tests/images/apng/different_durations.png") as im:
for _ in range(3):
diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py
new file mode 100644
index 000000000..392a4bbd5
--- /dev/null
+++ b/Tests/test_file_avif.py
@@ -0,0 +1,778 @@
+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])
+ 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.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_blp.py b/Tests/test_file_blp.py
index 9f2de8f98..9f50df22d 100644
--- a/Tests/test_file_blp.py
+++ b/Tests/test_file_blp.py
@@ -46,7 +46,7 @@ def test_invalid_file() -> None:
def test_save(tmp_path: Path) -> None:
- f = str(tmp_path / "temp.blp")
+ f = tmp_path / "temp.blp"
for version in ("BLP1", "BLP2"):
im = hopper("P")
@@ -56,7 +56,7 @@ def test_save(tmp_path: Path) -> None:
assert_image_equal(im.convert("RGB"), reloaded)
with Image.open("Tests/images/transparent.png") as im:
- f = str(tmp_path / "temp.blp")
+ f = tmp_path / "temp.blp"
im.convert("P").save(f, blp_version=version)
with Image.open(f) as reloaded:
diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py
index 2ff4160bd..757650711 100644
--- a/Tests/test_file_bmp.py
+++ b/Tests/test_file_bmp.py
@@ -15,25 +15,19 @@ from .helper import (
)
-def test_sanity(tmp_path: Path) -> None:
- def roundtrip(im: Image.Image) -> None:
- outfile = str(tmp_path / "temp.bmp")
+@pytest.mark.parametrize("mode", ("1", "L", "P", "RGB"))
+def test_sanity(mode: str, tmp_path: Path) -> None:
+ outfile = tmp_path / "temp.bmp"
- im.save(outfile, "BMP")
+ im = hopper(mode)
+ im.save(outfile, "BMP")
- with Image.open(outfile) as reloaded:
- reloaded.load()
- assert im.mode == reloaded.mode
- assert im.size == reloaded.size
- assert reloaded.format == "BMP"
- assert reloaded.get_format_mimetype() == "image/bmp"
-
- roundtrip(hopper())
-
- roundtrip(hopper("1"))
- roundtrip(hopper("L"))
- roundtrip(hopper("P"))
- roundtrip(hopper("RGB"))
+ with Image.open(outfile) as reloaded:
+ reloaded.load()
+ assert im.mode == reloaded.mode
+ assert im.size == reloaded.size
+ assert reloaded.format == "BMP"
+ assert reloaded.get_format_mimetype() == "image/bmp"
def test_invalid_file() -> None:
@@ -66,7 +60,7 @@ def test_small_palette(tmp_path: Path) -> None:
colors = [0, 0, 0, 125, 125, 125, 255, 255, 255]
im.putpalette(colors)
- out = str(tmp_path / "temp.bmp")
+ out = tmp_path / "temp.bmp"
im.save(out)
with Image.open(out) as reloaded:
@@ -74,7 +68,7 @@ def test_small_palette(tmp_path: Path) -> None:
def test_save_too_large(tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.bmp")
+ outfile = tmp_path / "temp.bmp"
with Image.new("RGB", (1, 1)) as im:
im._size = (37838, 37838)
with pytest.raises(ValueError):
@@ -96,7 +90,7 @@ def test_dpi() -> None:
def test_save_bmp_with_dpi(tmp_path: Path) -> None:
# Test for #1301
# Arrange
- outfile = str(tmp_path / "temp.jpg")
+ outfile = tmp_path / "temp.jpg"
with Image.open("Tests/images/hopper.bmp") as im:
assert im.info["dpi"] == (95.98654816726399, 95.98654816726399)
@@ -112,7 +106,7 @@ def test_save_bmp_with_dpi(tmp_path: Path) -> None:
def test_save_float_dpi(tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.bmp")
+ outfile = tmp_path / "temp.bmp"
with Image.open("Tests/images/hopper.bmp") as im:
im.save(outfile, dpi=(72.21216100543306, 72.21216100543306))
with Image.open(outfile) as reloaded:
@@ -152,7 +146,7 @@ def test_dib_header_size(header_size: int, path: str) -> None:
def test_save_dib(tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.dib")
+ outfile = tmp_path / "temp.dib"
with Image.open("Tests/images/clipboard.dib") as im:
im.save(outfile)
@@ -230,3 +224,13 @@ def test_offset() -> None:
# to exclude the palette size from the pixel data offset
with Image.open("Tests/images/pal8_offset.bmp") as im:
assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp")
+
+
+def test_use_raw_alpha(monkeypatch: pytest.MonkeyPatch) -> None:
+ with Image.open("Tests/images/bmp/g/rgb32.bmp") as im:
+ assert im.info["compression"] == BmpImagePlugin.BmpImageFile.COMPRESSIONS["RAW"]
+ assert im.mode == "RGB"
+
+ monkeypatch.setattr(BmpImagePlugin, "USE_RAW_ALPHA", True)
+ with Image.open("Tests/images/bmp/g/rgb32.bmp") as im:
+ assert im.mode == "RGBA"
diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py
index fc8920317..362578c56 100644
--- a/Tests/test_file_bufrstub.py
+++ b/Tests/test_file_bufrstub.py
@@ -43,7 +43,7 @@ def test_load() -> None:
def test_save(tmp_path: Path) -> None:
# Arrange
im = hopper()
- tmpfile = str(tmp_path / "temp.bufr")
+ tmpfile = tmp_path / "temp.bufr"
# Act / Assert: stub cannot save without an implemented handler
with pytest.raises(OSError):
@@ -79,7 +79,7 @@ def test_handler(tmp_path: Path) -> None:
im.load()
assert handler.is_loaded()
- temp_file = str(tmp_path / "temp.bufr")
+ temp_file = tmp_path / "temp.bufr"
im.save(temp_file)
assert handler.saved
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_dds.py b/Tests/test_file_dds.py
index 7cc4d79d4..3388fce16 100644
--- a/Tests/test_file_dds.py
+++ b/Tests/test_file_dds.py
@@ -9,7 +9,13 @@ import pytest
from PIL import DdsImagePlugin, Image
-from .helper import assert_image_equal, assert_image_equal_tofile, hopper
+from .helper import (
+ assert_image_equal,
+ assert_image_equal_tofile,
+ assert_image_similar,
+ assert_image_similar_tofile,
+ hopper,
+)
TEST_FILE_DXT1 = "Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds"
TEST_FILE_DXT3 = "Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds"
@@ -109,6 +115,32 @@ def test_sanity_ati1_bc4u(image_path: str) -> None:
assert_image_equal_tofile(im, TEST_FILE_ATI1.replace(".dds", ".png"))
+def test_dx10_bc2(tmp_path: Path) -> None:
+ out = tmp_path / "temp.dds"
+ with Image.open(TEST_FILE_DXT3) as im:
+ im.save(out, pixel_format="BC2")
+
+ with Image.open(out) as reloaded:
+ assert reloaded.format == "DDS"
+ assert reloaded.mode == "RGBA"
+ assert reloaded.size == (256, 256)
+
+ assert_image_similar(im, reloaded, 3.81)
+
+
+def test_dx10_bc3(tmp_path: Path) -> None:
+ out = tmp_path / "temp.dds"
+ with Image.open(TEST_FILE_DXT5) as im:
+ im.save(out, pixel_format="BC3")
+
+ with Image.open(out) as reloaded:
+ assert reloaded.format == "DDS"
+ assert reloaded.mode == "RGBA"
+ assert reloaded.size == (256, 256)
+
+ assert_image_similar(im, reloaded, 3.69)
+
+
@pytest.mark.parametrize(
"image_path",
(
@@ -368,9 +400,9 @@ def test_not_implemented(test_file: str) -> None:
def test_save_unsupported_mode(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.dds")
+ out = tmp_path / "temp.dds"
im = hopper("HSV")
- with pytest.raises(OSError):
+ with pytest.raises(OSError, match="cannot write mode HSV as DDS"):
im.save(out)
@@ -384,10 +416,98 @@ def test_save_unsupported_mode(tmp_path: Path) -> None:
],
)
def test_save(mode: str, test_file: str, tmp_path: Path) -> None:
- out = str(tmp_path / "temp.dds")
+ out = tmp_path / "temp.dds"
with Image.open(test_file) as im:
assert im.mode == mode
im.save(out)
- with Image.open(out) as reloaded:
- assert_image_equal(im, reloaded)
+ assert_image_equal_tofile(im, out)
+
+
+def test_save_unsupported_pixel_format(tmp_path: Path) -> None:
+ out = tmp_path / "temp.dds"
+ im = hopper()
+ with pytest.raises(OSError, match="cannot write pixel format UNKNOWN"):
+ im.save(out, pixel_format="UNKNOWN")
+
+
+def test_save_dxt1(tmp_path: Path) -> None:
+ # RGB
+ out = tmp_path / "temp.dds"
+ with Image.open(TEST_FILE_DXT1) as im:
+ im.convert("RGB").save(out, pixel_format="DXT1")
+ assert_image_similar_tofile(im, out, 1.84)
+
+ # RGBA
+ im_alpha = im.copy()
+ im_alpha.putpixel((0, 0), (0, 0, 0, 0))
+ im_alpha.save(out, pixel_format="DXT1")
+ with Image.open(out) as reloaded:
+ assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0)
+
+ # L
+ im_l = im.convert("L")
+ im_l.save(out, pixel_format="DXT1")
+ assert_image_similar_tofile(im_l.convert("RGBA"), out, 6.07)
+
+ # LA
+ im_alpha.convert("LA").save(out, pixel_format="DXT1")
+ with Image.open(out) as reloaded:
+ assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0)
+
+
+def test_save_dxt3(tmp_path: Path) -> None:
+ # RGB
+ out = tmp_path / "temp.dds"
+ with Image.open(TEST_FILE_DXT3) as im:
+ im_rgb = im.convert("RGB")
+ im_rgb.save(out, pixel_format="DXT3")
+ assert_image_similar_tofile(im_rgb.convert("RGBA"), out, 1.26)
+
+ # RGBA
+ im.save(out, pixel_format="DXT3")
+ assert_image_similar_tofile(im, out, 3.81)
+
+ # L
+ im_l = im.convert("L")
+ im_l.save(out, pixel_format="DXT3")
+ assert_image_similar_tofile(im_l.convert("RGBA"), out, 5.89)
+
+ # LA
+ im_la = im.convert("LA")
+ im_la.save(out, pixel_format="DXT3")
+ assert_image_similar_tofile(im_la.convert("RGBA"), out, 8.44)
+
+
+def test_save_dxt5(tmp_path: Path) -> None:
+ # RGB
+ out = tmp_path / "temp.dds"
+ with Image.open(TEST_FILE_DXT1) as im:
+ im.convert("RGB").save(out, pixel_format="DXT5")
+ assert_image_similar_tofile(im, out, 1.84)
+
+ # RGBA
+ with Image.open(TEST_FILE_DXT5) as im_rgba:
+ im_rgba.save(out, pixel_format="DXT5")
+ assert_image_similar_tofile(im_rgba, out, 3.69)
+
+ # L
+ im_l = im.convert("L")
+ im_l.save(out, pixel_format="DXT5")
+ assert_image_similar_tofile(im_l.convert("RGBA"), out, 6.07)
+
+ # LA
+ im_la = im_rgba.convert("LA")
+ im_la.save(out, pixel_format="DXT5")
+ assert_image_similar_tofile(im_la.convert("RGBA"), out, 8.32)
+
+
+def test_save_dx10_bc5(tmp_path: Path) -> None:
+ out = tmp_path / "temp.dds"
+ with Image.open(TEST_FILE_DX10_BC5_TYPELESS) as im:
+ im.save(out, pixel_format="BC5")
+ assert_image_similar_tofile(im, out, 9.56)
+
+ im = hopper("L")
+ with pytest.raises(OSError, match="only RGB mode can be written as BC5"):
+ im.save(out, pixel_format="BC5")
diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py
index a0c2f9216..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"
@@ -239,7 +243,7 @@ def test_transparency() -> None:
def test_file_object(tmp_path: Path) -> None:
# issue 479
with Image.open(FILE1) as image1:
- with open(str(tmp_path / "temp.eps"), "wb") as fh:
+ with open(tmp_path / "temp.eps", "wb") as fh:
image1.save(fh, "EPS")
@@ -274,7 +278,7 @@ def test_1(filename: str) -> None:
def test_image_mode_not_supported(tmp_path: Path) -> None:
im = hopper("RGBA")
- tmpfile = str(tmp_path / "temp.eps")
+ tmpfile = tmp_path / "temp.eps"
with pytest.raises(ValueError):
im.save(tmpfile)
@@ -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 8adbd30f5..81df1ab0b 100644
--- a/Tests/test_file_fli.py
+++ b/Tests/test_file_fli.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import io
import warnings
import pytest
@@ -21,6 +22,8 @@ animated_test_file_with_prefix_chunk = "Tests/images/2422.flc"
def test_sanity() -> None:
with Image.open(static_test_file) as im:
+ assert isinstance(im, FliImagePlugin.FliImageFile)
+
im.load()
assert im.mode == "P"
assert im.size == (128, 128)
@@ -28,6 +31,8 @@ def test_sanity() -> None:
assert not im.is_animated
with Image.open(animated_test_file) as im:
+ assert isinstance(im, FliImagePlugin.FliImageFile)
+
assert im.mode == "P"
assert im.size == (320, 200)
assert im.format == "FLI"
@@ -111,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
@@ -132,6 +140,15 @@ def test_eoferror() -> None:
im.seek(n_frames - 1)
+def test_missing_frame_size() -> None:
+ with open(animated_test_file, "rb") as fp:
+ data = fp.read()
+ data = data[:6188]
+ with Image.open(io.BytesIO(data)) as im:
+ with pytest.raises(EOFError, match="missing frame size"):
+ im.seek(1)
+
+
def test_seek_tell() -> None:
with Image.open(animated_test_file) as im:
layer_number = im.tell()
@@ -156,10 +173,14 @@ def test_seek_tell() -> None:
def test_seek() -> None:
with Image.open(animated_test_file) as im:
+ assert isinstance(im, FliImagePlugin.FliImageFile)
im.seek(50)
assert_image_equal_tofile(im, "Tests/images/a_fli.png")
+ with pytest.raises(ValueError, match="cannot seek to frame 52"):
+ im._seek(52)
+
@pytest.mark.parametrize(
"test_file",
diff --git a/Tests/test_file_fpx.py b/Tests/test_file_fpx.py
index e32f30a01..8d8064692 100644
--- a/Tests/test_file_fpx.py
+++ b/Tests/test_file_fpx.py
@@ -22,10 +22,11 @@ def test_sanity() -> None:
def test_close() -> None:
with Image.open("Tests/images/input_bw_one_band.fpx") as im:
- pass
+ assert isinstance(im, FpxImagePlugin.FpxImageFile)
assert im.ole.fp.closed
im = Image.open("Tests/images/input_bw_one_band.fpx")
+ assert isinstance(im, FpxImagePlugin.FpxImageFile)
im.close()
assert im.ole.fp.closed
diff --git a/Tests/test_file_ftex.py b/Tests/test_file_ftex.py
index 0c544245a..fdd7b3757 100644
--- a/Tests/test_file_ftex.py
+++ b/Tests/test_file_ftex.py
@@ -1,5 +1,8 @@
from __future__ import annotations
+import io
+import struct
+
import pytest
from PIL import FtexImagePlugin, Image
@@ -23,3 +26,15 @@ def test_invalid_file() -> None:
with pytest.raises(SyntaxError):
FtexImagePlugin.FtexImageFile(invalid_file)
+
+
+def test_invalid_texture() -> None:
+ with open("Tests/images/ftex_dxt1.ftc", "rb") as fp:
+ data = fp.read()
+
+ # Change texture compression format
+ data = data[:24] + struct.pack(" None:
with GdImageFile.open(TEST_GD_FILE) as im:
assert im.size == (128, 128)
assert im.format == "GD"
+ assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.jpg", 14)
def test_bad_mode() -> None:
diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py
index 2254178d5..20d58a9dd 100644
--- a/Tests/test_file_gif.py
+++ b/Tests/test_file_gif.py
@@ -228,7 +228,7 @@ def test_optimize_if_palette_can_be_reduced_by_half() -> None:
def test_full_palette_second_frame(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("P", (1, 256))
full_palette_im = Image.new("P", (1, 256))
@@ -249,7 +249,7 @@ def test_full_palette_second_frame(tmp_path: Path) -> None:
def test_roundtrip(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = hopper()
im.save(out)
with Image.open(out) as reread:
@@ -258,7 +258,7 @@ def test_roundtrip(tmp_path: Path) -> None:
def test_roundtrip2(tmp_path: Path) -> None:
# see https://github.com/python-pillow/Pillow/issues/403
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
with Image.open(TEST_GIF) as im:
im2 = im.copy()
im2.save(out)
@@ -268,7 +268,7 @@ def test_roundtrip2(tmp_path: Path) -> None:
def test_roundtrip_save_all(tmp_path: Path) -> None:
# Single frame image
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = hopper()
im.save(out, save_all=True)
with Image.open(out) as reread:
@@ -276,7 +276,7 @@ def test_roundtrip_save_all(tmp_path: Path) -> None:
# Multiframe image
with Image.open("Tests/images/dispose_bgnd.gif") as im:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im.save(out, save_all=True)
with Image.open(out) as reread:
@@ -284,7 +284,7 @@ def test_roundtrip_save_all(tmp_path: Path) -> None:
def test_roundtrip_save_all_1(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("1", (1, 1))
im2 = Image.new("1", (1, 1), 1)
im.save(out, save_all=True, append_images=[im2])
@@ -329,7 +329,7 @@ def test_headers_saving_for_animated_gifs(tmp_path: Path) -> None:
with Image.open("Tests/images/dispose_bgnd.gif") as im:
info = im.info.copy()
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im.save(out, save_all=True)
with Image.open(out) as reread:
for header in important_headers:
@@ -345,7 +345,7 @@ def test_palette_handling(tmp_path: Path) -> None:
im = im.resize((100, 100), Image.Resampling.LANCZOS)
im2 = im.convert("P", palette=Image.Palette.ADAPTIVE, colors=256)
- f = str(tmp_path / "temp.gif")
+ f = tmp_path / "temp.gif"
im2.save(f, optimize=True)
with Image.open(f) as reloaded:
@@ -356,7 +356,7 @@ def test_palette_434(tmp_path: Path) -> None:
# see https://github.com/python-pillow/Pillow/issues/434
def roundtrip(im: Image.Image, **kwargs: bool) -> Image.Image:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im.copy().save(out, "GIF", **kwargs)
reloaded = Image.open(out)
@@ -402,6 +402,7 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None:
def test_seek() -> None:
with Image.open("Tests/images/dispose_none.gif") as img:
+ assert isinstance(img, GifImagePlugin.GifImageFile)
frame_count = 0
try:
while True:
@@ -410,6 +411,10 @@ def test_seek() -> None:
except EOFError:
assert frame_count == 5
+ img.seek(0)
+ with pytest.raises(ValueError, match="cannot seek to frame 2"):
+ img._seek(2)
+
def test_seek_info() -> None:
with Image.open("Tests/images/iss634.gif") as im:
@@ -442,10 +447,12 @@ def test_seek_rewind() -> None:
def test_n_frames(path: str, n_frames: int) -> None:
# Test is_animated before n_frames
with Image.open(path) as im:
+ assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.is_animated == (n_frames != 1)
# Test is_animated after n_frames
with Image.open(path) as im:
+ assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.n_frames == n_frames
assert im.is_animated == (n_frames != 1)
@@ -455,6 +462,7 @@ def test_no_change() -> None:
with Image.open("Tests/images/dispose_bgnd.gif") as im:
im.seek(1)
expected = im.copy()
+ assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.n_frames == 5
assert_image_equal(im, expected)
@@ -462,17 +470,20 @@ def test_no_change() -> None:
with Image.open("Tests/images/dispose_bgnd.gif") as im:
im.seek(3)
expected = im.copy()
+ assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.is_animated
assert_image_equal(im, expected)
with Image.open("Tests/images/comment_after_only_frame.gif") as im:
expected = Image.new("P", (1, 1))
+ assert isinstance(im, GifImagePlugin.GifImageFile)
assert not im.is_animated
assert_image_equal(im, expected)
def test_eoferror() -> None:
with Image.open(TEST_GIF) as im:
+ assert isinstance(im, GifImagePlugin.GifImageFile)
n_frames = im.n_frames
# Test seeking past the last frame
@@ -491,6 +502,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)
@@ -514,6 +526,7 @@ def test_dispose_none_load_end() -> None:
def test_dispose_background() -> None:
with Image.open("Tests/images/dispose_bgnd.gif") as img:
+ assert isinstance(img, GifImagePlugin.GifImageFile)
try:
while True:
img.seek(img.tell() + 1)
@@ -567,6 +580,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)
@@ -595,15 +609,16 @@ def test_previous_frame_loaded() -> None:
def test_save_dispose(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im_list = [
Image.new("L", (100, 100), "#000"),
Image.new("L", (100, 100), "#111"),
Image.new("L", (100, 100), "#222"),
]
- for method in range(0, 4):
+ for method in range(4):
im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=method)
with Image.open(out) as img:
+ assert isinstance(img, GifImagePlugin.GifImageFile)
for _ in range(2):
img.seek(img.tell() + 1)
assert img.disposal_method == method
@@ -617,13 +632,14 @@ def test_save_dispose(tmp_path: Path) -> None:
)
with Image.open(out) as img:
+ assert isinstance(img, GifImagePlugin.GifImageFile)
for i in range(2):
img.seek(img.tell() + 1)
assert img.disposal_method == i + 1
def test_dispose2_palette(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
# Four colors: white, gray, black, red
circles = [(255, 255, 255), (153, 153, 153), (0, 0, 0), (255, 0, 0)]
@@ -657,7 +673,7 @@ def test_dispose2_palette(tmp_path: Path) -> None:
def test_dispose2_diff(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
# 4 frames: red/blue, red/red, blue/blue, red/blue
circles = [
@@ -699,7 +715,7 @@ def test_dispose2_diff(tmp_path: Path) -> None:
def test_dispose2_background(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im_list = []
@@ -725,7 +741,7 @@ def test_dispose2_background(tmp_path: Path) -> None:
def test_dispose2_background_frame(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im_list = [Image.new("RGBA", (1, 20))]
@@ -739,11 +755,12 @@ def test_dispose2_background_frame(tmp_path: Path) -> None:
im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=2)
with Image.open(out) as im:
+ assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.n_frames == 3
def test_dispose2_previous_frame(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("P", (100, 100))
im.info["transparency"] = 0
@@ -762,7 +779,7 @@ def test_dispose2_previous_frame(tmp_path: Path) -> None:
def test_dispose2_without_transparency(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("P", (100, 100))
@@ -777,7 +794,7 @@ def test_dispose2_without_transparency(tmp_path: Path) -> None:
def test_transparency_in_second_frame(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
with Image.open("Tests/images/different_transparency.gif") as im:
assert im.info["transparency"] == 0
@@ -807,7 +824,7 @@ def test_no_transparency_in_second_frame() -> None:
def test_remapped_transparency(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("P", (1, 2))
im2 = im.copy()
@@ -825,7 +842,7 @@ def test_remapped_transparency(tmp_path: Path) -> None:
def test_duration(tmp_path: Path) -> None:
duration = 1000
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("L", (100, 100), "#000")
# Check that the argument has priority over the info settings
@@ -839,7 +856,7 @@ def test_duration(tmp_path: Path) -> None:
def test_multiple_duration(tmp_path: Path) -> None:
duration_list = [1000, 2000, 3000]
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im_list = [
Image.new("L", (100, 100), "#000"),
Image.new("L", (100, 100), "#111"),
@@ -874,7 +891,7 @@ def test_multiple_duration(tmp_path: Path) -> None:
def test_roundtrip_info_duration(tmp_path: Path) -> None:
duration_list = [100, 500, 500]
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
with Image.open("Tests/images/transparent_dispose.gif") as im:
assert [
frame.info["duration"] for frame in ImageSequence.Iterator(im)
@@ -889,7 +906,7 @@ def test_roundtrip_info_duration(tmp_path: Path) -> None:
def test_roundtrip_info_duration_combined(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
with Image.open("Tests/images/duplicate_frame.gif") as im:
assert [frame.info["duration"] for frame in ImageSequence.Iterator(im)] == [
1000,
@@ -907,7 +924,7 @@ def test_roundtrip_info_duration_combined(tmp_path: Path) -> None:
def test_identical_frames(tmp_path: Path) -> None:
duration_list = [1000, 1500, 2000, 4000]
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im_list = [
Image.new("L", (100, 100), "#000"),
Image.new("L", (100, 100), "#000"),
@@ -920,6 +937,8 @@ def test_identical_frames(tmp_path: Path) -> None:
out, save_all=True, append_images=im_list[1:], duration=duration_list
)
with Image.open(out) as reread:
+ assert isinstance(reread, GifImagePlugin.GifImageFile)
+
# Assert that the first three frames were combined
assert reread.n_frames == 2
@@ -940,7 +959,7 @@ def test_identical_frames(tmp_path: Path) -> None:
def test_identical_frames_to_single_frame(
duration: int | list[int], tmp_path: Path
) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im_list = [
Image.new("L", (100, 100), "#000"),
Image.new("L", (100, 100), "#000"),
@@ -949,6 +968,8 @@ def test_identical_frames_to_single_frame(
im_list[0].save(out, save_all=True, append_images=im_list[1:], duration=duration)
with Image.open(out) as reread:
+ assert isinstance(reread, GifImagePlugin.GifImageFile)
+
# Assert that all frames were combined
assert reread.n_frames == 1
@@ -957,7 +978,7 @@ def test_identical_frames_to_single_frame(
def test_loop_none(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("L", (100, 100), "#000")
im.save(out, loop=None)
with Image.open(out) as reread:
@@ -967,7 +988,7 @@ def test_loop_none(tmp_path: Path) -> None:
def test_number_of_loops(tmp_path: Path) -> None:
number_of_loops = 2
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("L", (100, 100), "#000")
im.save(out, loop=number_of_loops)
with Image.open(out) as reread:
@@ -983,7 +1004,7 @@ def test_number_of_loops(tmp_path: Path) -> None:
def test_background(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("L", (100, 100), "#000")
im.info["background"] = 1
im.save(out)
@@ -992,7 +1013,7 @@ def test_background(tmp_path: Path) -> None:
def test_webp_background(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
# Test opaque WebP background
if features.check("webp"):
@@ -1010,7 +1031,7 @@ def test_comment(tmp_path: Path) -> None:
with Image.open(TEST_GIF) as im:
assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0"
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("L", (100, 100), "#000")
im.info["comment"] = b"Test comment text"
im.save(out)
@@ -1027,7 +1048,7 @@ def test_comment(tmp_path: Path) -> None:
def test_comment_over_255(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("L", (100, 100), "#000")
comment = b"Test comment text"
while len(comment) < 256:
@@ -1053,7 +1074,7 @@ def test_read_multiple_comment_blocks() -> None:
def test_empty_string_comment(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
with Image.open("Tests/images/chi.gif") as im:
assert "comment" in im.info
@@ -1087,7 +1108,7 @@ def test_retain_comment_in_subsequent_frames(tmp_path: Path) -> None:
assert "comment" not in im.info
# Test that a saved image keeps the comment
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
with Image.open("Tests/images/dispose_prev.gif") as im:
im.save(out, save_all=True, comment="Test")
@@ -1097,7 +1118,7 @@ def test_retain_comment_in_subsequent_frames(tmp_path: Path) -> None:
def test_version(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
def assert_version_after_save(im: Image.Image, version: bytes) -> None:
im.save(out)
@@ -1127,7 +1148,7 @@ def test_version(tmp_path: Path) -> None:
def test_append_images(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
# Test appending single frame images
im = Image.new("RGB", (100, 100), "#f00")
@@ -1135,6 +1156,14 @@ def test_append_images(tmp_path: Path) -> None:
im.copy().save(out, save_all=True, append_images=ims)
with Image.open(out) as reread:
+ assert isinstance(reread, GifImagePlugin.GifImageFile)
+ assert reread.n_frames == 3
+
+ # Test append_images without save_all
+ im.copy().save(out, append_images=ims)
+
+ with Image.open(out) as reread:
+ assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 3
# Tests appending using a generator
@@ -1144,6 +1173,7 @@ def test_append_images(tmp_path: Path) -> None:
im.save(out, save_all=True, append_images=im_generator(ims))
with Image.open(out) as reread:
+ assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 3
# Tests appending single and multiple frame images
@@ -1152,11 +1182,12 @@ def test_append_images(tmp_path: Path) -> None:
im.save(out, save_all=True, append_images=[im2])
with Image.open(out) as reread:
+ assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 10
def test_append_different_size_image(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("RGB", (100, 100))
bigger_im = Image.new("RGB", (200, 200), "#f00")
@@ -1183,7 +1214,7 @@ def test_transparent_optimize(tmp_path: Path) -> None:
im.frombytes(data)
im.putpalette(palette)
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im.save(out, transparency=im.getpixel((252, 0)))
with Image.open(out) as reloaded:
@@ -1191,7 +1222,7 @@ def test_transparent_optimize(tmp_path: Path) -> None:
def test_removed_transparency(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("RGB", (256, 1))
for x in range(256):
@@ -1206,7 +1237,7 @@ def test_removed_transparency(tmp_path: Path) -> None:
def test_rgb_transparency(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
# Single frame
im = Image.new("RGB", (1, 1))
@@ -1228,7 +1259,7 @@ def test_rgb_transparency(tmp_path: Path) -> None:
def test_rgba_transparency(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = hopper("P")
im.save(out, save_all=True, append_images=[Image.new("RGBA", im.size)])
@@ -1238,25 +1269,26 @@ def test_rgba_transparency(tmp_path: Path) -> None:
assert_image_equal(hopper("P").convert("RGB"), reloaded)
-def test_background_outside_palettte(tmp_path: Path) -> None:
+def test_background_outside_palettte() -> None:
with Image.open("Tests/images/background_outside_palette.gif") as im:
im.seek(1)
assert im.info["background"] == 255
def test_bbox(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("RGB", (100, 100), "#fff")
ims = [Image.new("RGB", (100, 100), "#000")]
im.save(out, save_all=True, append_images=ims)
with Image.open(out) as reread:
+ assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 2
def test_bbox_alpha(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im = Image.new("RGBA", (1, 2), (255, 0, 0, 255))
im.putpixel((0, 1), (255, 0, 0, 0))
@@ -1264,6 +1296,7 @@ def test_bbox_alpha(tmp_path: Path) -> None:
im.save(out, save_all=True, append_images=[im2])
with Image.open(out) as reread:
+ assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 2
@@ -1275,7 +1308,7 @@ def test_palette_save_L(tmp_path: Path) -> None:
palette = im.getpalette()
assert palette is not None
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im_l.save(out, palette=bytes(palette))
with Image.open(out) as reloaded:
@@ -1286,7 +1319,7 @@ def test_palette_save_P(tmp_path: Path) -> None:
im = Image.new("P", (1, 2))
im.putpixel((0, 1), 1)
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im.save(out, palette=bytes((1, 2, 3, 4, 5, 6)))
with Image.open(out) as reloaded:
@@ -1302,7 +1335,7 @@ def test_palette_save_duplicate_entries(tmp_path: Path) -> None:
im.putpalette((0, 0, 0, 0, 0, 0))
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im.save(out, palette=[0, 0, 0, 0, 0, 0, 1, 1, 1])
with Image.open(out) as reloaded:
@@ -1317,7 +1350,7 @@ def test_palette_save_all_P(tmp_path: Path) -> None:
frame.putpalette(color)
frames.append(frame)
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
frames[0].save(
out, save_all=True, palette=[255, 0, 0, 0, 255, 0], append_images=frames[1:]
)
@@ -1340,7 +1373,7 @@ def test_palette_save_ImagePalette(tmp_path: Path) -> None:
im = hopper("P")
palette = ImagePalette.ImagePalette("RGB", list(range(256))[::-1] * 3)
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im.save(out, palette=palette)
with Image.open(out) as reloaded:
@@ -1353,7 +1386,7 @@ def test_save_I(tmp_path: Path) -> None:
im = hopper("I")
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im.save(out)
with Image.open(out) as reloaded:
@@ -1415,6 +1448,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
@@ -1437,7 +1471,7 @@ def test_missing_background() -> None:
def test_saving_rgba(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
with Image.open("Tests/images/transparent.png") as im:
im.save(out)
@@ -1448,7 +1482,7 @@ def test_saving_rgba(tmp_path: Path) -> None:
@pytest.mark.parametrize("params", ({}, {"disposal": 2, "optimize": False}))
def test_p_rgba(tmp_path: Path, params: dict[str, Any]) -> None:
- out = str(tmp_path / "temp.gif")
+ out = tmp_path / "temp.gif"
im1 = Image.new("P", (100, 100))
d = ImageDraw.Draw(im1)
@@ -1462,4 +1496,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 e8d5f1705..08862113b 100644
--- a/Tests/test_file_gimppalette.py
+++ b/Tests/test_file_gimppalette.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from io import BytesIO
+
import pytest
from PIL.GimpPaletteFile import GimpPaletteFile
@@ -14,17 +16,20 @@ def test_sanity() -> None:
GimpPaletteFile(fp)
with open("Tests/images/bad_palette_file.gpl", "rb") as fp:
- with pytest.raises(SyntaxError):
+ with pytest.raises(SyntaxError, match="bad palette file"):
GimpPaletteFile(fp)
with open("Tests/images/bad_palette_entry.gpl", "rb") as fp:
- with pytest.raises(ValueError):
+ with pytest.raises(ValueError, match="bad palette entry"):
GimpPaletteFile(fp)
-def test_get_palette() -> None:
+@pytest.mark.parametrize(
+ "filename, size", (("custom_gimp_palette.gpl", 8), ("full_gimp_palette.gpl", 256))
+)
+def test_get_palette(filename: str, size: int) -> None:
# Arrange
- with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp:
+ with open("Tests/images/" + filename, "rb") as fp:
palette_file = GimpPaletteFile(fp)
# Act
@@ -32,3 +37,36 @@ def test_get_palette() -> None:
# Assert
assert mode == "RGB"
+ assert len(palette) / 3 == size
+
+
+def test_frombytes() -> None:
+ # Test that __init__ stops reading after 260 lines
+ with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp:
+ custom_data = fp.read()
+ custom_data += b"#\n" * 300 + b" 0 0 0 Index 12"
+ b = BytesIO(custom_data)
+ palette = GimpPaletteFile(b)
+ assert len(palette.palette) / 3 == 8
+
+ # Test that __init__ only reads 256 entries
+ with open("Tests/images/full_gimp_palette.gpl", "rb") as fp:
+ full_data = fp.read()
+ data = full_data.replace(b"#\n", b"") + b" 0 0 0 Index 256"
+ b = BytesIO(data)
+ palette = GimpPaletteFile(b)
+ assert len(palette.palette) / 3 == 256
+
+ # Test that frombytes() can read beyond that
+ palette = GimpPaletteFile.frombytes(data)
+ assert len(palette.palette) / 3 == 257
+
+ # Test that __init__ raises an error if a comment is too long
+ data = full_data[:-1] + b"a" * 100
+ b = BytesIO(data)
+ with pytest.raises(SyntaxError, match="bad palette file"):
+ palette = GimpPaletteFile(b)
+
+ # Test that frombytes() can read the data regardless
+ palette = GimpPaletteFile.frombytes(data)
+ assert len(palette.palette) / 3 == 256
diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py
index 02e464ff1..960e5f4be 100644
--- a/Tests/test_file_gribstub.py
+++ b/Tests/test_file_gribstub.py
@@ -43,7 +43,7 @@ def test_load() -> None:
def test_save(tmp_path: Path) -> None:
# Arrange
im = hopper()
- tmpfile = str(tmp_path / "temp.grib")
+ tmpfile = tmp_path / "temp.grib"
# Act / Assert: stub cannot save without an implemented handler
with pytest.raises(OSError):
@@ -79,7 +79,7 @@ def test_handler(tmp_path: Path) -> None:
im.load()
assert handler.is_loaded()
- temp_file = str(tmp_path / "temp.grib")
+ temp_file = tmp_path / "temp.grib"
im.save(temp_file)
assert handler.saved
diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py
index 024be9e80..e4f09a09c 100644
--- a/Tests/test_file_hdf5stub.py
+++ b/Tests/test_file_hdf5stub.py
@@ -43,7 +43,7 @@ def test_save() -> None:
# Arrange
with Image.open(TEST_FILE) as im:
dummy_fp = BytesIO()
- dummy_filename = "dummy.filename"
+ dummy_filename = "dummy.h5"
# Act / Assert: stub cannot save without an implemented handler
with pytest.raises(OSError):
@@ -81,7 +81,7 @@ def test_handler(tmp_path: Path) -> None:
im.load()
assert handler.is_loaded()
- temp_file = str(tmp_path / "temp.h5")
+ temp_file = tmp_path / "temp.h5"
im.save(temp_file)
assert handler.saved
diff --git a/Tests/test_file_icns.py b/Tests/test_file_icns.py
index 94f16aeec..2dabfd2f3 100644
--- a/Tests/test_file_icns.py
+++ b/Tests/test_file_icns.py
@@ -43,7 +43,7 @@ def test_load() -> None:
def test_save(tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.icns")
+ temp_file = tmp_path / "temp.icns"
with Image.open(TEST_FILE) as im:
im.save(temp_file)
@@ -60,7 +60,7 @@ def test_save(tmp_path: Path) -> None:
def test_save_append_images(tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.icns")
+ temp_file = tmp_path / "temp.icns"
provided_im = Image.new("RGBA", (32, 32), (255, 0, 0, 128))
with Image.open(TEST_FILE) as im:
@@ -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 2f5e4ca5a..5d2ace35e 100644
--- a/Tests/test_file_ico.py
+++ b/Tests/test_file_ico.py
@@ -41,7 +41,7 @@ def test_black_and_white() -> None:
def test_palette(tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.ico")
+ temp_file = tmp_path / "temp.ico"
im = Image.new("P", (16, 16))
im.save(temp_file)
@@ -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
@@ -88,12 +89,13 @@ def test_save_to_bytes() -> None:
def test_getpixel(tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.ico")
+ temp_file = tmp_path / "temp.ico"
im = hopper()
im.save(temp_file, "ico", sizes=[(32, 32), (64, 64)])
with Image.open(temp_file) as reloaded:
+ assert isinstance(reloaded, IcoImagePlugin.IcoImageFile)
reloaded.load()
reloaded.size = (32, 32)
@@ -101,8 +103,8 @@ def test_getpixel(tmp_path: Path) -> None:
def test_no_duplicates(tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.ico")
- temp_file2 = str(tmp_path / "temp2.ico")
+ temp_file = tmp_path / "temp.ico"
+ temp_file2 = tmp_path / "temp2.ico"
im = hopper()
sizes = [(32, 32), (64, 64)]
@@ -115,8 +117,8 @@ def test_no_duplicates(tmp_path: Path) -> None:
def test_different_bit_depths(tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.ico")
- temp_file2 = str(tmp_path / "temp2.ico")
+ temp_file = tmp_path / "temp.ico"
+ temp_file2 = tmp_path / "temp2.ico"
im = hopper()
im.save(temp_file, "ico", bitmap_format="bmp", sizes=[(128, 128)])
@@ -132,8 +134,8 @@ def test_different_bit_depths(tmp_path: Path) -> None:
assert os.path.getsize(temp_file) != os.path.getsize(temp_file2)
# Test that only matching sizes of different bit depths are saved
- temp_file3 = str(tmp_path / "temp3.ico")
- temp_file4 = str(tmp_path / "temp4.ico")
+ temp_file3 = tmp_path / "temp3.ico"
+ temp_file4 = tmp_path / "temp4.ico"
im.save(temp_file3, "ico", bitmap_format="bmp", sizes=[(128, 128)])
im.save(
@@ -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)
@@ -186,7 +190,7 @@ def test_save_256x256(tmp_path: Path) -> None:
"""Issue #2264 https://github.com/python-pillow/Pillow/issues/2264"""
# Arrange
with Image.open("Tests/images/hopper_256x256.ico") as im:
- outfile = str(tmp_path / "temp_saved_hopper_256x256.ico")
+ outfile = tmp_path / "temp_saved_hopper_256x256.ico"
# Act
im.save(outfile)
@@ -202,7 +206,7 @@ def test_only_save_relevant_sizes(tmp_path: Path) -> None:
"""
# Arrange
with Image.open("Tests/images/python.ico") as im: # 16x16, 32x32, 48x48
- outfile = str(tmp_path / "temp_saved_python.ico")
+ outfile = tmp_path / "temp_saved_python.ico"
# Act
im.save(outfile)
@@ -215,10 +219,11 @@ def test_save_append_images(tmp_path: Path) -> None:
# append_images should be used for scaled down versions of the image
im = hopper("RGBA")
provided_im = Image.new("RGBA", (32, 32), (255, 0, 0))
- outfile = str(tmp_path / "temp_saved_multi_icon.ico")
+ outfile = tmp_path / "temp_saved_multi_icon.ico"
im.save(outfile, sizes=[(32, 32), (128, 128)], append_images=[provided_im])
with Image.open(outfile) as reread:
+ assert isinstance(reread, IcoImagePlugin.IcoImageFile)
assert_image_equal(reread, hopper("RGBA"))
reread.size = (32, 32)
@@ -235,7 +240,7 @@ def test_unexpected_size() -> None:
def test_draw_reloaded(tmp_path: Path) -> None:
with Image.open(TEST_ICO_FILE) as im:
- outfile = str(tmp_path / "temp_saved_hopper_draw.ico")
+ outfile = tmp_path / "temp_saved_hopper_draw.ico"
draw = ImageDraw.Draw(im)
draw.line((0, 0) + im.size, "#f00")
diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py
index d29998801..55c6b7305 100644
--- a/Tests/test_file_im.py
+++ b/Tests/test_file_im.py
@@ -23,7 +23,7 @@ def test_sanity() -> None:
def test_name_limit(tmp_path: Path) -> None:
- out = str(tmp_path / ("name_limit_test" * 7 + ".im"))
+ out = tmp_path / ("name_limit_test" * 7 + ".im")
with Image.open(TEST_IM) as im:
im.save(out)
assert filecmp.cmp(out, "Tests/images/hopper_long_name.im")
@@ -68,12 +68,14 @@ def test_tell() -> None:
def test_n_frames() -> None:
with Image.open(TEST_IM) as im:
+ assert isinstance(im, ImImagePlugin.ImImageFile)
assert im.n_frames == 1
assert not im.is_animated
def test_eoferror() -> None:
with Image.open(TEST_IM) as im:
+ assert isinstance(im, ImImagePlugin.ImImageFile)
n_frames = im.n_frames
# Test seeking past the last frame
@@ -87,7 +89,7 @@ def test_eoferror() -> None:
@pytest.mark.parametrize("mode", ("RGB", "P", "PA"))
def test_roundtrip(mode: str, tmp_path: Path) -> None:
- out = str(tmp_path / "temp.im")
+ out = tmp_path / "temp.im"
im = hopper(mode)
im.save(out)
assert_image_equal_tofile(im, out)
@@ -98,7 +100,7 @@ def test_small_palette(tmp_path: Path) -> None:
colors = [0, 1, 2]
im.putpalette(colors)
- out = str(tmp_path / "temp.im")
+ out = tmp_path / "temp.im"
im.save(out)
with Image.open(out) as reloaded:
@@ -106,7 +108,7 @@ def test_small_palette(tmp_path: Path) -> None:
def test_save_unsupported_mode(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.im")
+ out = tmp_path / "temp.im"
im = hopper("HSV")
with pytest.raises(ValueError):
im.save(out)
diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py
index a2481c336..79f0ec1a8 100644
--- a/Tests/test_file_jpeg.py
+++ b/Tests/test_file_jpeg.py
@@ -83,7 +83,7 @@ class TestFileJpeg:
@pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0)))
def test_zero(self, size: tuple[int, int], tmp_path: Path) -> None:
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
im = Image.new("RGB", size)
with pytest.raises(ValueError):
im.save(f)
@@ -91,6 +91,7 @@ class TestFileJpeg:
def test_app(self) -> None:
# Test APP/COM reader (@PIL135)
with Image.open(TEST_FILE) as im:
+ assert isinstance(im, JpegImagePlugin.JpegImageFile)
assert im.applist[0] == ("APP0", b"JFIF\x00\x01\x01\x01\x00`\x00`\x00\x00")
assert im.applist[1] == (
"COM",
@@ -194,7 +195,7 @@ class TestFileJpeg:
icc_profile = im1.info["icc_profile"]
assert len(icc_profile) == 3144
# Roundtrip via physical file.
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
im1.save(f, icc_profile=icc_profile)
with Image.open(f) as im2:
assert im2.info.get("icc_profile") == icc_profile
@@ -238,7 +239,7 @@ class TestFileJpeg:
# Sometimes the meta data on the icc_profile block is bigger than
# Image.MAXBLOCK or the image size.
with Image.open("Tests/images/icc_profile_big.jpg") as im:
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
icc_profile = im.info["icc_profile"]
# Should not raise OSError for image with icc larger than image size.
im.save(
@@ -250,11 +251,11 @@ class TestFileJpeg:
)
with Image.open("Tests/images/flower2.jpg") as im:
- f = str(tmp_path / "temp2.jpg")
+ f = tmp_path / "temp2.jpg"
im.save(f, progressive=True, quality=94, icc_profile=b" " * 53955)
with Image.open("Tests/images/flower2.jpg") as im:
- f = str(tmp_path / "temp3.jpg")
+ f = tmp_path / "temp3.jpg"
im.save(f, progressive=True, quality=94, exif=b" " * 43668)
def test_optimize(self) -> None:
@@ -268,7 +269,7 @@ class TestFileJpeg:
def test_optimize_large_buffer(self, tmp_path: Path) -> None:
# https://github.com/python-pillow/Pillow/issues/148
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
# this requires ~ 1.5x Image.MAXBLOCK
im = Image.new("RGB", (4096, 4096), 0xFF3333)
im.save(f, format="JPEG", optimize=True)
@@ -288,13 +289,13 @@ class TestFileJpeg:
assert im1_bytes >= im3_bytes
def test_progressive_large_buffer(self, tmp_path: Path) -> None:
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
# this requires ~ 1.5x Image.MAXBLOCK
im = Image.new("RGB", (4096, 4096), 0xFF3333)
im.save(f, format="JPEG", progressive=True)
def test_progressive_large_buffer_highest_quality(self, tmp_path: Path) -> None:
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
im = self.gen_random_image((255, 255))
# this requires more bytes than pixels in the image
im.save(f, format="JPEG", progressive=True, quality=100)
@@ -307,7 +308,7 @@ class TestFileJpeg:
def test_large_exif(self, tmp_path: Path) -> None:
# https://github.com/python-pillow/Pillow/issues/148
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
im = hopper()
im.save(f, "JPEG", quality=90, exif=b"1" * 65533)
@@ -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()
@@ -335,7 +338,7 @@ class TestFileJpeg:
assert exif[gps_index] == expected_exif_gps
# Writing
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
exif = Image.Exif()
exif[gps_index] = expected_exif_gps
hopper().save(f, exif=exif)
@@ -500,20 +503,21 @@ class TestFileJpeg:
def test_mp(self) -> None:
with Image.open("Tests/images/pil_sample_rgb.jpg") as im:
+ assert isinstance(im, JpegImagePlugin.JpegImageFile)
assert im._getmp() is None
def test_quality_keep(self, tmp_path: Path) -> None:
# RGB
with Image.open("Tests/images/hopper.jpg") as im:
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
im.save(f, quality="keep")
# Grayscale
with Image.open("Tests/images/hopper_gray.jpg") as im:
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
im.save(f, quality="keep")
# CMYK
with Image.open("Tests/images/pil_sample_cmyk.jpg") as im:
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
im.save(f, quality="keep")
def test_junk_jpeg_header(self) -> None:
@@ -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)
@@ -726,7 +734,7 @@ class TestFileJpeg:
def test_MAXBLOCK_scaling(self, tmp_path: Path) -> None:
im = self.gen_random_image((512, 512))
- f = str(tmp_path / "temp.jpeg")
+ f = tmp_path / "temp.jpeg"
im.save(f, quality=100, optimize=True)
with Image.open(f) as reloaded:
@@ -762,7 +770,7 @@ class TestFileJpeg:
def test_save_tiff_with_dpi(self, tmp_path: Path) -> None:
# Arrange
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/hopper.tif") as im:
# Act
im.save(outfile, "JPEG", dpi=im.info["dpi"])
@@ -773,7 +781,7 @@ class TestFileJpeg:
assert im.info["dpi"] == reloaded.info["dpi"]
def test_save_dpi_rounding(self, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.jpg")
+ outfile = tmp_path / "temp.jpg"
with Image.open("Tests/images/hopper.jpg") as im:
im.save(outfile, dpi=(72.2, 72.2))
@@ -859,7 +867,7 @@ class TestFileJpeg:
exif = im.getexif()
assert exif[282] == 180
- out = str(tmp_path / "out.jpg")
+ out = tmp_path / "out.jpg"
with warnings.catch_warnings():
warnings.simplefilter("error")
@@ -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"]
@@ -1005,7 +1014,7 @@ class TestFileJpeg:
assert im.getxmp() == {"xmpmeta": None}
def test_save_xmp(self, tmp_path: Path) -> None:
- f = str(tmp_path / "temp.jpg")
+ f = tmp_path / "temp.jpg"
im = hopper()
im.save(f, xmp=b"XMP test")
with Image.open(f) as reloaded:
@@ -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):
@@ -1094,7 +1104,7 @@ class TestFileJpeg:
@skip_unless_feature("jpg")
class TestFileCloseW32:
def test_fd_leak(self, tmp_path: Path) -> None:
- tmpfile = str(tmp_path / "temp.jpg")
+ tmpfile = tmp_path / "temp.jpg"
with Image.open("Tests/images/hopper.jpg") as im:
im.save(tmpfile)
diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py
index 5748fa5a1..4095bfaf2 100644
--- a/Tests/test_file_jpeg2k.py
+++ b/Tests/test_file_jpeg2k.py
@@ -99,7 +99,7 @@ def test_bytesio(card: ImageFile.ImageFile) -> None:
def test_lossless(card: ImageFile.ImageFile, tmp_path: Path) -> None:
with Image.open("Tests/images/test-card-lossless.jp2") as im:
im.load()
- outfile = str(tmp_path / "temp_test-card.png")
+ outfile = tmp_path / "temp_test-card.png"
im.save(outfile)
assert_image_similar(im, card, 1.0e-3)
@@ -213,7 +213,7 @@ def test_header_errors() -> None:
def test_layers_type(card: ImageFile.ImageFile, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp_layers.jp2")
+ outfile = tmp_path / "temp_layers.jp2"
for quality_layers in [[100, 50, 10], (100, 50, 10), None]:
card.save(outfile, quality_layers=quality_layers)
@@ -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)
@@ -289,7 +291,7 @@ def test_mct(card: ImageFile.ImageFile) -> None:
def test_sgnd(tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.jp2")
+ outfile = tmp_path / "temp.jp2"
im = Image.new("L", (1, 1))
im.save(outfile)
@@ -313,6 +315,18 @@ def test_rgba(ext: str) -> None:
assert im.mode == "RGBA"
+def test_grayscale_four_channels() -> None:
+ with open("Tests/images/rgb_trns_ycbc.jp2", "rb") as fp:
+ data = fp.read()
+
+ # Change color space to OPJ_CLRSPC_GRAY
+ data = data[:76] + b"\x11" + data[77:]
+
+ with Image.open(BytesIO(data)) as im:
+ im.load()
+ assert im.mode == "RGBA"
+
+
@pytest.mark.skipif(
not os.path.exists(EXTRA_DIR), reason="Extra image files not installed"
)
diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 369c2db1b..9916215fb 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -36,10 +36,11 @@ 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.
- out = str(tmp_path / "temp.png")
+ out = tmp_path / "temp.png"
im.save(out)
out_bytes = io.BytesIO()
@@ -123,7 +124,7 @@ class TestFileLibTiff(LibTiffTestCase):
"""Checking to see that the saved image is the same as what we wrote"""
test_file = "Tests/images/hopper_g4_500.tif"
with Image.open(test_file) as orig:
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
rot = orig.transpose(Image.Transpose.ROTATE_90)
assert rot.size == (500, 500)
rot.save(out)
@@ -151,8 +152,9 @@ class TestFileLibTiff(LibTiffTestCase):
@pytest.mark.parametrize("legacy_api", (False, True))
def test_write_metadata(self, legacy_api: bool, tmp_path: Path) -> None:
"""Test metadata writing through libtiff"""
- f = str(tmp_path / "temp.tiff")
+ f = tmp_path / "temp.tiff"
with Image.open("Tests/images/hopper_g4.tif") as img:
+ assert isinstance(img, TiffImagePlugin.TiffImageFile)
img.save(f, tiffinfo=img.tag)
if legacy_api:
@@ -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]
@@ -247,7 +251,7 @@ class TestFileLibTiff(LibTiffTestCase):
# Extra samples really doesn't make sense in this application.
del new_ifd[338]
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
im.save(out, tiffinfo=new_ifd)
@@ -313,10 +317,11 @@ class TestFileLibTiff(LibTiffTestCase):
) -> None:
im = hopper()
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
im.save(out, tiffinfo=tiffinfo)
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
for tag, value in tiffinfo.items():
reloaded_value = reloaded.tag_v2[tag]
if (
@@ -347,14 +352,16 @@ class TestFileLibTiff(LibTiffTestCase):
)
def test_osubfiletype(self, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/g4_orientation_6.tif") as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.tag_v2[OSUBFILETYPE] = 1
im.save(outfile)
def test_subifd(self, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/g4_orientation_6.tif") as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.tag_v2[SUBIFD] = 10000
# Should not segfault
@@ -365,17 +372,18 @@ class TestFileLibTiff(LibTiffTestCase):
) -> None:
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
hopper().save(out, tiffinfo={700: b"xmlpacket tag"})
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
if 700 in reloaded.tag_v2:
assert reloaded.tag_v2[700] == b"xmlpacket tag"
def test_int_dpi(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
# issue #1765
im = hopper("RGB")
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
im.save(out, dpi=(72, 72))
with Image.open(out) as reloaded:
@@ -383,7 +391,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test_g3_compression(self, tmp_path: Path) -> None:
with Image.open("Tests/images/hopper_g4_500.tif") as i:
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
i.save(out, compression="group3")
with Image.open(out) as reread:
@@ -400,7 +408,7 @@ class TestFileLibTiff(LibTiffTestCase):
assert b[0] == ord(b"\xe0")
assert b[1] == ord(b"\x01")
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
# out = "temp.le.tif"
im.save(out)
with Image.open(out) as reread:
@@ -420,7 +428,7 @@ class TestFileLibTiff(LibTiffTestCase):
assert b[0] == ord(b"\x01")
assert b[1] == ord(b"\xe0")
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
im.save(out)
with Image.open(out) as reread:
assert reread.info["compression"] == im.info["compression"]
@@ -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:
- out = str(tmp_path / "temp.tif")
+ assert isinstance(orig, TiffImagePlugin.TiffImageFile)
+
+ out = tmp_path / "temp.tif"
orig.tag[269] = "temp.tif"
orig.save(out)
with Image.open(out) as reread:
+ assert isinstance(reread, TiffImagePlugin.TiffImageFile)
assert "temp.tif" == reread.tag_v2[269]
assert "temp.tif" == reread.tag[269][0]
@@ -457,7 +468,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test_blur(self, tmp_path: Path) -> None:
# test case from irc, how to do blur on b/w image
# and save to compressed tif.
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
with Image.open("Tests/images/pport_g4.tif") as im:
im = im.convert("L")
@@ -470,7 +481,7 @@ class TestFileLibTiff(LibTiffTestCase):
# Test various tiff compressions and assert similar image content but reduced
# file sizes.
im = hopper("RGB")
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
im.save(out)
size_raw = os.path.getsize(out)
@@ -494,7 +505,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test_tiff_jpeg_compression(self, tmp_path: Path) -> None:
im = hopper("RGB")
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
im.save(out, compression="tiff_jpeg")
with Image.open(out) as reloaded:
@@ -502,7 +513,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test_tiff_deflate_compression(self, tmp_path: Path) -> None:
im = hopper("RGB")
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
im.save(out, compression="tiff_deflate")
with Image.open(out) as reloaded:
@@ -510,7 +521,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test_quality(self, tmp_path: Path) -> None:
im = hopper("RGB")
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
with pytest.raises(ValueError):
im.save(out, compression="tiff_lzw", quality=50)
@@ -525,7 +536,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test_cmyk_save(self, tmp_path: Path) -> None:
im = hopper("CMYK")
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
im.save(out, compression="tiff_adobe_deflate")
assert_image_equal_tofile(im, out)
@@ -534,19 +545,20 @@ class TestFileLibTiff(LibTiffTestCase):
def test_palette_save(
self, im: Image.Image, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
im.save(out)
with Image.open(out) as reloaded:
# colormap/palette tag
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert len(reloaded.tag_v2[320]) == 768
@pytest.mark.parametrize("compression", ("tiff_ccitt", "group3", "group4"))
def test_bw_compression_w_rgb(self, compression: str, tmp_path: Path) -> None:
im = hopper("RGB")
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
with pytest.raises(OSError):
im.save(out, compression=compression)
@@ -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
@@ -686,25 +701,29 @@ class TestFileLibTiff(LibTiffTestCase):
def test_save_ycbcr(self, tmp_path: Path) -> None:
im = hopper("YCbCr")
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
im.save(outfile, compression="jpeg")
with Image.open(outfile) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[530] == (1, 1)
assert reloaded.tag_v2[532] == (0, 255, 128, 255, 128, 255)
def test_exif_ifd(self) -> None:
out = io.BytesIO()
with Image.open("Tests/images/tiff_adobe_deflate.tif") as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2[34665] == 125456
im.save(out, "TIFF")
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert 34665 not in reloaded.tag_v2
im.save(out, "TIFF", tiffinfo={34665: 125456})
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
if Image.core.libtiff_support_custom_tags:
assert reloaded.tag_v2[34665] == 125456
@@ -713,7 +732,7 @@ class TestFileLibTiff(LibTiffTestCase):
) -> None:
# issue 1597
with Image.open("Tests/images/rdf.tif") as im:
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
# this shouldn't crash
@@ -724,7 +743,7 @@ class TestFileLibTiff(LibTiffTestCase):
# Test TIFF with tag 297 (Page Number) having value of 0 0.
# The first number is the current page number.
# The second is the total number of pages, zero means not available.
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
# Created by printing a page in Chrome to PDF, then:
# /usr/bin/gs -q -sDEVICE=tiffg3 -sOutputFile=total-pages-zero.tif
# -dNOPAUSE /tmp/test.pdf -c quit
@@ -736,7 +755,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test_fd_duplication(self, tmp_path: Path) -> None:
# https://github.com/python-pillow/Pillow/issues/1651
- tmpfile = str(tmp_path / "temp.tif")
+ tmpfile = tmp_path / "temp.tif"
with open(tmpfile, "wb") as f:
with open("Tests/images/g4-multi.tiff", "rb") as src:
f.write(src.read())
@@ -779,13 +798,14 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open("Tests/images/hopper.iccprofile.tif") as img:
icc_profile = img.info["icc_profile"]
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
img.save(out, icc_profile=icc_profile)
with Image.open(out) as reloaded:
assert icc_profile == reloaded.info["icc_profile"]
def test_multipage_compression(self) -> None:
with Image.open("Tests/images/compression.tif") as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.seek(0)
assert im._compression == "tiff_ccitt"
assert im.size == (10, 10)
@@ -802,7 +822,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test_save_tiff_with_jpegtables(self, tmp_path: Path) -> None:
# Arrange
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
# Created with ImageMagick: convert hopper.jpg hopper_jpg.tif
# Contains JPEGTables (347) tag
@@ -864,7 +884,7 @@ class TestFileLibTiff(LibTiffTestCase):
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
im = Image.new("F", (1, 1))
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
im.save(out)
@@ -1008,7 +1028,7 @@ class TestFileLibTiff(LibTiffTestCase):
@pytest.mark.parametrize("compression", (None, "jpeg"))
def test_block_tile_tags(self, compression: str | None, tmp_path: Path) -> None:
im = hopper()
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
tags = {
TiffImagePlugin.TILEWIDTH: 256,
@@ -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()
@@ -1140,16 +1172,14 @@ class TestFileLibTiff(LibTiffTestCase):
def test_realloc_overflow(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open("Tests/images/tiff_overflow_rows_per_strip.tif") as im:
- with pytest.raises(OSError) as e:
- im.load()
-
# Assert that the error code is IMAGING_CODEC_MEMORY
- assert str(e.value) == "decoder error -9"
+ with pytest.raises(OSError, match="decoder error -9"):
+ im.load()
@pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg"))
def test_save_multistrip(self, compression: str, tmp_path: Path) -> None:
im = hopper("RGB").resize((256, 256))
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
im.save(out, compression=compression)
with Image.open(out) as im:
@@ -1162,7 +1192,7 @@ class TestFileLibTiff(LibTiffTestCase):
self, argument: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
im = hopper("RGB").resize((256, 256))
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
if not argument:
monkeypatch.setattr(TiffImagePlugin, "STRIP_SIZE", 2**18)
@@ -1178,13 +1208,13 @@ class TestFileLibTiff(LibTiffTestCase):
@pytest.mark.parametrize("compression", ("tiff_adobe_deflate", None))
def test_save_zero(self, compression: str | None, tmp_path: Path) -> None:
im = Image.new("RGB", (0, 0))
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
with pytest.raises(SystemError):
im.save(out, compression=compression)
def test_save_many_compressed(self, tmp_path: Path) -> None:
im = hopper()
- out = str(tmp_path / "temp.tif")
+ out = tmp_path / "temp.tif"
for _ in range(10000):
im.save(out, compression="jpeg")
diff --git a/Tests/test_file_mic.py b/Tests/test_file_mic.py
index 9a6f13ea3..9aeb306e4 100644
--- a/Tests/test_file_mic.py
+++ b/Tests/test_file_mic.py
@@ -30,11 +30,13 @@ def test_sanity() -> None:
def test_n_frames() -> None:
with Image.open(TEST_FILE) as im:
+ assert isinstance(im, MicImagePlugin.MicImageFile)
assert im.n_frames == 1
def test_is_animated() -> None:
with Image.open(TEST_FILE) as im:
+ assert isinstance(im, MicImagePlugin.MicImageFile)
assert not im.is_animated
@@ -55,10 +57,11 @@ def test_seek() -> None:
def test_close() -> None:
with Image.open(TEST_FILE) as im:
- pass
+ assert isinstance(im, MicImagePlugin.MicImageFile)
assert im.ole.fp.closed
im = Image.open(TEST_FILE)
+ assert isinstance(im, MicImagePlugin.MicImageFile)
im.close()
assert im.ole.fp.closed
diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py
index ab8f2d5a1..73838ef44 100644
--- a/Tests/test_file_mpo.py
+++ b/Tests/test_file_mpo.py
@@ -6,7 +6,7 @@ from typing import Any
import pytest
-from PIL import Image, ImageFile, MpoImagePlugin
+from PIL import Image, ImageFile, JpegImagePlugin, MpoImagePlugin
from .helper import (
assert_image_equal,
@@ -29,12 +29,17 @@ def roundtrip(im: Image.Image, **options: Any) -> ImageFile.ImageFile:
@pytest.mark.parametrize("test_file", test_files)
def test_sanity(test_file: str) -> None:
- with Image.open(test_file) as im:
+ def check(im: ImageFile.ImageFile) -> None:
im.load()
assert im.mode == "RGB"
assert im.size == (640, 480)
assert im.format == "MPO"
+ with Image.open(test_file) as im:
+ check(im)
+ with MpoImagePlugin.MpoImageFile(test_file) as im:
+ check(im)
+
@pytest.mark.skipif(is_pypy(), reason="Requires CPython")
def test_unclosed_file() -> None:
@@ -75,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(
@@ -215,12 +221,14 @@ def test_seek(test_file: str) -> None:
def test_n_frames() -> None:
with Image.open("Tests/images/sugarshack.mpo") as im:
+ assert isinstance(im, MpoImagePlugin.MpoImageFile)
assert im.n_frames == 2
assert im.is_animated
def test_eoferror() -> None:
with Image.open("Tests/images/sugarshack.mpo") as im:
+ assert isinstance(im, MpoImagePlugin.MpoImageFile)
n_frames = im.n_frames
# Test seeking past the last frame
@@ -234,6 +242,8 @@ def test_eoferror() -> None:
def test_adopt_jpeg() -> None:
with Image.open("Tests/images/hopper.jpg") as im:
+ assert isinstance(im, JpegImagePlugin.JpegImageFile)
+
with pytest.raises(ValueError):
MpoImagePlugin.MpoImageFile.adopt(im)
diff --git a/Tests/test_file_msp.py b/Tests/test_file_msp.py
index b0964aabe..8c91922bd 100644
--- a/Tests/test_file_msp.py
+++ b/Tests/test_file_msp.py
@@ -15,7 +15,7 @@ YA_EXTRA_DIR = "Tests/images/msp"
def test_sanity(tmp_path: Path) -> None:
- test_file = str(tmp_path / "temp.msp")
+ test_file = tmp_path / "temp.msp"
hopper("1").save(test_file)
@@ -84,7 +84,7 @@ def test_msp_v2() -> None:
def test_cannot_save_wrong_mode(tmp_path: Path) -> None:
# Arrange
im = hopper()
- filename = str(tmp_path / "temp.msp")
+ filename = tmp_path / "temp.msp"
# Act/Assert
with pytest.raises(OSError):
diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py
index 194f39b30..58208ba99 100644
--- a/Tests/test_file_palm.py
+++ b/Tests/test_file_palm.py
@@ -14,7 +14,7 @@ from .helper import assert_image_equal, hopper, magick_command
def helper_save_as_palm(tmp_path: Path, mode: str) -> None:
# Arrange
im = hopper(mode)
- outfile = str(tmp_path / ("temp_" + mode + ".palm"))
+ outfile = tmp_path / ("temp_" + mode + ".palm")
# Act
im.save(outfile)
@@ -25,7 +25,7 @@ def helper_save_as_palm(tmp_path: Path, mode: str) -> None:
def open_with_magick(magick: list[str], tmp_path: Path, f: str) -> Image.Image:
- outfile = str(tmp_path / "temp.png")
+ outfile = tmp_path / "temp.png"
rc = subprocess.call(
magick + [f, outfile], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT
)
@@ -43,6 +43,11 @@ def roundtrip(tmp_path: Path, mode: str) -> None:
im.save(outfile)
converted = open_with_magick(magick, tmp_path, outfile)
+ if mode == "P":
+ assert converted.mode == "P"
+
+ im = im.convert("RGB")
+ converted = converted.convert("RGB")
assert_image_equal(converted, im)
@@ -55,7 +60,6 @@ def test_monochrome(tmp_path: Path) -> None:
roundtrip(tmp_path, mode)
-@pytest.mark.xfail(reason="Palm P image is wrong")
def test_p_mode(tmp_path: Path) -> None:
# Arrange
mode = "P"
diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py
index b3f38c3e5..5d7fd1c1b 100644
--- a/Tests/test_file_pcx.py
+++ b/Tests/test_file_pcx.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import io
from pathlib import Path
import pytest
@@ -10,7 +11,7 @@ from .helper import assert_image_equal, hopper
def _roundtrip(tmp_path: Path, im: Image.Image) -> None:
- f = str(tmp_path / "temp.pcx")
+ f = tmp_path / "temp.pcx"
im.save(f)
with Image.open(f) as im2:
assert im2.mode == im.mode
@@ -30,12 +31,34 @@ def test_sanity(tmp_path: Path) -> None:
_roundtrip(tmp_path, im)
# Test an unsupported mode
- f = str(tmp_path / "temp.pcx")
+ f = tmp_path / "temp.pcx"
im = hopper("RGBA")
with pytest.raises(ValueError):
im.save(f)
+def test_bad_image_size() -> None:
+ with open("Tests/images/pil184.pcx", "rb") as fp:
+ data = fp.read()
+ data = data[:4] + b"\xff\xff" + data[6:]
+
+ b = io.BytesIO(data)
+ with pytest.raises(SyntaxError, match="bad PCX image size"):
+ with PcxImagePlugin.PcxImageFile(b):
+ pass
+
+
+def test_unknown_mode() -> None:
+ with open("Tests/images/pil184.pcx", "rb") as fp:
+ data = fp.read()
+ data = data[:3] + b"\xff" + data[4:]
+
+ b = io.BytesIO(data)
+ with pytest.raises(OSError, match="unknown PCX mode"):
+ with Image.open(b):
+ pass
+
+
def test_invalid_file() -> None:
invalid_file = "Tests/images/flower.jpg"
diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py
index 815686a52..bde1e3ab8 100644
--- a/Tests/test_file_pdf.py
+++ b/Tests/test_file_pdf.py
@@ -55,7 +55,7 @@ def test_save_alpha(tmp_path: Path, mode: str) -> None:
def test_p_alpha(tmp_path: Path) -> None:
# Arrange
- outfile = str(tmp_path / "temp.pdf")
+ outfile = tmp_path / "temp.pdf"
with Image.open("Tests/images/pil123p.png") as im:
assert im.mode == "P"
assert isinstance(im.info["transparency"], bytes)
@@ -80,7 +80,7 @@ def test_monochrome(tmp_path: Path) -> None:
def test_unsupported_mode(tmp_path: Path) -> None:
im = hopper("PA")
- outfile = str(tmp_path / "temp_PA.pdf")
+ outfile = tmp_path / "temp_PA.pdf"
with pytest.raises(ValueError):
im.save(outfile)
@@ -89,7 +89,7 @@ def test_unsupported_mode(tmp_path: Path) -> None:
def test_resolution(tmp_path: Path) -> None:
im = hopper()
- outfile = str(tmp_path / "temp.pdf")
+ outfile = tmp_path / "temp.pdf"
im.save(outfile, resolution=150)
with open(outfile, "rb") as fp:
@@ -117,7 +117,7 @@ def test_resolution(tmp_path: Path) -> None:
def test_dpi(params: dict[str, int | tuple[int, int]], tmp_path: Path) -> None:
im = hopper()
- outfile = str(tmp_path / "temp.pdf")
+ outfile = tmp_path / "temp.pdf"
im.save(outfile, "PDF", **params)
with open(outfile, "rb") as fp:
@@ -144,7 +144,7 @@ def test_save_all(tmp_path: Path) -> None:
# Multiframe image
with Image.open("Tests/images/dispose_bgnd.gif") as im:
- outfile = str(tmp_path / "temp.pdf")
+ outfile = tmp_path / "temp.pdf"
im.save(outfile, save_all=True)
assert os.path.isfile(outfile)
@@ -177,7 +177,7 @@ def test_save_all(tmp_path: Path) -> None:
def test_multiframe_normal_save(tmp_path: Path) -> None:
# Test saving a multiframe image without save_all
with Image.open("Tests/images/dispose_bgnd.gif") as im:
- outfile = str(tmp_path / "temp.pdf")
+ outfile = tmp_path / "temp.pdf"
im.save(outfile)
assert os.path.isfile(outfile)
diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py
index efd2e5cd9..0f0886ab8 100644
--- a/Tests/test_file_png.py
+++ b/Tests/test_file_png.py
@@ -68,7 +68,7 @@ def roundtrip(im: Image.Image, **options: Any) -> PngImagePlugin.PngImageFile:
@skip_unless_feature("zlib")
class TestFilePng:
- def get_chunks(self, filename: str) -> list[bytes]:
+ def get_chunks(self, filename: Path) -> list[bytes]:
chunks = []
with open(filename, "rb") as fp:
fp.read(8)
@@ -89,7 +89,7 @@ class TestFilePng:
assert version is not None
assert re.search(r"\d+(\.\d+){1,3}(\.zlib\-ng)?$", version)
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
hopper("RGB").save(test_file)
@@ -250,7 +250,7 @@ class TestFilePng:
# each palette entry
assert len(im.info["transparency"]) == 256
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im.save(test_file)
# check if saved image contains same transparency
@@ -271,7 +271,7 @@ class TestFilePng:
assert im.info["transparency"] == 164
assert im.getpixel((31, 31)) == 164
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im.save(test_file)
# check if saved image contains same transparency
@@ -294,7 +294,7 @@ class TestFilePng:
assert im.getcolors() == [(100, (0, 0, 0, 0))]
im = im.convert("P")
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im.save(test_file)
# check if saved image contains same transparency
@@ -315,7 +315,7 @@ class TestFilePng:
im_rgba = im.convert("RGBA")
assert im_rgba.getchannel("A").getcolors()[0][0] == num_transparent
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im.save(test_file)
with Image.open(test_file) as test_im:
@@ -329,7 +329,7 @@ class TestFilePng:
def test_save_rgb_single_transparency(self, tmp_path: Path) -> None:
in_file = "Tests/images/caption_6_33_22.png"
with Image.open(in_file) as im:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im.save(test_file)
def test_load_verify(self) -> None:
@@ -488,7 +488,7 @@ class TestFilePng:
im = hopper("P")
im.info["transparency"] = 0
- f = str(tmp_path / "temp.png")
+ f = tmp_path / "temp.png"
im.save(f)
with Image.open(f) as im2:
@@ -549,7 +549,7 @@ class TestFilePng:
def test_chunk_order(self, tmp_path: Path) -> None:
with Image.open("Tests/images/icc_profile.png") as im:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im.convert("P").save(test_file, dpi=(100, 100))
chunks = self.get_chunks(test_file)
@@ -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
@@ -661,7 +667,7 @@ class TestFilePng:
def test_specify_bits(self, save_all: bool, tmp_path: Path) -> None:
im = hopper("P")
- out = str(tmp_path / "temp.png")
+ out = tmp_path / "temp.png"
im.save(out, bits=4, save_all=save_all)
with Image.open(out) as reloaded:
@@ -671,8 +677,8 @@ class TestFilePng:
im = Image.new("P", (1, 1))
im.putpalette((1, 1, 1))
- out = str(tmp_path / "temp.png")
- im.save(str(tmp_path / "temp.png"))
+ out = tmp_path / "temp.png"
+ im.save(out)
with Image.open(out) as reloaded:
assert len(reloaded.png.im_palette[1]) == 3
@@ -721,11 +727,12 @@ class TestFilePng:
def test_exif_save(self, tmp_path: Path) -> None:
# Test exif is not saved from info
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
with Image.open("Tests/images/exif.png") as im:
im.save(test_file)
with Image.open(test_file) as reloaded:
+ assert isinstance(reloaded, PngImagePlugin.PngImageFile)
assert reloaded._getexif() is None
# Test passing in exif
@@ -741,7 +748,7 @@ class TestFilePng:
)
def test_exif_from_jpg(self, tmp_path: Path) -> None:
with Image.open("Tests/images/pil_sample_rgb.jpg") as im:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im.save(test_file, exif=im.getexif())
with Image.open(test_file) as reloaded:
@@ -750,7 +757,7 @@ class TestFilePng:
def test_exif_argument(self, tmp_path: Path) -> None:
with Image.open(TEST_PNG_FILE) as im:
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
im.save(test_file, exif=b"exifstring")
with Image.open(test_file) as reloaded:
diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py
index d87192ca5..41e2b5416 100644
--- a/Tests/test_file_ppm.py
+++ b/Tests/test_file_ppm.py
@@ -94,7 +94,7 @@ def test_16bit_pgm() -> None:
def test_16bit_pgm_write(tmp_path: Path) -> None:
with Image.open("Tests/images/16_bit_binary.pgm") as im:
- filename = str(tmp_path / "temp.pgm")
+ filename = tmp_path / "temp.pgm"
im.save(filename, "PPM")
assert_image_equal_tofile(im, filename)
@@ -106,7 +106,7 @@ def test_pnm(tmp_path: Path) -> None:
with Image.open("Tests/images/hopper.pnm") as im:
assert_image_similar(im, hopper(), 0.0001)
- filename = str(tmp_path / "temp.pnm")
+ filename = tmp_path / "temp.pnm"
im.save(filename)
assert_image_equal_tofile(im, filename)
@@ -117,7 +117,7 @@ def test_pfm(tmp_path: Path) -> None:
assert im.info["scale"] == 1.0
assert_image_equal(im, hopper("F"))
- filename = str(tmp_path / "tmp.pfm")
+ filename = tmp_path / "tmp.pfm"
im.save(filename)
assert_image_equal_tofile(im, filename)
@@ -128,7 +128,7 @@ def test_pfm_big_endian(tmp_path: Path) -> None:
assert im.info["scale"] == 2.5
assert_image_equal(im, hopper("F"))
- filename = str(tmp_path / "tmp.pfm")
+ filename = tmp_path / "tmp.pfm"
im.save(filename)
assert_image_equal_tofile(im, filename)
@@ -194,8 +194,8 @@ def test_16bit_plain_pgm() -> None:
def test_plain_data_with_comment(
tmp_path: Path, header: bytes, data: bytes, comment_count: int
) -> None:
- path1 = str(tmp_path / "temp1.ppm")
- path2 = str(tmp_path / "temp2.ppm")
+ path1 = tmp_path / "temp1.ppm"
+ path2 = tmp_path / "temp2.ppm"
comment = b"# comment" * comment_count
with open(path1, "wb") as f1, open(path2, "wb") as f2:
f1.write(header + b"\n\n" + data)
@@ -207,7 +207,7 @@ def test_plain_data_with_comment(
@pytest.mark.parametrize("data", (b"P1\n128 128\n", b"P3\n128 128\n255\n"))
def test_plain_truncated_data(tmp_path: Path, data: bytes) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(data)
@@ -218,7 +218,7 @@ def test_plain_truncated_data(tmp_path: Path, data: bytes) -> None:
@pytest.mark.parametrize("data", (b"P1\n128 128\n1009", b"P3\n128 128\n255\n100A"))
def test_plain_invalid_data(tmp_path: Path, data: bytes) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(data)
@@ -235,7 +235,7 @@ def test_plain_invalid_data(tmp_path: Path, data: bytes) -> None:
),
)
def test_plain_ppm_token_too_long(tmp_path: Path, data: bytes) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(data)
@@ -245,7 +245,7 @@ def test_plain_ppm_token_too_long(tmp_path: Path, data: bytes) -> None:
def test_plain_ppm_value_negative(tmp_path: Path) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(b"P3\n128 128\n255\n-1")
@@ -255,7 +255,7 @@ def test_plain_ppm_value_negative(tmp_path: Path) -> None:
def test_plain_ppm_value_too_large(tmp_path: Path) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(b"P3\n128 128\n255\n256")
@@ -270,7 +270,7 @@ def test_magic() -> None:
def test_header_with_comments(tmp_path: Path) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(b"P6 #comment\n#comment\r12#comment\r8\n128 #comment\n255\n")
@@ -279,7 +279,7 @@ def test_header_with_comments(tmp_path: Path) -> None:
def test_non_integer_token(tmp_path: Path) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(b"P6\nTEST")
@@ -289,29 +289,25 @@ def test_non_integer_token(tmp_path: Path) -> None:
def test_header_token_too_long(tmp_path: Path) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(b"P6\n 01234567890")
- with pytest.raises(ValueError) as e:
+ with pytest.raises(ValueError, match="Token too long in file header: 01234567890"):
with Image.open(path):
pass
- assert str(e.value) == "Token too long in file header: 01234567890"
-
def test_truncated_file(tmp_path: Path) -> None:
# Test EOF in header
- path = str(tmp_path / "temp.pgm")
+ path = tmp_path / "temp.pgm"
with open(path, "wb") as f:
f.write(b"P6")
- with pytest.raises(ValueError) as e:
+ with pytest.raises(ValueError, match="Reached EOF while reading header"):
with Image.open(path):
pass
- assert str(e.value) == "Reached EOF while reading header"
-
# Test EOF for PyDecoder
fp = BytesIO(b"P5 3 1 4")
with Image.open(fp) as im:
@@ -320,7 +316,7 @@ def test_truncated_file(tmp_path: Path) -> None:
def test_not_enough_image_data(tmp_path: Path) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(b"P2 1 2 255 255")
@@ -331,16 +327,16 @@ def test_not_enough_image_data(tmp_path: Path) -> None:
@pytest.mark.parametrize("maxval", (b"0", b"65536"))
def test_invalid_maxval(maxval: bytes, tmp_path: Path) -> None:
- path = str(tmp_path / "temp.ppm")
+ path = tmp_path / "temp.ppm"
with open(path, "wb") as f:
f.write(b"P6\n3 1 " + maxval)
- with pytest.raises(ValueError) as e:
+ with pytest.raises(
+ ValueError, match="maxval must be greater than 0 and less than 65536"
+ ):
with Image.open(path):
pass
- assert str(e.value) == "maxval must be greater than 0 and less than 65536"
-
def test_neg_ppm() -> None:
# Storage.c accepted negative values for xsize, ysize. the
@@ -354,7 +350,7 @@ def test_neg_ppm() -> None:
def test_mimetypes(tmp_path: Path) -> None:
- path = str(tmp_path / "temp.pgm")
+ path = tmp_path / "temp.pgm"
with open(path, "wb") as f:
f.write(b"P4\n128 128\n255")
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 e13a8019e..da0965fa1 100644
--- a/Tests/test_file_sgi.py
+++ b/Tests/test_file_sgi.py
@@ -71,31 +71,33 @@ def test_invalid_file() -> None:
SgiImagePlugin.SgiImageFile(invalid_file)
-def test_write(tmp_path: Path) -> None:
- def roundtrip(img: Image.Image) -> None:
- out = str(tmp_path / "temp.sgi")
- img.save(out, format="sgi")
+def roundtrip(img: Image.Image, tmp_path: Path) -> None:
+ out = tmp_path / "temp.sgi"
+ img.save(out, format="sgi")
+ assert_image_equal_tofile(img, out)
+
+ out = tmp_path / "fp.sgi"
+ with open(out, "wb") as fp:
+ img.save(fp)
assert_image_equal_tofile(img, out)
- out = str(tmp_path / "fp.sgi")
- with open(out, "wb") as fp:
- img.save(fp)
- assert_image_equal_tofile(img, out)
+ assert not fp.closed
- assert not fp.closed
- for mode in ("L", "RGB", "RGBA"):
- roundtrip(hopper(mode))
+@pytest.mark.parametrize("mode", ("L", "RGB", "RGBA"))
+def test_write(mode: str, tmp_path: Path) -> None:
+ roundtrip(hopper(mode), tmp_path)
- # Test 1 dimension for an L mode image
- roundtrip(Image.new("L", (10, 1)))
+
+def test_write_L_mode_1_dimension(tmp_path: Path) -> None:
+ roundtrip(Image.new("L", (10, 1)), tmp_path)
def test_write16(tmp_path: Path) -> None:
test_file = "Tests/images/hopper16.rgb"
with Image.open(test_file) as im:
- out = str(tmp_path / "temp.sgi")
+ out = tmp_path / "temp.sgi"
im.save(out, format="sgi", bpc=2)
assert_image_equal_tofile(im, out)
@@ -103,7 +105,7 @@ def test_write16(tmp_path: Path) -> None:
def test_unsupported_mode(tmp_path: Path) -> None:
im = hopper("LA")
- out = str(tmp_path / "temp.sgi")
+ out = tmp_path / "temp.sgi"
with pytest.raises(ValueError):
im.save(out, format="sgi")
diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py
index cdb7b3e0b..3b3c3b4a5 100644
--- a/Tests/test_file_spider.py
+++ b/Tests/test_file_spider.py
@@ -51,7 +51,7 @@ def test_context_manager() -> None:
def test_save(tmp_path: Path) -> None:
# Arrange
- temp = str(tmp_path / "temp.spider")
+ temp = tmp_path / "temp.spider"
im = hopper()
# Act
@@ -96,6 +96,7 @@ def test_tell() -> None:
def test_n_frames() -> None:
with Image.open(TEST_FILE) as im:
+ assert isinstance(im, SpiderImagePlugin.SpiderImageFile)
assert im.n_frames == 1
assert not im.is_animated
diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py
index b6396bd64..8b6ed3ed2 100644
--- a/Tests/test_file_tga.py
+++ b/Tests/test_file_tga.py
@@ -1,8 +1,6 @@
from __future__ import annotations
import os
-from glob import glob
-from itertools import product
from pathlib import Path
import pytest
@@ -15,16 +13,29 @@ _TGA_DIR = os.path.join("Tests", "images", "tga")
_TGA_DIR_COMMON = os.path.join(_TGA_DIR, "common")
-_MODES = ("L", "LA", "P", "RGB", "RGBA")
_ORIGINS = ("tl", "bl")
_ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1}
-@pytest.mark.parametrize("mode", _MODES)
-def test_sanity(mode: str, tmp_path: Path) -> None:
+@pytest.mark.parametrize(
+ "size_mode",
+ (
+ ("1x1", "L"),
+ ("200x32", "L"),
+ ("200x32", "LA"),
+ ("200x32", "P"),
+ ("200x32", "RGB"),
+ ("200x32", "RGBA"),
+ ),
+)
+@pytest.mark.parametrize("origin", _ORIGINS)
+@pytest.mark.parametrize("rle", (True, False))
+def test_sanity(
+ size_mode: tuple[str, str], origin: str, rle: str, tmp_path: Path
+) -> None:
def roundtrip(original_im: Image.Image) -> None:
- out = str(tmp_path / "temp.tga")
+ out = tmp_path / "temp.tga"
original_im.save(out, rle=rle)
with Image.open(out) as saved_im:
@@ -36,36 +47,29 @@ def test_sanity(mode: str, tmp_path: Path) -> None:
assert_image_equal(saved_im, original_im)
- png_paths = glob(os.path.join(_TGA_DIR_COMMON, f"*x*_{mode.lower()}.png"))
+ size, mode = size_mode
+ png_path = os.path.join(_TGA_DIR_COMMON, size + "_" + mode.lower() + ".png")
+ with Image.open(png_path) as reference_im:
+ assert reference_im.mode == mode
- for png_path in png_paths:
- with Image.open(png_path) as reference_im:
- assert reference_im.mode == mode
+ path_no_ext = os.path.splitext(png_path)[0]
+ tga_path = "{}_{}_{}.tga".format(path_no_ext, origin, "rle" if rle else "raw")
- path_no_ext = os.path.splitext(png_path)[0]
- for origin, rle in product(_ORIGINS, (True, False)):
- tga_path = "{}_{}_{}.tga".format(
- path_no_ext, origin, "rle" if rle else "raw"
- )
+ with Image.open(tga_path) as original_im:
+ assert original_im.format == "TGA"
+ assert original_im.get_format_mimetype() == "image/x-tga"
+ if rle:
+ assert original_im.info["compression"] == "tga_rle"
+ assert original_im.info["orientation"] == _ORIGIN_TO_ORIENTATION[origin]
+ if mode == "P":
+ assert original_im.getpalette() == reference_im.getpalette()
- with Image.open(tga_path) as original_im:
- assert original_im.format == "TGA"
- assert original_im.get_format_mimetype() == "image/x-tga"
- if rle:
- assert original_im.info["compression"] == "tga_rle"
- assert (
- original_im.info["orientation"]
- == _ORIGIN_TO_ORIENTATION[origin]
- )
- if mode == "P":
- assert original_im.getpalette() == reference_im.getpalette()
+ assert_image_equal(original_im, reference_im)
- assert_image_equal(original_im, reference_im)
-
- roundtrip(original_im)
+ roundtrip(original_im)
-def test_palette_depth_8(tmp_path: Path) -> None:
+def test_palette_depth_8() -> None:
with pytest.raises(UnidentifiedImageError):
Image.open("Tests/images/p_8.tga")
@@ -76,7 +80,7 @@ def test_palette_depth_16(tmp_path: Path) -> None:
assert im.palette.mode == "RGBA"
assert_image_equal_tofile(im.convert("RGBA"), "Tests/images/p_16.png")
- out = str(tmp_path / "temp.png")
+ out = tmp_path / "temp.png"
im.save(out)
with Image.open(out) as reloaded:
assert_image_equal_tofile(reloaded.convert("RGBA"), "Tests/images/p_16.png")
@@ -122,7 +126,7 @@ def test_cross_scan_line() -> None:
def test_save(tmp_path: Path) -> None:
test_file = "Tests/images/tga_id_field.tga"
with Image.open(test_file) as im:
- out = str(tmp_path / "temp.tga")
+ out = tmp_path / "temp.tga"
# Save
im.save(out)
@@ -141,7 +145,7 @@ def test_small_palette(tmp_path: Path) -> None:
colors = [0, 0, 0]
im.putpalette(colors)
- out = str(tmp_path / "temp.tga")
+ out = tmp_path / "temp.tga"
im.save(out)
with Image.open(out) as reloaded:
@@ -155,7 +159,7 @@ def test_missing_palette() -> None:
def test_save_wrong_mode(tmp_path: Path) -> None:
im = hopper("PA")
- out = str(tmp_path / "temp.tga")
+ out = tmp_path / "temp.tga"
with pytest.raises(OSError):
im.save(out)
@@ -172,7 +176,7 @@ def test_save_mapdepth() -> None:
def test_save_id_section(tmp_path: Path) -> None:
test_file = "Tests/images/rgb32rle.tga"
with Image.open(test_file) as im:
- out = str(tmp_path / "temp.tga")
+ out = tmp_path / "temp.tga"
# Check there is no id section
im.save(out)
@@ -202,7 +206,7 @@ def test_save_id_section(tmp_path: Path) -> None:
def test_save_orientation(tmp_path: Path) -> None:
test_file = "Tests/images/rgb32rle.tga"
- out = str(tmp_path / "temp.tga")
+ out = tmp_path / "temp.tga"
with Image.open(test_file) as im:
assert im.info["orientation"] == -1
@@ -229,7 +233,7 @@ def test_save_rle(tmp_path: Path) -> None:
with Image.open(test_file) as im:
assert im.info["compression"] == "tga_rle"
- out = str(tmp_path / "temp.tga")
+ out = tmp_path / "temp.tga"
# Save
im.save(out)
@@ -266,7 +270,7 @@ def test_save_l_transparency(tmp_path: Path) -> None:
assert im.mode == "LA"
assert im.getchannel("A").getcolors()[0][0] == num_transparent
- out = str(tmp_path / "temp.tga")
+ out = tmp_path / "temp.tga"
im.save(out)
with Image.open(out) as test_im:
diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py
index a8a407963..502d9df9a 100644
--- a/Tests/test_file_tiff.py
+++ b/Tests/test_file_tiff.py
@@ -9,7 +9,13 @@ from types import ModuleType
import pytest
-from PIL import Image, ImageFile, TiffImagePlugin, UnidentifiedImageError
+from PIL import (
+ Image,
+ ImageFile,
+ JpegImagePlugin,
+ TiffImagePlugin,
+ UnidentifiedImageError,
+)
from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION
from .helper import (
@@ -31,7 +37,7 @@ except ImportError:
class TestFileTiff:
def test_sanity(self, tmp_path: Path) -> None:
- filename = str(tmp_path / "temp.tif")
+ filename = tmp_path / "temp.tif"
hopper("RGB").save(filename)
@@ -112,20 +118,23 @@ class TestFileTiff:
assert_image_equal_tofile(im, "Tests/images/hopper.tif")
with Image.open("Tests/images/hopper_bigtiff.tif") as im:
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2)
def test_bigtiff_save(self, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
im = hopper()
im.save(outfile, big_tiff=True)
with Image.open(outfile) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2._bigtiff is True
im.save(outfile, save_all=True, append_images=[im], big_tiff=True)
with Image.open(outfile) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2._bigtiff is True
def test_seek_too_large(self) -> None:
@@ -134,13 +143,14 @@ class TestFileTiff:
def test_set_legacy_api(self) -> None:
ifd = TiffImagePlugin.ImageFileDirectory_v2()
- with pytest.raises(Exception) as e:
+ with pytest.raises(Exception, match="Not allowing setting of legacy api"):
ifd.legacy_api = False
- assert str(e.value) == "Not allowing setting of legacy api"
def test_xyres_tiff(self) -> None:
filename = "Tests/images/pil168.tif"
with Image.open(filename) as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
+
# legacy api
assert isinstance(im.tag[X_RESOLUTION][0], tuple)
assert isinstance(im.tag[Y_RESOLUTION][0], tuple)
@@ -154,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)
@@ -168,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
@@ -182,11 +196,12 @@ class TestFileTiff:
with Image.open(
"Tests/images/hopper_float_dpi_" + str(resolution_unit) + ".tif"
) as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2.get(RESOLUTION_UNIT) == resolution_unit
assert im.info["dpi"] == (dpi, dpi)
def test_save_float_dpi(self, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/hopper.tif") as im:
dpi = (72.2, 72.2)
im.save(outfile, dpi=dpi)
@@ -199,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
@@ -214,19 +230,21 @@ class TestFileTiff:
TiffImagePlugin.PREFIXES.pop()
def test_bad_exif(self) -> None:
- with Image.open("Tests/images/hopper_bad_exif.jpg") as i:
+ with Image.open("Tests/images/hopper_bad_exif.jpg") as im:
+ assert isinstance(im, JpegImagePlugin.JpegImageFile)
+
# Should not raise struct.error.
with pytest.warns(UserWarning):
- i._getexif()
+ im._getexif()
def test_save_rgba(self, tmp_path: Path) -> None:
im = hopper("RGBA")
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
im.save(outfile)
def test_save_unsupported_mode(self, tmp_path: Path) -> None:
im = hopper("HSV")
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
with pytest.raises(OSError):
im.save(outfile)
@@ -308,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
@@ -356,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)
@@ -379,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,
@@ -418,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))
@@ -450,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:
@@ -486,14 +515,14 @@ class TestFileTiff:
assert gps[0] == b"\x03\x02\x00\x00"
assert gps[18] == "WGS-84"
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/ifd_tag_type.tiff") as im:
exif = im.getexif()
check_exif(exif)
im.save(outfile, exif=exif)
- outfile2 = str(tmp_path / "temp2.tif")
+ outfile2 = tmp_path / "temp2.tif"
with Image.open(outfile) as im:
exif = im.getexif()
check_exif(exif)
@@ -505,7 +534,7 @@ class TestFileTiff:
check_exif(exif)
def test_modify_exif(self, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/ifd_tag_type.tiff") as im:
exif = im.getexif()
exif[264] = 100
@@ -534,10 +563,11 @@ class TestFileTiff:
@pytest.mark.parametrize("mode", ("1", "L"))
def test_photometric(self, mode: str, tmp_path: Path) -> None:
- filename = str(tmp_path / "temp.tif")
+ filename = tmp_path / "temp.tif"
im = hopper(mode)
im.save(filename, tiffinfo={262: 0})
with Image.open(filename) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[262] == 0
assert_image_equal(im, reloaded)
@@ -613,9 +643,11 @@ class TestFileTiff:
def test_with_underscores(self, tmp_path: Path) -> None:
kwargs = {"resolution_unit": "inch", "x_resolution": 72, "y_resolution": 36}
- filename = str(tmp_path / "temp.tif")
+ filename = tmp_path / "temp.tif"
hopper("RGB").save(filename, "TIFF", **kwargs)
with Image.open(filename) as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
+
# legacy interface
assert im.tag[X_RESOLUTION][0][0] == 72
assert im.tag[Y_RESOLUTION][0][0] == 36
@@ -631,14 +663,14 @@ class TestFileTiff:
with Image.open(infile) as im:
assert im.getpixel((0, 0)) == pixel_value
- tmpfile = str(tmp_path / "temp.tif")
+ tmpfile = tmp_path / "temp.tif"
im.save(tmpfile)
assert_image_equal_tofile(im, tmpfile)
def test_iptc(self, tmp_path: Path) -> None:
# Do not preserve IPTC_NAA_CHUNK by default if type is LONG
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/hopper.tif") as im:
im.load()
assert isinstance(im, TiffImagePlugin.TiffImageFile)
@@ -653,7 +685,7 @@ class TestFileTiff:
assert 33723 not in im.tag_v2
def test_rowsperstrip(self, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
im = hopper()
im.save(outfile, tiffinfo={278: 256})
@@ -661,6 +693,18 @@ class TestFileTiff:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2[278] == 256
+ im = hopper()
+ im2 = Image.new("L", (128, 128))
+ im2.encoderinfo = {"tiffinfo": {278: 256}}
+ im.save(outfile, save_all=True, append_images=[im2])
+
+ with Image.open(outfile) as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
+ assert im.tag_v2[278] == 128
+
+ im.seek(1)
+ assert im.tag_v2[278] == 256
+
def test_strip_raw(self) -> None:
infile = "Tests/images/tiff_strip_raw.tif"
with Image.open(infile) as im:
@@ -690,9 +734,10 @@ class TestFileTiff:
def test_planar_configuration_save(self, tmp_path: Path) -> None:
infile = "Tests/images/tiff_tiled_planar_raw.tif"
with Image.open(infile) as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im._planar_configuration == 2
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
im.save(outfile)
with Image.open(outfile) as reloaded:
@@ -707,7 +752,7 @@ class TestFileTiff:
@pytest.mark.parametrize("mode", ("P", "PA"))
def test_palette(self, mode: str, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
im = hopper(mode)
im.save(outfile)
@@ -722,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
@@ -732,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
@@ -743,6 +790,7 @@ class TestFileTiff:
mp.seek(0, os.SEEK_SET)
with Image.open(mp) as reread:
+ assert isinstance(reread, TiffImagePlugin.TiffImageFile)
assert reread.n_frames == 3
def test_fixoffsets(self) -> None:
@@ -801,7 +849,7 @@ class TestFileTiff:
im.info["icc_profile"] = "Dummy value"
# Try save-load round trip to make sure both handle icc_profile.
- tmpfile = str(tmp_path / "temp.tif")
+ tmpfile = tmp_path / "temp.tif"
im.save(tmpfile, "TIFF", compression="raw")
with Image.open(tmpfile) as reloaded:
assert b"Dummy value" == reloaded.info["icc_profile"]
@@ -810,7 +858,7 @@ class TestFileTiff:
im = hopper()
assert "icc_profile" not in im.info
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
icc_profile = b"Dummy value"
im.save(outfile, icc_profile=icc_profile)
@@ -821,11 +869,11 @@ class TestFileTiff:
with Image.open("Tests/images/hopper.bmp") as im:
assert im.info["compression"] == 0
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
im.save(outfile)
def test_discard_icc_profile(self, tmp_path: Path) -> None:
- outfile = str(tmp_path / "temp.tif")
+ outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/icc_profile.png") as im:
assert "icc_profile" in im.info
@@ -853,6 +901,7 @@ class TestFileTiff:
def test_get_photoshop_blocks(self) -> None:
with Image.open("Tests/images/lab.tif") as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert list(im.get_photoshop_blocks().keys()) == [
1061,
1002,
@@ -878,7 +927,7 @@ class TestFileTiff:
]
def test_tiff_chunks(self, tmp_path: Path) -> None:
- tmpfile = str(tmp_path / "temp.tif")
+ tmpfile = tmp_path / "temp.tif"
im = hopper()
with open(tmpfile, "wb") as fp:
@@ -900,7 +949,7 @@ class TestFileTiff:
def test_close_on_load_exclusive(self, tmp_path: Path) -> None:
# similar to test_fd_leak, but runs on unixlike os
- tmpfile = str(tmp_path / "temp.tif")
+ tmpfile = tmp_path / "temp.tif"
with Image.open("Tests/images/uint16_1_4660.tif") as im:
im.save(tmpfile)
@@ -912,7 +961,7 @@ class TestFileTiff:
assert fp.closed
def test_close_on_load_nonexclusive(self, tmp_path: Path) -> None:
- tmpfile = str(tmp_path / "temp.tif")
+ tmpfile = tmp_path / "temp.tif"
with Image.open("Tests/images/uint16_1_4660.tif") as im:
im.save(tmpfile)
@@ -963,7 +1012,7 @@ class TestFileTiff:
@pytest.mark.skipif(not is_win32(), reason="Windows only")
class TestFileTiffW32:
def test_fd_leak(self, tmp_path: Path) -> None:
- tmpfile = str(tmp_path / "temp.tif")
+ tmpfile = tmp_path / "temp.tif"
# this is an mmaped file.
with Image.open("Tests/images/uint16_1_4660.tif") as im:
diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py
index 36aabf4f8..884868345 100644
--- a/Tests/test_file_tiff_metadata.py
+++ b/Tests/test_file_tiff_metadata.py
@@ -56,11 +56,12 @@ def test_rt_metadata(tmp_path: Path) -> None:
info[ImageDescription] = text_data
- f = str(tmp_path / "temp.tif")
+ f = tmp_path / "temp.tif"
img.save(f, tiffinfo=info)
with Image.open(f) as loaded:
+ assert isinstance(loaded, TiffImagePlugin.TiffImageFile)
assert loaded.tag[ImageJMetaDataByteCounts] == (len(bin_data),)
assert loaded.tag_v2[ImageJMetaDataByteCounts] == (len(bin_data),)
@@ -80,12 +81,14 @@ def test_rt_metadata(tmp_path: Path) -> None:
info[ImageJMetaDataByteCounts] = (8, len(bin_data) - 8)
img.save(f, tiffinfo=info)
with Image.open(f) as loaded:
+ assert isinstance(loaded, TiffImagePlugin.TiffImageFile)
assert loaded.tag[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8)
assert loaded.tag_v2[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8)
def test_read_metadata() -> None:
with Image.open("Tests/images/hopper_g4.tif") as img:
+ assert isinstance(img, TiffImagePlugin.TiffImageFile)
assert {
"YResolution": IFDRational(4294967295, 113653537),
"PlanarConfiguration": 1,
@@ -128,13 +131,15 @@ def test_read_metadata() -> None:
def test_write_metadata(tmp_path: Path) -> None:
"""Test metadata writing through the python code"""
with Image.open("Tests/images/hopper.tif") as img:
- f = str(tmp_path / "temp.tiff")
+ assert isinstance(img, TiffImagePlugin.TiffImageFile)
+ f = tmp_path / "temp.tiff"
del img.tag[278]
img.save(f, tiffinfo=img.tag)
original = img.tag_v2.named()
with Image.open(f) as loaded:
+ assert isinstance(loaded, TiffImagePlugin.TiffImageFile)
reloaded = loaded.tag_v2.named()
ignored = ["StripByteCounts", "RowsPerStrip", "PageNumber", "StripOffsets"]
@@ -163,8 +168,9 @@ def test_write_metadata(tmp_path: Path) -> None:
def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
with Image.open("Tests/images/hopper.tif") as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
info = im.tag_v2
del info[278]
@@ -178,6 +184,7 @@ def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None:
im.save(out, tiffinfo=info)
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] == TiffTags.LONG
@@ -210,7 +217,7 @@ def test_no_duplicate_50741_tag() -> None:
def test_iptc(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
with Image.open("Tests/images/hopper.Lab.tif") as im:
im.save(out)
@@ -227,10 +234,11 @@ def test_writing_other_types_to_ascii(
info[271] = value
im = hopper()
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info)
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[271] == expected
@@ -244,10 +252,11 @@ def test_writing_other_types_to_bytes(value: int | IFDRational, tmp_path: Path)
info[700] = value
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info)
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[700] == b"\x01"
@@ -263,10 +272,11 @@ def test_writing_other_types_to_undefined(
info[33723] = value
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info)
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[33723] == b"1"
@@ -296,7 +306,7 @@ def test_empty_metadata() -> None:
def test_iccprofile(tmp_path: Path) -> None:
# https://github.com/python-pillow/Pillow/issues/1462
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
with Image.open("Tests/images/hopper.iccprofile.tif") as im:
im.save(out)
@@ -311,19 +321,20 @@ def test_iccprofile_binary() -> None:
# but probably won't be able to save it.
with Image.open("Tests/images/hopper.iccprofile_binary.tif") as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2.tagtype[34675] == 1
assert im.info["icc_profile"]
def test_iccprofile_save_png(tmp_path: Path) -> None:
with Image.open("Tests/images/hopper.iccprofile.tif") as im:
- outfile = str(tmp_path / "temp.png")
+ outfile = tmp_path / "temp.png"
im.save(outfile)
def test_iccprofile_binary_save_png(tmp_path: Path) -> None:
with Image.open("Tests/images/hopper.iccprofile_binary.tif") as im:
- outfile = str(tmp_path / "temp.png")
+ outfile = tmp_path / "temp.png"
im.save(outfile)
@@ -332,10 +343,11 @@ def test_exif_div_zero(tmp_path: Path) -> None:
info = TiffImagePlugin.ImageFileDirectory_v2()
info[41988] = TiffImagePlugin.IFDRational(0, 0)
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert 0 == reloaded.tag_v2[41988].numerator
assert 0 == reloaded.tag_v2[41988].denominator
@@ -351,10 +363,11 @@ def test_ifd_unsigned_rational(tmp_path: Path) -> None:
info[41493] = TiffImagePlugin.IFDRational(numerator, 1)
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert max_long == reloaded.tag_v2[41493].numerator
assert 1 == reloaded.tag_v2[41493].denominator
@@ -363,10 +376,11 @@ def test_ifd_unsigned_rational(tmp_path: Path) -> None:
info[41493] = TiffImagePlugin.IFDRational(numerator, 1)
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert max_long == reloaded.tag_v2[41493].numerator
assert 1 == reloaded.tag_v2[41493].denominator
@@ -381,10 +395,11 @@ def test_ifd_signed_rational(tmp_path: Path) -> None:
info[37380] = TiffImagePlugin.IFDRational(numerator, denominator)
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert numerator == reloaded.tag_v2[37380].numerator
assert denominator == reloaded.tag_v2[37380].denominator
@@ -393,10 +408,11 @@ def test_ifd_signed_rational(tmp_path: Path) -> None:
info[37380] = TiffImagePlugin.IFDRational(numerator, denominator)
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert numerator == reloaded.tag_v2[37380].numerator
assert denominator == reloaded.tag_v2[37380].denominator
@@ -406,10 +422,11 @@ def test_ifd_signed_rational(tmp_path: Path) -> None:
info[37380] = TiffImagePlugin.IFDRational(numerator, denominator)
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert 2**31 - 1 == reloaded.tag_v2[37380].numerator
assert -1 == reloaded.tag_v2[37380].denominator
@@ -420,10 +437,11 @@ def test_ifd_signed_long(tmp_path: Path) -> None:
info[37000] = -60000
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[37000] == -60000
@@ -444,11 +462,13 @@ def test_empty_values() -> None:
def test_photoshop_info(tmp_path: Path) -> None:
with Image.open("Tests/images/issue_2278.tif") as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert len(im.tag_v2[34377]) == 70
assert isinstance(im.tag_v2[34377], bytes)
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
im.save(out)
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert len(reloaded.tag_v2[34377]) == 70
assert isinstance(reloaded.tag_v2[34377], bytes)
@@ -480,7 +500,7 @@ def test_tag_group_data() -> None:
def test_empty_subifd(tmp_path: Path) -> None:
- out = str(tmp_path / "temp.jpg")
+ out = tmp_path / "temp.jpg"
im = hopper()
exif = im.getexif()
diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py
index abe888241..f61e2c82e 100644
--- a/Tests/test_file_webp.py
+++ b/Tests/test_file_webp.py
@@ -154,9 +154,8 @@ class TestFileWebp:
@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system")
def test_write_encoding_error_message(self, tmp_path: Path) -> None:
im = Image.new("RGB", (15000, 15000))
- with pytest.raises(ValueError) as e:
+ with pytest.raises(ValueError, match="encoding error 6"):
im.save(tmp_path / "temp.webp", method=0)
- assert str(e.value) == "encoding error 6"
@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system")
def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None:
@@ -231,7 +230,7 @@ class TestFileWebp:
with Image.open(out_gif) as reread:
reread_value = reread.convert("RGB").getpixel((1, 1))
- difference = sum(abs(original_value[i] - reread_value[i]) for i in range(0, 3))
+ difference = sum(abs(original_value[i] - reread_value[i]) for i in range(3))
assert difference < 5
def test_duration(self, tmp_path: Path) -> None:
diff --git a/Tests/test_file_webp_alpha.py b/Tests/test_file_webp_alpha.py
index c88fe3589..c573390c4 100644
--- a/Tests/test_file_webp_alpha.py
+++ b/Tests/test_file_webp_alpha.py
@@ -42,7 +42,7 @@ def test_write_lossless_rgb(tmp_path: Path) -> None:
Does it have the bits we expect?
"""
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
# temp_file = "temp.webp"
pil_image = hopper("RGBA")
@@ -71,7 +71,7 @@ def test_write_rgba(tmp_path: Path) -> None:
Does it have the bits we expect?
"""
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
pil_image = Image.new("RGBA", (10, 10), (255, 0, 0, 20))
pil_image.save(temp_file)
@@ -104,7 +104,7 @@ def test_keep_rgb_values_when_transparent(tmp_path: Path) -> None:
half_transparent_image.putalpha(new_alpha)
# save with transparent area preserved
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
half_transparent_image.save(temp_file, exact=True, lossless=True)
with Image.open(temp_file) as reloaded:
@@ -123,7 +123,7 @@ def test_write_unsupported_mode_PA(tmp_path: Path) -> None:
should work, and be similar to the original file.
"""
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
file_path = "Tests/images/transparent.gif"
with Image.open(file_path) as im:
im.save(temp_file)
@@ -142,10 +142,10 @@ def test_write_unsupported_mode_PA(tmp_path: Path) -> None:
def test_alpha_quality(tmp_path: Path) -> None:
with Image.open("Tests/images/transparent.png") as im:
- out = str(tmp_path / "temp.webp")
+ out = tmp_path / "temp.webp"
im.save(out)
- out_quality = str(tmp_path / "quality.webp")
+ out_quality = tmp_path / "quality.webp"
im.save(out_quality, alpha_quality=50)
with Image.open(out) as reloaded:
with Image.open(out_quality) as reloaded_quality:
diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py
index 967a0aae8..503761374 100644
--- a/Tests/test_file_webp_animated.py
+++ b/Tests/test_file_webp_animated.py
@@ -6,7 +6,7 @@ from pathlib import Path
import pytest
from packaging.version import parse as parse_version
-from PIL import Image, features
+from PIL import GifImagePlugin, Image, WebPImagePlugin, features
from .helper import (
assert_image_equal,
@@ -22,10 +22,12 @@ def test_n_frames() -> None:
"""Ensure that WebP format sets n_frames and is_animated attributes correctly."""
with Image.open("Tests/images/hopper.webp") as im:
+ assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 1
assert not im.is_animated
with Image.open("Tests/images/iss634.webp") as im:
+ assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 42
assert im.is_animated
@@ -37,11 +39,13 @@ def test_write_animation_L(tmp_path: Path) -> None:
"""
with Image.open("Tests/images/iss634.gif") as orig:
+ assert isinstance(orig, GifImagePlugin.GifImageFile)
assert orig.n_frames > 1
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
orig.save(temp_file, save_all=True)
with Image.open(temp_file) as im:
+ assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == orig.n_frames
# Compare first and last frames to the original animated GIF
@@ -67,8 +71,9 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
are visually similar to the originals.
"""
- def check(temp_file: str) -> None:
+ def check(temp_file: Path) -> None:
with Image.open(temp_file) as im:
+ assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 2
# Compare first frame to original
@@ -87,7 +92,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
with Image.open("Tests/images/anim_frame1.webp") as frame1:
with Image.open("Tests/images/anim_frame2.webp") as frame2:
- temp_file1 = str(tmp_path / "temp.webp")
+ temp_file1 = tmp_path / "temp.webp"
frame1.copy().save(
temp_file1, save_all=True, append_images=[frame2], lossless=True
)
@@ -99,7 +104,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
) -> Generator[Image.Image, None, None]:
yield from ims
- temp_file2 = str(tmp_path / "temp_generator.webp")
+ temp_file2 = tmp_path / "temp_generator.webp"
frame1.copy().save(
temp_file2,
save_all=True,
@@ -116,7 +121,7 @@ def test_timestamp_and_duration(tmp_path: Path) -> None:
"""
durations = [0, 10, 20, 30, 40]
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
with Image.open("Tests/images/anim_frame1.webp") as frame1:
with Image.open("Tests/images/anim_frame2.webp") as frame2:
frame1.save(
@@ -127,6 +132,7 @@ def test_timestamp_and_duration(tmp_path: Path) -> None:
)
with Image.open(temp_file) as im:
+ assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 5
assert im.is_animated
@@ -141,7 +147,7 @@ def test_timestamp_and_duration(tmp_path: Path) -> None:
def test_float_duration(tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
with Image.open("Tests/images/iss634.apng") as im:
assert im.info["duration"] == 70.0
@@ -159,7 +165,7 @@ def test_seeking(tmp_path: Path) -> None:
"""
dur = 33
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
with Image.open("Tests/images/anim_frame1.webp") as frame1:
with Image.open("Tests/images/anim_frame2.webp") as frame2:
frame1.save(
@@ -170,6 +176,7 @@ def test_seeking(tmp_path: Path) -> None:
)
with Image.open(temp_file) as im:
+ assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 5
assert im.is_animated
@@ -196,10 +203,10 @@ def test_alpha_quality(tmp_path: Path) -> None:
with Image.open("Tests/images/transparent.png") as im:
first_frame = Image.new("L", im.size)
- out = str(tmp_path / "temp.webp")
+ out = tmp_path / "temp.webp"
first_frame.save(out, save_all=True, append_images=[im])
- out_quality = str(tmp_path / "quality.webp")
+ out_quality = tmp_path / "quality.webp"
first_frame.save(
out_quality, save_all=True, append_images=[im], alpha_quality=50
)
diff --git a/Tests/test_file_webp_lossless.py b/Tests/test_file_webp_lossless.py
index 80429715e..5eaa4f599 100644
--- a/Tests/test_file_webp_lossless.py
+++ b/Tests/test_file_webp_lossless.py
@@ -13,7 +13,7 @@ RGB_MODE = "RGB"
def test_write_lossless_rgb(tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
hopper(RGB_MODE).save(temp_file, lossless=True)
diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py
index c68a20d7a..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()
@@ -146,7 +147,7 @@ def test_write_animated_metadata(tmp_path: Path) -> None:
exif_data = b""
xmp_data = b""
- temp_file = str(tmp_path / "temp.webp")
+ temp_file = tmp_path / "temp.webp"
with Image.open("Tests/images/anim_frame1.webp") as frame1:
with Image.open("Tests/images/anim_frame2.webp") as frame2:
frame1.save(
diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py
index 97469b77e..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
@@ -59,7 +68,7 @@ def test_register_handler(tmp_path: Path) -> None:
WmfImagePlugin.register_handler(handler)
im = hopper()
- tmpfile = str(tmp_path / "temp.wmf")
+ tmpfile = tmp_path / "temp.wmf"
im.save(tmpfile)
assert handler.methodCalled
@@ -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,11 +98,27 @@ def test_load_set_dpi() -> None:
assert_image_similar_tofile(im, "Tests/images/drawing_wmf_ref_144.png", 2.1)
+ with Image.open("Tests/images/drawing.emf") as im:
+ assert im.size == (1625, 1625)
+
+ if not hasattr(Image.core, "drawwmf"):
+ return
+ assert isinstance(im, WmfImagePlugin.WmfStubImageFile)
+ im.load(im.info["dpi"])
+ assert im.size == (1625, 1625)
+
+ with Image.open("Tests/images/drawing.emf") as im:
+ assert isinstance(im, WmfImagePlugin.WmfStubImageFile)
+ im.load((72, 144))
+ assert im.size == (82, 164)
+
+ assert_image_equal_tofile(im, "Tests/images/drawing_emf_ref_72_144.png")
+
@pytest.mark.parametrize("ext", (".wmf", ".emf"))
def test_save(ext: str, tmp_path: Path) -> None:
im = hopper()
- tmpfile = str(tmp_path / ("temp" + ext))
+ tmpfile = tmp_path / ("temp" + ext)
with pytest.raises(OSError):
im.save(tmpfile)
diff --git a/Tests/test_file_xbm.py b/Tests/test_file_xbm.py
index 44dd2541f..154f3dcc0 100644
--- a/Tests/test_file_xbm.py
+++ b/Tests/test_file_xbm.py
@@ -73,7 +73,7 @@ def test_invalid_file() -> None:
def test_save_wrong_mode(tmp_path: Path) -> None:
im = hopper()
- out = str(tmp_path / "temp.xbm")
+ out = tmp_path / "temp.xbm"
with pytest.raises(OSError):
im.save(out)
@@ -81,7 +81,7 @@ def test_save_wrong_mode(tmp_path: Path) -> None:
def test_hotspot(tmp_path: Path) -> None:
im = hopper("1")
- out = str(tmp_path / "temp.xbm")
+ out = tmp_path / "temp.xbm"
hotspot = (0, 7)
im.save(out, hotspot=hotspot)
diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py
index 26afe93f4..73c62a44d 100644
--- a/Tests/test_file_xpm.py
+++ b/Tests/test_file_xpm.py
@@ -30,6 +30,7 @@ def test_invalid_file() -> None:
def test_load_read() -> None:
# Arrange
with Image.open(TEST_FILE) as im:
+ assert isinstance(im, XpmImagePlugin.XpmImageFile)
dummy_bytes = 1
# Act
diff --git a/Tests/test_font_bdf.py b/Tests/test_font_bdf.py
index 136070f9e..b4155c879 100644
--- a/Tests/test_font_bdf.py
+++ b/Tests/test_font_bdf.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import io
+
import pytest
from PIL import BdfFontFile, FontFile
@@ -8,13 +10,20 @@ filename = "Tests/images/courB08.bdf"
def test_sanity() -> None:
- with open(filename, "rb") as test_file:
- font = BdfFontFile.BdfFontFile(test_file)
+ with open(filename, "rb") as fp:
+ font = BdfFontFile.BdfFontFile(fp)
assert isinstance(font, FontFile.FontFile)
assert len([_f for _f in font.glyph if _f]) == 190
+def test_zero_width_chars() -> None:
+ with open(filename, "rb") as fp:
+ data = fp.read()
+ data = data[:2650] + b"\x00\x00" + data[2652:]
+ BdfFontFile.BdfFontFile(io.BytesIO(data))
+
+
def test_invalid_file() -> None:
with open("Tests/images/flower.jpg", "rb") as fp:
with pytest.raises(SyntaxError):
diff --git a/Tests/test_fontfile.py b/Tests/test_fontfile.py
index 206499a04..575dada86 100644
--- a/Tests/test_fontfile.py
+++ b/Tests/test_fontfile.py
@@ -4,7 +4,20 @@ from pathlib import Path
import pytest
-from PIL import FontFile
+from PIL import FontFile, Image
+
+
+def test_compile() -> None:
+ font = FontFile.FontFile()
+ font.glyph[0] = ((0, 0), (0, 0, 0, 0), (0, 0, 0, 1), Image.new("L", (0, 0)))
+ font.compile()
+ assert font.ysize == 1
+
+ font.ysize = 2
+ font.compile()
+
+ # Assert that compiling again did not change anything
+ assert font.ysize == 2
def test_save(tmp_path: Path) -> None:
diff --git a/Tests/test_image.py b/Tests/test_image.py
index 5474f951c..c2e850c36 100644
--- a/Tests/test_image.py
+++ b/Tests/test_image.py
@@ -65,9 +65,8 @@ class TestImage:
@pytest.mark.parametrize("mode", ("", "bad", "very very long"))
def test_image_modes_fail(self, mode: str) -> None:
- with pytest.raises(ValueError) as e:
+ with pytest.raises(ValueError, match="unrecognized image mode"):
Image.new(mode, (1, 1))
- assert str(e.value) == "unrecognized image mode"
def test_exception_inheritance(self) -> None:
assert issubclass(UnidentifiedImageError, OSError)
@@ -176,6 +175,13 @@ class TestImage:
with Image.open(io.StringIO()): # type: ignore[arg-type]
pass
+ def test_string(self, tmp_path: Path) -> None:
+ out = str(tmp_path / "temp.png")
+ im = hopper()
+ im.save(out)
+ with Image.open(out) as reloaded:
+ assert_image_equal(im, reloaded)
+
def test_pathlib(self, tmp_path: Path) -> None:
with Image.open(Path("Tests/images/multipage-mmap.tiff")) as im:
assert im.mode == "P"
@@ -188,14 +194,13 @@ class TestImage:
for ext in (".jpg", ".jp2"):
if ext == ".jp2" and not features.check_codec("jpg_2000"):
pytest.skip("jpg_2000 not available")
- temp_file = str(tmp_path / ("temp." + ext))
- im.save(Path(temp_file))
+ im.save(tmp_path / ("temp." + ext))
def test_fp_name(self, tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.jpg")
+ temp_file = tmp_path / "temp.jpg"
class FP(io.BytesIO):
- name: str
+ name: Path
if sys.version_info >= (3, 12):
from collections.abc import Buffer
@@ -225,10 +230,10 @@ class TestImage:
assert_image_similar(im, reloaded, 20)
def test_unknown_extension(self, tmp_path: Path) -> None:
- im = hopper()
- temp_file = str(tmp_path / "temp.unknown")
- with pytest.raises(ValueError):
- im.save(temp_file)
+ temp_file = tmp_path / "temp.unknown"
+ with hopper() as im:
+ with pytest.raises(ValueError):
+ im.save(temp_file)
def test_internals(self) -> None:
im = Image.new("L", (100, 100))
@@ -246,7 +251,7 @@ class TestImage:
reason="Test requires opening an mmaped file for writing",
)
def test_readonly_save(self, tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.bmp")
+ temp_file = tmp_path / "temp.bmp"
shutil.copy("Tests/images/rgb32bf-rgba.bmp", temp_file)
with Image.open(temp_file) as im:
@@ -729,7 +734,7 @@ class TestImage:
# https://github.com/python-pillow/Pillow/issues/835
# Arrange
test_file = "Tests/images/hopper.png"
- temp_file = str(tmp_path / "temp.jpg")
+ temp_file = tmp_path / "temp.jpg"
# Act/Assert
with Image.open(test_file) as im:
@@ -739,7 +744,7 @@ class TestImage:
im.save(temp_file)
def test_no_new_file_on_error(self, tmp_path: Path) -> None:
- temp_file = str(tmp_path / "temp.jpg")
+ temp_file = tmp_path / "temp.jpg"
im = Image.new("RGB", (0, 0))
with pytest.raises(ValueError):
@@ -806,7 +811,7 @@ class TestImage:
assert exif[296] == 2
assert exif[11] == "gThumb 3.0.1"
- out = str(tmp_path / "temp.jpg")
+ out = tmp_path / "temp.jpg"
exif[258] = 8
del exif[274]
del exif[282]
@@ -828,7 +833,7 @@ class TestImage:
assert exif[274] == 1
assert exif[305] == "Adobe Photoshop CC 2017 (Macintosh)"
- out = str(tmp_path / "temp.jpg")
+ out = tmp_path / "temp.jpg"
exif[258] = 8
del exif[306]
exif[274] = 455
@@ -847,7 +852,7 @@ class TestImage:
exif = im.getexif()
assert exif == {}
- out = str(tmp_path / "temp.webp")
+ out = tmp_path / "temp.webp"
exif[258] = 8
exif[40963] = 455
exif[305] = "Pillow test"
@@ -869,7 +874,7 @@ class TestImage:
exif = im.getexif()
assert exif == {274: 1}
- out = str(tmp_path / "temp.png")
+ out = tmp_path / "temp.png"
exif[258] = 8
del exif[274]
exif[40963] = 455
diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py
index 5f8b35c79..7d4f78c23 100644
--- a/Tests/test_image_convert.py
+++ b/Tests/test_image_convert.py
@@ -118,7 +118,7 @@ def test_trns_p(tmp_path: Path) -> None:
im = hopper("P")
im.info["transparency"] = 0
- f = str(tmp_path / "temp.png")
+ f = tmp_path / "temp.png"
im_l = im.convert("L")
assert im_l.info["transparency"] == 0
@@ -154,7 +154,7 @@ def test_trns_l(tmp_path: Path) -> None:
im = hopper("L")
im.info["transparency"] = 128
- f = str(tmp_path / "temp.png")
+ f = tmp_path / "temp.png"
im_la = im.convert("LA")
assert "transparency" not in im_la.info
@@ -177,7 +177,7 @@ def test_trns_RGB(tmp_path: Path) -> None:
im = hopper("RGB")
im.info["transparency"] = im.getpixel((0, 0))
- f = str(tmp_path / "temp.png")
+ f = tmp_path / "temp.png"
im_l = im.convert("L")
assert im_l.info["transparency"] == im_l.getpixel((0, 0)) # undone
diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py
index 1166371b8..f700d20c0 100644
--- a/Tests/test_image_resize.py
+++ b/Tests/test_image_resize.py
@@ -171,7 +171,7 @@ class TestImagingCoreResize:
# platforms. So if a future Pillow change requires that the test file
# be updated, that is okay.
im = hopper().resize((64, 64))
- temp_file = str(tmp_path / "temp.gif")
+ temp_file = tmp_path / "temp.gif"
im.save(temp_file)
with Image.open(temp_file) as reloaded:
diff --git a/Tests/test_image_split.py b/Tests/test_image_split.py
index 3385f81f5..43068535e 100644
--- a/Tests/test_image_split.py
+++ b/Tests/test_image_split.py
@@ -45,9 +45,9 @@ def test_split_merge(mode: str) -> None:
def test_split_open(tmp_path: Path) -> None:
if features.check("zlib"):
- test_file = str(tmp_path / "temp.png")
+ test_file = tmp_path / "temp.png"
else:
- test_file = str(tmp_path / "temp.pcx")
+ test_file = tmp_path / "temp.pcx"
def split_open(mode: str) -> int:
hopper(mode).save(test_file)
diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py
index d127175eb..ffe9c0979 100644
--- a/Tests/test_imagedraw.py
+++ b/Tests/test_imagedraw.py
@@ -39,6 +39,8 @@ BBOX = (((X0, Y0), (X1, Y1)), [(X0, Y0), (X1, Y1)], (X0, Y0, X1, Y1), [X0, Y0, X
POINTS = (
((10, 10), (20, 40), (30, 30)),
[(10, 10), (20, 40), (30, 30)],
+ ([10, 10], [20, 40], [30, 30]),
+ [[10, 10], [20, 40], [30, 30]],
(10, 10, 20, 40, 30, 30),
[10, 10, 20, 40, 30, 30],
)
@@ -46,6 +48,8 @@ POINTS = (
KITE_POINTS = (
((10, 50), (70, 10), (90, 50), (70, 90), (10, 50)),
[(10, 50), (70, 10), (90, 50), (70, 90), (10, 50)],
+ ([10, 50], [70, 10], [90, 50], [70, 90], [10, 50]),
+ [[10, 50], [70, 10], [90, 50], [70, 90], [10, 50]],
)
@@ -448,7 +452,6 @@ def test_shape1() -> None:
x3, y3 = 95, 5
# Act
- assert ImageDraw.Outline is not None
s = ImageDraw.Outline()
s.move(x0, y0)
s.curve(x1, y1, x2, y2, x3, y3)
@@ -470,7 +473,6 @@ def test_shape2() -> None:
x3, y3 = 5, 95
# Act
- assert ImageDraw.Outline is not None
s = ImageDraw.Outline()
s.move(x0, y0)
s.curve(x1, y1, x2, y2, x3, y3)
@@ -489,7 +491,6 @@ def test_transform() -> None:
draw = ImageDraw.Draw(im)
# Act
- assert ImageDraw.Outline is not None
s = ImageDraw.Outline()
s.line(0, 0)
s.transform((0, 0, 0, 0, 0, 0))
@@ -1047,8 +1048,8 @@ def create_base_image_draw(
background2: tuple[int, int, int] = GRAY,
) -> tuple[Image.Image, ImageDraw.ImageDraw]:
img = Image.new(mode, size, background1)
- for x in range(0, size[0]):
- for y in range(0, size[1]):
+ for x in range(size[0]):
+ for y in range(size[1]):
if (x + y) % 2 == 0:
img.putpixel((x, y), background2)
return img, ImageDraw.Draw(img)
@@ -1526,7 +1527,6 @@ def test_same_color_outline(bbox: Coords) -> None:
x2, y2 = 95, 50
x3, y3 = 95, 5
- assert ImageDraw.Outline is not None
s = ImageDraw.Outline()
s.move(x0, y0)
s.curve(x1, y1, x2, y2, x3, y3)
@@ -1630,7 +1630,7 @@ def test_compute_regular_polygon_vertices(
0,
ValueError,
"bounding_circle should contain 2D coordinates "
- "and a radius (e.g. (x, y, r) or ((x, y), r) )",
+ r"and a radius \(e.g. \(x, y, r\) or \(\(x, y\), r\) \)",
),
(
3,
@@ -1644,7 +1644,7 @@ def test_compute_regular_polygon_vertices(
((50, 50, 50), 25),
0,
ValueError,
- "bounding_circle centre should contain 2D coordinates (e.g. (x, y))",
+ r"bounding_circle centre should contain 2D coordinates \(e.g. \(x, y\)\)",
),
(
3,
@@ -1669,9 +1669,8 @@ def test_compute_regular_polygon_vertices_input_error_handling(
expected_error: type[Exception],
error_message: str,
) -> None:
- with pytest.raises(expected_error) as e:
+ with pytest.raises(expected_error, match=error_message):
ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) # type: ignore[arg-type]
- assert str(e.value) == error_message
def test_continuous_horizontal_edges_polygon() -> None:
@@ -1705,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 b05d29dae..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):
@@ -176,9 +196,8 @@ class TestImageFile:
b"0" * ImageFile.SAFEBLOCK
) # only SAFEBLOCK bytes, so that the header is truncated
)
- with pytest.raises(OSError) as e:
+ with pytest.raises(OSError, match="Truncated File Read"):
BmpImagePlugin.BmpImageFile(b)
- assert str(e.value) == "Truncated File Read"
@skip_unless_feature("zlib")
def test_truncated_with_errors(self) -> None:
diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py
index 4cce8f180..69533c2f8 100644
--- a/Tests/test_imagefont.py
+++ b/Tests/test_imagefont.py
@@ -124,7 +124,7 @@ def test_render_equal(layout_engine: ImageFont.Layout) -> None:
def test_non_ascii_path(tmp_path: Path, layout_engine: ImageFont.Layout) -> None:
- tempfile = str(tmp_path / ("temp_" + chr(128) + ".ttf"))
+ tempfile = tmp_path / ("temp_" + chr(128) + ".ttf")
try:
shutil.copy(FONT_PATH, tempfile)
except UnicodeEncodeError:
diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py
index 6180a7b5d..515e29cea 100644
--- a/Tests/test_imagemorph.py
+++ b/Tests/test_imagemorph.py
@@ -80,15 +80,12 @@ def test_lut(op: str) -> None:
def test_no_operator_loaded() -> None:
im = Image.new("L", (1, 1))
mop = ImageMorph.MorphOp()
- with pytest.raises(Exception) as e:
+ with pytest.raises(Exception, match="No operator loaded"):
mop.apply(im)
- assert str(e.value) == "No operator loaded"
- with pytest.raises(Exception) as e:
+ with pytest.raises(Exception, match="No operator loaded"):
mop.match(im)
- assert str(e.value) == "No operator loaded"
- with pytest.raises(Exception) as e:
+ with pytest.raises(Exception, match="No operator loaded"):
mop.save_lut("")
- assert str(e.value) == "No operator loaded"
# Test the named patterns
@@ -238,15 +235,12 @@ def test_incorrect_mode() -> None:
im = hopper("RGB")
mop = ImageMorph.MorphOp(op_name="erosion8")
- with pytest.raises(ValueError) as e:
+ with pytest.raises(ValueError, match="Image mode must be L"):
mop.apply(im)
- assert str(e.value) == "Image mode must be L"
- with pytest.raises(ValueError) as e:
+ with pytest.raises(ValueError, match="Image mode must be L"):
mop.match(im)
- assert str(e.value) == "Image mode must be L"
- with pytest.raises(ValueError) as e:
+ with pytest.raises(ValueError, match="Image mode must be L"):
mop.get_on_pixels(im)
- assert str(e.value) == "Image mode must be L"
def test_add_patterns() -> None:
@@ -279,9 +273,10 @@ def test_pattern_syntax_error() -> None:
lb.add_patterns(new_patterns)
# Act / Assert
- with pytest.raises(Exception) as e:
+ with pytest.raises(
+ Exception, match='Syntax error in pattern "a pattern with a syntax error"'
+ ):
lb.build_lut()
- assert str(e.value) == 'Syntax error in pattern "a pattern with a syntax error"'
def test_load_invalid_mrl() -> None:
@@ -290,9 +285,8 @@ def test_load_invalid_mrl() -> None:
mop = ImageMorph.MorphOp()
# Act / Assert
- with pytest.raises(Exception) as e:
+ with pytest.raises(Exception, match="Wrong size operator file!"):
mop.load_lut(invalid_mrl)
- assert str(e.value) == "Wrong size operator file!"
def test_roundtrip_mrl(tmp_path: Path) -> None:
diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py
index 3621aa50f..9f2fd5ba2 100644
--- a/Tests/test_imageops.py
+++ b/Tests/test_imageops.py
@@ -448,6 +448,15 @@ def test_exif_transpose() -> None:
assert 0x0112 not in transposed_im.getexif()
+def test_exif_transpose_with_xmp_tuple() -> None:
+ with Image.open("Tests/images/xmp_tags_orientation.png") as im:
+ assert im.getexif()[0x0112] == 3
+
+ im.info["xmp"] = (b"test",)
+ transposed_im = ImageOps.exif_transpose(im)
+ assert 0x0112 not in transposed_im.getexif()
+
+
def test_exif_transpose_xml_without_xmp() -> None:
with Image.open("Tests/images/xmp_tags_orientation.png") as im:
assert im.getexif()[0x0112] == 3
diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py
index e0b6359b0..782022f51 100644
--- a/Tests/test_imagepalette.py
+++ b/Tests/test_imagepalette.py
@@ -112,7 +112,7 @@ def test_make_linear_lut() -> None:
assert isinstance(lut, list)
assert len(lut) == 256
# Check values
- for i in range(0, len(lut)):
+ for i in range(len(lut)):
assert lut[i] == i
diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py
index 1b1ee6bac..ad8acde49 100644
--- a/Tests/test_imagepath.py
+++ b/Tests/test_imagepath.py
@@ -68,25 +68,10 @@ def test_path_constructors(
assert list(p) == [(0.0, 1.0)]
-@pytest.mark.parametrize(
- "coords",
- (
- ("a", "b"),
- ([0, 1],),
- [[0, 1]],
- ([0.0, 1.0],),
- [[0.0, 1.0]],
- ),
-)
-def test_invalid_path_constructors(
- coords: tuple[str, str] | Sequence[Sequence[int]],
-) -> None:
- # Act
- with pytest.raises(ValueError) as e:
- ImagePath.Path(coords)
-
- # Assert
- assert str(e.value) == "incorrect coordinate type"
+def test_invalid_path_constructors() -> None:
+ # Arrange / Act
+ with pytest.raises(ValueError, match="incorrect coordinate type"):
+ ImagePath.Path(("a", "b"))
@pytest.mark.parametrize(
@@ -99,13 +84,9 @@ def test_invalid_path_constructors(
),
)
def test_path_odd_number_of_coordinates(coords: Sequence[int]) -> None:
- # Act
- with pytest.raises(ValueError) as e:
+ with pytest.raises(ValueError, match="wrong number of coordinates"):
ImagePath.Path(coords)
- # Assert
- assert str(e.value) == "wrong number of coordinates"
-
@pytest.mark.parametrize(
"coords, expected",
diff --git a/Tests/test_imagesequence.py b/Tests/test_imagesequence.py
index 9b37435eb..7b9ac80bc 100644
--- a/Tests/test_imagesequence.py
+++ b/Tests/test_imagesequence.py
@@ -4,13 +4,13 @@ from pathlib import Path
import pytest
-from PIL import Image, ImageSequence, TiffImagePlugin
+from PIL import Image, ImageSequence, PsdImagePlugin, TiffImagePlugin
from .helper import assert_image_equal, hopper, skip_unless_feature
def test_sanity(tmp_path: Path) -> None:
- test_file = str(tmp_path / "temp.im")
+ test_file = tmp_path / "temp.im"
im = hopper("RGB")
im.save(test_file)
@@ -31,8 +31,9 @@ def test_sanity(tmp_path: Path) -> None:
def test_iterator() -> None:
with Image.open("Tests/images/multipage.tiff") as im:
+ assert isinstance(im, TiffImagePlugin.TiffImageFile)
i = ImageSequence.Iterator(im)
- for index in range(0, im.n_frames):
+ for index in range(im.n_frames):
assert i[index] == next(i)
with pytest.raises(IndexError):
i[index + 1]
@@ -42,6 +43,7 @@ def test_iterator() -> None:
def test_iterator_min_frame() -> None:
with Image.open("Tests/images/hopper.psd") as im:
+ assert isinstance(im, PsdImagePlugin.PsdImageFile)
i = ImageSequence.Iterator(im)
for index in range(1, im.n_frames):
assert i[index] == next(i)
diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py
index c23a5c690..e8468e59f 100644
--- a/Tests/test_imagewin_pointers.py
+++ b/Tests/test_imagewin_pointers.py
@@ -88,7 +88,7 @@ if is_win32():
def test_pointer(tmp_path: Path) -> None:
im = hopper()
(width, height) = im.size
- opath = str(tmp_path / "temp.png")
+ opath = tmp_path / "temp.png"
imdib = ImageWin.Dib(im)
hdr = BITMAPINFOHEADER()
diff --git a/Tests/test_mode_i16.py b/Tests/test_mode_i16.py
index e26f5d283..b78b7984f 100644
--- a/Tests/test_mode_i16.py
+++ b/Tests/test_mode_i16.py
@@ -44,7 +44,7 @@ def test_basic(tmp_path: Path, mode: str) -> None:
im_out = im_in.transform((w, h), Image.Transform.EXTENT, (0, 0, w, h))
verify(im_out) # transform
- filename = str(tmp_path / "temp.im")
+ filename = tmp_path / "temp.im"
im_in.save(filename)
with Image.open(filename) as im_out:
diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py
index c4f8de013..70661ecc1 100644
--- a/Tests/test_pickle.py
+++ b/Tests/test_pickle.py
@@ -18,7 +18,7 @@ def helper_pickle_file(
) -> None:
# Arrange
with Image.open(test_file) as im:
- filename = str(tmp_path / "temp.pkl")
+ filename = tmp_path / "temp.pkl"
if mode:
im = im.convert(mode)
@@ -65,7 +65,7 @@ def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> Non
("Tests/images/itxt_chunks.png", None),
],
)
-@pytest.mark.parametrize("protocol", range(0, pickle.HIGHEST_PROTOCOL + 1))
+@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1))
def test_pickle_image(
tmp_path: Path, test_file: str, test_mode: str | None, protocol: int
) -> None:
@@ -87,12 +87,12 @@ def test_pickle_jpeg() -> None:
def test_pickle_la_mode_with_palette(tmp_path: Path) -> None:
# Arrange
- filename = str(tmp_path / "temp.pkl")
+ filename = tmp_path / "temp.pkl"
with Image.open("Tests/images/hopper.jpg") as im:
im = im.convert("PA")
# Act / Assert
- for protocol in range(0, pickle.HIGHEST_PROTOCOL + 1):
+ for protocol in range(pickle.HIGHEST_PROTOCOL + 1):
im._mode = "LA"
with open(filename, "wb") as f:
pickle.dump(im, f, protocol)
@@ -133,7 +133,7 @@ def helper_assert_pickled_font_images(
@skip_unless_feature("freetype2")
-@pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1)))
+@pytest.mark.parametrize("protocol", list(range(pickle.HIGHEST_PROTOCOL + 1)))
def test_pickle_font_string(protocol: int) -> None:
# Arrange
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
@@ -147,11 +147,11 @@ def test_pickle_font_string(protocol: int) -> None:
@skip_unless_feature("freetype2")
-@pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1)))
+@pytest.mark.parametrize("protocol", list(range(pickle.HIGHEST_PROTOCOL + 1)))
def test_pickle_font_file(tmp_path: Path, protocol: int) -> None:
# Arrange
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
- filename = str(tmp_path / "temp.pkl")
+ filename = tmp_path / "temp.pkl"
# Act: roundtrip
with open(filename, "wb") as f:
diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py
index a743d831f..78f5632c5 100644
--- a/Tests/test_psdraw.py
+++ b/Tests/test_psdraw.py
@@ -35,7 +35,7 @@ def test_draw_postscript(tmp_path: Path) -> None:
# https://pillow.readthedocs.io/en/latest/handbook/tutorial.html#drawing-postscript
# Arrange
- tempfile = str(tmp_path / "temp.ps")
+ tempfile = tmp_path / "temp.ps"
with open(tempfile, "wb") as fp:
# Act
ps = PSDraw.PSDraw(fp)
diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py
index dd4fc46c3..03e92b5b9 100644
--- a/Tests/test_shell_injection.py
+++ b/Tests/test_shell_injection.py
@@ -35,10 +35,11 @@ class TestShellInjection:
@pytest.mark.skipif(not djpeg_available(), reason="djpeg not available")
def test_load_djpeg_filename(self, tmp_path: Path) -> None:
for filename in test_filenames:
- src_file = str(tmp_path / filename)
+ src_file = tmp_path / filename
shutil.copy(TEST_JPG, src_file)
with Image.open(src_file) as im:
+ assert isinstance(im, JpegImagePlugin.JpegImageFile)
im.load_djpeg()
@pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available")
diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py
index 13f1f9c80..42d06b896 100644
--- a/Tests/test_tiff_ifdrational.py
+++ b/Tests/test_tiff_ifdrational.py
@@ -65,11 +65,12 @@ def test_ifd_rational_save(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, libtiff: bool
) -> None:
im = hopper()
- out = str(tmp_path / "temp.tiff")
+ out = tmp_path / "temp.tiff"
res = IFDRational(301, 1)
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", libtiff)
im.save(out, dpi=(res, res), compression="raw")
with Image.open(out) as reloaded:
+ assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert float(IFDRational(301, 1)) == float(reloaded.tag_v2[282])
diff --git a/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/conf.py b/docs/conf.py
index e1e3f1b8f..bfbcf9151 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -22,7 +22,7 @@ import PIL
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
-needs_sphinx = "8.1"
+needs_sphinx = "8.2"
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
@@ -121,7 +121,7 @@ nitpicky = True
# generating warnings in “nitpicky mode”. Note that type should include the domain name
# if present. Example entries would be ('py:func', 'int') or
# ('envvar', 'LD_LIBRARY_PATH').
-nitpick_ignore = [("py:class", "_io.BytesIO"), ("py:class", "_CmsProfileCompatible")]
+nitpick_ignore = [("py:class", "_CmsProfileCompatible")]
# -- Options for HTML output ----------------------------------------------
diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst
index a915ee4e2..bfa462c04 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
^^^
@@ -68,7 +145,7 @@ by DirectX.
DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode.
.. versionadded:: 3.4.0
- DXT3 images can be read in ``RGB`` mode and DX10 images can be read in
+ DXT3 images can be read in ``RGBA`` mode and DX10 images can be read in
``RGB`` and ``RGBA`` mode.
.. versionadded:: 6.0.0
@@ -93,6 +170,12 @@ DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode.
in ``P`` mode.
+.. versionadded:: 11.2.0
+ DXT1, DXT3, DXT5, BC2, BC3 and BC5 pixel formats can be saved::
+
+ im.save(out, pixel_format="DXT1")
+
+
DIB
^^^
@@ -229,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.
@@ -454,7 +538,8 @@ The :py:meth:`~PIL.Image.open` method may set the following
Raw EXIF data from the image.
**comment**
- A comment about the image.
+ A comment about the image, from the COM marker. This is separate from the
+ UserComment tag that may be stored in the EXIF data.
.. versionadded:: 7.1.0
@@ -716,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
@@ -927,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.
@@ -1156,15 +1242,14 @@ Saving
The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments:
**save_all**
- If true, Pillow will save all frames of the image to a multiframe tiff document.
+ If true, or if ``append_images`` is not empty, Pillow will save all frames of the
+ image to a multiframe tiff document.
.. versionadded:: 3.4.0
**append_images**
A list of images to append as additional frames. Each of the
- images in the list can be single or multiframe images. Note however, that for
- correct results, all the appended images should have the same
- ``encoderinfo`` and ``encoderconfig`` properties.
+ images in the list can be single or multiframe images.
.. versionadded:: 4.2.0
@@ -1308,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
@@ -1611,15 +1696,14 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum
**save_all**
If a multiframe image is used, by default, only the first image will be saved.
To save all frames, each frame to a separate page of the PDF, the ``save_all``
- parameter must be present and set to ``True``.
+ parameter should be present and set to ``True`` or ``append_images`` should not be
+ empty.
.. versionadded:: 3.0.0
**append_images**
A list of :py:class:`PIL.Image.Image` objects to append as additional pages. Each
- of the images in the list can be single or multiframe images. The ``save_all``
- parameter must be present and set to ``True`` in conjunction with
- ``append_images``.
+ of the images in the list can be single or multiframe images.
.. versionadded:: 4.2.0
diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst
index 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 46a4c1245..9f953e718 100644
--- a/docs/installation/building-from-source.rst
+++ b/docs/installation/building-from-source.rst
@@ -44,14 +44,14 @@ Many of Pillow's features require external libraries:
* **libtiff** provides compressed TIFF functionality
- * Pillow has been tested with libtiff versions **3.x** and **4.0-4.6.0**
+ * Pillow has been tested with libtiff versions **3.x** and **4.0-4.7.0**
* **libfreetype** provides type related services
* **littlecms** provides color management
* Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and
- above uses liblcms2. Tested with **1.19** and **2.7-2.16**.
+ above uses liblcms2. Tested with **1.19** and **2.7-2.17**.
* **libwebp** provides the WebP format.
@@ -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.
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/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/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
index f7e644cf3..dbaa8a4a4 100644
--- a/docs/releasenotes/11.2.0.rst
+++ b/docs/releasenotes/11.2.0.rst
@@ -4,21 +4,12 @@
Security
========
-TODO
-^^^^
+Undefined shift when loading compressed DDS images
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-TODO
-
-:cve:`YYYY-XXXXX`: TODO
-^^^^^^^^^^^^^^^^^^^^^^^
-
-TODO
-
-Backwards Incompatible Changes
-==============================
-
-TODO
-^^^^
+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
============
@@ -36,10 +27,14 @@ an :py:class:`PIL.ImageFile.ImageFile` instance.
API Changes
===========
-TODO
-^^^^
+``append_images`` no longer requires ``save_all``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-TODO
+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
=============
@@ -66,10 +61,19 @@ libjpeg library, and what version of MozJPEG is being used::
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
-^^^^
+Reading and writing AVIF images
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-TODO
+Pillow can now read and write AVIF images. If you are building Pillow from source, this
+will require libavif 1.0.0 or later.
diff --git a/pyproject.toml b/pyproject.toml
index a43c53bda..856419215 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,7 @@ dynamic = [
optional-dependencies.docs = [
"furo",
"olefile",
- "sphinx>=8.1",
+ "sphinx>=8.2",
"sphinx-copybutton",
"sphinx-inline-tabs",
"sphinxext-opengraph",
@@ -126,6 +126,7 @@ lint.select = [
"ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging
"PGH", # pygrep-hooks
+ "PIE", # flake8-pie
"PT", # flake8-pytest-style
"PYI", # flake8-pyi
"RUF100", # unused noqa (yesqa)
@@ -138,6 +139,7 @@ lint.ignore = [
"E221", # Multiple spaces before operator
"E226", # Missing whitespace around arithmetic operator
"E241", # Multiple spaces after ','
+ "PIE790", # flake8-pie: unnecessary-placeholder
"PT001", # pytest-fixture-incorrect-parentheses-style
"PT007", # pytest-parametrize-values-wrong-type
"PT011", # pytest-raises-too-broad
diff --git a/setup.py b/setup.py
index 80289e0c2..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
@@ -69,6 +70,7 @@ _LIB_IMAGING = (
"Reduce",
"Bands",
"BcnDecode",
+ "BcnEncode",
"BitDecode",
"Blend",
"Chops",
@@ -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/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py
index 5747c1252..f7be7746d 100644
--- a/src/PIL/BlpImagePlugin.py
+++ b/src/PIL/BlpImagePlugin.py
@@ -291,7 +291,7 @@ class BlpImageFile(ImageFile.ImageFile):
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
-class _BLPBaseDecoder(ImageFile.PyDecoder):
+class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
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/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py
index cdae8dfee..26307817c 100644
--- a/src/PIL/DdsImagePlugin.py
+++ b/src/PIL/DdsImagePlugin.py
@@ -419,6 +419,14 @@ class DdsImageFile(ImageFile.ImageFile):
self._mode = "RGBA"
self.pixel_format = "BC1"
n = 1
+ elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM):
+ self._mode = "RGBA"
+ self.pixel_format = "BC2"
+ n = 2
+ elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM):
+ self._mode = "RGBA"
+ self.pixel_format = "BC3"
+ n = 3
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
self._mode = "L"
self.pixel_format = "BC4"
@@ -518,30 +526,68 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
msg = f"cannot write mode {im.mode} as DDS"
raise OSError(msg)
- alpha = im.mode[-1] == "A"
- if im.mode[0] == "L":
- pixel_flags = DDPF.LUMINANCE
- rawmode = im.mode
- if alpha:
- rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
- else:
- rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
- else:
- pixel_flags = DDPF.RGB
- rawmode = im.mode[::-1]
- rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
-
- if alpha:
- r, g, b, a = im.split()
- im = Image.merge("RGBA", (a, r, g, b))
- if alpha:
- pixel_flags |= DDPF.ALPHAPIXELS
- rgba_mask.append(0xFF000000 if alpha else 0)
-
- flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PITCH | DDSD.PIXELFORMAT
+ flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
bitcount = len(im.getbands()) * 8
- pitch = (im.width * bitcount + 7) // 8
+ pixel_format = im.encoderinfo.get("pixel_format")
+ args: tuple[int] | str
+ if pixel_format:
+ codec_name = "bcn"
+ flags |= DDSD.LINEARSIZE
+ pitch = (im.width + 3) * 4
+ rgba_mask = [0, 0, 0, 0]
+ pixel_flags = DDPF.FOURCC
+ if pixel_format == "DXT1":
+ fourcc = D3DFMT.DXT1
+ args = (1,)
+ elif pixel_format == "DXT3":
+ fourcc = D3DFMT.DXT3
+ args = (2,)
+ elif pixel_format == "DXT5":
+ fourcc = D3DFMT.DXT5
+ args = (3,)
+ else:
+ fourcc = D3DFMT.DX10
+ if pixel_format == "BC2":
+ args = (2,)
+ dxgi_format = DXGI_FORMAT.BC2_TYPELESS
+ elif pixel_format == "BC3":
+ args = (3,)
+ dxgi_format = DXGI_FORMAT.BC3_TYPELESS
+ elif pixel_format == "BC5":
+ args = (5,)
+ dxgi_format = DXGI_FORMAT.BC5_TYPELESS
+ if im.mode != "RGB":
+ msg = "only RGB mode can be written as BC5"
+ raise OSError(msg)
+ else:
+ msg = f"cannot write pixel format {pixel_format}"
+ raise OSError(msg)
+ else:
+ codec_name = "raw"
+ flags |= DDSD.PITCH
+ pitch = (im.width * bitcount + 7) // 8
+ alpha = im.mode[-1] == "A"
+ if im.mode[0] == "L":
+ pixel_flags = DDPF.LUMINANCE
+ args = im.mode
+ if alpha:
+ rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
+ else:
+ rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
+ else:
+ pixel_flags = DDPF.RGB
+ args = im.mode[::-1]
+ rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
+
+ if alpha:
+ r, g, b, a = im.split()
+ im = Image.merge("RGBA", (a, r, g, b))
+ if alpha:
+ pixel_flags |= DDPF.ALPHAPIXELS
+ rgba_mask.append(0xFF000000 if alpha else 0)
+
+ fourcc = D3DFMT.UNKNOWN
fp.write(
o32(DDS_MAGIC)
+ struct.pack(
@@ -556,11 +602,16 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
)
+ struct.pack("11I", *((0,) * 11)) # reserved
# pfsize, pfflags, fourcc, bitcount
- + struct.pack("<4I", 32, pixel_flags, 0, bitcount)
+ + struct.pack("<4I", 32, pixel_flags, fourcc, bitcount)
+ struct.pack("<4I", *rgba_mask) # dwRGBABitMask
+ struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0)
)
- ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)])
+ if fourcc == D3DFMT.DX10:
+ fp.write(
+ # dxgi_format, 2D resource, misc, array size, straight alpha
+ struct.pack("<5I", dxgi_format, 3, 0, 0, 1)
+ )
+ ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)])
def _accept(prefix: bytes) -> bool:
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/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py
index 26e5bd4a6..d60e75bb6 100644
--- a/src/PIL/FtexImagePlugin.py
+++ b/src/PIL/FtexImagePlugin.py
@@ -79,8 +79,6 @@ class FtexImageFile(ImageFile.ImageFile):
self._size = struct.unpack("<2i", self.fp.read(8))
mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8))
- self._mode = "RGB"
-
# Only support single-format files.
# I don't know of any multi-format file.
assert format_count == 1
@@ -95,6 +93,7 @@ class FtexImageFile(ImageFile.ImageFile):
self._mode = "RGBA"
self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
elif format == Format.UNCOMPRESSED:
+ self._mode = "RGB"
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")]
else:
msg = f"Invalid texture compression format: {repr(format)}"
diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py
index fc4801e9d..891225ce2 100644
--- a/src/PIL/GdImageFile.py
+++ b/src/PIL/GdImageFile.py
@@ -56,7 +56,7 @@ class GdImageFile(ImageFile.ImageFile):
msg = "Not a valid GD 2.x .gd file"
raise SyntaxError(msg)
- self._mode = "L" # FIXME: "P"
+ self._mode = "P"
self._size = i16(s, 2), i16(s, 4)
true_color = s[6]
@@ -68,14 +68,14 @@ class GdImageFile(ImageFile.ImageFile):
self.info["transparency"] = tindex
self.palette = ImagePalette.raw(
- "XBGR", s[7 + true_color_offset + 4 : 7 + true_color_offset + 4 + 256 * 4]
+ "RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4]
)
self.tile = [
ImageFile._Tile(
"raw",
(0, 0) + self.size,
- 7 + true_color_offset + 4 + 256 * 4,
+ 7 + true_color_offset + 6 + 256 * 4,
"L",
)
]
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
index 259e93f09..4392c4cb9 100644
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -31,7 +31,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,
@@ -45,7 +45,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
@@ -167,6 +169,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 1b7a394c0..379ffd739 100644
--- a/src/PIL/GimpPaletteFile.py
+++ b/src/PIL/GimpPaletteFile.py
@@ -16,24 +16,27 @@
from __future__ import annotations
import re
+from io import BytesIO
from typing import IO
-from ._binary import o8
-
class GimpPaletteFile:
"""File handler for GIMP's palette format."""
rawmode = "RGB"
- def __init__(self, fp: IO[bytes]) -> None:
- palette = [o8(i) * 3 for i in range(256)]
-
+ def _read(self, fp: IO[bytes], limit: bool = True) -> None:
if not fp.readline().startswith(b"GIMP Palette"):
msg = "not a GIMP palette file"
raise SyntaxError(msg)
- for i in range(256):
+ palette: list[int] = []
+ i = 0
+ while True:
+ if limit and i == 256 + 3:
+ break
+
+ i += 1
s = fp.readline()
if not s:
break
@@ -41,18 +44,29 @@ class GimpPaletteFile:
# skip fields and comment lines
if re.match(rb"\w+:|#", s):
continue
- if len(s) > 100:
+ if limit and len(s) > 100:
msg = "bad palette file"
raise SyntaxError(msg)
- v = tuple(map(int, s.split()[:3]))
- if len(v) != 3:
+ v = s.split(maxsplit=3)
+ if len(v) < 3:
msg = "bad palette entry"
raise ValueError(msg)
- palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2])
+ palette += (int(v[i]) for i in range(3))
+ if limit and len(palette) == 768:
+ break
- self.palette = b"".join(palette)
+ self.palette = bytes(palette)
+
+ def __init__(self, fp: IO[bytes]) -> None:
+ self._read(fp)
+
+ @classmethod
+ def frombytes(cls, data: bytes) -> GimpPaletteFile:
+ self = cls.__new__(cls)
+ self._read(BytesIO(data), False)
+ return self
def getpalette(self) -> tuple[bytes, str]:
return self.palette, self.rawmode
diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py
index a5d5b93ae..5a88429e5 100644
--- a/src/PIL/IcnsImagePlugin.py
+++ b/src/PIL/IcnsImagePlugin.py
@@ -123,8 +123,7 @@ def read_png_or_jpeg2000(
Image._decompression_bomb_check(im.size)
return {"RGBA": im}
elif (
- sig.startswith(b"\xff\x4f\xff\x51")
- or sig.startswith(b"\x0d\x0a\x87\x0a")
+ sig.startswith((b"\xff\x4f\xff\x51", b"\x0d\x0a\x87\x0a"))
or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"
):
if not enable_jpeg2k:
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 188cb91f1..233df592c 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)
@@ -630,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
@@ -1019,7 +1014,7 @@ class Image:
elif len(mode) == 3:
transparency = tuple(
convert_transparency(matrix[i * 4 : i * 4 + 4], transparency)
- for i in range(0, len(transparency))
+ for i in range(len(transparency))
)
new_im.info["transparency"] = transparency
return new_im
@@ -1543,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:
@@ -2493,7 +2490,21 @@ class Image:
format to use is determined from the filename extension.
If a file object was used instead of a filename, this
parameter should always be used.
- :param params: Extra parameters to the image writer.
+ :param params: Extra parameters to the image writer. These can also be
+ set on the image itself through ``encoderinfo``. This is useful when
+ saving multiple images::
+
+ # Saving XMP data to a single image
+ from PIL import Image
+ red = Image.new("RGB", (1, 1), "#f00")
+ red.save("out.mpo", xmp=b"test")
+
+ # Saving XMP data to the second frame of an image
+ from PIL import Image
+ black = Image.new("RGB", (1, 1))
+ red = Image.new("RGB", (1, 1), "#f00")
+ red.encoderinfo = {"xmp": b"test"}
+ black.save("out.mpo", save_all=True, append_images=[red])
:returns: None
:exception ValueError: If the output format could not be determined
from the file name. Use the format option to solve this.
@@ -2515,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()
@@ -2536,9 +2540,20 @@ class Image:
msg = f"unknown file extension: {ext}"
raise ValueError(msg) from e
+ # may mutate self!
+ self._ensure_mutable()
+
+ save_all = params.pop("save_all", None)
+ self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params}
+ self.encoderconfig: tuple[Any, ...] = ()
+
if format.upper() not in SAVE:
init()
- if save_all:
+ if save_all or (
+ save_all is None
+ and params.get("append_images")
+ and format.upper() in SAVE_ALL
+ ):
save_handler = SAVE_ALL[format.upper()]
else:
save_handler = SAVE[format.upper()]
@@ -2984,7 +2999,7 @@ class Image:
# Abstract handlers.
-class ImagePointHandler:
+class ImagePointHandler(abc.ABC):
"""
Used as a mixin by point transforms
(for use with :py:meth:`~PIL.Image.Image.point`)
@@ -2995,7 +3010,7 @@ class ImagePointHandler:
pass
-class ImageTransformHandler:
+class ImageTransformHandler(abc.ABC):
"""
Used as a mixin by geometry transforms
(for use with :py:meth:`~PIL.Image.Image.transform`)
@@ -4083,7 +4098,7 @@ class Exif(_ExifBase):
ifd_data = tag_data[ifd_offset:]
makernote = {}
- for i in range(0, struct.unpack("H", tag_data[:2])[0]):
+ for i in range(struct.unpack(">H", tag_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack(
">HHL4s", tag_data[i * 12 + 2 : (i + 1) * 12 + 2]
)
diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py
index 742b5f587..e6c7b0298 100644
--- a/src/PIL/ImageDraw.py
+++ b/src/PIL/ImageDraw.py
@@ -35,19 +35,16 @@ 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
from ._typing import Coords
# experimental access to the outline API
-Outline: Callable[[], Image.core._Outline] | None
-try:
- Outline = Image.core.outline
-except AttributeError:
- Outline = None
+Outline: Callable[[], Image.core._Outline] = Image.core.outline
+TYPE_CHECKING = False
if TYPE_CHECKING:
from . import ImageDraw2, ImageFont
@@ -1208,7 +1205,7 @@ def _compute_regular_polygon_vertices(
degrees = 360 / n_sides
# Start with the bottom left polygon vertex
current_angle = (270 - 0.5 * degrees) + rotation
- for _ in range(0, n_sides):
+ for _ in range(n_sides):
angles.append(current_angle)
current_angle += degrees
if current_angle > 360:
@@ -1231,4 +1228,4 @@ def _color_diff(
first = color1 if isinstance(color1, tuple) else (color1,)
second = color2 if isinstance(color2, tuple) else (color2,)
- return sum(abs(first[i] - second[i]) for i in range(0, len(second)))
+ return sum(abs(first[i] - second[i]) for i in range(len(second)))
diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py
index c3901d488..c5d6383a5 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"))
@@ -278,8 +278,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 +343,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 +356,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:
@@ -438,7 +441,7 @@ class ImageFile(Image.Image):
return self.tell() != frame
-class StubHandler:
+class StubHandler(abc.ABC):
def open(self, im: StubImageFile) -> None:
pass
@@ -447,7 +450,7 @@ class StubHandler:
pass
-class StubImageFile(ImageFile):
+class StubImageFile(ImageFile, metaclass=abc.ABCMeta):
"""
Base class for stub image loaders.
@@ -455,9 +458,9 @@ class StubImageFile(ImageFile):
certain format, but relies on external code to load the file.
"""
+ @abc.abstractmethod
def _open(self) -> None:
- msg = "StubImageFile subclass must implement _open"
- raise NotImplementedError(msg)
+ pass
def load(self) -> Image.core.PixelAccess | None:
loader = self._load()
@@ -471,10 +474,10 @@ class StubImageFile(ImageFile):
self.__dict__ = image.__dict__
return image.load()
+ @abc.abstractmethod
def _load(self) -> StubHandler | None:
"""(Hook) Find actual image loader."""
- msg = "StubImageFile subclass must implement _load"
- raise NotImplementedError(msg)
+ pass
class Parser:
diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py
index 1c8b29b11..b9ed54ab2 100644
--- a/src/PIL/ImageFilter.py
+++ b/src/PIL/ImageFilter.py
@@ -20,14 +20,15 @@ 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
-class Filter:
+class Filter(abc.ABC):
@abc.abstractmethod
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
pass
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/ImageOps.py b/src/PIL/ImageOps.py
index fef1d7328..da28854b5 100644
--- a/src/PIL/ImageOps.py
+++ b/src/PIL/ImageOps.py
@@ -213,14 +213,14 @@ def colorize(
blue = []
# Create the low-end values
- for i in range(0, blackpoint):
+ for i in range(blackpoint):
red.append(rgb_black[0])
green.append(rgb_black[1])
blue.append(rgb_black[2])
# Create the mapping (2-color)
if rgb_mid is None:
- range_map = range(0, whitepoint - blackpoint)
+ range_map = range(whitepoint - blackpoint)
for i in range_map:
red.append(
@@ -235,8 +235,8 @@ def colorize(
# Create the mapping (3-color)
else:
- range_map1 = range(0, midpoint - blackpoint)
- range_map2 = range(0, whitepoint - midpoint)
+ range_map1 = range(midpoint - blackpoint)
+ range_map2 = range(whitepoint - midpoint)
for i in range_map1:
red.append(
@@ -256,7 +256,7 @@ def colorize(
blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2))
# Create the high-end values
- for i in range(0, 256 - whitepoint):
+ for i in range(256 - whitepoint):
red.append(rgb_white[0])
green.append(rgb_white[1])
blue.append(rgb_white[2])
@@ -729,11 +729,15 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image
r"([0-9])",
):
value = exif_image.info[key]
- exif_image.info[key] = (
- re.sub(pattern, "", value)
- if isinstance(value, str)
- else re.sub(pattern.encode(), b"", value)
- )
+ if isinstance(value, str):
+ value = re.sub(pattern, "", value)
+ elif isinstance(value, tuple):
+ value = tuple(
+ re.sub(pattern.encode(), b"", v) for v in value
+ )
+ else:
+ value = re.sub(pattern.encode(), b"", value)
+ exif_image.info[key] = value
if not in_place:
return transposed_image
elif not in_place:
diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py
index 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/ImageShow.py b/src/PIL/ImageShow.py
index d62893d9c..dd240fb55 100644
--- a/src/PIL/ImageShow.py
+++ b/src/PIL/ImageShow.py
@@ -192,7 +192,7 @@ if sys.platform == "darwin":
register(MacViewer)
-class UnixViewer(Viewer):
+class UnixViewer(abc.ABC, Viewer):
format = "PNG"
options = {"compress_level": 1, "save_all": True}
diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py
index bf29fdba5..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, cast
+from typing import Any
from . import Image, ImageFile
+TYPE_CHECKING = False
if TYPE_CHECKING:
from ._typing import CapsuleType
@@ -263,28 +264,3 @@ def getimage(photo: PhotoImage) -> Image.Image:
_pyimagingtkcall("PyImagingPhotoGet", photo, im.getim())
return im
-
-
-def _show(image: Image.Image, title: str | None) -> None:
- """Helper for the Image.show method."""
-
- class UI(tkinter.Label):
- def __init__(self, master: tkinter.Toplevel, im: Image.Image) -> None:
- self.image: BitmapImage | PhotoImage
- if im.mode == "1":
- self.image = BitmapImage(im, foreground="white", master=master)
- else:
- self.image = PhotoImage(im, master=master)
- if TYPE_CHECKING:
- image = cast(tkinter._Image, self.image)
- else:
- image = self.image
- super().__init__(master, image=image, bg="black", bd=0)
-
- if not getattr(tkinter, "_default_root"):
- msg = "tkinter not initialized"
- raise OSError(msg)
- top = tkinter.Toplevel()
- if title:
- top.title(title)
- UI(top, image).pack()
diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py
index 3e882403b..cc1d54b93 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
@@ -569,7 +570,7 @@ def _getmp(self: JpegImageFile) -> dict[int, Any] | None:
mpentries = []
try:
rawmpentries = mp[0xB002]
- for entrynum in range(0, quant):
+ for entrynum in range(quant):
unpackedentry = struct.unpack_from(
f"{endianness}LLLHH", rawmpentries, entrynum * 16
)
diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py
index bbddd972e..9ce38c427 100644
--- a/src/PIL/MicImagePlugin.py
+++ b/src/PIL/MicImagePlugin.py
@@ -73,12 +73,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
- try:
- filename = self.images[frame]
- except IndexError as e:
- msg = "no such frame"
- raise EOFError(msg) from e
-
+ filename = self.images[frame]
self.fp = self.ole.openstream(filename)
TiffImagePlugin.TiffImageFile._open(self)
diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py
index e08f80b6b..f7393eac0 100644
--- a/src/PIL/MpoImagePlugin.py
+++ b/src/PIL/MpoImagePlugin.py
@@ -32,6 +32,7 @@ from . import (
TiffImagePlugin,
)
from ._binary import o32le
+from ._util import DeferredError
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
@@ -125,11 +126,15 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
self.readonly = 1
def load_seek(self, pos: int) -> None:
+ if isinstance(self._fp, DeferredError):
+ raise self._fp.ex
self._fp.seek(pos)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
+ if isinstance(self._fp, DeferredError):
+ raise self._fp.ex
self.fp = self._fp
self.offset = self.__mpoffsets[frame]
diff --git a/src/PIL/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 4fc6217e1..f3815a122 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 c59d302e5..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,17 +170,15 @@ class PsdImageFile(ImageFile.ImageFile):
def seek(self, layer: int) -> None:
if not self._seek_check(layer):
return
+ if isinstance(self._fp, DeferredError):
+ raise self._fp.ex
# seek to given layer (1..max)
- try:
- _, mode, _, tile = self.layers[layer - 1]
- self._mode = mode
- self.tile = tile
- self.frame = layer
- self.fp = self._fp
- except IndexError as e:
- msg = "no such layer"
- raise EOFError(msg) from e
+ _, mode, _, tile = self.layers[layer - 1]
+ self._mode = mode
+ self.tile = tile
+ self.frame = layer
+ self.fp = self._fp
def tell(self) -> int:
# return layer number (0=image, 1..max=layers)
diff --git a/src/PIL/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 0454038e8..88af9162e 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
@@ -404,7 +405,7 @@ class IFDRational(Rational):
def __repr__(self) -> str:
return str(float(self._val))
- def __hash__(self) -> int:
+ def __hash__(self) -> int: # type: ignore[override]
return self._val.__hash__()
def __eq__(self, other: object) -> bool:
@@ -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:
@@ -1584,7 +1587,7 @@ class TiffImageFile(ImageFile.ImageFile):
# byte order.
elif rawmode == "I;16":
rawmode = "I;16N"
- elif rawmode.endswith(";16B") or rawmode.endswith(";16L"):
+ elif rawmode.endswith((";16B", ";16L")):
rawmode = rawmode[:-1] + "N"
# Offset in the tile tuple is 0, we go from 0,0 to
@@ -1608,6 +1611,10 @@ class TiffImageFile(ImageFile.ImageFile):
raise ValueError(msg)
w = tilewidth
+ if w == xsize and h == ysize and self._planar_configuration != 2:
+ # Every tile covers the image. Only use the last offset
+ offsets = offsets[-1:]
+
for offset in offsets:
if x + w > xsize:
stride = w * sum(bps_tuple) / 8 # bytes per line
@@ -1630,11 +1637,11 @@ class TiffImageFile(ImageFile.ImageFile):
args,
)
)
- x = x + w
+ x += w
if x >= xsize:
x, y = 0, y + h
if y >= ysize:
- x = y = 0
+ y = 0
layer += 1
else:
logger.debug("- unsupported data organization")
@@ -2295,9 +2302,7 @@ class AppendingTiffWriter(io.BytesIO):
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
- encoderinfo = im.encoderinfo.copy()
- encoderconfig = im.encoderconfig
- append_images = list(encoderinfo.get("append_images", []))
+ append_images = list(im.encoderinfo.get("append_images", []))
if not hasattr(im, "n_frames") and not append_images:
return _save(im, fp, filename)
@@ -2305,12 +2310,11 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
try:
with AppendingTiffWriter(fp) as tf:
for ims in [im] + append_images:
- ims.encoderinfo = encoderinfo
- ims.encoderconfig = encoderconfig
- if not hasattr(ims, "n_frames"):
- nfr = 1
- else:
- nfr = ims.n_frames
+ if not hasattr(ims, "encoderinfo"):
+ ims.encoderinfo = {}
+ if not hasattr(ims, "encoderconfig"):
+ ims.encoderconfig = ()
+ nfr = getattr(ims, "n_frames", 1)
for idx in range(nfr):
ims.seek(idx)
diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py
index c2dde4431..1716a18cc 100644
--- a/src/PIL/WebPImagePlugin.py
+++ b/src/PIL/WebPImagePlugin.py
@@ -238,7 +238,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
cur_idx = im.tell()
try:
for ims in [im] + append_images:
- # Get # of frames in this image
+ # Get number of frames in this image
nfr = getattr(ims, "n_frames", 1)
for idx in range(nfr):
diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py
index 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/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..eabd9958e
--- /dev/null
+++ b/src/_avif.c
@@ -0,0 +1,908 @@
+#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 6; // 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 8; // 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:
+ if (&rgb) {
+ 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 e4a985a82..72f122143 100644
--- a/src/_imaging.c
+++ b/src/_imaging.c
@@ -4153,6 +4153,8 @@ PyImaging_ZipDecoderNew(PyObject *self, PyObject *args);
/* Encoders (in encode.c) */
extern PyObject *
+PyImaging_BcnEncoderNew(PyObject *self, PyObject *args);
+extern PyObject *
PyImaging_EpsEncoderNew(PyObject *self, PyObject *args);
extern PyObject *
PyImaging_GifEncoderNew(PyObject *self, PyObject *args);
@@ -4222,6 +4224,7 @@ static PyMethodDef functions[] = {
/* Codecs */
{"bcn_decoder", (PyCFunction)PyImaging_BcnDecoderNew, METH_VARARGS},
+ {"bcn_encoder", (PyCFunction)PyImaging_BcnEncoderNew, METH_VARARGS},
{"bit_decoder", (PyCFunction)PyImaging_BitDecoderNew, METH_VARARGS},
{"eps_encoder", (PyCFunction)PyImaging_EpsEncoderNew, METH_VARARGS},
{"fli_decoder", (PyCFunction)PyImaging_FliDecoderNew, METH_VARARGS},
diff --git a/src/display.c b/src/display.c
index a05387504..ac2134a57 100644
--- a/src/display.c
+++ b/src/display.c
@@ -687,6 +687,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 +775,7 @@ PyImaging_DrawWmf(PyObject *self, PyObject *args) {
/* FIXME: make background transparent? configurable? */
FillRect(dc, &rect, GetStockObject(WHITE_BRUSH));
- if (!PlayEnhMetaFile(dc, meta, &rect)) {
- PyErr_SetString(PyExc_OSError, "cannot render metafile");
- goto error;
- }
+ EnumEnhMetaFile(dc, meta, enhMetaFileProc, NULL, &rect);
/* step 4: extract bits from bitmap */
diff --git a/src/encode.c b/src/encode.c
index 13d5cdaf7..7c365a74f 100644
--- a/src/encode.c
+++ b/src/encode.c
@@ -27,6 +27,7 @@
#include "thirdparty/pythoncapi_compat.h"
#include "libImaging/Imaging.h"
+#include "libImaging/Bcn.h"
#include "libImaging/Gif.h"
#ifdef HAVE_UNISTD_H
@@ -350,6 +351,31 @@ get_packer(ImagingEncoderObject *encoder, const char *mode, const char *rawmode)
return 0;
}
+/* -------------------------------------------------------------------- */
+/* BCN */
+/* -------------------------------------------------------------------- */
+
+PyObject *
+PyImaging_BcnEncoderNew(PyObject *self, PyObject *args) {
+ ImagingEncoderObject *encoder;
+
+ char *mode;
+ int n;
+ if (!PyArg_ParseTuple(args, "si", &mode, &n)) {
+ return NULL;
+ }
+
+ encoder = PyImaging_EncoderNew(0);
+ if (encoder == NULL) {
+ return NULL;
+ }
+
+ encoder->encode = ImagingBcnEncode;
+ encoder->state.state = n;
+
+ return (PyObject *)encoder;
+}
+
/* -------------------------------------------------------------------- */
/* EPS */
/* -------------------------------------------------------------------- */
diff --git a/src/libImaging/BcnDecode.c b/src/libImaging/BcnDecode.c
index 9a41febc7..7b3d8f908 100644
--- a/src/libImaging/BcnDecode.c
+++ b/src/libImaging/BcnDecode.c
@@ -25,7 +25,6 @@ typedef struct {
typedef struct {
UINT16 c0, c1;
- UINT32 lut;
} bc1_color;
typedef struct {
@@ -40,13 +39,10 @@ typedef struct {
#define LOAD16(p) (p)[0] | ((p)[1] << 8)
-#define LOAD32(p) (p)[0] | ((p)[1] << 8) | ((p)[2] << 16) | ((p)[3] << 24)
-
static void
bc1_color_load(bc1_color *dst, const UINT8 *src) {
dst->c0 = LOAD16(src);
dst->c1 = LOAD16(src + 2);
- dst->lut = LOAD32(src + 4);
}
static rgba
@@ -70,7 +66,7 @@ static void
decode_bc1_color(rgba *dst, const UINT8 *src, int separate_alpha) {
bc1_color col;
rgba p[4];
- int n, cw;
+ int n, o, cw;
UINT16 r0, g0, b0, r1, g1, b1;
bc1_color_load(&col, src);
@@ -103,9 +99,11 @@ decode_bc1_color(rgba *dst, const UINT8 *src, int separate_alpha) {
p[3].b = 0;
p[3].a = 0;
}
- for (n = 0; n < 16; n++) {
- cw = 3 & (col.lut >> (2 * n));
- dst[n] = p[cw];
+ for (n = 0; n < 4; n++) {
+ for (o = 0; o < 4; o++) {
+ cw = 3 & ((src + 4)[n] >> (2 * o));
+ dst[n * 4 + o] = p[cw];
+ }
}
}
diff --git a/src/libImaging/BcnEncode.c b/src/libImaging/BcnEncode.c
new file mode 100644
index 000000000..2bad73b92
--- /dev/null
+++ b/src/libImaging/BcnEncode.c
@@ -0,0 +1,298 @@
+/*
+ * The Python Imaging Library
+ *
+ * encoder for DXT1-compressed data
+ *
+ * Format documentation:
+ * https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt
+ *
+ */
+
+#include "Imaging.h"
+
+typedef struct {
+ UINT8 color[3];
+} rgb;
+
+typedef struct {
+ UINT8 color[4];
+} rgba;
+
+static rgb
+decode_565(UINT16 x) {
+ rgb item;
+ int r, g, b;
+ r = (x & 0xf800) >> 8;
+ r |= r >> 5;
+ item.color[0] = r;
+ g = (x & 0x7e0) >> 3;
+ g |= g >> 6;
+ item.color[1] = g;
+ b = (x & 0x1f) << 3;
+ b |= b >> 5;
+ item.color[2] = b;
+ return item;
+}
+
+static UINT16
+encode_565(rgba item) {
+ UINT8 r, g, b;
+ r = item.color[0] >> (8 - 5);
+ g = item.color[1] >> (8 - 6);
+ b = item.color[2] >> (8 - 5);
+ return (r << (5 + 6)) | (g << 5) | b;
+}
+
+static void
+encode_bc1_color(Imaging im, ImagingCodecState state, UINT8 *dst, int separate_alpha) {
+ int i, j, k;
+ UINT16 color_min = 0, color_max = 0;
+ rgb color_min_rgb, color_max_rgb;
+ rgba block[16], *current_rgba;
+
+ // Determine the min and max colors in this 4x4 block
+ int first = 1;
+ int transparency = 0;
+ for (i = 0; i < 4; i++) {
+ for (j = 0; j < 4; j++) {
+ current_rgba = &block[i + j * 4];
+
+ int x = state->x + i * im->pixelsize;
+ int y = state->y + j;
+ if (x >= state->xsize * im->pixelsize || y >= state->ysize) {
+ // The 4x4 block extends past the edge of the image
+ for (k = 0; k < 3; k++) {
+ current_rgba->color[k] = 0;
+ }
+ continue;
+ }
+
+ for (k = 0; k < 3; k++) {
+ current_rgba->color[k] =
+ (UINT8)im->image[y][x + (im->pixelsize == 1 ? 0 : k)];
+ }
+ if (separate_alpha) {
+ if ((UINT8)im->image[y][x + 3] == 0) {
+ current_rgba->color[3] = 0;
+ transparency = 1;
+ continue;
+ } else {
+ current_rgba->color[3] = 1;
+ }
+ }
+
+ UINT16 color = encode_565(*current_rgba);
+ if (first || color < color_min) {
+ color_min = color;
+ }
+ if (first || color > color_max) {
+ color_max = color;
+ }
+ first = 0;
+ }
+ }
+
+ if (transparency) {
+ *dst++ = color_min;
+ *dst++ = color_min >> 8;
+ }
+ *dst++ = color_max;
+ *dst++ = color_max >> 8;
+ if (!transparency) {
+ *dst++ = color_min;
+ *dst++ = color_min >> 8;
+ }
+
+ color_min_rgb = decode_565(color_min);
+ color_max_rgb = decode_565(color_max);
+ for (i = 0; i < 4; i++) {
+ UINT8 l = 0;
+ for (j = 3; j > -1; j--) {
+ current_rgba = &block[i * 4 + j];
+ if (transparency && !current_rgba->color[3]) {
+ l |= 3 << (j * 2);
+ continue;
+ }
+
+ float distance = 0;
+ int total = 0;
+ for (k = 0; k < 3; k++) {
+ float denom =
+ (float)abs(color_max_rgb.color[k] - color_min_rgb.color[k]);
+ if (denom != 0) {
+ distance +=
+ abs(current_rgba->color[k] - color_min_rgb.color[k]) / denom;
+ total += 1;
+ }
+ }
+ if (total == 0) {
+ continue;
+ }
+ if (transparency) {
+ distance *= 4 / total;
+ if (distance < 1) {
+ // color_max
+ } else if (distance < 3) {
+ l |= 2 << (j * 2); // 1/2 * color_min + 1/2 * color_max
+ } else {
+ l |= 1 << (j * 2); // color_min
+ }
+ } else {
+ distance *= 6 / total;
+ if (distance < 1) {
+ l |= 1 << (j * 2); // color_min
+ } else if (distance < 3) {
+ l |= 3 << (j * 2); // 1/3 * color_min + 2/3 * color_max
+ } else if (distance < 5) {
+ l |= 2 << (j * 2); // 2/3 * color_min + 1/3 * color_max
+ } else {
+ // color_max
+ }
+ }
+ }
+ *dst++ = l;
+ }
+}
+
+static void
+encode_bc2_block(Imaging im, ImagingCodecState state, UINT8 *dst) {
+ int i, j;
+ UINT8 block[16], current_alpha;
+ for (i = 0; i < 4; i++) {
+ for (j = 0; j < 4; j++) {
+ int x = state->x + i * im->pixelsize;
+ int y = state->y + j;
+ if (x >= state->xsize * im->pixelsize || y >= state->ysize) {
+ // The 4x4 block extends past the edge of the image
+ block[i + j * 4] = 0;
+ continue;
+ }
+
+ current_alpha = (UINT8)im->image[y][x + 3];
+ block[i + j * 4] = current_alpha;
+ }
+ }
+
+ for (i = 0; i < 4; i++) {
+ UINT16 l = 0;
+ for (j = 3; j > -1; j--) {
+ current_alpha = block[i * 4 + j];
+ l |= current_alpha << (j * 4);
+ }
+ *dst++ = l;
+ *dst++ = l >> 8;
+ }
+}
+
+static void
+encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst, int o) {
+ int i, j;
+ UINT8 alpha_min = 0, alpha_max = 0;
+ UINT8 block[16], current_alpha;
+
+ // Determine the min and max colors in this 4x4 block
+ int first = 1;
+ for (i = 0; i < 4; i++) {
+ for (j = 0; j < 4; j++) {
+ int x = state->x + i * im->pixelsize;
+ int y = state->y + j;
+ if (x >= state->xsize * im->pixelsize || y >= state->ysize) {
+ // The 4x4 block extends past the edge of the image
+ block[i + j * 4] = 0;
+ continue;
+ }
+
+ current_alpha = (UINT8)im->image[y][x + o];
+ block[i + j * 4] = current_alpha;
+
+ if (first || current_alpha < alpha_min) {
+ alpha_min = current_alpha;
+ }
+ if (first || current_alpha > alpha_max) {
+ alpha_max = current_alpha;
+ }
+ first = 0;
+ }
+ }
+
+ *dst++ = alpha_min;
+ *dst++ = alpha_max;
+
+ float denom = (float)abs(alpha_max - alpha_min);
+ for (i = 0; i < 2; i++) {
+ UINT32 l = 0;
+ for (j = 7; j > -1; j--) {
+ current_alpha = block[i * 8 + j];
+ if (!current_alpha) {
+ l |= 6 << (j * 3);
+ continue;
+ } else if (current_alpha == 255) {
+ l |= 7 << (j * 3);
+ continue;
+ }
+
+ float distance =
+ denom == 0 ? 0 : abs(current_alpha - alpha_min) / denom * 10;
+ if (distance < 3) {
+ l |= 2 << (j * 3); // 4/5 * alpha_min + 1/5 * alpha_max
+ } else if (distance < 5) {
+ l |= 3 << (j * 3); // 3/5 * alpha_min + 2/5 * alpha_max
+ } else if (distance < 7) {
+ l |= 4 << (j * 3); // 2/5 * alpha_min + 3/5 * alpha_max
+ } else {
+ l |= 5 << (j * 3); // 1/5 * alpha_min + 4/5 * alpha_max
+ }
+ }
+ *dst++ = l;
+ *dst++ = l >> 8;
+ *dst++ = l >> 16;
+ }
+}
+
+int
+ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {
+ int n = state->state;
+ int has_alpha_channel =
+ strcmp(im->mode, "RGBA") == 0 || strcmp(im->mode, "LA") == 0;
+
+ UINT8 *dst = buf;
+
+ for (;;) {
+ if (n == 5) {
+ encode_bc3_alpha(im, state, dst, 0);
+ dst += 8;
+
+ encode_bc3_alpha(im, state, dst, 1);
+ } else {
+ if (n == 2 || n == 3) {
+ if (has_alpha_channel) {
+ if (n == 2) {
+ encode_bc2_block(im, state, dst);
+ } else {
+ encode_bc3_alpha(im, state, dst, 3);
+ }
+ dst += 8;
+ } else {
+ for (int i = 0; i < 8; i++) {
+ *dst++ = 0xff;
+ }
+ }
+ }
+ encode_bc1_color(im, state, dst, n == 1 && has_alpha_channel);
+ }
+ dst += 8;
+
+ state->x += im->pixelsize * 4;
+
+ if (state->x >= state->xsize * im->pixelsize) {
+ state->x = 0;
+ state->y += 4;
+ if (state->y >= state->ysize) {
+ state->errcode = IMAGING_CODEC_END;
+ break;
+ }
+ }
+ }
+
+ return dst - buf;
+}
diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c
index 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 5687db3b8..234f9943c 100644
--- a/src/libImaging/Imaging.h
+++ b/src/libImaging/Imaging.h
@@ -596,6 +596,8 @@ typedef int (*ImagingCodec)(
extern int
ImagingBcnDecode(Imaging im, ImagingCodecState state, UINT8 *buffer, Py_ssize_t bytes);
extern int
+ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int bytes);
+extern int
ImagingBitDecode(Imaging im, ImagingCodecState state, UINT8 *buffer, Py_ssize_t bytes);
extern int
ImagingEpsEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int bytes);
diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c
index 4f185b529..cc6955ca5 100644
--- a/src/libImaging/Jpeg2KDecode.c
+++ b/src/libImaging/Jpeg2KDecode.c
@@ -615,6 +615,7 @@ static const struct j2k_decode_unpacker j2k_unpackers[] = {
{"RGBA", OPJ_CLRSPC_GRAY, 2, 0, j2ku_graya_la},
{"RGBA", OPJ_CLRSPC_SRGB, 3, 1, j2ku_srgb_rgb},
{"RGBA", OPJ_CLRSPC_SYCC, 3, 1, j2ku_sycc_rgb},
+ {"RGBA", OPJ_CLRSPC_GRAY, 4, 1, j2ku_srgba_rgba},
{"RGBA", OPJ_CLRSPC_SRGB, 4, 1, j2ku_srgba_rgba},
{"RGBA", OPJ_CLRSPC_SYCC, 4, 1, j2ku_sycca_rgba},
{"CMYK", OPJ_CLRSPC_CMYK, 4, 1, j2ku_srgba_rgba},
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index e4da9162d..9a2db95b4 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -299,6 +299,7 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) {
return -1;
}
+ img.orientation = ORIENTATION_TOPLEFT;
img.req_orientation = ORIENTATION_TOPLEFT;
img.col_offset = 0;
diff --git a/src/path.c b/src/path.c
index 5affe3a1f..38300547c 100644
--- a/src/path.c
+++ b/src/path.c
@@ -109,6 +109,39 @@ path_dealloc(PyPathObject *path) {
#define PyPath_Check(op) (Py_TYPE(op) == &PyPathType)
+static int
+assign_item_to_array(double *xy, Py_ssize_t j, PyObject *op) {
+ if (PyFloat_Check(op)) {
+ xy[j++] = PyFloat_AS_DOUBLE(op);
+ } else if (PyLong_Check(op)) {
+ xy[j++] = (float)PyLong_AS_LONG(op);
+ } else if (PyNumber_Check(op)) {
+ xy[j++] = PyFloat_AsDouble(op);
+ } else if (PyList_Check(op)) {
+ for (int k = 0; k < 2; k++) {
+ PyObject *op1 = PyList_GetItemRef(op, k);
+ if (op1 == NULL) {
+ return -1;
+ }
+ j = assign_item_to_array(xy, j, op1);
+ Py_DECREF(op1);
+ if (j == -1) {
+ return -1;
+ }
+ }
+ } else {
+ double x, y;
+ if (PyArg_ParseTuple(op, "dd", &x, &y)) {
+ xy[j++] = x;
+ xy[j++] = y;
+ } else {
+ PyErr_SetString(PyExc_ValueError, "incorrect coordinate type");
+ return -1;
+ }
+ }
+ return j;
+}
+
Py_ssize_t
PyPath_Flatten(PyObject *data, double **pxy) {
Py_ssize_t i, j, n;
@@ -164,48 +197,32 @@ PyPath_Flatten(PyObject *data, double **pxy) {
return -1;
}
-#define assign_item_to_array(op, decref) \
- if (PyFloat_Check(op)) { \
- xy[j++] = PyFloat_AS_DOUBLE(op); \
- } else if (PyLong_Check(op)) { \
- xy[j++] = (float)PyLong_AS_LONG(op); \
- } else if (PyNumber_Check(op)) { \
- xy[j++] = PyFloat_AsDouble(op); \
- } else if (PyArg_ParseTuple(op, "dd", &x, &y)) { \
- xy[j++] = x; \
- xy[j++] = y; \
- } else { \
- PyErr_SetString(PyExc_ValueError, "incorrect coordinate type"); \
- if (decref) { \
- Py_DECREF(op); \
- } \
- free(xy); \
- return -1; \
- } \
- if (decref) { \
- Py_DECREF(op); \
- }
-
/* Copy table to path array */
if (PyList_Check(data)) {
for (i = 0; i < n; i++) {
- double x, y;
PyObject *op = PyList_GetItemRef(data, i);
if (op == NULL) {
free(xy);
return -1;
}
- assign_item_to_array(op, 1);
+ j = assign_item_to_array(xy, j, op);
+ Py_DECREF(op);
+ if (j == -1) {
+ free(xy);
+ return -1;
+ }
}
} else if (PyTuple_Check(data)) {
for (i = 0; i < n; i++) {
- double x, y;
PyObject *op = PyTuple_GET_ITEM(data, i);
- assign_item_to_array(op, 0);
+ j = assign_item_to_array(xy, j, op);
+ if (j == -1) {
+ free(xy);
+ return -1;
+ }
}
} else {
for (i = 0; i < n; i++) {
- double x, y;
PyObject *op = PySequence_GetItem(data, i);
if (!op) {
/* treat IndexError as end of sequence */
@@ -217,7 +234,12 @@ PyPath_Flatten(PyObject *data, double **pxy) {
return -1;
}
}
- assign_item_to_array(op, 1);
+ j = assign_item_to_array(xy, j, op);
+ Py_DECREF(op);
+ if (j == -1) {
+ free(xy);
+ return -1;
+ }
}
}
diff --git a/wheels/dependency_licenses/AOM.txt b/wheels/dependency_licenses/AOM.txt
new file mode 100644
index 000000000..3a2e46c26
--- /dev/null
+++ b/wheels/dependency_licenses/AOM.txt
@@ -0,0 +1,26 @@
+Copyright (c) 2016, Alliance for Open Media. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
diff --git a/wheels/dependency_licenses/DAV1D.txt b/wheels/dependency_licenses/DAV1D.txt
new file mode 100644
index 000000000..875b138ec
--- /dev/null
+++ b/wheels/dependency_licenses/DAV1D.txt
@@ -0,0 +1,23 @@
+Copyright © 2018-2019, VideoLAN and dav1d authors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/wheels/dependency_licenses/LIBAVIF.txt b/wheels/dependency_licenses/LIBAVIF.txt
new file mode 100644
index 000000000..350eb9d15
--- /dev/null
+++ b/wheels/dependency_licenses/LIBAVIF.txt
@@ -0,0 +1,387 @@
+Copyright 2019 Joe Drago. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+------------------------------------------------------------------------------
+
+Files: src/obu.c
+
+Copyright © 2018-2019, VideoLAN and dav1d authors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+------------------------------------------------------------------------------
+
+Files: third_party/iccjpeg/*
+
+In plain English:
+
+1. We don't promise that this software works. (But if you find any bugs,
+ please let us know!)
+2. You can use this software for whatever you want. You don't have to pay us.
+3. You may not pretend that you wrote this software. If you use it in a
+ program, you must acknowledge somewhere in your documentation that
+ you've used the IJG code.
+
+In legalese:
+
+The authors make NO WARRANTY or representation, either express or implied,
+with respect to this software, its quality, accuracy, merchantability, or
+fitness for a particular purpose. This software is provided "AS IS", and you,
+its user, assume the entire risk as to its quality and accuracy.
+
+This software is copyright (C) 1991-2013, Thomas G. Lane, Guido Vollbeding.
+All Rights Reserved except as specified below.
+
+Permission is hereby granted to use, copy, modify, and distribute this
+software (or portions thereof) for any purpose, without fee, subject to these
+conditions:
+(1) If any part of the source code for this software is distributed, then this
+README file must be included, with this copyright and no-warranty notice
+unaltered; and any additions, deletions, or changes to the original files
+must be clearly indicated in accompanying documentation.
+(2) If only executable code is distributed, then the accompanying
+documentation must state that "this software is based in part on the work of
+the Independent JPEG Group".
+(3) Permission for use of this software is granted only if the user accepts
+full responsibility for any undesirable consequences; the authors accept
+NO LIABILITY for damages of any kind.
+
+These conditions apply to any software derived from or based on the IJG code,
+not just to the unmodified library. If you use our work, you ought to
+acknowledge us.
+
+Permission is NOT granted for the use of any IJG author's name or company name
+in advertising or publicity relating to this software or products derived from
+it. This software may be referred to only as "the Independent JPEG Group's
+software".
+
+We specifically permit and encourage the use of this software as the basis of
+commercial products, provided that all warranty or liability claims are
+assumed by the product vendor.
+
+
+The Unix configuration script "configure" was produced with GNU Autoconf.
+It is copyright by the Free Software Foundation but is freely distributable.
+The same holds for its supporting scripts (config.guess, config.sub,
+ltmain.sh). Another support script, install-sh, is copyright by X Consortium
+but is also freely distributable.
+
+The IJG distribution formerly included code to read and write GIF files.
+To avoid entanglement with the Unisys LZW patent, GIF reading support has
+been removed altogether, and the GIF writer has been simplified to produce
+"uncompressed GIFs". This technique does not use the LZW algorithm; the
+resulting GIF files are larger than usual, but are readable by all standard
+GIF decoders.
+
+We are required to state that
+ "The Graphics Interchange Format(c) is the Copyright property of
+ CompuServe Incorporated. GIF(sm) is a Service Mark property of
+ CompuServe Incorporated."
+
+------------------------------------------------------------------------------
+
+Files: contrib/gdk-pixbuf/*
+
+Copyright 2020 Emmanuel Gil Peyrot. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+------------------------------------------------------------------------------
+
+Files: android_jni/gradlew*
+
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+------------------------------------------------------------------------------
+
+Files: third_party/libyuv/*
+
+Copyright 2011 The LibYuv Project Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+ * Neither the name of Google nor the names of its contributors may
+ be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/wheels/dependency_licenses/LIBYUV.txt b/wheels/dependency_licenses/LIBYUV.txt
new file mode 100644
index 000000000..c911747a6
--- /dev/null
+++ b/wheels/dependency_licenses/LIBYUV.txt
@@ -0,0 +1,29 @@
+Copyright 2011 The LibYuv Project Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+ * Neither the name of Google nor the names of its contributors may
+ be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/wheels/dependency_licenses/RAV1E.txt b/wheels/dependency_licenses/RAV1E.txt
new file mode 100644
index 000000000..3d6c825c4
--- /dev/null
+++ b/wheels/dependency_licenses/RAV1E.txt
@@ -0,0 +1,25 @@
+BSD 2-Clause License
+
+Copyright (c) 2017-2023, the rav1e contributors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/wheels/dependency_licenses/SVT-AV1.txt b/wheels/dependency_licenses/SVT-AV1.txt
new file mode 100644
index 000000000..532a982b3
--- /dev/null
+++ b/wheels/dependency_licenses/SVT-AV1.txt
@@ -0,0 +1,26 @@
+Copyright (c) 2019, Alliance for Open Media. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
diff --git a/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 f942716cb..e4901859e 100644
--- a/winbuild/build_prepare.py
+++ b/winbuild/build_prepare.py
@@ -113,14 +113,15 @@ V = {
"BROTLI": "1.1.0",
"FREETYPE": "2.13.3",
"FRIBIDI": "1.0.16",
- "HARFBUZZ": "10.2.0",
+ "HARFBUZZ": "11.0.0",
"JPEGTURBO": "3.1.0",
- "LCMS2": "2.16",
+ "LCMS2": "2.17",
+ "LIBAVIF": "1.2.1",
"LIBIMAGEQUANT": "4.3.4",
- "LIBPNG": "1.6.46",
+ "LIBPNG": "1.6.47",
"LIBWEBP": "1.5.0",
"OPENJPEG": "2.5.3",
- "TIFF": "4.6.0",
+ "TIFF": "4.7.0",
"XZ": "5.6.4",
"ZLIBNG": "2.2.4",
}
@@ -378,6 +379,26 @@ 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",
+ ),
+ cmd_xcopy("include", "{inc_dir}"),
+ ],
+ "libs": ["avif.lib"],
+ },
}
@@ -683,6 +704,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 +749,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,