Merge branch 'main' into imagetext

This commit is contained in:
Andrew Murray 2025-10-15 19:38:38 +11:00
commit e533ccccfc
134 changed files with 1918 additions and 618 deletions

View File

@ -51,10 +51,10 @@ pushd depends && ./install_webp.sh && popd
pushd depends && ./install_imagequant.sh && popd pushd depends && ./install_imagequant.sh && popd
# raqm # raqm
pushd depends && ./install_raqm.sh && popd pushd depends && sudo ./install_raqm.sh && popd
# libavif # libavif
pushd depends && ./install_libavif.sh && popd pushd depends && sudo ./install_libavif.sh && popd
# extra test images # extra test images
pushd depends && ./install_extra_test_images.sh && popd pushd depends && ./install_extra_test_images.sh && popd

View File

@ -1 +1 @@
cibuildwheel==3.1.2 cibuildwheel==3.2.1

View File

@ -1,4 +1,4 @@
mypy==1.17.0 mypy==1.18.2
IceSpringPySideStubs-PyQt6 IceSpringPySideStubs-PyQt6
IceSpringPySideStubs-PySide6 IceSpringPySideStubs-PySide6
ipython ipython

View File

@ -32,12 +32,12 @@ jobs:
name: Docs name: Docs
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.x" python-version: "3.x"
cache: pip cache: pip

View File

@ -20,7 +20,7 @@ jobs:
name: Lint name: Lint
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
@ -33,7 +33,7 @@ jobs:
lint-pre-commit- lint-pre-commit-
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.x" python-version: "3.x"
cache: pip cache: pip

View File

@ -2,9 +2,6 @@
set -e set -e
if [[ "$ImageOS" == "macos13" ]]; then
brew uninstall gradle maven
fi
brew install \ brew install \
aom \ aom \
dav1d \ dav1d \

View File

@ -22,7 +22,7 @@ jobs:
steps: steps:
- name: "Check issues" - name: "Check issues"
uses: actions/stale@v9 uses: actions/stale@v10
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
only-labels: "Awaiting OP Action" only-labels: "Awaiting OP Action"

View File

@ -47,6 +47,8 @@ jobs:
centos-stream-10-amd64, centos-stream-10-amd64,
debian-12-bookworm-x86, debian-12-bookworm-x86,
debian-12-bookworm-amd64, debian-12-bookworm-amd64,
debian-13-trixie-x86,
debian-13-trixie-amd64,
fedora-41-amd64, fedora-41-amd64,
fedora-42-amd64, fedora-42-amd64,
gentoo, gentoo,
@ -66,7 +68,7 @@ jobs:
name: ${{ matrix.docker }} name: ${{ matrix.docker }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -45,7 +45,7 @@ jobs:
steps: steps:
- name: Checkout Pillow - name: Checkout Pillow
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -41,7 +41,7 @@ jobs:
name: ${{ matrix.docker }} name: ${{ matrix.docker }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -39,7 +39,7 @@ jobs:
name: ${{ matrix.docker }} name: ${{ matrix.docker }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false

View File

@ -47,19 +47,19 @@ jobs:
steps: steps:
- name: Checkout Pillow - name: Checkout Pillow
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
- name: Checkout cached dependencies - name: Checkout cached dependencies
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
repository: python-pillow/pillow-depends repository: python-pillow/pillow-depends
path: winbuild\depends path: winbuild\depends
- name: Checkout extra test images - name: Checkout extra test images
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
repository: python-pillow/test-images repository: python-pillow/test-images
@ -67,7 +67,7 @@ jobs:
# sets env: pythonLocation # sets env: pythonLocation
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
allow-prereleases: true allow-prereleases: true
@ -97,8 +97,8 @@ jobs:
choco install nasm --no-progress choco install nasm --no-progress
echo "C:\Program Files\NASM" >> $env:GITHUB_PATH echo "C:\Program Files\NASM" >> $env:GITHUB_PATH
choco install ghostscript --version=10.5.1 --no-progress choco install ghostscript --version=10.6.0 --no-progress
echo "C:\Program Files\gs\gs10.05.1\bin" >> $env:GITHUB_PATH echo "C:\Program Files\gs\gs10.06.0\bin" >> $env:GITHUB_PATH
# Install extra test images # Install extra test images
xcopy /S /Y Tests\test-images\* Tests\images xcopy /S /Y Tests\test-images\* Tests\images

View File

@ -57,7 +57,7 @@ jobs:
- { python-version: "3.14t", disable-gil: true } - { python-version: "3.14t", disable-gil: true }
- { python-version: "3.13t", disable-gil: true } - { python-version: "3.13t", disable-gil: true }
# Intel # Intel
- { os: "macos-13", python-version: "3.10" } - { os: "macos-15-intel", python-version: "3.10" }
exclude: exclude:
- { os: "macos-latest", python-version: "3.10" } - { os: "macos-latest", python-version: "3.10" }
@ -65,12 +65,12 @@ jobs:
name: ${{ matrix.os }} Python ${{ matrix.python-version }} name: ${{ matrix.os }} Python ${{ matrix.python-version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
allow-prereleases: true allow-prereleases: true
@ -111,7 +111,7 @@ jobs:
GHA_PYTHON_VERSION: ${{ matrix.python-version }} GHA_PYTHON_VERSION: ${{ matrix.python-version }}
- name: Register gcc problem matcher - name: Register gcc problem matcher
if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'" if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14'"
run: echo "::add-matcher::.github/problem-matchers/gcc.json" run: echo "::add-matcher::.github/problem-matchers/gcc.json"
- name: Build - name: Build

View File

@ -93,16 +93,20 @@ ARCHIVE_SDIR=pillow-depends-main
# Package versions for fresh source builds. Version numbers with "Patched" # Package versions for fresh source builds. Version numbers with "Patched"
# annotations have a source code patch that is required for some platforms. If # annotations have a source code patch that is required for some platforms. If
# you change those versions, ensure the patch is also updated. # you change those versions, ensure the patch is also updated.
if [[ -n "$IOS_SDK" ]]; then
FREETYPE_VERSION=2.13.3 FREETYPE_VERSION=2.13.3
HARFBUZZ_VERSION=11.2.1 else
FREETYPE_VERSION=2.14.1
fi
HARFBUZZ_VERSION=12.1.0
LIBPNG_VERSION=1.6.50 LIBPNG_VERSION=1.6.50
JPEGTURBO_VERSION=3.1.1 JPEGTURBO_VERSION=3.1.2
OPENJPEG_VERSION=2.5.3 OPENJPEG_VERSION=2.5.4
XZ_VERSION=5.8.1 XZ_VERSION=5.8.1
TIFF_VERSION=4.7.0 ZSTD_VERSION=1.5.7
TIFF_VERSION=4.7.1
LCMS2_VERSION=2.17 LCMS2_VERSION=2.17
ZLIB_VERSION=1.3.1 ZLIB_NG_VERSION=2.2.5
ZLIB_NG_VERSION=2.2.4
LIBWEBP_VERSION=1.6.0 LIBWEBP_VERSION=1.6.0
BZIP2_VERSION=1.0.8 BZIP2_VERSION=1.0.8
LIBXCB_VERSION=1.17.0 LIBXCB_VERSION=1.17.0
@ -165,7 +169,7 @@ function build_brotli {
local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-$BROTLI_VERSION.tar.gz) local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-$BROTLI_VERSION.tar.gz)
(cd $out_dir \ (cd $out_dir \
&& cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib $HOST_CMAKE_FLAGS . \ && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib $HOST_CMAKE_FLAGS . \
&& make install) && make -j4 install)
touch brotli-stamp touch brotli-stamp
} }
@ -249,18 +253,26 @@ function build_libavif {
cp $WORKDIR/meson-cross.txt $out_dir/crossfile-apple.meson cp $WORKDIR/meson-cross.txt $out_dir/crossfile-apple.meson
fi fi
(cd $out_dir && make install) (cd $out_dir && make -j4 install)
touch libavif-stamp touch libavif-stamp
} }
function build_zstd {
if [ -e zstd-stamp ]; then return; fi
local out_dir=$(fetch_unpack https://github.com/facebook/zstd/releases/download/v$ZSTD_VERSION/zstd-$ZSTD_VERSION.tar.gz)
(cd $out_dir \
&& make -j4 install)
touch zstd-stamp
}
function build { function build {
build_xz build_xz
if [ -z "$IS_ALPINE" ] && [ -z "$SANITIZER" ] && [ -z "$IS_MACOS" ]; then if [ -z "$IS_ALPINE" ] && [ -z "$SANITIZER" ] && [ -z "$IS_MACOS" ]; then
yum remove -y zlib-devel yum remove -y zlib-devel
fi fi
if [[ -n "$IS_MACOS" ]] && [[ "$MACOSX_DEPLOYMENT_TARGET" == "10.10" || "$MACOSX_DEPLOYMENT_TARGET" == "10.13" ]]; then if [[ -n "$IS_MACOS" ]]; then
build_new_zlib CFLAGS="$CFLAGS -headerpad_max_install_names" build_zlib_ng
else else
build_zlib_ng build_zlib_ng
fi fi
@ -285,6 +297,7 @@ function build {
--with-jpeg-include-dir=$BUILD_PREFIX/include --with-jpeg-lib-dir=$BUILD_PREFIX/lib \ --with-jpeg-include-dir=$BUILD_PREFIX/include --with-jpeg-lib-dir=$BUILD_PREFIX/lib \
--disable-webp --disable-libdeflate --disable-zstd --disable-webp --disable-libdeflate --disable-zstd
else else
build_zstd
build_tiff build_tiff
fi fi
@ -309,6 +322,10 @@ function build {
if [[ -n "$IS_MACOS" ]]; then if [[ -n "$IS_MACOS" ]]; then
# Custom freetype build # Custom freetype build
if [[ -z "$IOS_SDK" ]]; then
build_simple sed 4.9 https://mirrors.middlendian.com/gnu/sed
fi
build_simple freetype $FREETYPE_VERSION https://download.savannah.gnu.org/releases/freetype tar.gz --with-harfbuzz=no build_simple freetype $FREETYPE_VERSION https://download.savannah.gnu.org/releases/freetype tar.gz --with-harfbuzz=no
else else
build_freetype build_freetype

View File

@ -39,6 +39,7 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
EXPECTED_DISTS: 91
FORCE_COLOR: 1 FORCE_COLOR: 1
jobs: jobs:
@ -52,21 +53,21 @@ jobs:
include: include:
- name: "macOS 10.10 x86_64" - name: "macOS 10.10 x86_64"
platform: macos platform: macos
os: macos-13 os: macos-15-intel
cibw_arch: x86_64 cibw_arch: x86_64
build: "cp3{9,10,11}*" build: "cp3{10,11}*"
macosx_deployment_target: "10.10" macosx_deployment_target: "10.10"
- name: "macOS 10.13 x86_64" - name: "macOS 10.13 x86_64"
platform: macos platform: macos
os: macos-13 os: macos-15-intel
cibw_arch: x86_64 cibw_arch: x86_64
build: "cp3{12,13,14}*" build: "cp3{12,13}*"
macosx_deployment_target: "10.13" macosx_deployment_target: "10.13"
- name: "macOS 10.15 x86_64" - name: "macOS 10.15 x86_64"
platform: macos platform: macos
os: macos-13 os: macos-15-intel
cibw_arch: x86_64 cibw_arch: x86_64
build: "pp3*" build: "{cp314,pp3}*"
macosx_deployment_target: "10.15" macosx_deployment_target: "10.15"
- name: "macOS arm64" - name: "macOS arm64"
platform: macos platform: macos
@ -103,15 +104,15 @@ jobs:
cibw_arch: arm64_iphonesimulator cibw_arch: arm64_iphonesimulator
- name: "iOS x86_64 simulator" - name: "iOS x86_64 simulator"
platform: ios platform: ios
os: macos-13 os: macos-15-intel
cibw_arch: x86_64_iphonesimulator cibw_arch: x86_64_iphonesimulator
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
submodules: true submodules: true
- uses: actions/setup-python@v5 - uses: actions/setup-python@v6
with: with:
python-version: "3.x" python-version: "3.x"
@ -153,18 +154,18 @@ jobs:
- cibw_arch: ARM64 - cibw_arch: ARM64
os: windows-11-arm os: windows-11-arm
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
- name: Checkout extra test images - name: Checkout extra test images
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
repository: python-pillow/test-images repository: python-pillow/test-images
path: Tests\test-images path: Tests\test-images
- uses: actions/setup-python@v5 - uses: actions/setup-python@v6
with: with:
python-version: "3.x" python-version: "3.x"
@ -231,15 +232,15 @@ jobs:
path: winbuild\build\bin\fribidi* path: winbuild\build\bin\fribidi*
sdist: sdist:
if: github.event_name != 'schedule' if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.x" python-version: "3.x"
@ -250,15 +251,33 @@ jobs:
name: dist-sdist name: dist-sdist
path: dist/*.tar.gz path: dist/*.tar.gz
count-dists:
needs: [build-native-wheels, windows, sdist]
runs-on: ubuntu-latest
name: Count dists
steps:
- uses: actions/download-artifact@v5
with:
pattern: dist-*
path: dist
merge-multiple: true
- name: "What did we get?"
run: |
ls -alR
echo "Number of dists, should be $EXPECTED_DISTS:"
files=$(ls dist 2>/dev/null | wc -l)
echo $files
[ "$files" -eq $EXPECTED_DISTS ] || exit 1
scientific-python-nightly-wheels-publish: scientific-python-nightly-wheels-publish:
if: github.repository_owner == 'python-pillow' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') if: github.repository_owner == 'python-pillow' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
needs: [build-native-wheels, windows] needs: count-dists
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Upload wheels to scientific-python-nightly-wheels name: Upload wheels to scientific-python-nightly-wheels
steps: steps:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v5
with: with:
pattern: dist-* pattern: dist-!(sdist)*
path: dist path: dist
merge-multiple: true merge-multiple: true
- name: Upload wheels to scientific-python-nightly-wheels - name: Upload wheels to scientific-python-nightly-wheels
@ -269,7 +288,7 @@ jobs:
pypi-publish: pypi-publish:
if: github.repository_owner == 'python-pillow' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') if: github.repository_owner == 'python-pillow' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
needs: [build-native-wheels, windows, sdist] needs: count-dists
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Upload release to PyPI name: Upload release to PyPI
environment: environment:
@ -278,7 +297,7 @@ jobs:
permissions: permissions:
id-token: write id-token: write
steps: steps:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v5
with: with:
pattern: dist-* pattern: dist-*
path: dist path: dist

2
.github/zizmor.yml vendored
View File

@ -1,5 +1,5 @@
# Configuration for the zizmor static analysis tool, run via pre-commit in CI # Configuration for the zizmor static analysis tool, run via pre-commit in CI
# https://woodruffw.github.io/zizmor/configuration/ # https://docs.zizmor.sh/configuration/
rules: rules:
unpinned-uses: unpinned-uses:
config: config:

View File

@ -1,12 +1,12 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.2 rev: v0.13.3
hooks: hooks:
- id: ruff-check - id: ruff-check
args: [--exit-non-zero-on-fix] args: [--exit-non-zero-on-fix]
- repo: https://github.com/psf/black-pre-commit-mirror - repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.1.0 rev: 25.9.0
hooks: hooks:
- id: black - id: black
@ -24,7 +24,7 @@ repos:
exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$|\.patch$) exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$|\.patch$)
- repo: https://github.com/pre-commit/mirrors-clang-format - repo: https://github.com/pre-commit/mirrors-clang-format
rev: v20.1.7 rev: v21.1.2
hooks: hooks:
- id: clang-format - id: clang-format
types: [c] types: [c]
@ -36,7 +36,7 @@ repos:
- id: rst-backticks - id: rst-backticks
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v6.0.0
hooks: hooks:
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable - id: check-shebang-scripts-are-executable
@ -51,14 +51,14 @@ repos:
exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/|\.patch$ exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/|\.patch$
- repo: https://github.com/python-jsonschema/check-jsonschema - repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.33.2 rev: 0.34.0
hooks: hooks:
- id: check-github-workflows - id: check-github-workflows
- id: check-readthedocs - id: check-readthedocs
- id: check-renovate - id: check-renovate
- repo: https://github.com/woodruffw/zizmor-pre-commit - repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.11.0 rev: v1.14.2
hooks: hooks:
- id: zizmor - id: zizmor
@ -68,7 +68,7 @@ repos:
- id: sphinx-lint - id: sphinx-lint
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.6.0 rev: v2.7.0
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
@ -79,7 +79,7 @@ repos:
additional_dependencies: [trove-classifiers>=2024.10.12] additional_dependencies: [trove-classifiers>=2024.10.12]
- repo: https://github.com/tox-dev/tox-ini-fmt - repo: https://github.com/tox-dev/tox-ini-fmt
rev: 1.5.0 rev: 1.6.0
hooks: hooks:
- id: tox-ini-fmt - id: tox-ini-fmt

1
Tests/createfontdatachunk.py Executable file → Normal file
View File

@ -1,4 +1,3 @@
#!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import base64 import base64

View File

@ -175,6 +175,14 @@ def skip_unless_feature(feature: str) -> pytest.MarkDecorator:
return pytest.mark.skipif(not features.check(feature), reason=reason) return pytest.mark.skipif(not features.check(feature), reason=reason)
def has_feature_version(feature: str, required: str) -> bool:
version = features.version(feature)
assert version is not None
version_required = parse_version(required)
version_available = parse_version(version)
return version_available >= version_required
def skip_unless_feature_version( def skip_unless_feature_version(
feature: str, required: str, reason: str | None = None feature: str, required: str, reason: str | None = None
) -> pytest.MarkDecorator: ) -> pytest.MarkDecorator:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
Tests/images/frame_size.mpo Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

275
Tests/test_arro3.py Normal file
View File

@ -0,0 +1,275 @@
from __future__ import annotations
import json
from typing import Any, NamedTuple
import pytest
from PIL import Image
from .helper import (
assert_deep_equal,
assert_image_equal,
hopper,
is_big_endian,
)
TYPE_CHECKING = False
if TYPE_CHECKING:
from arro3 import compute # type: ignore [import-not-found]
from arro3.core import ( # type: ignore [import-not-found]
Array,
DataType,
Field,
fixed_size_list_array,
)
else:
arro3 = pytest.importorskip("arro3", reason="Arro3 not installed")
from arro3 import compute
from arro3.core import Array, DataType, Field, fixed_size_list_array
TEST_IMAGE_SIZE = (10, 10)
def _test_img_equals_pyarray(
img: Image.Image, arr: Any, mask: list[int] | None, elts_per_pixel: int = 1
) -> None:
assert img.height * img.width * elts_per_pixel == len(arr)
px = img.load()
assert px is not None
if elts_per_pixel > 1 and mask is None:
# have to do element-wise comparison when we're comparing
# flattened r,g,b,a to a pixel.
mask = list(range(elts_per_pixel))
for x in range(0, img.size[0], int(img.size[0] / 10)):
for y in range(0, img.size[1], int(img.size[1] / 10)):
if mask:
pixel = px[x, y]
assert isinstance(pixel, tuple)
for ix, elt in enumerate(mask):
if elts_per_pixel == 1:
assert pixel[ix] == arr[y * img.width + x].as_py()[elt]
else:
assert (
pixel[ix]
== arr[(y * img.width + x) * elts_per_pixel + elt].as_py()
)
else:
assert_deep_equal(px[x, y], arr[y * img.width + x].as_py())
def _test_img_equals_int32_pyarray(
img: Image.Image, arr: Any, mask: list[int] | None, elts_per_pixel: int = 1
) -> None:
assert img.height * img.width * elts_per_pixel == len(arr)
px = img.load()
assert px is not None
if mask is None:
# have to do element-wise comparison when we're comparing
# flattened rgba in an uint32 to a pixel.
mask = list(range(elts_per_pixel))
for x in range(0, img.size[0], int(img.size[0] / 10)):
for y in range(0, img.size[1], int(img.size[1] / 10)):
pixel = px[x, y]
assert isinstance(pixel, tuple)
arr_pixel_int = arr[y * img.width + x].as_py()
arr_pixel_tuple = (
arr_pixel_int % 256,
(arr_pixel_int // 256) % 256,
(arr_pixel_int // 256**2) % 256,
(arr_pixel_int // 256**3),
)
if is_big_endian():
arr_pixel_tuple = arr_pixel_tuple[::-1]
for ix, elt in enumerate(mask):
assert pixel[ix] == arr_pixel_tuple[elt]
fl_uint8_4_type = DataType.list(Field("_", DataType.uint8()).with_nullable(False), 4)
@pytest.mark.parametrize(
"mode, dtype, mask",
(
("L", DataType.uint8(), None),
("I", DataType.int32(), None),
("F", DataType.float32(), None),
("LA", fl_uint8_4_type, [0, 3]),
("RGB", fl_uint8_4_type, [0, 1, 2]),
("RGBA", fl_uint8_4_type, None),
("RGBX", fl_uint8_4_type, None),
("CMYK", fl_uint8_4_type, None),
("YCbCr", fl_uint8_4_type, [0, 1, 2]),
("HSV", fl_uint8_4_type, [0, 1, 2]),
),
)
def test_to_array(mode: str, dtype: DataType, mask: list[int] | None) -> None:
img = hopper(mode)
# Resize to non-square
img = img.crop((3, 0, 124, 127))
assert img.size == (121, 127)
arr = Array(img)
_test_img_equals_pyarray(img, arr, mask)
assert arr.type == dtype
reloaded = Image.fromarrow(arr, mode, img.size)
assert_image_equal(img, reloaded)
def test_lifetime() -> None:
# valgrind shouldn't error out here.
# arrays should be accessible after the image is deleted.
img = hopper("L")
arr_1 = Array(img)
arr_2 = Array(img)
del img
assert compute.sum(arr_1).as_py() > 0
del arr_1
assert compute.sum(arr_2).as_py() > 0
del arr_2
def test_lifetime2() -> None:
# valgrind shouldn't error out here.
# img should remain after the arrays are collected.
img = hopper("L")
arr_1 = Array(img)
arr_2 = Array(img)
assert compute.sum(arr_1).as_py() > 0
del arr_1
assert compute.sum(arr_2).as_py() > 0
del arr_2
img2 = img.copy()
px = img2.load()
assert px # make mypy happy
assert isinstance(px[0, 0], int)
class DataShape(NamedTuple):
dtype: DataType
# Strictly speaking, elt should be a pixel or pixel component, so
# list[uint8][4], float, int, uint32, uint8, etc. But more
# correctly, it should be exactly the dtype from the line above.
elt: Any
elts_per_pixel: int
UINT_ARR = DataShape(
dtype=fl_uint8_4_type,
elt=[1, 2, 3, 4], # array of 4 uint8 per pixel
elts_per_pixel=1, # only one array per pixel
)
UINT = DataShape(
dtype=DataType.uint8(),
elt=3, # one uint8,
elts_per_pixel=4, # but repeated 4x per pixel
)
UINT32 = DataShape(
dtype=DataType.uint32(),
elt=0xABCDEF45, # one packed int, doesn't fit in a int32 > 0x80000000
elts_per_pixel=1, # one per pixel
)
INT32 = DataShape(
dtype=DataType.uint32(),
elt=0x12CDEF45, # one packed int
elts_per_pixel=1, # one per pixel
)
@pytest.mark.parametrize(
"mode, data_tp, mask",
(
("L", DataShape(DataType.uint8(), 3, 1), None),
("I", DataShape(DataType.int32(), 1 << 24, 1), None),
("F", DataShape(DataType.float32(), 3.14159, 1), None),
("LA", UINT_ARR, [0, 3]),
("LA", UINT, [0, 3]),
("RGB", UINT_ARR, [0, 1, 2]),
("RGBA", UINT_ARR, None),
("CMYK", UINT_ARR, None),
("YCbCr", UINT_ARR, [0, 1, 2]),
("HSV", UINT_ARR, [0, 1, 2]),
("RGB", UINT, [0, 1, 2]),
("RGBA", UINT, None),
("CMYK", UINT, None),
("YCbCr", UINT, [0, 1, 2]),
("HSV", UINT, [0, 1, 2]),
),
)
def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> None:
(dtype, elt, elts_per_pixel) = data_tp
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
if dtype == fl_uint8_4_type:
tmp_arr = Array(elt * (ct_pixels * elts_per_pixel), type=DataType.uint8())
arr = fixed_size_list_array(tmp_arr, 4)
else:
arr = Array([elt] * (ct_pixels * elts_per_pixel), type=dtype)
img = Image.fromarrow(arr, mode, TEST_IMAGE_SIZE)
_test_img_equals_pyarray(img, arr, mask, elts_per_pixel)
@pytest.mark.parametrize(
"mode, mask",
(
("LA", [0, 3]),
("RGB", [0, 1, 2]),
("RGBA", None),
("CMYK", None),
("YCbCr", [0, 1, 2]),
("HSV", [0, 1, 2]),
),
)
@pytest.mark.parametrize("data_tp", (UINT32, INT32))
def test_from_int32array(mode: str, mask: list[int] | None, data_tp: DataShape) -> None:
(dtype, elt, elts_per_pixel) = data_tp
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
arr = Array([elt] * (ct_pixels * elts_per_pixel), type=dtype)
img = Image.fromarrow(arr, mode, TEST_IMAGE_SIZE)
_test_img_equals_int32_pyarray(img, arr, mask, elts_per_pixel)
@pytest.mark.parametrize(
"mode, metadata",
(
("LA", ["L", "X", "X", "A"]),
("RGB", ["R", "G", "B", "X"]),
("RGBX", ["R", "G", "B", "X"]),
("RGBA", ["R", "G", "B", "A"]),
("CMYK", ["C", "M", "Y", "K"]),
("YCbCr", ["Y", "Cb", "Cr", "X"]),
("HSV", ["H", "S", "V", "X"]),
),
)
def test_image_metadata(mode: str, metadata: list[str]) -> None:
img = hopper(mode)
arr = Array(img)
assert arr.type.value_field
assert arr.type.value_field.metadata
assert arr.type.value_field.metadata[b"image"]
parsed_metadata = json.loads(arr.type.value_field.metadata[b"image"].decode("utf8"))
assert "bands" in parsed_metadata
assert parsed_metadata["bands"] == metadata

View File

@ -14,6 +14,7 @@ import pytest
from PIL import ( from PIL import (
AvifImagePlugin, AvifImagePlugin,
GifImagePlugin,
Image, Image,
ImageDraw, ImageDraw,
ImageFile, ImageFile,
@ -220,6 +221,7 @@ class TestFileAvif:
def test_background_from_gif(self, tmp_path: Path) -> None: def test_background_from_gif(self, tmp_path: Path) -> None:
with Image.open("Tests/images/chi.gif") as im: with Image.open("Tests/images/chi.gif") as im:
original_value = im.convert("RGB").getpixel((1, 1)) original_value = im.convert("RGB").getpixel((1, 1))
assert isinstance(original_value, tuple)
# Save as AVIF # Save as AVIF
out_avif = tmp_path / "temp.avif" out_avif = tmp_path / "temp.avif"
@ -232,6 +234,7 @@ class TestFileAvif:
with Image.open(out_gif) as reread: with Image.open(out_gif) as reread:
reread_value = reread.convert("RGB").getpixel((1, 1)) reread_value = reread.convert("RGB").getpixel((1, 1))
assert isinstance(reread_value, tuple)
difference = sum([abs(original_value[i] - reread_value[i]) for i in range(3)]) difference = sum([abs(original_value[i] - reread_value[i]) for i in range(3)])
assert difference <= 6 assert difference <= 6
@ -240,6 +243,7 @@ class TestFileAvif:
with Image.open("Tests/images/chi.gif") as im: with Image.open("Tests/images/chi.gif") as im:
im.save(temp_file) im.save(temp_file)
with Image.open(temp_file) as im: with Image.open(temp_file) as im:
assert isinstance(im, AvifImagePlugin.AvifImageFile)
assert im.n_frames == 1 assert im.n_frames == 1
def test_invalid_file(self) -> None: def test_invalid_file(self) -> None:
@ -598,10 +602,12 @@ class TestAvifAnimation:
""" """
with Image.open(TEST_AVIF_FILE) as im: with Image.open(TEST_AVIF_FILE) as im:
assert isinstance(im, AvifImagePlugin.AvifImageFile)
assert im.n_frames == 1 assert im.n_frames == 1
assert not im.is_animated assert not im.is_animated
with Image.open("Tests/images/avif/star.avifs") as im: with Image.open("Tests/images/avif/star.avifs") as im:
assert isinstance(im, AvifImagePlugin.AvifImageFile)
assert im.n_frames == 5 assert im.n_frames == 5
assert im.is_animated assert im.is_animated
@ -612,11 +618,13 @@ class TestAvifAnimation:
""" """
with Image.open("Tests/images/avif/star.gif") as original: with Image.open("Tests/images/avif/star.gif") as original:
assert isinstance(original, GifImagePlugin.GifImageFile)
assert original.n_frames > 1 assert original.n_frames > 1
temp_file = tmp_path / "temp.avif" temp_file = tmp_path / "temp.avif"
original.save(temp_file, save_all=True) original.save(temp_file, save_all=True)
with Image.open(temp_file) as im: with Image.open(temp_file) as im:
assert isinstance(im, AvifImagePlugin.AvifImageFile)
assert im.n_frames == original.n_frames assert im.n_frames == original.n_frames
# Compare first frame in P mode to frame from original GIF # Compare first frame in P mode to frame from original GIF
@ -636,6 +644,7 @@ class TestAvifAnimation:
def check(temp_file: Path) -> None: def check(temp_file: Path) -> None:
with Image.open(temp_file) as im: with Image.open(temp_file) as im:
assert isinstance(im, AvifImagePlugin.AvifImageFile)
assert im.n_frames == 4 assert im.n_frames == 4
# Compare first frame to original # Compare first frame to original
@ -708,6 +717,7 @@ class TestAvifAnimation:
) )
with Image.open(temp_file) as im: with Image.open(temp_file) as im:
assert isinstance(im, AvifImagePlugin.AvifImageFile)
assert im.n_frames == 5 assert im.n_frames == 5
assert im.is_animated assert im.is_animated
@ -737,6 +747,7 @@ class TestAvifAnimation:
) )
with Image.open(temp_file) as im: with Image.open(temp_file) as im:
assert isinstance(im, AvifImagePlugin.AvifImageFile)
assert im.n_frames == 5 assert im.n_frames == 5
assert im.is_animated assert im.is_animated

View File

@ -6,6 +6,8 @@ from pathlib import Path
import pytest import pytest
from PIL import BmpImagePlugin, Image, _binary from PIL import BmpImagePlugin, Image, _binary
from PIL._binary import o16le as o16
from PIL._binary import o32le as o32
from .helper import ( from .helper import (
assert_image_equal, assert_image_equal,
@ -114,7 +116,7 @@ def test_save_float_dpi(tmp_path: Path) -> None:
def test_load_dib() -> None: def test_load_dib() -> None:
# test for #1293, Imagegrab returning Unsupported Bitfields Format # test for #1293, ImageGrab returning Unsupported Bitfields Format
with Image.open("Tests/images/clipboard.dib") as im: with Image.open("Tests/images/clipboard.dib") as im:
assert im.format == "DIB" assert im.format == "DIB"
assert im.get_format_mimetype() == "image/bmp" assert im.get_format_mimetype() == "image/bmp"
@ -219,6 +221,18 @@ def test_rle8_eof(file_name: str, length: int) -> None:
im.load() im.load()
def test_unsupported_bmp_bitfields_layout() -> None:
fp = io.BytesIO(
o32(40) # header size
+ b"\x00" * 10
+ o16(1) # bits
+ o32(3) # BITFIELDS compression
+ b"\x00" * 32
)
with pytest.raises(OSError, match="Unsupported BMP bitfields layout"):
Image.open(fp)
def test_offset() -> None: def test_offset() -> None:
# This image has been hexedited # This image has been hexedited
# to exclude the palette size from the pixel data offset # to exclude the palette size from the pixel data offset

View File

@ -1,8 +1,13 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO
import pytest import pytest
from PIL import CurImagePlugin, Image from PIL import CurImagePlugin, Image
from PIL._binary import o8
from PIL._binary import o16le as o16
from PIL._binary import o32le as o32
TEST_FILE = "Tests/images/deerstalker.cur" TEST_FILE = "Tests/images/deerstalker.cur"
@ -17,6 +22,24 @@ def test_sanity() -> None:
assert im.getpixel((16, 16)) == (84, 87, 86, 255) assert im.getpixel((16, 16)) == (84, 87, 86, 255)
def test_largest_cursor() -> None:
magic = b"\x00\x00\x02\x00"
sizes = ((1, 1), (8, 8), (4, 4))
data = magic + o16(len(sizes))
for w, h in sizes:
image_offset = 6 + len(sizes) * 16 if (w, h) == max(sizes) else 0
data += o8(w) + o8(h) + o8(0) * 10 + o32(image_offset)
data += (
o32(12) # header size
+ o16(8) # width
+ o16(16) # height
+ o16(0) # planes
+ o16(1) # bits
)
with Image.open(BytesIO(data)) as im:
assert im.size == (8, 8)
def test_invalid_file() -> None: def test_invalid_file() -> None:
invalid_file = "Tests/images/flower.jpg" invalid_file = "Tests/images/flower.jpg"
@ -26,6 +49,7 @@ def test_invalid_file() -> None:
no_cursors_file = "Tests/images/no_cursors.cur" no_cursors_file = "Tests/images/no_cursors.cur"
cur = CurImagePlugin.CurImageFile(TEST_FILE) cur = CurImagePlugin.CurImageFile(TEST_FILE)
assert cur.fp is not None
cur.fp.close() cur.fp.close()
with open(no_cursors_file, "rb") as cur.fp: with open(no_cursors_file, "rb") as cur.fp:
with pytest.raises(TypeError): with pytest.raises(TypeError):

View File

@ -197,6 +197,14 @@ def test_load_long_binary_data(prefix: bytes) -> None:
assert img.format == "EPS" assert img.format == "EPS"
def test_begin_binary() -> None:
with open("Tests/images/eps/binary_preview_map.eps", "rb") as fp:
data = bytearray(fp.read())
data[76875 : 76875 + 11] = b"%" * 11
with Image.open(io.BytesIO(data)) as img:
assert img.size == (399, 480)
@mark_if_feature_version( @mark_if_feature_version(
pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing"
) )

View File

@ -48,6 +48,7 @@ def test_sanity() -> None:
def test_prefix_chunk(monkeypatch: pytest.MonkeyPatch) -> None: def test_prefix_chunk(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True)
with Image.open(animated_test_file_with_prefix_chunk) as im: with Image.open(animated_test_file_with_prefix_chunk) as im:
assert isinstance(im, FliImagePlugin.FliImageFile)
assert im.mode == "P" assert im.mode == "P"
assert im.size == (320, 200) assert im.size == (320, 200)
assert im.format == "FLI" assert im.format == "FLI"
@ -55,6 +56,7 @@ def test_prefix_chunk(monkeypatch: pytest.MonkeyPatch) -> None:
assert im.is_animated assert im.is_animated
palette = im.getpalette() palette = im.getpalette()
assert palette is not None
assert palette[3:6] == [255, 255, 255] assert palette[3:6] == [255, 255, 255]
assert palette[381:384] == [204, 204, 12] assert palette[381:384] == [204, 204, 12]
assert palette[765:] == [252, 0, 0] assert palette[765:] == [252, 0, 0]

View File

@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO
import pytest import pytest
from PIL import GbrImagePlugin, Image from PIL import GbrImagePlugin, Image, _binary
from .helper import assert_image_equal_tofile from .helper import assert_image_equal_tofile
@ -31,8 +33,49 @@ def test_multiple_load_operations() -> None:
assert_image_equal_tofile(im, "Tests/images/gbr.png") assert_image_equal_tofile(im, "Tests/images/gbr.png")
def test_invalid_file() -> None: def create_gbr_image(info: dict[str, int] = {}, magic_number=b"") -> BytesIO:
invalid_file = "Tests/images/flower.jpg" return BytesIO(
b"".join(
_binary.o32be(i)
for i in [
info.get("header_size", 20),
info.get("version", 1),
info.get("width", 1),
info.get("height", 1),
info.get("color_depth", 1),
]
)
+ magic_number
)
with pytest.raises(SyntaxError):
def test_invalid_file() -> None:
for f in [
create_gbr_image({"header_size": 0}),
create_gbr_image({"width": 0}),
create_gbr_image({"height": 0}),
]:
with pytest.raises(SyntaxError, match="not a GIMP brush"):
GbrImagePlugin.GbrImageFile(f)
invalid_file = "Tests/images/flower.jpg"
with pytest.raises(SyntaxError, match="Unsupported GIMP brush version"):
GbrImagePlugin.GbrImageFile(invalid_file) GbrImagePlugin.GbrImageFile(invalid_file)
def test_unsupported_gimp_brush() -> None:
f = create_gbr_image({"color_depth": 2})
with pytest.raises(SyntaxError, match="Unsupported GIMP brush color depth: 2"):
GbrImagePlugin.GbrImageFile(f)
def test_bad_magic_number() -> None:
f = create_gbr_image({"version": 2}, magic_number=b"badm")
with pytest.raises(SyntaxError, match="not a GIMP brush, bad magic number"):
GbrImagePlugin.GbrImageFile(f)
def test_L() -> None:
f = create_gbr_image()
with Image.open(f) as im:
assert im.mode == "L"

View File

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO
import pytest import pytest
from PIL import GdImageFile, UnidentifiedImageError from PIL import GdImageFile, UnidentifiedImageError
@ -16,6 +18,14 @@ def test_sanity() -> None:
assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.jpg", 14) assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.jpg", 14)
def test_transparency() -> None:
with open(TEST_GD_FILE, "rb") as fp:
data = bytearray(fp.read())
data[7:11] = b"\x00\x00\x00\x05"
with GdImageFile.open(BytesIO(data)) as im:
assert im.info["transparency"] == 5
def test_bad_mode() -> None: def test_bad_mode() -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
GdImageFile.open(TEST_GD_FILE, "bad mode") GdImageFile.open(TEST_GD_FILE, "bad mode")

View File

@ -293,6 +293,7 @@ def test_roundtrip_save_all(tmp_path: Path) -> None:
im.save(out, save_all=True) im.save(out, save_all=True)
with Image.open(out) as reread: with Image.open(out) as reread:
assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 5 assert reread.n_frames == 5
@ -1374,6 +1375,7 @@ def test_palette_save_all_P(tmp_path: Path) -> None:
with Image.open(out) as im: with Image.open(out) as im:
# Assert that the frames are correct, and each frame has the same palette # Assert that the frames are correct, and each frame has the same palette
assert isinstance(im, GifImagePlugin.GifImageFile)
assert_image_equal(im.convert("RGB"), frames[0].convert("RGB")) assert_image_equal(im.convert("RGB"), frames[0].convert("RGB"))
assert im.palette is not None assert im.palette is not None
assert im.global_palette is not None assert im.global_palette is not None

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from io import BytesIO from io import BytesIO
import pytest
from PIL import Image, IptcImagePlugin, TiffImagePlugin, TiffTags from PIL import Image, IptcImagePlugin, TiffImagePlugin, TiffTags
from .helper import assert_image_equal, hopper from .helper import assert_image_equal, hopper
@ -9,21 +11,78 @@ from .helper import assert_image_equal, hopper
TEST_FILE = "Tests/images/iptc.jpg" TEST_FILE = "Tests/images/iptc.jpg"
def create_iptc_image(info: dict[str, int] = {}) -> BytesIO:
def field(tag, value):
return bytes((0x1C,) + tag + (0, len(value))) + value
data = field((3, 60), bytes((info.get("layers", 1), info.get("component", 0))))
data += field((3, 120), bytes((info.get("compression", 1),)))
if "band" in info:
data += field((3, 65), bytes((info["band"] + 1,)))
data += field((3, 20), b"\x01") # width
data += field((3, 30), b"\x01") # height
data += field(
(8, 10),
bytes((info.get("data", 0),)),
)
return BytesIO(data)
def test_open() -> None: def test_open() -> None:
expected = Image.new("L", (1, 1)) expected = Image.new("L", (1, 1))
f = BytesIO( f = create_iptc_image()
b"\x1c\x03<\x00\x02\x01\x00\x1c\x03x\x00\x01\x01\x1c\x03\x14\x00\x01\x01"
b"\x1c\x03\x1e\x00\x01\x01\x1c\x08\n\x00\x01\x00"
)
with Image.open(f) as im: with Image.open(f) as im:
assert im.tile == [("iptc", (0, 0, 1, 1), 25, "raw")] assert im.tile == [("iptc", (0, 0, 1, 1), 25, ("raw", None))]
assert_image_equal(im, expected) assert_image_equal(im, expected)
with Image.open(f) as im: with Image.open(f) as im:
assert im.load() is not None assert im.load() is not None
def test_field_length() -> None:
f = create_iptc_image()
f.seek(28)
f.write(b"\xff")
with pytest.raises(OSError, match="illegal field length in IPTC/NAA file"):
with Image.open(f):
pass
@pytest.mark.parametrize("layers, mode", ((3, "RGB"), (4, "CMYK")))
def test_layers(layers: int, mode: str) -> None:
for band in range(-1, layers):
info = {"layers": layers, "component": 1, "data": 5}
if band != -1:
info["band"] = band
f = create_iptc_image(info)
with Image.open(f) as im:
assert im.mode == mode
data = [0] * layers
data[max(band, 0)] = 5
assert im.getpixel((0, 0)) == tuple(data)
def test_unknown_compression() -> None:
f = create_iptc_image({"compression": 2})
with pytest.raises(OSError, match="Unknown IPTC image compression"):
with Image.open(f):
pass
def test_getiptcinfo() -> None:
f = create_iptc_image()
with Image.open(f) as im:
assert IptcImagePlugin.getiptcinfo(im) == {
(3, 60): b"\x01\x00",
(3, 120): b"\x01",
(3, 20): b"\x01",
(3, 30): b"\x01",
}
def test_getiptcinfo_jpg_none() -> None: def test_getiptcinfo_jpg_none() -> None:
# Arrange # Arrange
with hopper() as im: with hopper() as im:

View File

@ -330,8 +330,10 @@ class TestFileJpeg:
# Reading # Reading
with Image.open("Tests/images/exif_gps.jpg") as im: with Image.open("Tests/images/exif_gps.jpg") as im:
exif = im._getexif() assert isinstance(im, JpegImagePlugin.JpegImageFile)
assert exif[gps_index] == expected_exif_gps exif_data = im._getexif()
assert exif_data is not None
assert exif_data[gps_index] == expected_exif_gps
# Writing # Writing
f = tmp_path / "temp.jpg" f = tmp_path / "temp.jpg"
@ -340,8 +342,10 @@ class TestFileJpeg:
hopper().save(f, exif=exif) hopper().save(f, exif=exif)
with Image.open(f) as reloaded: with Image.open(f) as reloaded:
exif = reloaded._getexif() assert isinstance(reloaded, JpegImagePlugin.JpegImageFile)
assert exif[gps_index] == expected_exif_gps exif_data = reloaded._getexif()
assert exif_data is not None
assert exif_data[gps_index] == expected_exif_gps
def test_empty_exif_gps(self) -> None: def test_empty_exif_gps(self) -> None:
with Image.open("Tests/images/empty_gps_ifd.jpg") as im: with Image.open("Tests/images/empty_gps_ifd.jpg") as im:
@ -368,6 +372,7 @@ class TestFileJpeg:
exifs = [] exifs = []
for i in range(2): for i in range(2):
with Image.open("Tests/images/exif-200dpcm.jpg") as im: with Image.open("Tests/images/exif-200dpcm.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
exifs.append(im._getexif()) exifs.append(im._getexif())
assert exifs[0] == exifs[1] assert exifs[0] == exifs[1]
@ -401,13 +406,17 @@ class TestFileJpeg:
} }
with Image.open("Tests/images/exif_gps.jpg") as im: with Image.open("Tests/images/exif_gps.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
exif = im._getexif() exif = im._getexif()
assert exif is not None
for tag, value in expected_exif.items(): for tag, value in expected_exif.items():
assert value == exif[tag] assert value == exif[tag]
def test_exif_gps_typeerror(self) -> None: def test_exif_gps_typeerror(self) -> None:
with Image.open("Tests/images/exif_gps_typeerror.jpg") as im: with Image.open("Tests/images/exif_gps_typeerror.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
# Should not raise a TypeError # Should not raise a TypeError
im._getexif() im._getexif()
@ -487,7 +496,9 @@ class TestFileJpeg:
def test_exif(self) -> None: def test_exif(self) -> None:
with Image.open("Tests/images/pil_sample_rgb.jpg") as im: with Image.open("Tests/images/pil_sample_rgb.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
info = im._getexif() info = im._getexif()
assert info is not None
assert info[305] == "Adobe Photoshop CS Macintosh" assert info[305] == "Adobe Photoshop CS Macintosh"
def test_get_child_images(self) -> None: def test_get_child_images(self) -> None:
@ -690,11 +701,13 @@ class TestFileJpeg:
def test_save_multiple_16bit_qtables(self) -> None: def test_save_multiple_16bit_qtables(self) -> None:
with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im: with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
im2 = self.roundtrip(im, qtables="keep") im2 = self.roundtrip(im, qtables="keep")
assert im.quantization == im2.quantization assert im.quantization == im2.quantization
def test_save_single_16bit_qtable(self) -> None: def test_save_single_16bit_qtable(self) -> None:
with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im: with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
im2 = self.roundtrip(im, qtables={0: im.quantization[0]}) im2 = self.roundtrip(im, qtables={0: im.quantization[0]})
assert len(im2.quantization) == 1 assert len(im2.quantization) == 1
assert im2.quantization[0] == im.quantization[0] assert im2.quantization[0] == im.quantization[0]
@ -898,7 +911,10 @@ class TestFileJpeg:
# in contrast to normal 8 # in contrast to normal 8
with Image.open("Tests/images/exif-ifd-offset.jpg") as im: with Image.open("Tests/images/exif-ifd-offset.jpg") as im:
# Act / Assert # Act / Assert
assert im._getexif()[306] == "2017:03:13 23:03:09" assert isinstance(im, JpegImagePlugin.JpegImageFile)
exif = im._getexif()
assert exif is not None
assert exif[306] == "2017:03:13 23:03:09"
def test_multiple_exif(self) -> None: def test_multiple_exif(self) -> None:
with Image.open("Tests/images/multiple_exif.jpg") as im: with Image.open("Tests/images/multiple_exif.jpg") as im:

View File

@ -355,6 +355,27 @@ class TestFileLibTiff(LibTiffTestCase):
# Should not segfault # Should not segfault
im.save(outfile) im.save(outfile)
def test_whitepoint_tag(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
out = tmp_path / "temp.tif"
hopper().save(out, tiffinfo={318: (0.3127, 0.3289)})
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[318] == pytest.approx((0.3127, 0.3289))
# Save tag by default
out = tmp_path / "temp2.tif"
with Image.open("Tests/images/rdf.tif") as im:
im.save(out)
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[318] == pytest.approx((0.3127, 0.3289999))
def test_xmlpacket_tag( def test_xmlpacket_tag(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None: ) -> None:
@ -365,7 +386,6 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
if 700 in reloaded.tag_v2:
assert reloaded.tag_v2[700] == b"xmlpacket tag" assert reloaded.tag_v2[700] == b"xmlpacket tag"
def test_int_dpi(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def test_int_dpi(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:

View File

@ -104,25 +104,27 @@ def test_exif(test_file: str) -> None:
def test_frame_size() -> None: def test_frame_size() -> None:
# This image has been hexedited to contain a different size with Image.open("Tests/images/frame_size.mpo") as im:
# in the SOF marker of the second frame assert im.size == (56, 70)
with Image.open("Tests/images/sugarshack_frame_size.mpo") as im: im.load()
assert im.size == (640, 480)
im.seek(1) im.seek(1)
assert im.size == (680, 480) assert im.size == (349, 434)
im.load()
im.seek(0) im.seek(0)
assert im.size == (640, 480) assert im.size == (56, 70)
def test_ignore_frame_size() -> None: def test_ignore_frame_size() -> None:
# Ignore the different size of the second frame # Ignore the different size of the second frame
# since this is not a "Large Thumbnail" image # since this is not a "Large Thumbnail" image
with Image.open("Tests/images/ignore_frame_size.mpo") as im: with Image.open("Tests/images/ignore_frame_size.mpo") as im:
assert isinstance(im, MpoImagePlugin.MpoImageFile)
assert im.size == (64, 64) assert im.size == (64, 64)
im.seek(1) im.seek(1)
assert im.mpinfo is not None
assert ( assert (
im.mpinfo[0xB002][1]["Attribute"]["MPType"] im.mpinfo[0xB002][1]["Attribute"]["MPType"]
== "Multi-Frame Image: (Disparity)" == "Multi-Frame Image: (Disparity)"
@ -155,6 +157,7 @@ def test_reload_exif_after_seek() -> None:
@pytest.mark.parametrize("test_file", test_files) @pytest.mark.parametrize("test_file", test_files)
def test_mp(test_file: str) -> None: def test_mp(test_file: str) -> None:
with Image.open(test_file) as im: with Image.open(test_file) as im:
assert isinstance(im, MpoImagePlugin.MpoImageFile)
mpinfo = im._getmp() mpinfo = im._getmp()
assert mpinfo is not None assert mpinfo is not None
assert mpinfo[45056] == b"0100" assert mpinfo[45056] == b"0100"
@ -165,6 +168,7 @@ def test_mp_offset() -> None:
# This image has been manually hexedited to have an IFD offset of 10 # This image has been manually hexedited to have an IFD offset of 10
# in APP2 data, in contrast to normal 8 # in APP2 data, in contrast to normal 8
with Image.open("Tests/images/sugarshack_ifd_offset.mpo") as im: with Image.open("Tests/images/sugarshack_ifd_offset.mpo") as im:
assert isinstance(im, MpoImagePlugin.MpoImageFile)
mpinfo = im._getmp() mpinfo = im._getmp()
assert mpinfo is not None assert mpinfo is not None
assert mpinfo[45056] == b"0100" assert mpinfo[45056] == b"0100"
@ -182,6 +186,7 @@ def test_mp_no_data() -> None:
@pytest.mark.parametrize("test_file", test_files) @pytest.mark.parametrize("test_file", test_files)
def test_mp_attribute(test_file: str) -> None: def test_mp_attribute(test_file: str) -> None:
with Image.open(test_file) as im: with Image.open(test_file) as im:
assert isinstance(im, MpoImagePlugin.MpoImageFile)
mpinfo = im._getmp() mpinfo = im._getmp()
assert mpinfo is not None assert mpinfo is not None
for frame_number, mpentry in enumerate(mpinfo[0xB002]): for frame_number, mpentry in enumerate(mpinfo[0xB002]):

View File

@ -1,10 +1,17 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO
import pytest
from PIL import Image from PIL import Image
from .helper import assert_image_equal
def test_load_raw() -> None: def test_load_raw() -> None:
with Image.open("Tests/images/hopper.pcd") as im: with Image.open("Tests/images/hopper.pcd") as im:
assert im.size == (768, 512)
im.load() # should not segfault. im.load() # should not segfault.
# Note that this image was created with a resized hopper # Note that this image was created with a resized hopper
@ -15,3 +22,18 @@ def test_load_raw() -> None:
# target = hopper().resize((768,512)) # target = hopper().resize((768,512))
# assert_image_similar(im, target, 10) # assert_image_similar(im, target, 10)
@pytest.mark.parametrize("orientation", (1, 3))
def test_rotated(orientation: int) -> None:
with open("Tests/images/hopper.pcd", "rb") as fp:
data = bytearray(fp.read())
data[2048 + 1538] = orientation
f = BytesIO(data)
with Image.open(f) as im:
assert im.size == (512, 768)
with Image.open("Tests/images/hopper.pcd") as expected:
assert_image_equal(
im, expected.rotate(90 if orientation == 1 else 270, expand=True)
)

View File

@ -229,7 +229,9 @@ class TestFilePng:
assert_image(im, "RGBA", (162, 150)) assert_image(im, "RGBA", (162, 150))
# image has 124 unique alpha values # image has 124 unique alpha values
assert len(im.getchannel("A").getcolors()) == 124 colors = im.getchannel("A").getcolors()
assert colors is not None
assert len(colors) == 124
def test_load_transparent_rgb(self) -> None: def test_load_transparent_rgb(self) -> None:
test_file = "Tests/images/rgb_trns.png" test_file = "Tests/images/rgb_trns.png"
@ -241,7 +243,9 @@ class TestFilePng:
assert_image(im, "RGBA", (64, 64)) assert_image(im, "RGBA", (64, 64))
# image has 876 transparent pixels # image has 876 transparent pixels
assert im.getchannel("A").getcolors()[0][0] == 876 colors = im.getchannel("A").getcolors()
assert colors is not None
assert colors[0][0] == 876
def test_save_p_transparent_palette(self, tmp_path: Path) -> None: def test_save_p_transparent_palette(self, tmp_path: Path) -> None:
in_file = "Tests/images/pil123p.png" in_file = "Tests/images/pil123p.png"
@ -262,7 +266,9 @@ class TestFilePng:
assert_image(im, "RGBA", (162, 150)) assert_image(im, "RGBA", (162, 150))
# image has 124 unique alpha values # image has 124 unique alpha values
assert len(im.getchannel("A").getcolors()) == 124 colors = im.getchannel("A").getcolors()
assert colors is not None
assert len(colors) == 124
def test_save_p_single_transparency(self, tmp_path: Path) -> None: def test_save_p_single_transparency(self, tmp_path: Path) -> None:
in_file = "Tests/images/p_trns_single.png" in_file = "Tests/images/p_trns_single.png"
@ -285,7 +291,9 @@ class TestFilePng:
assert im.getpixel((31, 31)) == (0, 255, 52, 0) assert im.getpixel((31, 31)) == (0, 255, 52, 0)
# image has 876 transparent pixels # image has 876 transparent pixels
assert im.getchannel("A").getcolors()[0][0] == 876 colors = im.getchannel("A").getcolors()
assert colors is not None
assert colors[0][0] == 876
def test_save_p_transparent_black(self, tmp_path: Path) -> None: def test_save_p_transparent_black(self, tmp_path: Path) -> None:
# check if solid black image with full transparency # check if solid black image with full transparency
@ -313,7 +321,9 @@ class TestFilePng:
assert im.info["transparency"] == 255 assert im.info["transparency"] == 255
im_rgba = im.convert("RGBA") im_rgba = im.convert("RGBA")
assert im_rgba.getchannel("A").getcolors()[0][0] == num_transparent colors = im_rgba.getchannel("A").getcolors()
assert colors is not None
assert colors[0][0] == num_transparent
test_file = tmp_path / "temp.png" test_file = tmp_path / "temp.png"
im.save(test_file) im.save(test_file)
@ -324,7 +334,9 @@ class TestFilePng:
assert_image_equal(im, test_im) assert_image_equal(im, test_im)
test_im_rgba = test_im.convert("RGBA") test_im_rgba = test_im.convert("RGBA")
assert test_im_rgba.getchannel("A").getcolors()[0][0] == num_transparent colors = test_im_rgba.getchannel("A").getcolors()
assert colors is not None
assert colors[0][0] == num_transparent
def test_save_rgb_single_transparency(self, tmp_path: Path) -> None: def test_save_rgb_single_transparency(self, tmp_path: Path) -> None:
in_file = "Tests/images/caption_6_33_22.png" in_file = "Tests/images/caption_6_33_22.png"
@ -671,6 +683,9 @@ class TestFilePng:
im.save(out, bits=4, save_all=save_all) im.save(out, bits=4, save_all=save_all)
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert isinstance(reloaded, PngImagePlugin.PngImageFile)
assert reloaded.png is not None
assert reloaded.png.im_palette is not None
assert len(reloaded.png.im_palette[1]) == 48 assert len(reloaded.png.im_palette[1]) == 48
def test_plte_length(self, tmp_path: Path) -> None: def test_plte_length(self, tmp_path: Path) -> None:
@ -681,6 +696,9 @@ class TestFilePng:
im.save(out) im.save(out)
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert isinstance(reloaded, PngImagePlugin.PngImageFile)
assert reloaded.png is not None
assert reloaded.png.im_palette is not None
assert len(reloaded.png.im_palette[1]) == 3 assert len(reloaded.png.im_palette[1]) == 3
def test_getxmp(self) -> None: def test_getxmp(self) -> None:
@ -702,13 +720,17 @@ class TestFilePng:
def test_exif(self) -> None: def test_exif(self) -> None:
# With an EXIF chunk # With an EXIF chunk
with Image.open("Tests/images/exif.png") as im: with Image.open("Tests/images/exif.png") as im:
exif = im._getexif() assert isinstance(im, PngImagePlugin.PngImageFile)
assert exif[274] == 1 exif_data = im._getexif()
assert exif_data is not None
assert exif_data[274] == 1
# With an ImageMagick zTXt chunk # With an ImageMagick zTXt chunk
with Image.open("Tests/images/exif_imagemagick.png") as im: with Image.open("Tests/images/exif_imagemagick.png") as im:
exif = im._getexif() assert isinstance(im, PngImagePlugin.PngImageFile)
assert exif[274] == 1 exif_data = im._getexif()
assert exif_data is not None
assert exif_data[274] == 1
# Assert that info still can be extracted # Assert that info still can be extracted
# when the image is no longer a PngImageFile instance # when the image is no longer a PngImageFile instance
@ -717,8 +739,10 @@ class TestFilePng:
# With a tEXt chunk # With a tEXt chunk
with Image.open("Tests/images/exif_text.png") as im: with Image.open("Tests/images/exif_text.png") as im:
exif = im._getexif() assert isinstance(im, PngImagePlugin.PngImageFile)
assert exif[274] == 1 exif_data = im._getexif()
assert exif_data is not None
assert exif_data[274] == 1
# With XMP tags # With XMP tags
with Image.open("Tests/images/xmp_tags_orientation.png") as im: with Image.open("Tests/images/xmp_tags_orientation.png") as im:
@ -740,8 +764,10 @@ class TestFilePng:
im.save(test_file, exif=im.getexif()) im.save(test_file, exif=im.getexif())
with Image.open(test_file) as reloaded: with Image.open(test_file) as reloaded:
exif = reloaded._getexif() assert isinstance(reloaded, PngImagePlugin.PngImageFile)
assert exif[274] == 1 exif_data = reloaded._getexif()
assert exif_data is not None
assert exif_data[274] == 1
@mark_if_feature_version( @mark_if_feature_version(
pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing"

View File

@ -92,6 +92,13 @@ def test_16bit_pgm() -> None:
assert_image_equal_tofile(im, "Tests/images/16_bit_binary_pgm.tiff") assert_image_equal_tofile(im, "Tests/images/16_bit_binary_pgm.tiff")
def test_p4_save(tmp_path: Path) -> None:
with Image.open("Tests/images/hopper_1bit.pbm") as im:
filename = tmp_path / "temp.pbm"
im.save(filename)
assert_image_equal_tofile(im, filename)
def test_16bit_pgm_write(tmp_path: Path) -> None: def test_16bit_pgm_write(tmp_path: Path) -> None:
with Image.open("Tests/images/16_bit_binary.pgm") as im: with Image.open("Tests/images/16_bit_binary.pgm") as im:
filename = tmp_path / "temp.pgm" filename = tmp_path / "temp.pgm"
@ -134,6 +141,12 @@ def test_pfm_big_endian(tmp_path: Path) -> None:
assert_image_equal_tofile(im, filename) assert_image_equal_tofile(im, filename)
def test_save_unsupported_mode(tmp_path: Path) -> None:
im = hopper("P")
with pytest.raises(OSError, match="cannot write mode P as PPM"):
im.save(tmp_path / "out.ppm")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"data", "data",
[ [

View File

@ -274,13 +274,17 @@ def test_save_l_transparency(tmp_path: Path) -> None:
in_file = "Tests/images/la.tga" in_file = "Tests/images/la.tga"
with Image.open(in_file) as im: with Image.open(in_file) as im:
assert im.mode == "LA" assert im.mode == "LA"
assert im.getchannel("A").getcolors()[0][0] == num_transparent colors = im.getchannel("A").getcolors()
assert colors is not None
assert colors[0][0] == num_transparent
out = tmp_path / "temp.tga" out = tmp_path / "temp.tga"
im.save(out) im.save(out)
with Image.open(out) as test_im: with Image.open(out) as test_im:
assert test_im.mode == "LA" assert test_im.mode == "LA"
assert test_im.getchannel("A").getcolors()[0][0] == num_transparent colors = test_im.getchannel("A").getcolors()
assert colors is not None
assert colors[0][0] == num_transparent
assert_image_equal(im, test_im) assert_image_equal(im, test_im)

View File

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO
from PIL import WalImageFile from PIL import WalImageFile
from .helper import assert_image_equal_tofile from .helper import assert_image_equal_tofile
@ -13,12 +15,22 @@ def test_open() -> None:
assert im.format_description == "Quake2 Texture" assert im.format_description == "Quake2 Texture"
assert im.mode == "P" assert im.mode == "P"
assert im.size == (128, 128) assert im.size == (128, 128)
assert "next_name" not in im.info
assert isinstance(im, WalImageFile.WalImageFile) assert isinstance(im, WalImageFile.WalImageFile)
assert_image_equal_tofile(im, "Tests/images/hopper_wal.png") assert_image_equal_tofile(im, "Tests/images/hopper_wal.png")
def test_next_name() -> None:
with open(TEST_FILE, "rb") as fp:
data = bytearray(fp.read())
data[56:60] = b"Test"
f = BytesIO(data)
with WalImageFile.open(f) as im:
assert im.info["next_name"] == b"Test"
def test_load() -> None: def test_load() -> None:
with WalImageFile.open(TEST_FILE) as im: with WalImageFile.open(TEST_FILE) as im:
px = im.load() px = im.load()

View File

@ -4,13 +4,13 @@ from collections.abc import Generator
from pathlib import Path from pathlib import Path
import pytest import pytest
from packaging.version import parse as parse_version
from PIL import GifImagePlugin, Image, WebPImagePlugin, features from PIL import GifImagePlugin, Image, WebPImagePlugin
from .helper import ( from .helper import (
assert_image_equal, assert_image_equal,
assert_image_similar, assert_image_similar,
has_feature_version,
is_big_endian, is_big_endian,
skip_unless_feature, skip_unless_feature,
) )
@ -53,10 +53,7 @@ def test_write_animation_L(tmp_path: Path) -> None:
im.load() im.load()
assert_image_similar(im, orig.convert("RGBA"), 32.9) assert_image_similar(im, orig.convert("RGBA"), 32.9)
if is_big_endian(): if is_big_endian() and not has_feature_version("webp", "1.2.2"):
version = features.version_module("webp")
assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2") pytest.skip("Fails with libwebp earlier than 1.2.2")
orig.seek(orig.n_frames - 1) orig.seek(orig.n_frames - 1)
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
@ -81,10 +78,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
assert_image_equal(im, frame1.convert("RGBA")) assert_image_equal(im, frame1.convert("RGBA"))
# Compare second frame to original # Compare second frame to original
if is_big_endian(): if is_big_endian() and not has_feature_version("webp", "1.2.2"):
version = features.version_module("webp")
assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2") pytest.skip("Fails with libwebp earlier than 1.2.2")
im.seek(1) im.seek(1)
im.load() im.load()

View File

@ -22,11 +22,13 @@ except ImportError:
def test_read_exif_metadata() -> None: def test_read_exif_metadata() -> None:
file_path = "Tests/images/flower.webp" file_path = "Tests/images/flower.webp"
with Image.open(file_path) as image: with Image.open(file_path) as image:
assert isinstance(image, WebPImagePlugin.WebPImageFile)
assert image.format == "WEBP" assert image.format == "WEBP"
exif_data = image.info.get("exif", None) exif_data = image.info.get("exif", None)
assert exif_data assert exif_data
exif = image._getexif() exif = image._getexif()
assert exif is not None
# Camera make # Camera make
assert exif[271] == "Canon" assert exif[271] == "Canon"

View File

@ -9,7 +9,8 @@ from .helper import skip_unless_feature
class TestFontCrash: class TestFontCrash:
def _fuzz_font(self, font: ImageFont.FreeTypeFont) -> None: def _fuzz_font(self, font: ImageFont.FreeTypeFont) -> None:
# from fuzzers.fuzz_font # Copy of the code from fuzz_font() in Tests/oss-fuzz/fuzzers.py
# that triggered a problem when fuzzing
font.getbbox("ABC") font.getbbox("ABC")
font.getmask("test text") font.getmask("test text")
with Image.new(mode="RGBA", size=(200, 200)) as im: with Image.new(mode="RGBA", size=(200, 200)) as im:

View File

@ -19,6 +19,7 @@ from PIL import (
ImageDraw, ImageDraw,
ImageFile, ImageFile,
ImagePalette, ImagePalette,
ImageShow,
UnidentifiedImageError, UnidentifiedImageError,
features, features,
) )
@ -283,33 +284,6 @@ class TestImage:
assert item is not None assert item is not None
assert item != num assert item != num
def test_expand_x(self) -> None:
# Arrange
im = hopper()
orig_size = im.size
xmargin = 5
# Act
im = im._expand(xmargin)
# Assert
assert im.size[0] == orig_size[0] + 2 * xmargin
assert im.size[1] == orig_size[1] + 2 * xmargin
def test_expand_xy(self) -> None:
# Arrange
im = hopper()
orig_size = im.size
xmargin = 5
ymargin = 3
# Act
im = im._expand(xmargin, ymargin)
# Assert
assert im.size[0] == orig_size[0] + 2 * xmargin
assert im.size[1] == orig_size[1] + 2 * ymargin
def test_getbands(self) -> None: def test_getbands(self) -> None:
# Assert # Assert
assert hopper("RGB").getbands() == ("R", "G", "B") assert hopper("RGB").getbands() == ("R", "G", "B")
@ -388,6 +362,37 @@ class TestImage:
assert img_colors is not None assert img_colors is not None
assert sorted(img_colors) == expected_colors assert sorted(img_colors) == expected_colors
def test_alpha_composite_la(self) -> None:
# Arrange
expected_colors = sorted(
[
(3300, (255, 255)),
(1156, (170, 192)),
(1122, (128, 255)),
(1089, (0, 0)),
(1122, (255, 128)),
(1122, (0, 128)),
(1089, (0, 255)),
]
)
dst = Image.new("LA", size=(100, 100), color=(0, 255))
draw = ImageDraw.Draw(dst)
draw.rectangle((0, 33, 100, 66), fill=(0, 128))
draw.rectangle((0, 67, 100, 100), fill=(0, 0))
src = Image.new("LA", size=(100, 100), color=(255, 255))
draw = ImageDraw.Draw(src)
draw.rectangle((33, 0, 66, 100), fill=(255, 128))
draw.rectangle((67, 0, 100, 100), fill=(255, 0))
# Act
img = Image.alpha_composite(dst, src)
# Assert
img_colors = img.getcolors()
assert img_colors is not None
assert sorted(img_colors) == expected_colors
def test_alpha_inplace(self) -> None: def test_alpha_inplace(self) -> None:
src = Image.new("RGBA", (128, 128), "blue") src = Image.new("RGBA", (128, 128), "blue")
@ -922,6 +927,17 @@ class TestImage:
reloaded_exif.load(exif.tobytes()) reloaded_exif.load(exif.tobytes())
assert reloaded_exif.get_ifd(0x8769) == exif.get_ifd(0x8769) assert reloaded_exif.get_ifd(0x8769) == exif.get_ifd(0x8769)
def test_delete_ifd_tag(self) -> None:
with Image.open("Tests/images/flower.jpg") as im:
exif = im.getexif()
exif.get_ifd(0x8769)
assert 0x8769 in exif
del exif[0x8769]
reloaded_exif = Image.Exif()
reloaded_exif.load(exif.tobytes())
assert 0x8769 not in reloaded_exif
def test_exif_load_from_fp(self) -> None: def test_exif_load_from_fp(self) -> None:
with Image.open("Tests/images/flower.jpg") as im: with Image.open("Tests/images/flower.jpg") as im:
data = im.info["exif"] data = im.info["exif"]
@ -1005,6 +1021,13 @@ class TestImage:
with pytest.warns(DeprecationWarning, match="Image.Image.get_child_images"): with pytest.warns(DeprecationWarning, match="Image.Image.get_child_images"):
assert im.get_child_images() == [] assert im.get_child_images() == []
def test_show(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ImageShow, "_viewers", [])
im = Image.new("RGB", (1, 1))
with pytest.warns(DeprecationWarning, match="Image._show"):
Image._show(im)
@pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0)))
def test_zero_tobytes(self, size: tuple[int, int]) -> None: def test_zero_tobytes(self, size: tuple[int, int]) -> None:
im = Image.new("RGB", size) im = Image.new("RGB", size)
@ -1076,6 +1099,12 @@ class TestImage:
assert im.palette is not None assert im.palette is not None
assert im.palette.colors[(27, 35, 6, 214)] == 24 assert im.palette.colors[(27, 35, 6, 214)] == 24
def test_merge_pa(self) -> None:
p = hopper("P")
a = Image.new("L", p.size)
pa = Image.merge("PA", (p, a))
assert p.getpalette() == pa.getpalette()
def test_constants(self) -> None: def test_constants(self) -> None:
for enum in ( for enum in (
Image.Transpose, Image.Transpose,

View File

@ -101,8 +101,7 @@ def test_fromarray_strides_without_tobytes() -> None:
self.__array_interface__ = arr_params self.__array_interface__ = arr_params
with pytest.raises(ValueError): with pytest.raises(ValueError):
wrapped = Wrapper({"shape": (1, 1), "strides": (1, 1)}) wrapped = Wrapper({"shape": (1, 1), "strides": (1, 1), "typestr": "|u1"})
with pytest.warns(DeprecationWarning, match="'mode' parameter"):
Image.fromarray(wrapped, "L") Image.fromarray(wrapped, "L")
@ -112,9 +111,16 @@ def test_fromarray_palette() -> None:
a = numpy.array(i) a = numpy.array(i)
# Act # Act
with pytest.warns(DeprecationWarning, match="'mode' parameter"):
out = Image.fromarray(a, "P") out = Image.fromarray(a, "P")
# Assert that the Python and C palettes match # Assert that the Python and C palettes match
assert out.palette is not None assert out.palette is not None
assert len(out.palette.colors) == len(out.im.getpalette()) / 3 assert len(out.palette.colors) == len(out.im.getpalette()) / 3
def test_deprecation() -> None:
a = numpy.array(im.convert("L"))
with pytest.warns(
DeprecationWarning, match="'mode' parameter for changing data types"
):
Image.fromarray(a, "1")

View File

@ -97,6 +97,13 @@ def test_opaque() -> None:
assert_image_equal(alpha, solid) assert_image_equal(alpha, solid)
def test_rgba() -> None:
with Image.open("Tests/images/transparent.png") as im:
assert im.mode == "RGBA"
assert_image_similar(im.convert("RGBa").convert("RGB"), im.convert("RGB"), 1.5)
def test_rgba_p() -> None: def test_rgba_p() -> None:
im = hopper("RGBA") im = hopper("RGBA")
im.putalpha(hopper("L")) im.putalpha(hopper("L"))
@ -107,11 +114,19 @@ def test_rgba_p() -> None:
assert_image_similar(im, comparable, 20) assert_image_similar(im, comparable, 20)
def test_rgba() -> None: def test_rgba_pa() -> None:
with Image.open("Tests/images/transparent.png") as im: im = hopper("RGBA").convert("PA").convert("RGB")
assert im.mode == "RGBA" expected = hopper("RGB")
assert_image_similar(im.convert("RGBa").convert("RGB"), im.convert("RGB"), 1.5) assert_image_similar(im, expected, 9.3)
def test_pa() -> None:
im = hopper().convert("PA")
palette = im.palette
assert palette is not None
assert palette.colors != {}
def test_trns_p(tmp_path: Path) -> None: def test_trns_p(tmp_path: Path) -> None:

View File

@ -124,6 +124,21 @@ class TestImagingPaste:
im = im.crop((12, 23, im2.width + 12, im2.height + 23)) im = im.crop((12, 23, im2.width + 12, im2.height + 23))
assert_image_equal(im, im2) assert_image_equal(im, im2)
@pytest.mark.parametrize("y", [10, -10])
@pytest.mark.parametrize("mode", ["L", "RGB"])
@pytest.mark.parametrize("mask_mode", ["", "1", "L", "LA", "RGBa"])
def test_image_self(self, y: int, mode: str, mask_mode: str) -> None:
im = getattr(self, "gradient_" + mode)
mask = Image.new(mask_mode, im.size, 0xFFFFFFFF) if mask_mode else None
im_self = im.copy()
im_self.paste(im_self, (0, y), mask)
im_copy = im.copy()
im_copy.paste(im_copy.copy(), (0, y), mask)
assert_image_equal(im_self, im_copy)
@pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"])
def test_image_mask_1(self, mode: str) -> None: def test_image_mask_1(self, mode: str) -> None:
im = Image.new(mode, (200, 200), "white") im = Image.new(mode, (200, 200), "white")

View File

@ -62,6 +62,7 @@ def test_putpalette_with_alpha_values() -> None:
expected = im.convert("RGBA") expected = im.convert("RGBA")
palette = im.getpalette() palette = im.getpalette()
assert palette is not None
transparency = im.info.pop("transparency") transparency = im.info.pop("transparency")
palette_with_alpha_values = [] palette_with_alpha_values = []

View File

@ -1,11 +1,16 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from packaging.version import parse as parse_version
from PIL import Image, features from PIL import Image
from .helper import assert_image_similar, hopper, is_ppc64le, skip_unless_feature from .helper import (
assert_image_similar,
has_feature_version,
hopper,
is_ppc64le,
skip_unless_feature,
)
def test_sanity() -> None: def test_sanity() -> None:
@ -23,10 +28,7 @@ def test_sanity() -> None:
@skip_unless_feature("libimagequant") @skip_unless_feature("libimagequant")
def test_libimagequant_quantize() -> None: def test_libimagequant_quantize() -> None:
image = hopper() image = hopper()
if is_ppc64le(): if is_ppc64le() and not has_feature_version("libimagequant", "4"):
version = features.version_feature("libimagequant")
assert version is not None
if parse_version(version) < parse_version("4"):
pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le") pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT) converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT)
assert converted.mode == "P" assert converted.mode == "P"
@ -116,6 +118,15 @@ def test_quantize_kmeans(method: Image.Quantize) -> None:
im.quantize(kmeans=-1, method=method) im.quantize(kmeans=-1, method=method)
@skip_unless_feature("libimagequant")
def test_resize() -> None:
im = hopper().resize((100, 100))
converted = im.quantize(100, Image.Quantize.LIBIMAGEQUANT)
colors = converted.getcolors()
assert colors is not None
assert len(colors) == 100
def test_colors() -> None: def test_colors() -> None:
im = hopper() im = hopper()
colors = 2 colors = 2

View File

@ -1494,7 +1494,9 @@ def test_default_font_size() -> None:
def draw_text() -> None: def draw_text() -> None:
draw.text((0, 0), text, font_size=16) draw.text((0, 0), text, font_size=16)
assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") assert_image_similar_tofile(
im, "Tests/images/imagedraw_default_font_size.png", 1
)
check(draw_text) check(draw_text)
@ -1513,7 +1515,9 @@ def test_default_font_size() -> None:
def draw_multiline_text() -> None: def draw_multiline_text() -> None:
draw.multiline_text((0, 0), text, font_size=16) draw.multiline_text((0, 0), text, font_size=16)
assert_image_equal_tofile(im, "Tests/images/imagedraw_default_font_size.png") assert_image_similar_tofile(
im, "Tests/images/imagedraw_default_font_size.png", 1
)
check(draw_multiline_text) check(draw_multiline_text)

View File

@ -164,6 +164,11 @@ class TestImageFile:
with pytest.raises(OSError): with pytest.raises(OSError):
p.close() p.close()
def test_negative_offset(self) -> None:
with Image.open("Tests/images/raw_negative_stride.bin") as im:
with pytest.raises(ValueError, match="Tile offset cannot be negative"):
im.load()
def test_no_format(self) -> None: def test_no_format(self) -> None:
buf = BytesIO(b"\x00" * 255) buf = BytesIO(b"\x00" * 255)

View File

@ -19,6 +19,7 @@ from .helper import (
assert_image_equal, assert_image_equal,
assert_image_equal_tofile, assert_image_equal_tofile,
assert_image_similar_tofile, assert_image_similar_tofile,
has_feature_version,
is_win32, is_win32,
skip_unless_feature, skip_unless_feature,
skip_unless_feature_version, skip_unless_feature_version,
@ -492,6 +493,11 @@ def test_stroke_mask() -> None:
assert mask.getpixel((42, 5)) == 255 assert mask.getpixel((42, 5)) == 255
def test_load_invalid_file() -> None:
with pytest.raises(SyntaxError, match="Not a PILfont file"):
ImageFont.load("Tests/images/1_trns.png")
def test_load_when_image_not_found() -> None: def test_load_when_image_not_found() -> None:
with tempfile.NamedTemporaryFile(delete=False) as tmp: with tempfile.NamedTemporaryFile(delete=False) as tmp:
pass pass
@ -549,7 +555,7 @@ def test_default_font() -> None:
draw.text((10, 60), txt, font=larger_default_font) draw.text((10, 60), txt, font=larger_default_font)
# Assert # Assert
assert_image_equal_tofile(im, "Tests/images/default_font_freetype.png") assert_image_similar_tofile(im, "Tests/images/default_font_freetype.png", 0.13)
@pytest.mark.parametrize("mode", ("", "1", "RGBA")) @pytest.mark.parametrize("mode", ("", "1", "RGBA"))
@ -1055,7 +1061,10 @@ def test_colr(layout_engine: ImageFont.Layout) -> None:
d.text((15, 5), "Bungee", font=font, embedded_color=True) d.text((15, 5), "Bungee", font=font, embedded_color=True)
assert_image_similar_tofile(im, "Tests/images/colr_bungee.png", 21) if has_feature_version("freetype2", "2.14.0"):
assert_image_similar_tofile(im, "Tests/images/colr_bungee.png", 6.1)
else:
assert_image_similar_tofile(im, "Tests/images/colr_bungee_older.png", 21)
@skip_unless_feature_version("freetype2", "2.10.0") @skip_unless_feature_version("freetype2", "2.10.0")
@ -1071,7 +1080,7 @@ def test_colr_mask(layout_engine: ImageFont.Layout) -> None:
d.text((15, 5), "Bungee", "black", font=font) d.text((15, 5), "Bungee", "black", font=font)
assert_image_similar_tofile(im, "Tests/images/colr_bungee_mask.png", 22) assert_image_similar_tofile(im, "Tests/images/colr_bungee_mask.png", 14.1)
def test_woff2(layout_engine: ImageFont.Layout) -> None: def test_woff2(layout_engine: ImageFont.Layout) -> None:

View File

@ -7,6 +7,7 @@ from PIL import Image, ImageDraw, ImageFont
from .helper import ( from .helper import (
assert_image_equal_tofile, assert_image_equal_tofile,
assert_image_similar_tofile, assert_image_similar_tofile,
has_feature_version,
skip_unless_feature, skip_unless_feature,
) )
@ -104,11 +105,9 @@ def test_text_direction_ttb() -> None:
im = Image.new(mode="RGB", size=(100, 300)) im = Image.new(mode="RGB", size=(100, 300))
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
try: if not has_feature_version("raqm", "0.7"):
draw.text((0, 0), "English あい", font=ttf, fill=500, direction="ttb")
except ValueError as ex:
if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
pytest.skip("libraqm 0.7 or greater not available") pytest.skip("libraqm 0.7 or greater not available")
draw.text((0, 0), "English あい", font=ttf, fill=500, direction="ttb")
target = "Tests/images/test_direction_ttb.png" target = "Tests/images/test_direction_ttb.png"
assert_image_similar_tofile(im, target, 2.8) assert_image_similar_tofile(im, target, 2.8)
@ -119,7 +118,8 @@ def test_text_direction_ttb_stroke() -> None:
im = Image.new(mode="RGB", size=(100, 300)) im = Image.new(mode="RGB", size=(100, 300))
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
try: if not has_feature_version("raqm", "0.7"):
pytest.skip("libraqm 0.7 or greater not available")
draw.text( draw.text(
(27, 27), (27, 27),
"あい", "あい",
@ -129,9 +129,6 @@ def test_text_direction_ttb_stroke() -> None:
stroke_width=2, stroke_width=2,
stroke_fill="#0f0", stroke_fill="#0f0",
) )
except ValueError as ex:
if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
pytest.skip("libraqm 0.7 or greater not available")
target = "Tests/images/test_direction_ttb_stroke.png" target = "Tests/images/test_direction_ttb_stroke.png"
assert_image_similar_tofile(im, target, 19.4) assert_image_similar_tofile(im, target, 19.4)
@ -186,7 +183,7 @@ def test_x_max_and_y_offset() -> None:
draw.text((0, 0), "لح", font=ttf, fill=500) draw.text((0, 0), "لح", font=ttf, fill=500)
target = "Tests/images/test_x_max_and_y_offset.png" target = "Tests/images/test_x_max_and_y_offset.png"
assert_image_similar_tofile(im, target, 0.5) assert_image_similar_tofile(im, target, 3.8)
def test_language() -> None: def test_language() -> None:
@ -219,14 +216,9 @@ def test_getlength(
im = Image.new(mode, (1, 1), 0) im = Image.new(mode, (1, 1), 0)
d = ImageDraw.Draw(im) d = ImageDraw.Draw(im)
try: if direction == "ttb" and not has_feature_version("raqm", "0.7"):
assert d.textlength(text, ttf, direction) == expected
except ValueError as ex:
if (
direction == "ttb"
and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction"
):
pytest.skip("libraqm 0.7 or greater not available") pytest.skip("libraqm 0.7 or greater not available")
assert d.textlength(text, ttf, direction) == expected
@pytest.mark.parametrize("mode", ("L", "1")) @pytest.mark.parametrize("mode", ("L", "1"))
@ -242,17 +234,12 @@ def test_getlength_combine(mode: str, direction: str, text: str) -> None:
ttf = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) ttf = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
try: if direction == "ttb" and not has_feature_version("raqm", "0.7"):
pytest.skip("libraqm 0.7 or greater not available")
target = ttf.getlength("ii", mode, direction) target = ttf.getlength("ii", mode, direction)
actual = ttf.getlength(text, mode, direction) actual = ttf.getlength(text, mode, direction)
assert actual == target assert actual == target
except ValueError as ex:
if (
direction == "ttb"
and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction"
):
pytest.skip("libraqm 0.7 or greater not available")
@pytest.mark.parametrize("anchor", ("lt", "mm", "rb", "sm")) @pytest.mark.parametrize("anchor", ("lt", "mm", "rb", "sm"))
@ -265,11 +252,9 @@ def test_anchor_ttb(anchor: str) -> None:
d = ImageDraw.Draw(im) d = ImageDraw.Draw(im)
d.line(((0, 200), (200, 200)), "gray") d.line(((0, 200), (200, 200)), "gray")
d.line(((100, 0), (100, 400)), "gray") d.line(((100, 0), (100, 400)), "gray")
try: if not has_feature_version("raqm", "0.7"):
d.text((100, 200), text, fill="black", anchor=anchor, direction="ttb", font=f)
except ValueError as ex:
if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
pytest.skip("libraqm 0.7 or greater not available") pytest.skip("libraqm 0.7 or greater not available")
d.text((100, 200), text, fill="black", anchor=anchor, direction="ttb", font=f)
assert_image_similar_tofile(im, path, 1) # fails at 5 assert_image_similar_tofile(im, path, 1) # fails at 5
@ -310,10 +295,12 @@ combine_tests = (
# this tests various combining characters for anchor alignment and clipping # this tests various combining characters for anchor alignment and clipping
@pytest.mark.parametrize( @pytest.mark.parametrize(
"name, text, anchor, dir, epsilon", combine_tests, ids=[r[0] for r in combine_tests] "name, text, anchor, direction, epsilon",
combine_tests,
ids=[r[0] for r in combine_tests],
) )
def test_combine( def test_combine(
name: str, text: str, dir: str | None, anchor: str | None, epsilon: float name: str, text: str, direction: str | None, anchor: str | None, epsilon: float
) -> None: ) -> None:
path = f"Tests/images/test_combine_{name}.png" path = f"Tests/images/test_combine_{name}.png"
f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
@ -322,11 +309,9 @@ def test_combine(
d = ImageDraw.Draw(im) d = ImageDraw.Draw(im)
d.line(((0, 200), (400, 200)), "gray") d.line(((0, 200), (400, 200)), "gray")
d.line(((200, 0), (200, 400)), "gray") d.line(((200, 0), (200, 400)), "gray")
try: if direction == "ttb" and not has_feature_version("raqm", "0.7"):
d.text((200, 200), text, fill="black", anchor=anchor, direction=dir, font=f)
except ValueError as ex:
if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
pytest.skip("libraqm 0.7 or greater not available") pytest.skip("libraqm 0.7 or greater not available")
d.text((200, 200), text, fill="black", anchor=anchor, direction=direction, font=f)
assert_image_similar_tofile(im, path, epsilon) assert_image_similar_tofile(im, path, epsilon)

View File

@ -30,6 +30,14 @@ def test_default_font(font: ImageFont.ImageFont) -> None:
assert_image_equal_tofile(im, "Tests/images/default_font.png") assert_image_equal_tofile(im, "Tests/images/default_font.png")
def test_invalid_mode() -> None:
font = ImageFont.ImageFont()
fp = BytesIO()
with Image.open("Tests/images/hopper.png") as im:
with pytest.raises(TypeError, match="invalid font image mode"):
font._load_pilfont_data(fp, im)
def test_without_freetype() -> None: def test_without_freetype() -> None:
original_core = ImageFont.core original_core = ImageFont.core
if features.check_module("freetype2"): if features.check_module("freetype2"):

View File

@ -7,7 +7,7 @@ import pytest
from PIL import Image, ImageMorph, _imagingmorph from PIL import Image, ImageMorph, _imagingmorph
from .helper import assert_image_equal_tofile, hopper from .helper import assert_image_equal_tofile, hopper, timeout_unless_slower_valgrind
def string_to_img(image_string: str) -> Image.Image: def string_to_img(image_string: str) -> Image.Image:
@ -266,16 +266,18 @@ def test_unknown_pattern() -> None:
ImageMorph.LutBuilder(op_name="unknown") ImageMorph.LutBuilder(op_name="unknown")
def test_pattern_syntax_error() -> None: @pytest.mark.parametrize(
"pattern", ("a pattern with a syntax error", "4:(" + "X" * 30000)
)
@timeout_unless_slower_valgrind(1)
def test_pattern_syntax_error(pattern: str) -> None:
# Arrange # Arrange
lb = ImageMorph.LutBuilder(op_name="corner") lb = ImageMorph.LutBuilder(op_name="corner")
new_patterns = ["a pattern with a syntax error"] new_patterns = [pattern]
lb.add_patterns(new_patterns) lb.add_patterns(new_patterns)
# Act / Assert # Act / Assert
with pytest.raises( with pytest.raises(Exception, match='Syntax error in pattern "'):
Exception, match='Syntax error in pattern "a pattern with a syntax error"'
):
lb.build_lut() lb.build_lut()

View File

@ -186,6 +186,21 @@ def test_palette(mode: str) -> None:
) )
def test_rgba_palette() -> None:
im = Image.new("P", (1, 1))
red = (255, 0, 0, 255)
translucent_black = (0, 0, 0, 127)
im.putpalette(red + translucent_black, "RGBA")
expanded_im = ImageOps.expand(im, 1, 1)
palette = expanded_im.palette
assert palette is not None
assert palette.mode == "RGBA"
assert expanded_im.convert("RGBA").getpixel((0, 0)) == translucent_black
def test_pil163() -> None: def test_pil163() -> None:
# Division by zero in equalize if < 255 pixels in image (@PIL163) # Division by zero in equalize if < 255 pixels in image (@PIL163)

View File

@ -76,9 +76,14 @@ def test_consecutive() -> None:
def test_palette_mmap() -> None: def test_palette_mmap() -> None:
# Using mmap in ImageFile can require to reload the palette. # Using mmap in ImageFile can require to reload the palette.
with Image.open("Tests/images/multipage-mmap.tiff") as im: with Image.open("Tests/images/multipage-mmap.tiff") as im:
color1 = im.getpalette()[:3] palette = im.getpalette()
assert palette is not None
color1 = palette[:3]
im.seek(0) im.seek(0)
color2 = im.getpalette()[:3]
palette = im.getpalette()
assert palette is not None
color2 = palette[:3]
assert color1 == color2 assert color1 == color2

View File

@ -59,15 +59,12 @@ def test_show(mode: str) -> None:
assert ImageShow.show(im) assert ImageShow.show(im)
def test_show_without_viewers() -> None: def test_show_without_viewers(monkeypatch: pytest.MonkeyPatch) -> None:
viewers = ImageShow._viewers monkeypatch.setattr(ImageShow, "_viewers", [])
ImageShow._viewers = []
with hopper() as im: with hopper() as im:
assert not ImageShow.show(im) assert not ImageShow.show(im)
ImageShow._viewers = viewers
@pytest.mark.parametrize( @pytest.mark.parametrize(
"viewer", "viewer",

View File

@ -57,3 +57,13 @@ def test_constant() -> None:
assert st.rms[0] == 128 assert st.rms[0] == 128
assert st.var[0] == 0 assert st.var[0] == 0
assert st.stddev[0] == 0 assert st.stddev[0] == 0
def test_zero_count() -> None:
im = Image.new("L", (0, 0))
st = ImageStat.Stat(im)
assert st.mean == [0]
assert st.rms == [0]
assert st.var == [0]

302
Tests/test_nanoarrow.py Normal file
View File

@ -0,0 +1,302 @@
from __future__ import annotations
import json
from typing import Any, NamedTuple
import pytest
from PIL import Image
from .helper import (
assert_deep_equal,
assert_image_equal,
hopper,
is_big_endian,
)
TYPE_CHECKING = False
if TYPE_CHECKING:
import nanoarrow # type: ignore [import-not-found]
else:
nanoarrow = pytest.importorskip("nanoarrow", reason="Nanoarrow not installed")
TEST_IMAGE_SIZE = (10, 10)
def _test_img_equals_pyarray(
img: Image.Image, arr: Any, mask: list[int] | None, elts_per_pixel: int = 1
) -> None:
assert img.height * img.width * elts_per_pixel == len(arr)
px = img.load()
assert px is not None
if elts_per_pixel > 1 and mask is None:
# have to do element-wise comparison when we're comparing
# flattened r,g,b,a to a pixel.
mask = list(range(elts_per_pixel))
for x in range(0, img.size[0], int(img.size[0] / 10)):
for y in range(0, img.size[1], int(img.size[1] / 10)):
if mask:
pixel = px[x, y]
assert isinstance(pixel, tuple)
for ix, elt in enumerate(mask):
if elts_per_pixel == 1:
assert pixel[ix] == arr[y * img.width + x].as_py()[elt]
else:
assert (
pixel[ix]
== arr[(y * img.width + x) * elts_per_pixel + elt].as_py()
)
else:
assert_deep_equal(px[x, y], arr[y * img.width + x].as_py())
def _test_img_equals_int32_pyarray(
img: Image.Image, arr: Any, mask: list[int] | None, elts_per_pixel: int = 1
) -> None:
assert img.height * img.width * elts_per_pixel == len(arr)
px = img.load()
assert px is not None
if mask is None:
# have to do element-wise comparison when we're comparing
# flattened rgba in an uint32 to a pixel.
mask = list(range(elts_per_pixel))
for x in range(0, img.size[0], int(img.size[0] / 10)):
for y in range(0, img.size[1], int(img.size[1] / 10)):
pixel = px[x, y]
assert isinstance(pixel, tuple)
arr_pixel_int = arr[y * img.width + x].as_py()
arr_pixel_tuple = (
arr_pixel_int % 256,
(arr_pixel_int // 256) % 256,
(arr_pixel_int // 256**2) % 256,
(arr_pixel_int // 256**3),
)
if is_big_endian():
arr_pixel_tuple = arr_pixel_tuple[::-1]
for ix, elt in enumerate(mask):
assert pixel[ix] == arr_pixel_tuple[elt]
fl_uint8_4_type = nanoarrow.fixed_size_list(
value_type=nanoarrow.uint8(nullable=False), list_size=4, nullable=False
)
@pytest.mark.parametrize(
"mode, dtype, mask",
(
("L", nanoarrow.uint8(nullable=False), None),
("I", nanoarrow.int32(nullable=False), None),
("F", nanoarrow.float32(nullable=False), None),
("LA", fl_uint8_4_type, [0, 3]),
("RGB", fl_uint8_4_type, [0, 1, 2]),
("RGBA", fl_uint8_4_type, None),
("RGBX", fl_uint8_4_type, None),
("CMYK", fl_uint8_4_type, None),
("YCbCr", fl_uint8_4_type, [0, 1, 2]),
("HSV", fl_uint8_4_type, [0, 1, 2]),
),
)
def test_to_array(mode: str, dtype: nanoarrow, mask: list[int] | None) -> None:
img = hopper(mode)
# Resize to non-square
img = img.crop((3, 0, 124, 127))
assert img.size == (121, 127)
arr = nanoarrow.Array(img)
_test_img_equals_pyarray(img, arr, mask)
assert arr.schema.type == dtype.type
assert arr.schema.nullable == dtype.nullable
reloaded = Image.fromarrow(arr, mode, img.size)
assert_image_equal(img, reloaded)
def test_lifetime() -> None:
# valgrind shouldn't error out here.
# arrays should be accessible after the image is deleted.
img = hopper("L")
arr_1 = nanoarrow.Array(img)
arr_2 = nanoarrow.Array(img)
del img
assert sum(arr_1.iter_py()) > 0
del arr_1
assert sum(arr_2.iter_py()) > 0
del arr_2
def test_lifetime2() -> None:
# valgrind shouldn't error out here.
# img should remain after the arrays are collected.
img = hopper("L")
arr_1 = nanoarrow.Array(img)
arr_2 = nanoarrow.Array(img)
assert sum(arr_1.iter_py()) > 0
del arr_1
assert sum(arr_2.iter_py()) > 0
del arr_2
img2 = img.copy()
px = img2.load()
assert px # make mypy happy
assert isinstance(px[0, 0], int)
class DataShape(NamedTuple):
dtype: nanoarrow
# Strictly speaking, elt should be a pixel or pixel component, so
# list[uint8][4], float, int, uint32, uint8, etc. But more
# correctly, it should be exactly the dtype from the line above.
elt: Any
elts_per_pixel: int
UINT_ARR = DataShape(
dtype=fl_uint8_4_type,
elt=[1, 2, 3, 4], # array of 4 uint8 per pixel
elts_per_pixel=1, # only one array per pixel
)
UINT = DataShape(
dtype=nanoarrow.uint8(),
elt=3, # one uint8,
elts_per_pixel=4, # but repeated 4x per pixel
)
UINT32 = DataShape(
dtype=nanoarrow.uint32(),
elt=0xABCDEF45, # one packed int, doesn't fit in a int32 > 0x80000000
elts_per_pixel=1, # one per pixel
)
INT32 = DataShape(
dtype=nanoarrow.uint32(),
elt=0x12CDEF45, # one packed int
elts_per_pixel=1, # one per pixel
)
@pytest.mark.parametrize(
"mode, data_tp, mask",
(
("L", DataShape(nanoarrow.uint8(), 3, 1), None),
("I", DataShape(nanoarrow.int32(), 1 << 24, 1), None),
("F", DataShape(nanoarrow.float32(), 3.14159, 1), None),
("LA", UINT_ARR, [0, 3]),
("LA", UINT, [0, 3]),
("RGB", UINT_ARR, [0, 1, 2]),
("RGBA", UINT_ARR, None),
("CMYK", UINT_ARR, None),
("YCbCr", UINT_ARR, [0, 1, 2]),
("HSV", UINT_ARR, [0, 1, 2]),
("RGB", UINT, [0, 1, 2]),
("RGBA", UINT, None),
("CMYK", UINT, None),
("YCbCr", UINT, [0, 1, 2]),
("HSV", UINT, [0, 1, 2]),
),
)
def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> None:
(dtype, elt, elts_per_pixel) = data_tp
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
if dtype == fl_uint8_4_type:
tmp_arr = nanoarrow.Array(
elt * (ct_pixels * elts_per_pixel), schema=nanoarrow.uint8()
)
c_array = nanoarrow.c_array_from_buffers(
dtype, ct_pixels, buffers=[], children=[tmp_arr]
)
arr = nanoarrow.Array(c_array)
else:
arr = nanoarrow.Array(
nanoarrow.c_array([elt] * (ct_pixels * elts_per_pixel), schema=dtype)
)
img = Image.fromarrow(arr, mode, TEST_IMAGE_SIZE)
_test_img_equals_pyarray(img, arr, mask, elts_per_pixel)
@pytest.mark.parametrize(
"mode, mask",
(
("LA", [0, 3]),
("RGB", [0, 1, 2]),
("RGBA", None),
("CMYK", None),
("YCbCr", [0, 1, 2]),
("HSV", [0, 1, 2]),
),
)
@pytest.mark.parametrize("data_tp", (UINT32, INT32))
def test_from_int32array(mode: str, mask: list[int] | None, data_tp: DataShape) -> None:
(dtype, elt, elts_per_pixel) = data_tp
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
arr = nanoarrow.Array(
nanoarrow.c_array([elt] * (ct_pixels * elts_per_pixel), schema=dtype)
)
img = Image.fromarrow(arr, mode, TEST_IMAGE_SIZE)
_test_img_equals_int32_pyarray(img, arr, mask, elts_per_pixel)
@pytest.mark.parametrize(
"mode, metadata",
(
("LA", ["L", "X", "X", "A"]),
("RGB", ["R", "G", "B", "X"]),
("RGBX", ["R", "G", "B", "X"]),
("RGBA", ["R", "G", "B", "A"]),
("CMYK", ["C", "M", "Y", "K"]),
("YCbCr", ["Y", "Cb", "Cr", "X"]),
("HSV", ["H", "S", "V", "X"]),
),
)
def test_image_nested_metadata(mode: str, metadata: list[str]) -> None:
img = hopper(mode)
arr = nanoarrow.Array(img)
assert arr.schema.value_type.metadata
assert arr.schema.value_type.metadata[b"image"]
parsed_metadata = json.loads(
arr.schema.value_type.metadata[b"image"].decode("utf8")
)
assert "bands" in parsed_metadata
assert parsed_metadata["bands"] == metadata
@pytest.mark.parametrize(
"mode, metadata",
(
("L", ["L"]),
("I", ["I"]),
("F", ["F"]),
),
)
def test_image_flat_metadata(mode: str, metadata: list[str]) -> None:
img = hopper(mode)
arr = nanoarrow.Array(img)
assert arr.schema.metadata
assert arr.schema.metadata[b"image"]
parsed_metadata = json.loads(arr.schema.metadata[b"image"].decode("utf8"))
assert "bands" in parsed_metadata
assert parsed_metadata["bands"] == metadata

View File

@ -28,15 +28,13 @@ def test_numpy_to_image() -> None:
a = numpy.array(data, dtype=dtype) a = numpy.array(data, dtype=dtype)
a.shape = TEST_IMAGE_SIZE a.shape = TEST_IMAGE_SIZE
i = Image.fromarray(a) i = Image.fromarray(a)
if list(i.getdata()) != data: assert list(i.getdata()) == data
print("data mismatch for", dtype)
else: else:
data = list(range(100)) data = list(range(100))
a = numpy.array([[x] * bands for x in data], dtype=dtype) a = numpy.array([[x] * bands for x in data], dtype=dtype)
a.shape = TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1], bands a.shape = TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1], bands
i = Image.fromarray(a) i = Image.fromarray(a)
if list(i.getchannel(0).getdata()) != list(range(100)): assert list(i.getchannel(0).getdata()) == list(range(100))
print("data mismatch for", dtype)
return i return i
# Check supported 1-bit integer formats # Check supported 1-bit integer formats

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import Any, NamedTuple from typing import Any, NamedTuple
import pytest import pytest
@ -244,3 +245,29 @@ def test_from_int32array(mode: str, data_tp: DataShape, mask: list[int] | None)
img = Image.fromarrow(arr, mode, TEST_IMAGE_SIZE) img = Image.fromarrow(arr, mode, TEST_IMAGE_SIZE)
_test_img_equals_int32_pyarray(img, arr, mask, elts_per_pixel) _test_img_equals_int32_pyarray(img, arr, mask, elts_per_pixel)
@pytest.mark.parametrize(
"mode, metadata",
(
("LA", ["L", "X", "X", "A"]),
("RGB", ["R", "G", "B", "X"]),
("RGBX", ["R", "G", "B", "X"]),
("RGBA", ["R", "G", "B", "A"]),
("CMYK", ["C", "M", "Y", "K"]),
("YCbCr", ["Y", "Cb", "Cr", "X"]),
("HSV", ["H", "S", "V", "X"]),
),
)
def test_image_metadata(mode: str, metadata: list[str]) -> None:
img = hopper(mode)
arr = pyarrow.array(img) # type: ignore[call-overload]
assert arr.type.field(0).metadata
assert arr.type.field(0).metadata[b"image"]
parsed_metadata = json.loads(arr.type.field(0).metadata[b"image"].decode("utf8"))
assert "bands" in parsed_metadata
assert parsed_metadata["bands"] == metadata

View File

@ -9,7 +9,7 @@ from PIL import __version__
pyroma = pytest.importorskip("pyroma", reason="Pyroma not installed") pyroma = pytest.importorskip("pyroma", reason="Pyroma not installed")
def map_metadata_keys(metadata): def map_metadata_keys(md):
# Convert installed wheel metadata into canonical Core Metadata 2.4 format. # Convert installed wheel metadata into canonical Core Metadata 2.4 format.
# This was a utility method in pyroma 4.3.3; it was removed in 5.0. # This was a utility method in pyroma 4.3.3; it was removed in 5.0.
# This implementation is constructed from the relevant logic from # This implementation is constructed from the relevant logic from
@ -17,8 +17,8 @@ def map_metadata_keys(metadata):
# upstream to Pyroma as https://github.com/regebro/pyroma/pull/116, # upstream to Pyroma as https://github.com/regebro/pyroma/pull/116,
# so it may be possible to simplify this test in future. # so it may be possible to simplify this test in future.
data = {} data = {}
for key in set(metadata.keys()): for key in set(md.keys()):
value = metadata.get_all(key) value = md.get_all(key)
key = pyroma.projectdata.normalize(key) key = pyroma.projectdata.normalize(key)
if len(value) == 1: if len(value) == 1:

1
checks/32bit_segfault_check.py Executable file → Normal file
View File

@ -1,4 +1,3 @@
#!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import sys import sys

8
checks/check_imaging_leaks.py Executable file → Normal file
View File

@ -1,6 +1,6 @@
#!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import sys
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
@ -8,12 +8,12 @@ import pytest
from PIL import Image from PIL import Image
from .helper import is_win32
min_iterations = 100 min_iterations = 100
max_iterations = 10000 max_iterations = 10000
pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") pytestmark = pytest.mark.skipif(
sys.platform.startswith("win32"), reason="requires Unix or macOS"
)
def _get_mem_usage() -> float: def _get_mem_usage() -> float:

View File

@ -1,12 +1,11 @@
from __future__ import annotations from __future__ import annotations
import sys
from io import BytesIO from io import BytesIO
import pytest import pytest
from PIL import Image from PIL import Image, features
from .helper import is_win32, skip_unless_feature
# Limits for testing the leak # Limits for testing the leak
mem_limit = 1024 * 1048576 mem_limit = 1024 * 1048576
@ -15,8 +14,10 @@ iterations = int((mem_limit / stack_size) * 2)
test_file = "Tests/images/rgb_trns_ycbc.jp2" test_file = "Tests/images/rgb_trns_ycbc.jp2"
pytestmark = [ pytestmark = [
pytest.mark.skipif(is_win32(), reason="requires Unix or macOS"), pytest.mark.skipif(
skip_unless_feature("jpg_2000"), sys.platform.startswith("win32"), reason="requires Unix or macOS"
),
pytest.mark.skipif(not features.check("jpg_2000"), reason="jpg_2000 not available"),
] ]

View File

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import sys
from io import BytesIO from io import BytesIO
import pytest import pytest
from .helper import hopper, is_win32 from PIL import Image
iterations = 5000 iterations = 5000
@ -18,7 +19,9 @@ valgrind --tool=massif python test-installed.py -s -v checks/check_jpeg_leaks.py
""" """
pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") pytestmark = pytest.mark.skipif(
sys.platform.startswith("win32"), reason="requires Unix or macOS"
)
""" """
pre patch: pre patch:
@ -112,7 +115,7 @@ standard_chrominance_qtable = (
), ),
) )
def test_qtables_leak(qtables: tuple[tuple[int, ...]] | list[tuple[int, ...]]) -> None: def test_qtables_leak(qtables: tuple[tuple[int, ...]] | list[tuple[int, ...]]) -> None:
im = hopper("RGB") with Image.open("Tests/images/hopper.ppm") as im:
for _ in range(iterations): for _ in range(iterations):
test_output = BytesIO() test_output = BytesIO()
im.save(test_output, "JPEG", qtables=qtables) im.save(test_output, "JPEG", qtables=qtables)
@ -173,9 +176,9 @@ def test_exif_leak() -> None:
0 +----------------------------------------------------------------------->Gi 0 +----------------------------------------------------------------------->Gi
0 11.33 0 11.33
""" """
im = hopper("RGB")
exif = b"12345678" * 4096 exif = b"12345678" * 4096
with Image.open("Tests/images/hopper.ppm") as im:
for _ in range(iterations): for _ in range(iterations):
test_output = BytesIO() test_output = BytesIO()
im.save(test_output, "JPEG", exif=exif) im.save(test_output, "JPEG", exif=exif)
@ -207,8 +210,7 @@ def test_base_save() -> None:
| :@ @@ @ # : : :: :: @:: :::: :::: :::: : : : : : : :::::::::::: :::@::: | :@ @@ @ # : : :: :: @:: :::: :::: :::: : : : : : : :::::::::::: :::@:::
0 +----------------------------------------------------------------------->Gi 0 +----------------------------------------------------------------------->Gi
0 7.882""" 0 7.882"""
im = hopper("RGB") with Image.open("Tests/images/hopper.ppm") as im:
for _ in range(iterations): for _ in range(iterations):
test_output = BytesIO() test_output = BytesIO()
im.save(test_output, "JPEG") im.save(test_output, "JPEG")

View File

@ -4,7 +4,6 @@ import platform
import sys import sys
from PIL import features from PIL import features
from Tests.helper import is_pypy
def test_wheel_modules() -> None: def test_wheel_modules() -> None:
@ -48,8 +47,6 @@ def test_wheel_features() -> None:
if sys.platform == "win32": if sys.platform == "win32":
expected_features.remove("xcb") expected_features.remove("xcb")
elif sys.platform == "darwin" and not is_pypy() and platform.processor() != "arm":
expected_features.remove("zlib_ng")
elif sys.platform == "ios": elif sys.platform == "ios":
# Can't distribute raqm due to licensing, and there's no system version; # Can't distribute raqm due to licensing, and there's no system version;
# fribidi and harfbuzz won't be available if raqm isn't available. # fribidi and harfbuzz won't be available if raqm isn't available.

View File

@ -2,7 +2,7 @@
# install libimagequant # install libimagequant
archive_name=libimagequant archive_name=libimagequant
archive_version=4.3.4 archive_version=4.4.0
archive=$archive_name-$archive_version archive=$archive_name-$archive_version

View File

@ -59,6 +59,6 @@ cmake \
"${LIBAVIF_CMAKE_FLAGS[@]}" \ "${LIBAVIF_CMAKE_FLAGS[@]}" \
. .
sudo make install make install
popd popd

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# install openjpeg # install openjpeg
archive=openjpeg-2.5.3 archive=openjpeg-2.5.4
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz

View File

@ -2,12 +2,12 @@
# install raqm # install raqm
archive=libraqm-0.10.2 archive=libraqm-0.10.3
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz
pushd $archive pushd $archive
meson build --prefix=/usr && sudo ninja -C build install meson build --prefix=/usr && ninja -C build install
popd popd

View File

@ -35,8 +35,12 @@ Image.fromarray mode parameter
.. deprecated:: 11.3.0 .. deprecated:: 11.3.0
The ``mode`` parameter in :py:meth:`~PIL.Image.fromarray()` has been deprecated. The Using the ``mode`` parameter in :py:meth:`~PIL.Image.fromarray()` was deprecated in
mode can be automatically determined from the object's shape and type instead. Pillow 11.3.0. In Pillow 12.0.0, this was partially reverted, and it is now only
deprecated when changing data types. Since pixel values do not contain information
about palettes or color spaces, the parameter can still be used to place grayscale L
mode data within a P mode image, or read RGB data as YCbCr for example. If omitted, the
mode will be automatically determined from the object's shape and type.
Saving I mode images as PNG Saving I mode images as PNG
^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -61,6 +65,14 @@ ImageCms.ImageCmsProfile.product_name and .product_info
``.product_info`` attributes have been deprecated, and will be removed in ``.product_info`` attributes have been deprecated, and will be removed in
Pillow 13 (2026-10-15). They have been set to ``None`` since Pillow 2.3.0. Pillow 13 (2026-10-15). They have been set to ``None`` since Pillow 2.3.0.
Image._show
~~~~~~~~~~~
.. deprecated:: 12.0.0
``Image._show`` has been deprecated, and will be removed in Pillow 13 (2026-10-15).
Use :py:meth:`~PIL.ImageShow.show` instead.
Removed features Removed features
---------------- ----------------

View File

@ -11,7 +11,7 @@ Here is a list of PyPI projects that offer additional plugins:
* :pypi:`heif-image-plugin`: Simple HEIF/HEIC images plugin, based on the pyheif library. * :pypi:`heif-image-plugin`: Simple HEIF/HEIC images plugin, based on the pyheif library.
* :pypi:`jxlpy`: Introduces reading and writing support for JPEG XL. * :pypi:`jxlpy`: Introduces reading and writing support for JPEG XL.
* :pypi:`pillow-heif`: Python bindings to libheif for working with HEIF images. * :pypi:`pillow-heif`: Python bindings to libheif for working with HEIF images.
* :pypi:`pillow-jpls`: Plugin for the JPEG-LS codec, based on the Charls JPEG-LS implemetation. Python bindings implemented using pybind11. * :pypi:`pillow-jpls`: Plugin for the JPEG-LS codec, based on the Charls JPEG-LS implementation. Python bindings implemented using pybind11.
* :pypi:`pillow-jxl-plugin`: Plugin for JPEG-XL, using Rust for bindings. * :pypi:`pillow-jxl-plugin`: Plugin for JPEG-XL, using Rust for bindings.
* :pypi:`pillow-mbm`: Adds support for KSP's proprietary MBM texture format. * :pypi:`pillow-mbm`: Adds support for KSP's proprietary MBM texture format.
* :pypi:`pillow-svg`: Implements basic SVG read support. Supports basic paths, shapes, and text. * :pypi:`pillow-svg`: Implements basic SVG read support. Supports basic paths, shapes, and text.

View File

@ -44,7 +44,7 @@ Many of Pillow's features require external libraries:
* **libtiff** provides compressed TIFF functionality * **libtiff** provides compressed TIFF functionality
* Pillow has been tested with libtiff versions **4.0-4.7.0** * Pillow has been tested with libtiff versions **4.0-4.7.1**
* **libfreetype** provides type related services * **libfreetype** provides type related services
@ -58,13 +58,13 @@ Many of Pillow's features require external libraries:
* **openjpeg** provides JPEG 2000 functionality. * **openjpeg** provides JPEG 2000 functionality.
* Pillow has been tested with openjpeg **2.0.0**, **2.1.0**, **2.3.1**, * Pillow has been tested with openjpeg **2.0.0**, **2.1.0**, **2.3.1**,
**2.4.0**, **2.5.0**, **2.5.2** and **2.5.3**. **2.4.0**, **2.5.0**, **2.5.2**, **2.5.3** and **2.5.4**.
* Pillow does **not** support the earlier **1.5** series which ships * Pillow does **not** support the earlier **1.5** series which ships
with Debian Jessie. with Debian Jessie.
* **libimagequant** provides improved color quantization * **libimagequant** provides improved color quantization
* Pillow has been tested with libimagequant **2.6-4.3.4** * Pillow has been tested with libimagequant **2.6-4.4.0**
* Libimagequant is licensed GPLv3, which is more restrictive than * Libimagequant is licensed GPLv3, which is more restrictive than
the Pillow license, therefore we will not be distributing binaries the Pillow license, therefore we will not be distributing binaries
with libimagequant support enabled. with libimagequant support enabled.

View File

@ -31,15 +31,17 @@ These platforms are built and tested for every change.
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| Debian 12 Bookworm | 3.11 | x86, x86-64 | | Debian 12 Bookworm | 3.11 | x86, x86-64 |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| Debian 13 Trixie | 3.13 | x86, x86-64 |
+----------------------------------+----------------------------+---------------------+
| Fedora 41 | 3.13 | x86-64 | | Fedora 41 | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| Fedora 42 | 3.13 | x86-64 | | Fedora 42 | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| Gentoo | 3.12 | x86-64 | | Gentoo | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| macOS 13 Ventura | 3.10 | x86-64 | | macOS 15 Sequoia | 3.10 | x86-64 |
+----------------------------------+----------------------------+---------------------+ | +----------------------------+---------------------+
| macOS 14 Sonoma | 3.11, 3.12, 3.13, 3.14 | arm64 | | | 3.11, 3.12, 3.13, 3.14, | arm64 |
| | PyPy3 | | | | PyPy3 | |
+----------------------------------+----------------------------+---------------------+ +----------------------------------+----------------------------+---------------------+
| Ubuntu Linux 22.04 LTS (Jammy) | 3.10 | x86-64 | | Ubuntu Linux 22.04 LTS (Jammy) | 3.10 | x86-64 |
@ -73,6 +75,8 @@ These platforms have been reported to work at the versions mentioned.
| Operating system | | Tested Python | | Latest tested | | Tested | | Operating system | | Tested Python | | Latest tested | | Tested |
| | | versions | | Pillow version | | processors | | | | versions | | Pillow version | | processors |
+==================================+============================+==================+==============+ +==================================+============================+==================+==============+
| macOS 26 Tahoe | 3.9, 3.10, 3.11, 3.12, 3.13| 11.3.0 |arm |
+----------------------------------+----------------------------+------------------+--------------+
| macOS 15 Sequoia | 3.9, 3.10, 3.11, 3.12, 3.13| 11.3.0 |arm | | macOS 15 Sequoia | 3.9, 3.10, 3.11, 3.12, 3.13| 11.3.0 |arm |
| +----------------------------+------------------+ | | +----------------------------+------------------+ |
| | 3.8 | 10.4.0 | | | | 3.8 | 10.4.0 | |

View File

@ -57,6 +57,43 @@ Color names
See :ref:`color-names` for the color names supported by Pillow. See :ref:`color-names` for the color names supported by Pillow.
Alpha channel
^^^^^^^^^^^^^
By default, when drawing onto an existing image, the image's pixel values are simply
replaced by the new color::
im = Image.new("RGBA", (1, 1), (255, 0, 0))
d = ImageDraw.Draw(im)
d.rectangle((0, 0, 1, 1), (0, 255, 0, 127))
assert im.getpixel((0, 0)) == (0, 255, 0, 127)
# Alpha channel values have no effect when drawing with RGB mode
im = Image.new("RGB", (1, 1), (255, 0, 0))
d = ImageDraw.Draw(im)
d.rectangle((0, 0, 1, 1), (0, 255, 0, 127))
assert im.getpixel((0, 0)) == (0, 255, 0)
If you would like to combine translucent color with an RGB image, then initialize the
ImageDraw instance with the RGBA mode::
from PIL import Image, ImageDraw
im = Image.new("RGB", (1, 1), (255, 0, 0))
d = ImageDraw.Draw(im, "RGBA")
d.rectangle((0, 0, 1, 1), (0, 255, 0, 127))
assert im.getpixel((0, 0)) == (128, 127, 0)
If you would like to combine translucent color with an RGBA image underneath, you will
need to combine multiple images::
from PIL import Image, ImageDraw
im = Image.new("RGBA", (1, 1), (255, 0, 0, 255))
im2 = Image.new("RGBA", (1, 1))
d = ImageDraw.Draw(im2)
d.rectangle((0, 0, 1, 1), (0, 255, 0, 127))
im.paste(im2.convert("RGB"), mask=im2)
assert im.getpixel((0, 0)) == (128, 127, 0, 255)
Fonts Fonts
^^^^^ ^^^^^

View File

@ -74,5 +74,6 @@ Constants
--------- ---------
.. autodata:: PIL.ImageFile.LOAD_TRUNCATED_IMAGES .. autodata:: PIL.ImageFile.LOAD_TRUNCATED_IMAGES
.. autodata:: PIL.ImageFile.MAXBLOCK
.. autodata:: PIL.ImageFile.ERRORS .. autodata:: PIL.ImageFile.ERRORS
:annotation: :annotation:

View File

@ -20,7 +20,9 @@ or the clipboard to a PIL image memory.
used as a fallback if they are installed. To disable this behaviour, pass used as a fallback if they are installed. To disable this behaviour, pass
``xdisplay=""`` instead. ``xdisplay=""`` instead.
.. versionadded:: 1.1.3 (Windows), 3.0.0 (macOS), 7.1.0 (Linux) .. versionadded:: 1.1.3 Windows support
.. versionadded:: 3.0.0 macOS support
.. versionadded:: 7.1.0 Linux support
:param bbox: What region to copy. Default is the entire screen. :param bbox: What region to copy. Default is the entire screen.
On macOS, this is not increased to 2x for Retina screens, so the full On macOS, this is not increased to 2x for Retina screens, so the full
@ -53,7 +55,9 @@ or the clipboard to a PIL image memory.
On Linux, ``wl-paste`` or ``xclip`` is required. On Linux, ``wl-paste`` or ``xclip`` is required.
.. versionadded:: 1.1.4 (Windows), 3.3.0 (macOS), 9.4.0 (Linux) .. versionadded:: 1.1.4 Windows support
.. versionadded:: 3.3.0 macOS support
.. versionadded:: 9.4.0 Linux support
:return: On Windows, an image, a list of filenames, :return: On Windows, an image, a list of filenames,
or None if the clipboard does not contain image data or filenames. or None if the clipboard does not contain image data or filenames.

View File

@ -29,6 +29,13 @@ Image.fromarray mode parameter
The ``mode`` parameter in :py:meth:`~PIL.Image.fromarray()` has been deprecated. The The ``mode`` parameter in :py:meth:`~PIL.Image.fromarray()` has been deprecated. The
mode can be automatically determined from the object's shape and type instead. mode can be automatically determined from the object's shape and type instead.
.. note::
Since pixel values do not contain information about palettes or color spaces, part
of this functionality was restored in Pillow 12.0.0. The parameter can be used to
place grayscale L mode data within a P mode image, or read RGB data as YCbCr for
example.
Saving I mode images as PNG Saving I mode images as PNG
^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -1,19 +1,6 @@
12.0.0 12.0.0
------ ------
Security
========
TODO
^^^^
TODO
:cve:`YYYY-XXXXX`: TODO
^^^^^^^^^^^^^^^^^^^^^^^
TODO
Backwards incompatible changes Backwards incompatible changes
============================== ==============================
@ -116,6 +103,12 @@ vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`).
Deprecations Deprecations
============ ============
Image._show
^^^^^^^^^^^
``Image._show`` has been deprecated, and will be removed in Pillow 13 (2026-10-15).
Use :py:meth:`~PIL.ImageShow.show` instead.
ImageCms.ImageCmsProfile.product_name and .product_info ImageCms.ImageCmsProfile.product_name and .product_info
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -126,18 +119,10 @@ Pillow 13 (2026-10-15). They have been set to ``None`` since Pillow 2.3.0.
API changes API changes
=========== ===========
TODO Image.alpha_composite: LA images
^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TODO :py:meth:`~PIL.Image.alpha_composite` can now use LA images as well as RGBA.
API additions
=============
TODO
^^^^
TODO
Other changes Other changes
============= =============
@ -150,3 +135,19 @@ others prepare for 3.14, and to ensure Pillow could be used immediately at the r
of 3.14.0 final (2025-10-07, :pep:`745`). of 3.14.0 final (2025-10-07, :pep:`745`).
Pillow 12.0.0 now officially supports Python 3.14. Pillow 12.0.0 now officially supports Python 3.14.
Image.fromarray mode parameter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In Pillow 11.3.0, the ``mode`` parameter in :py:meth:`~PIL.Image.fromarray()` was
deprecated. Part of this functionality has been restored in Pillow 12.0.0. Since pixel
values do not contain information about palettes or color spaces, the parameter can be
used to place grayscale L mode data within a P mode image, or read RGB data as YCbCr
for example.
ImageMorph operations must have length 1
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Valid ImageMorph operations are 4, N, 1 and M. By limiting the length to 1 character
within Pillow, long execution times can be avoided if a user provided long pattern
strings. Reported by `Jang Choi <https://github.com/uko3211>`__.

View File

@ -57,6 +57,9 @@ optional-dependencies.mic = [
"olefile", "olefile",
] ]
optional-dependencies.test-arrow = [ optional-dependencies.test-arrow = [
"arro3-compute",
"arro3-core",
"nanoarrow",
"pyarrow", "pyarrow",
] ]

View File

@ -17,7 +17,7 @@
# #
from __future__ import annotations from __future__ import annotations
from . import BmpImagePlugin, Image, ImageFile from . import BmpImagePlugin, Image
from ._binary import i16le as i16 from ._binary import i16le as i16
from ._binary import i32le as i32 from ._binary import i32le as i32
@ -38,6 +38,7 @@ class CurImageFile(BmpImagePlugin.BmpImageFile):
format_description = "Windows Cursor" format_description = "Windows Cursor"
def _open(self) -> None: def _open(self) -> None:
assert self.fp is not None
offset = self.fp.tell() offset = self.fp.tell()
# check magic # check magic
@ -63,8 +64,7 @@ class CurImageFile(BmpImagePlugin.BmpImageFile):
# patch up the bitmap height # patch up the bitmap height
self._size = self.size[0], self.size[1] // 2 self._size = self.size[0], self.size[1] // 2
d, e, o, a = self.tile[0] self.tile = [self.tile[0]._replace(extents=(0, 0) + self.size)]
self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a)
# #

View File

@ -354,6 +354,9 @@ class EpsImageFile(ImageFile.ImageFile):
read_comment(s) read_comment(s)
elif bytes_mv[:9] == b"%%Trailer": elif bytes_mv[:9] == b"%%Trailer":
trailer_reached = True trailer_reached = True
elif bytes_mv[:14] == b"%%BeginBinary:":
bytecount = int(byte_arr[14:bytes_read])
self.fp.seek(bytecount, os.SEEK_CUR)
bytes_read = 0 bytes_read = 0
# A "BoundingBox" is always required, # A "BoundingBox" is always required,

View File

@ -30,7 +30,7 @@ from ._util import DeferredError
def _accept(prefix: bytes) -> bool: def _accept(prefix: bytes) -> bool:
return ( return (
len(prefix) >= 6 len(prefix) >= 16
and i16(prefix, 4) in [0xAF11, 0xAF12] and i16(prefix, 4) in [0xAF11, 0xAF12]
and i16(prefix, 14) in [0, 3] # flags and i16(prefix, 14) in [0, 3] # flags
) )
@ -48,8 +48,14 @@ class FliImageFile(ImageFile.ImageFile):
def _open(self) -> None: def _open(self) -> None:
# HEAD # HEAD
assert self.fp is not None
s = self.fp.read(128) s = self.fp.read(128)
if not (_accept(s) and s[20:22] == b"\x00\x00"): if not (
_accept(s)
and s[20:22] == b"\x00" * 2
and s[42:80] == b"\x00" * 38
and s[88:] == b"\x00" * 40
):
msg = "not an FLI/FLC file" msg = "not an FLI/FLC file"
raise SyntaxError(msg) raise SyntaxError(msg)
@ -77,8 +83,7 @@ class FliImageFile(ImageFile.ImageFile):
if i16(s, 4) == 0xF100: if i16(s, 4) == 0xF100:
# prefix chunk; ignore it # prefix chunk; ignore it
self.__offset = self.__offset + i32(s) self.fp.seek(self.__offset + i32(s))
self.fp.seek(self.__offset)
s = self.fp.read(16) s = self.fp.read(16)
if i16(s, 4) == 0xF1FA: if i16(s, 4) == 0xF1FA:
@ -111,6 +116,7 @@ class FliImageFile(ImageFile.ImageFile):
# load palette # load palette
i = 0 i = 0
assert self.fp is not None
for e in range(i16(self.fp.read(2))): for e in range(i16(self.fp.read(2))):
s = self.fp.read(2) s = self.fp.read(2)
i = i + s[0] i = i + s[0]

View File

@ -54,7 +54,7 @@ class GbrImageFile(ImageFile.ImageFile):
width = i32(self.fp.read(4)) width = i32(self.fp.read(4))
height = i32(self.fp.read(4)) height = i32(self.fp.read(4))
color_depth = i32(self.fp.read(4)) color_depth = i32(self.fp.read(4))
if width <= 0 or height <= 0: if width == 0 or height == 0:
msg = "not a GIMP brush" msg = "not a GIMP brush"
raise SyntaxError(msg) raise SyntaxError(msg)
if color_depth not in (1, 4): if color_depth not in (1, 4):
@ -71,7 +71,7 @@ class GbrImageFile(ImageFile.ImageFile):
raise SyntaxError(msg) raise SyntaxError(msg)
self.info["spacing"] = i32(self.fp.read(4)) self.info["spacing"] = i32(self.fp.read(4))
comment = self.fp.read(comment_length)[:-1] self.info["comment"] = self.fp.read(comment_length)[:-1]
if color_depth == 1: if color_depth == 1:
self._mode = "L" self._mode = "L"
@ -80,8 +80,6 @@ class GbrImageFile(ImageFile.ImageFile):
self._size = width, height self._size = width, height
self.info["comment"] = comment
# Image might not be small # Image might not be small
Image._decompression_bomb_check(self.size) Image._decompression_bomb_check(self.size)

View File

@ -33,7 +33,7 @@ def register_handler(handler: ImageFile.StubHandler | None) -> None:
def _accept(prefix: bytes) -> bool: def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"GRIB") and prefix[7] == 1 return len(prefix) >= 8 and prefix.startswith(b"GRIB") and prefix[7] == 1
class GribStubImageFile(ImageFile.StubImageFile): class GribStubImageFile(ImageFile.StubImageFile):

View File

@ -103,7 +103,6 @@ try:
raise ImportError(msg) raise ImportError(msg)
except ImportError as v: except ImportError as v:
core = DeferredError.new(ImportError("The _imaging C module is not installed."))
# Explanations for ways that we know we might have an import error # Explanations for ways that we know we might have an import error
if str(v).startswith("Module use of python"): if str(v).startswith("Module use of python"):
# The _imaging C module is present, but not compiled for # The _imaging C module is present, but not compiled for
@ -1010,8 +1009,14 @@ class Image:
new_im.info["transparency"] = transparency new_im.info["transparency"] = transparency
return new_im return new_im
if mode == "P" and self.mode == "RGBA": if self.mode == "RGBA":
if mode == "P":
return self.quantize(colors) return self.quantize(colors)
elif mode == "PA":
r, g, b, a = self.split()
rgb = merge("RGB", (r, g, b))
p = rgb.quantize(colors)
return merge("PA", (p, a))
trns = None trns = None
delete_trns = False delete_trns = False
@ -1143,7 +1148,7 @@ class Image:
raise ValueError(msg) from e raise ValueError(msg) from e
new_im = self._new(im) new_im = self._new(im)
if mode == "P" and palette != Palette.ADAPTIVE: if mode in ("P", "PA") and palette != Palette.ADAPTIVE:
from . import ImagePalette from . import ImagePalette
new_im.palette = ImagePalette.ImagePalette("RGB", im.getpalette("RGB")) new_im.palette = ImagePalette.ImagePalette("RGB", im.getpalette("RGB"))
@ -1337,12 +1342,6 @@ class Image:
""" """
pass pass
def _expand(self, xmargin: int, ymargin: int | None = None) -> Image:
if ymargin is None:
ymargin = xmargin
self.load()
return self._new(self.im.expand(xmargin, ymargin))
def filter(self, filter: ImageFilter.Filter | type[ImageFilter.Filter]) -> Image: def filter(self, filter: ImageFilter.Filter | type[ImageFilter.Filter]) -> Image:
""" """
Filters this image using the given filter. For a list of Filters this image using the given filter. For a list of
@ -2071,9 +2070,7 @@ class Image:
:param value: The pixel value. :param value: The pixel value.
""" """
if self.readonly: self._ensure_mutable()
self._copy()
self.load()
if ( if (
self.mode in ("P", "PA") self.mode in ("P", "PA")
@ -2633,7 +2630,9 @@ class Image:
:param title: Optional title to use for the image window, where possible. :param title: Optional title to use for the image window, where possible.
""" """
_show(self, title=title) from . import ImageShow
ImageShow.show(self, title)
def split(self) -> tuple[Image, ...]: def split(self) -> tuple[Image, ...]:
""" """
@ -3258,19 +3257,10 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image:
transferred. This means that P and PA mode images will lose their palette. transferred. This means that P and PA mode images will lose their palette.
:param obj: Object with array interface :param obj: Object with array interface
:param mode: Optional mode to use when reading ``obj``. Will be determined from :param mode: Optional mode to use when reading ``obj``. Since pixel values do not
type if ``None``. Deprecated. contain information about palettes or color spaces, this can be used to place
grayscale L mode data within a P mode image, or read RGB data as YCbCr for
This will not be used to convert the data after reading, but will be used to example.
change how the data is read::
from PIL import Image
import numpy as np
a = np.full((1, 1), 300)
im = Image.fromarray(a, mode="L")
im.getpixel((0, 0)) # 44
im = Image.fromarray(a, mode="RGB")
im.getpixel((0, 0)) # (44, 1, 0)
See: :ref:`concept-modes` for general information about modes. See: :ref:`concept-modes` for general information about modes.
:returns: An image object. :returns: An image object.
@ -3281,21 +3271,28 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image:
shape = arr["shape"] shape = arr["shape"]
ndim = len(shape) ndim = len(shape)
strides = arr.get("strides", None) strides = arr.get("strides", None)
if mode is None:
try: try:
typekey = (1, 1) + shape[2:], arr["typestr"] typekey = (1, 1) + shape[2:], arr["typestr"]
except KeyError as e: except KeyError as e:
if mode is not None:
typekey = None
color_modes: list[str] = []
else:
msg = "Cannot handle this data type" msg = "Cannot handle this data type"
raise TypeError(msg) from e raise TypeError(msg) from e
if typekey is not None:
try: try:
mode, rawmode = _fromarray_typemap[typekey] typemode, rawmode, color_modes = _fromarray_typemap[typekey]
except KeyError as e: except KeyError as e:
typekey_shape, typestr = typekey typekey_shape, typestr = typekey
msg = f"Cannot handle this data type: {typekey_shape}, {typestr}" msg = f"Cannot handle this data type: {typekey_shape}, {typestr}"
raise TypeError(msg) from e raise TypeError(msg) from e
else: if mode is not None:
deprecate("'mode' parameter", 13) if mode != typemode and mode not in color_modes:
deprecate("'mode' parameter for changing data types", 13)
rawmode = mode rawmode = mode
else:
mode = typemode
if mode in ["1", "L", "I", "P", "F"]: if mode in ["1", "L", "I", "P", "F"]:
ndmax = 2 ndmax = 2
elif mode == "RGB": elif mode == "RGB":
@ -3392,29 +3389,29 @@ def fromqpixmap(im: ImageQt.QPixmap) -> ImageFile.ImageFile:
_fromarray_typemap = { _fromarray_typemap = {
# (shape, typestr) => mode, rawmode # (shape, typestr) => mode, rawmode, color modes
# first two members of shape are set to one # first two members of shape are set to one
((1, 1), "|b1"): ("1", "1;8"), ((1, 1), "|b1"): ("1", "1;8", []),
((1, 1), "|u1"): ("L", "L"), ((1, 1), "|u1"): ("L", "L", ["P"]),
((1, 1), "|i1"): ("I", "I;8"), ((1, 1), "|i1"): ("I", "I;8", []),
((1, 1), "<u2"): ("I", "I;16"), ((1, 1), "<u2"): ("I", "I;16", []),
((1, 1), ">u2"): ("I", "I;16B"), ((1, 1), ">u2"): ("I", "I;16B", []),
((1, 1), "<i2"): ("I", "I;16S"), ((1, 1), "<i2"): ("I", "I;16S", []),
((1, 1), ">i2"): ("I", "I;16BS"), ((1, 1), ">i2"): ("I", "I;16BS", []),
((1, 1), "<u4"): ("I", "I;32"), ((1, 1), "<u4"): ("I", "I;32", []),
((1, 1), ">u4"): ("I", "I;32B"), ((1, 1), ">u4"): ("I", "I;32B", []),
((1, 1), "<i4"): ("I", "I;32S"), ((1, 1), "<i4"): ("I", "I;32S", []),
((1, 1), ">i4"): ("I", "I;32BS"), ((1, 1), ">i4"): ("I", "I;32BS", []),
((1, 1), "<f4"): ("F", "F;32F"), ((1, 1), "<f4"): ("F", "F;32F", []),
((1, 1), ">f4"): ("F", "F;32BF"), ((1, 1), ">f4"): ("F", "F;32BF", []),
((1, 1), "<f8"): ("F", "F;64F"), ((1, 1), "<f8"): ("F", "F;64F", []),
((1, 1), ">f8"): ("F", "F;64BF"), ((1, 1), ">f8"): ("F", "F;64BF", []),
((1, 1, 2), "|u1"): ("LA", "LA"), ((1, 1, 2), "|u1"): ("LA", "LA", ["La", "PA"]),
((1, 1, 3), "|u1"): ("RGB", "RGB"), ((1, 1, 3), "|u1"): ("RGB", "RGB", ["YCbCr", "LAB", "HSV"]),
((1, 1, 4), "|u1"): ("RGBA", "RGBA"), ((1, 1, 4), "|u1"): ("RGBA", "RGBA", ["RGBa", "RGBX", "CMYK"]),
# shortcuts: # shortcuts:
((1, 1), f"{_ENDIAN}i4"): ("I", "I"), ((1, 1), f"{_ENDIAN}i4"): ("I", "I", []),
((1, 1), f"{_ENDIAN}f4"): ("F", "F"), ((1, 1), f"{_ENDIAN}f4"): ("F", "F", []),
} }
@ -3571,9 +3568,8 @@ def alpha_composite(im1: Image, im2: Image) -> Image:
""" """
Alpha composite im2 over im1. Alpha composite im2 over im1.
:param im1: The first image. Must have mode RGBA. :param im1: The first image. Must have mode RGBA or LA.
:param im2: The second image. Must have mode RGBA, and the same size as :param im2: The second image. Must have the same mode and size as the first image.
the first image.
:returns: An :py:class:`~PIL.Image.Image` object. :returns: An :py:class:`~PIL.Image.Image` object.
""" """
@ -3799,6 +3795,7 @@ def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None:
def _show(image: Image, **options: Any) -> None: def _show(image: Image, **options: Any) -> None:
from . import ImageShow from . import ImageShow
deprecate("Image._show", 13, "ImageShow.show")
ImageShow.show(image, **options) ImageShow.show(image, **options)
@ -4220,6 +4217,8 @@ class Exif(_ExifBase):
del self._info[tag] del self._info[tag]
else: else:
del self._data[tag] del self._data[tag]
if tag in self._ifds:
del self._ifds[tag]
def __iter__(self) -> Iterator[int]: def __iter__(self) -> Iterator[int]:
keys = set(self._data) keys = set(self._data)

View File

@ -74,9 +74,7 @@ class ImageDraw:
must be the same as the image mode. If omitted, the mode must be the same as the image mode. If omitted, the mode
defaults to the mode of the image. defaults to the mode of the image.
""" """
im.load() im._ensure_mutable()
if im.readonly:
im._copy() # make it writeable
blend = 0 blend = 0
if mode is None: if mode is None:
mode = im.mode mode = im.mode

View File

@ -46,6 +46,18 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAXBLOCK = 65536 MAXBLOCK = 65536
"""
By default, Pillow processes image data in blocks. This helps to prevent excessive use
of resources. Codecs may disable this behaviour with ``_pulls_fd`` or ``_pushes_fd``.
When reading an image, this is the number of bytes to read at once.
When writing an image, this is the number of bytes to write at once.
If the image width times 4 is greater, then that will be used instead.
Plugins may also set a greater number.
User code may set this to another number.
"""
SAFEBLOCK = 1024 * 1024 SAFEBLOCK = 1024 * 1024
@ -301,6 +313,9 @@ class ImageFile(Image.Image):
and args[0] == self.mode and args[0] == self.mode
and args[0] in Image._MAPMODES and args[0] in Image._MAPMODES
): ):
if offset < 0:
msg = "Tile offset cannot be negative"
raise ValueError(msg)
try: try:
# use mmap, if possible # use mmap, if possible
import mmap import mmap

View File

@ -125,11 +125,16 @@ class ImageFont:
image.close() image.close()
def _load_pilfont_data(self, file: IO[bytes], image: Image.Image) -> None: def _load_pilfont_data(self, file: IO[bytes], image: Image.Image) -> None:
# check image
if image.mode not in ("1", "L"):
msg = "invalid font image mode"
raise TypeError(msg)
# read PILfont header # read PILfont header
if file.readline() != b"PILfont\n": if file.read(8) != b"PILfont\n":
msg = "Not a PILfont file" msg = "Not a PILfont file"
raise SyntaxError(msg) raise SyntaxError(msg)
file.readline().split(b";") file.readline()
self.info = [] # FIXME: should be a dictionary self.info = [] # FIXME: should be a dictionary
while True: while True:
s = file.readline() s = file.readline()
@ -140,11 +145,6 @@ class ImageFont:
# read PILfont metrics # read PILfont metrics
data = file.read(256 * 20) data = file.read(256 * 20)
# check image
if image.mode not in ("1", "L"):
msg = "invalid font image mode"
raise TypeError(msg)
image.load() image.load()
self.font = Image.core.font(image.im, data) self.font = Image.core.font(image.im, data)
@ -671,11 +671,7 @@ class FreeTypeFont:
:returns: A list of the named styles in a variation font. :returns: A list of the named styles in a variation font.
:exception OSError: If the font is not a variation font. :exception OSError: If the font is not a variation font.
""" """
try:
names = self.font.getvarnames() names = self.font.getvarnames()
except AttributeError as e:
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
return [name.replace(b"\x00", b"") for name in names] return [name.replace(b"\x00", b"") for name in names]
def set_variation_by_name(self, name: str | bytes) -> None: def set_variation_by_name(self, name: str | bytes) -> None:
@ -702,11 +698,7 @@ class FreeTypeFont:
:returns: A list of the axes in a variation font. :returns: A list of the axes in a variation font.
:exception OSError: If the font is not a variation font. :exception OSError: If the font is not a variation font.
""" """
try:
axes = self.font.getvaraxes() axes = self.font.getvaraxes()
except AttributeError as e:
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
for axis in axes: for axis in axes:
if axis["name"]: if axis["name"]:
axis["name"] = axis["name"].replace(b"\x00", b"") axis["name"] = axis["name"].replace(b"\x00", b"")
@ -717,11 +709,7 @@ class FreeTypeFont:
:param axes: A list of values for each axis. :param axes: A list of values for each axis.
:exception OSError: If the font is not a variation font. :exception OSError: If the font is not a variation font.
""" """
try:
self.font.setvaraxes(axes) self.font.setvaraxes(axes)
except AttributeError as e:
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
class TransposedFont: class TransposedFont:

View File

@ -150,7 +150,7 @@ class LutBuilder:
# Parse and create symmetries of the patterns strings # Parse and create symmetries of the patterns strings
for p in self.patterns: for p in self.patterns:
m = re.search(r"(\w*):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", "")) m = re.search(r"(\w):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", ""))
if not m: if not m:
msg = 'Syntax error in pattern "' + p + '"' msg = 'Syntax error in pattern "' + p + '"'
raise Exception(msg) raise Exception(msg)

View File

@ -499,14 +499,15 @@ def expand(
height = top + image.size[1] + bottom height = top + image.size[1] + bottom
color = _color(fill, image.mode) color = _color(fill, image.mode)
if image.palette: if image.palette:
palette = ImagePalette.ImagePalette(palette=image.getpalette()) mode = image.palette.mode
palette = ImagePalette.ImagePalette(mode, image.getpalette(mode))
if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4): if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4):
color = palette.getcolor(color) color = palette.getcolor(color)
else: else:
palette = None palette = None
out = Image.new(image.mode, (width, height), color) out = Image.new(image.mode, (width, height), color)
if palette: if palette:
out.putpalette(palette.palette) out.putpalette(palette.palette, mode)
out.paste(image, (left, top)) out.paste(image, (left, top))
return out return out

View File

@ -120,7 +120,7 @@ class Stat:
@cached_property @cached_property
def mean(self) -> list[float]: def mean(self) -> list[float]:
"""Average (arithmetic mean) pixel level for each band in the image.""" """Average (arithmetic mean) pixel level for each band in the image."""
return [self.sum[i] / self.count[i] for i in self.bands] return [self.sum[i] / self.count[i] if self.count[i] else 0 for i in self.bands]
@cached_property @cached_property
def median(self) -> list[int]: def median(self) -> list[int]:
@ -141,13 +141,20 @@ class Stat:
@cached_property @cached_property
def rms(self) -> list[float]: def rms(self) -> list[float]:
"""RMS (root-mean-square) for each band in the image.""" """RMS (root-mean-square) for each band in the image."""
return [math.sqrt(self.sum2[i] / self.count[i]) for i in self.bands] return [
math.sqrt(self.sum2[i] / self.count[i]) if self.count[i] else 0
for i in self.bands
]
@cached_property @cached_property
def var(self) -> list[float]: def var(self) -> list[float]:
"""Variance for each band in the image.""" """Variance for each band in the image."""
return [ return [
(
(self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i] (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i]
if self.count[i]
else 0
)
for i in self.bands for i in self.bands
] ]

View File

@ -34,10 +34,6 @@ def _i(c: bytes) -> int:
return i32((b"\0\0\0\0" + c)[-4:]) return i32((b"\0\0\0\0" + c)[-4:])
def _i8(c: int | bytes) -> int:
return c if isinstance(c, int) else c[0]
## ##
# Image plugin for IPTC/NAA datastreams. To read IPTC/NAA fields # Image plugin for IPTC/NAA datastreams. To read IPTC/NAA fields
# from TIFF and JPEG files, use the <b>getiptcinfo</b> function. # from TIFF and JPEG files, use the <b>getiptcinfo</b> function.
@ -100,16 +96,18 @@ class IptcImageFile(ImageFile.ImageFile):
# mode # mode
layers = self.info[(3, 60)][0] layers = self.info[(3, 60)][0]
component = self.info[(3, 60)][1] component = self.info[(3, 60)][1]
if (3, 65) in self.info:
id = self.info[(3, 65)][0] - 1
else:
id = 0
if layers == 1 and not component: if layers == 1 and not component:
self._mode = "L" self._mode = "L"
elif layers == 3 and component: band = None
self._mode = "RGB"[id] else:
if layers == 3 and component:
self._mode = "RGB"
elif layers == 4 and component: elif layers == 4 and component:
self._mode = "CMYK"[id] self._mode = "CMYK"
if (3, 65) in self.info:
band = self.info[(3, 65)][0] - 1
else:
band = 0
# size # size
self._size = self.getint((3, 20)), self.getint((3, 30)) self._size = self.getint((3, 20)), self.getint((3, 30))
@ -124,16 +122,16 @@ class IptcImageFile(ImageFile.ImageFile):
# tile # tile
if tag == (8, 10): if tag == (8, 10):
self.tile = [ self.tile = [
ImageFile._Tile("iptc", (0, 0) + self.size, offset, compression) ImageFile._Tile("iptc", (0, 0) + self.size, offset, (compression, band))
] ]
def load(self) -> Image.core.PixelAccess | None: def load(self) -> Image.core.PixelAccess | None:
if len(self.tile) != 1 or self.tile[0][0] != "iptc": if self.tile:
return ImageFile.ImageFile.load(self) args = self.tile[0].args
assert isinstance(args, tuple)
compression, band = args
offset, compression = self.tile[0][2:] self.fp.seek(self.tile[0].offset)
self.fp.seek(offset)
# Copy image data to temporary file # Copy image data to temporary file
o = BytesIO() o = BytesIO()
@ -153,10 +151,15 @@ class IptcImageFile(ImageFile.ImageFile):
size -= len(s) size -= len(s)
with Image.open(o) as _im: with Image.open(o) as _im:
if band is not None:
bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode)
bands[band] = _im
_im = Image.merge(self.mode, bands)
else:
_im.load() _im.load()
self.im = _im.im self.im = _im.im
self.tile = [] self.tile = []
return Image.Image.load(self) return ImageFile.ImageFile.load(self)
Image.register_open(IptcImageFile.format, IptcImageFile) Image.register_open(IptcImageFile.format, IptcImageFile)

Some files were not shown because too many files have changed in this diff Show More