Merge branch 'main' into progress

This commit is contained in:
Andrew Murray 2025-04-21 12:40:46 +10:00
commit 09e4df10af
148 changed files with 4662 additions and 784 deletions

View File

@ -23,7 +23,7 @@ if [[ $(uname) != CYGWIN* ]]; then
sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\
ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\
cmake meson imagemagick libharfbuzz-dev libfribidi-dev\
sway wl-clipboard libopenblas-dev
sway wl-clipboard libopenblas-dev nasm
fi
python3 -m pip install --upgrade pip
@ -36,6 +36,9 @@ python3 -m pip install -U pytest
python3 -m pip install -U pytest-cov
python3 -m pip install -U pytest-timeout
python3 -m pip install pyroma
# optional test dependency, only install if there's a binary package.
# fails on beta 3.14 and PyPy
python3 -m pip install --only-binary=:all: pyarrow || true
if [[ $(uname) != CYGWIN* ]]; then
python3 -m pip install numpy
@ -50,7 +53,7 @@ if [[ $(uname) != CYGWIN* ]]; then
# Pyroma uses non-isolated build and fails with old setuptools
if [[ $GHA_PYTHON_VERSION == 3.9 ]]; then
# To match pyproject.toml
python3 -m pip install "setuptools>=67.8"
python3 -m pip install "setuptools>=77"
fi
# webp
@ -62,6 +65,9 @@ if [[ $(uname) != CYGWIN* ]]; then
# raqm
pushd depends && ./install_raqm.sh && popd
# libavif
pushd depends && CMAKE_POLICY_VERSION_MINIMUM=3.5 ./install_libavif.sh && popd
# extra test images
pushd depends && ./install_extra_test_images.sh && popd
else

View File

@ -1 +1 @@
cibuildwheel==2.23.1
cibuildwheel==2.23.2

View File

@ -1,5 +1,26 @@
# A clang-format style that approximates Python's PEP 7
# Useful for IDE integration
Language: C
BasedOnStyle: Google
AlwaysBreakAfterReturnType: All
AllowShortIfStatementsOnASingleLine: false
AlignAfterOpenBracket: BlockIndent
BinPackArguments: false
BinPackParameters: false
BreakBeforeBraces: Attach
ColumnLimit: 88
DerivePointerAlignment: false
IndentGotoLabels: false
IndentWidth: 4
PointerAlignment: Right
ReflowComments: true
SortIncludes: false
SpaceBeforeParens: ControlStatements
SpacesInParentheses: false
TabWidth: 4
UseTab: Never
---
Language: Cpp
BasedOnStyle: Google
AlwaysBreakAfterReturnType: All
AllowShortIfStatementsOnASingleLine: false
@ -11,7 +32,6 @@ ColumnLimit: 88
DerivePointerAlignment: false
IndentGotoLabels: false
IndentWidth: 4
Language: Cpp
PointerAlignment: Right
ReflowComments: true
SortIncludes: false

View File

@ -6,6 +6,8 @@ if [[ "$ImageOS" == "macos13" ]]; then
brew uninstall gradle maven
fi
brew install \
aom \
dav1d \
freetype \
ghostscript \
jpeg-turbo \
@ -14,6 +16,8 @@ brew install \
libtiff \
little-cms2 \
openjpeg \
rav1e \
svt-av1 \
webp
export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig"
@ -26,6 +30,12 @@ python3 -m pip install -U pytest-cov
python3 -m pip install -U pytest-timeout
python3 -m pip install pyroma
python3 -m pip install numpy
# optional test dependency, only install if there's a binary package.
# fails on beta 3.14 and PyPy
python3 -m pip install --only-binary=:all: pyarrow || true
# libavif
pushd depends && ./install_libavif.sh && popd
# extra test images
pushd depends && ./install_extra_test_images.sh && popd

View File

@ -47,8 +47,8 @@ jobs:
centos-stream-10-amd64,
debian-12-bookworm-x86,
debian-12-bookworm-amd64,
fedora-40-amd64,
fedora-41-amd64,
fedora-42-amd64,
gentoo,
ubuntu-22.04-jammy-amd64,
ubuntu-24.04-noble-amd64,

View File

@ -60,6 +60,7 @@ jobs:
mingw-w64-x86_64-gcc \
mingw-w64-x86_64-ghostscript \
mingw-w64-x86_64-lcms2 \
mingw-w64-x86_64-libavif \
mingw-w64-x86_64-libimagequant \
mingw-w64-x86_64-libjpeg-turbo \
mingw-w64-x86_64-libraqm \

View File

@ -42,7 +42,7 @@ jobs:
# Test the oldest Python on 32-bit
- { python-version: "3.9", architecture: "x86", os: "windows-2019" }
timeout-minutes: 30
timeout-minutes: 45
name: Python ${{ matrix.python-version }} (${{ matrix.architecture }})
@ -88,6 +88,10 @@ jobs:
run: |
python3 -m pip install PyQt6
- name: Install PyArrow dependency
run: |
python3 -m pip install --only-binary=:all: pyarrow || true
- name: Install dependencies
id: install
run: |
@ -145,6 +149,10 @@ jobs:
if: steps.build-cache.outputs.cache-hit != 'true'
run: "& winbuild\\build\\build_dep_libpng.cmd"
- name: Build dependencies / libavif
if: steps.build-cache.outputs.cache-hit != 'true' && matrix.architecture == 'x64'
run: "& winbuild\\build\\build_dep_libavif.cmd"
# for FreeType WOFF2 font support
- name: Build dependencies / brotli
if: steps.build-cache.outputs.cache-hit != 'true'

View File

@ -70,7 +70,7 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: Quansight-Labs/setup-python@v5
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true

View File

@ -25,7 +25,7 @@ else
MB_ML_LIBC=${AUDITWHEEL_POLICY::9}
MB_ML_VER=${AUDITWHEEL_POLICY:9}
fi
PLAT=$CIBW_ARCHS
PLAT="${CIBW_ARCHS:-$AUDITWHEEL_ARCH}"
# Define custom utilities
source wheels/multibuild/common_utils.sh
@ -38,13 +38,14 @@ ARCHIVE_SDIR=pillow-depends-main
# Package versions for fresh source builds
FREETYPE_VERSION=2.13.3
HARFBUZZ_VERSION=10.4.0
HARFBUZZ_VERSION=11.1.0
LIBPNG_VERSION=1.6.47
JPEGTURBO_VERSION=3.1.0
OPENJPEG_VERSION=2.5.3
XZ_VERSION=5.6.4
XZ_VERSION=5.8.1
TIFF_VERSION=4.7.0
LCMS2_VERSION=2.17
ZLIB_VERSION=1.3.1
ZLIB_NG_VERSION=2.2.4
LIBWEBP_VERSION=1.5.0
BZIP2_VERSION=1.0.8
@ -64,11 +65,7 @@ function build_pkg_config {
function build_zlib_ng {
if [ -e zlib-stamp ]; then return; fi
fetch_unpack https://github.com/zlib-ng/zlib-ng/archive/$ZLIB_NG_VERSION.tar.gz zlib-ng-$ZLIB_NG_VERSION.tar.gz
(cd zlib-ng-$ZLIB_NG_VERSION \
&& ./configure --prefix=$BUILD_PREFIX --zlib-compat \
&& make -j4 \
&& make install)
build_github zlib-ng/zlib-ng $ZLIB_NG_VERSION --zlib-compat
if [ -n "$IS_MACOS" ]; then
# Ensure that on macOS, the library name is an absolute path, not an
@ -95,7 +92,7 @@ function build_harfbuzz {
local out_dir=$(fetch_unpack https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION/harfbuzz-$HARFBUZZ_VERSION.tar.xz harfbuzz-$HARFBUZZ_VERSION.tar.xz)
(cd $out_dir \
&& meson setup build --prefix=$BUILD_PREFIX --libdir=$BUILD_PREFIX/lib --buildtype=release -Dfreetype=enabled -Dglib=disabled)
&& meson setup build --prefix=$BUILD_PREFIX --libdir=$BUILD_PREFIX/lib --buildtype=minsize -Dfreetype=enabled -Dglib=disabled -Dtests=disabled)
(cd $out_dir/build \
&& meson install)
touch harfbuzz-stamp
@ -106,7 +103,11 @@ function build {
if [ -z "$IS_ALPINE" ] && [ -z "$SANITIZER" ] && [ -z "$IS_MACOS" ]; then
yum remove -y zlib-devel
fi
build_zlib_ng
if [[ -n "$IS_MACOS" ]] && [[ "$MACOSX_DEPLOYMENT_TARGET" == "10.10" || "$MACOSX_DEPLOYMENT_TARGET" == "10.13" ]]; then
build_new_zlib
else
build_zlib_ng
fi
build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto
if [ -n "$IS_MACOS" ]; then

View File

@ -121,14 +121,17 @@ jobs:
windows:
if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow'
name: Windows ${{ matrix.cibw_arch }}
runs-on: windows-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- cibw_arch: x86
os: windows-latest
- cibw_arch: AMD64
os: windows-latest
- cibw_arch: ARM64
os: windows-11-arm
steps:
- uses: actions/checkout@v4
with:
@ -157,7 +160,7 @@ jobs:
# Install extra test images
xcopy /S /Y Tests\test-images\* Tests\images
& python.exe winbuild\build_prepare.py -v --no-imagequant --architecture=${{ matrix.cibw_arch }}
& python.exe winbuild\build_prepare.py -v --no-imagequant --no-avif --architecture=${{ matrix.cibw_arch }}
shell: pwsh
- name: Build wheels
@ -240,7 +243,7 @@ jobs:
path: dist
merge-multiple: true
- name: Upload wheels to scientific-python-nightly-wheels
uses: scientific-python/upload-nightly-action@82396a2ed4269ba06c6b2988bb4fd568ef3c3d6b # 0.6.1
uses: scientific-python/upload-nightly-action@b36e8c0c10dbcfd2e05bf95f17ef8c14fd708dbf # 0.6.2
with:
artifacts_path: dist
anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }}

View File

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.9
rev: v0.11.4
hooks:
- id: ruff
args: [--exit-non-zero-on-fix]
@ -24,7 +24,7 @@ repos:
exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$)
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v19.1.7
rev: v20.1.0
hooks:
- id: clang-format
types: [c]
@ -44,20 +44,21 @@ repos:
- id: check-json
- id: check-toml
- id: check-yaml
args: [--allow-multiple-documents]
- id: end-of-file-fixer
exclude: ^Tests/images/
- id: trailing-whitespace
exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.31.2
rev: 0.32.1
hooks:
- id: check-github-workflows
- id: check-readthedocs
- id: check-renovate
- repo: https://github.com/woodruffw/zizmor-pre-commit
rev: v1.4.1
rev: v1.5.2
hooks:
- id: zizmor
@ -72,7 +73,7 @@ repos:
- id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.23
rev: v0.24.1
hooks:
- id: validate-pyproject
additional_dependencies: [trove-classifiers>=2024.10.12]

View File

@ -23,6 +23,10 @@ doc html:
htmlview:
$(MAKE) -C docs htmlview
.PHONY: htmllive
htmllive:
$(MAKE) -C docs htmllive
.PHONY: doccheck
doccheck:
$(MAKE) doc
@ -43,6 +47,7 @@ help:
@echo " docserve run an HTTP server on the docs directory"
@echo " html make HTML docs"
@echo " htmlview open the index page built by the html target in your browser"
@echo " htmllive rebuild and reload HTML files in your browser"
@echo " install make and install"
@echo " install-coverage make and install with C coverage"
@echo " lint run the lint checks"

View File

@ -1,9 +1,12 @@
from __future__ import annotations
import platform
import sys
from PIL import features
from .helper import is_pypy
def test_wheel_modules() -> None:
expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"}
@ -40,5 +43,7 @@ def test_wheel_features() -> None:
if sys.platform == "win32":
expected_features.remove("xcb")
elif sys.platform == "darwin" and not is_pypy() and platform.processor() != "arm":
expected_features.remove("zlib_ng")
assert set(features.get_supported_features()) == expected_features

BIN
Tests/images/avif/exif.avif Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
Tests/images/avif/star.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
Tests/images/avif/star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 B

View File

@ -0,0 +1,260 @@
GIMP Palette
Name: fullpalette
Columns: 4
#
0 0 0 Index 0
1 1 1 Index 1
2 2 2 Index 2
3 3 3 Index 3
4 4 4 Index 4
5 5 5 Index 5
6 6 6 Index 6
7 7 7 Index 7
8 8 8 Index 8
9 9 9 Index 9
10 10 10 Index 10
11 11 11 Index 11
12 12 12 Index 12
13 13 13 Index 13
14 14 14 Index 14
15 15 15 Index 15
16 16 16 Index 16
17 17 17 Index 17
18 18 18 Index 18
19 19 19 Index 19
20 20 20 Index 20
21 21 21 Index 21
22 22 22 Index 22
23 23 23 Index 23
24 24 24 Index 24
25 25 25 Index 25
26 26 26 Index 26
27 27 27 Index 27
28 28 28 Index 28
29 29 29 Index 29
30 30 30 Index 30
31 31 31 Index 31
32 32 32 Index 32
33 33 33 Index 33
34 34 34 Index 34
35 35 35 Index 35
36 36 36 Index 36
37 37 37 Index 37
38 38 38 Index 38
39 39 39 Index 39
40 40 40 Index 40
41 41 41 Index 41
42 42 42 Index 42
43 43 43 Index 43
44 44 44 Index 44
45 45 45 Index 45
46 46 46 Index 46
47 47 47 Index 47
48 48 48 Index 48
49 49 49 Index 49
50 50 50 Index 50
51 51 51 Index 51
52 52 52 Index 52
53 53 53 Index 53
54 54 54 Index 54
55 55 55 Index 55
56 56 56 Index 56
57 57 57 Index 57
58 58 58 Index 58
59 59 59 Index 59
60 60 60 Index 60
61 61 61 Index 61
62 62 62 Index 62
63 63 63 Index 63
64 64 64 Index 64
65 65 65 Index 65
66 66 66 Index 66
67 67 67 Index 67
68 68 68 Index 68
69 69 69 Index 69
70 70 70 Index 70
71 71 71 Index 71
72 72 72 Index 72
73 73 73 Index 73
74 74 74 Index 74
75 75 75 Index 75
76 76 76 Index 76
77 77 77 Index 77
78 78 78 Index 78
79 79 79 Index 79
80 80 80 Index 80
81 81 81 Index 81
82 82 82 Index 82
83 83 83 Index 83
84 84 84 Index 84
85 85 85 Index 85
86 86 86 Index 86
87 87 87 Index 87
88 88 88 Index 88
89 89 89 Index 89
90 90 90 Index 90
91 91 91 Index 91
92 92 92 Index 92
93 93 93 Index 93
94 94 94 Index 94
95 95 95 Index 95
96 96 96 Index 96
97 97 97 Index 97
98 98 98 Index 98
99 99 99 Index 99
100 100 100 Index 100
101 101 101 Index 101
102 102 102 Index 102
103 103 103 Index 103
104 104 104 Index 104
105 105 105 Index 105
106 106 106 Index 106
107 107 107 Index 107
108 108 108 Index 108
109 109 109 Index 109
110 110 110 Index 110
111 111 111 Index 111
112 112 112 Index 112
113 113 113 Index 113
114 114 114 Index 114
115 115 115 Index 115
116 116 116 Index 116
117 117 117 Index 117
118 118 118 Index 118
119 119 119 Index 119
120 120 120 Index 120
121 121 121 Index 121
122 122 122 Index 122
123 123 123 Index 123
124 124 124 Index 124
125 125 125 Index 125
126 126 126 Index 126
127 127 127 Index 127
128 128 128 Index 128
129 129 129 Index 129
130 130 130 Index 130
131 131 131 Index 131
132 132 132 Index 132
133 133 133 Index 133
134 134 134 Index 134
135 135 135 Index 135
136 136 136 Index 136
137 137 137 Index 137
138 138 138 Index 138
139 139 139 Index 139
140 140 140 Index 140
141 141 141 Index 141
142 142 142 Index 142
143 143 143 Index 143
144 144 144 Index 144
145 145 145 Index 145
146 146 146 Index 146
147 147 147 Index 147
148 148 148 Index 148
149 149 149 Index 149
150 150 150 Index 150
151 151 151 Index 151
152 152 152 Index 152
153 153 153 Index 153
154 154 154 Index 154
155 155 155 Index 155
156 156 156 Index 156
157 157 157 Index 157
158 158 158 Index 158
159 159 159 Index 159
160 160 160 Index 160
161 161 161 Index 161
162 162 162 Index 162
163 163 163 Index 163
164 164 164 Index 164
165 165 165 Index 165
166 166 166 Index 166
167 167 167 Index 167
168 168 168 Index 168
169 169 169 Index 169
170 170 170 Index 170
171 171 171 Index 171
172 172 172 Index 172
173 173 173 Index 173
174 174 174 Index 174
175 175 175 Index 175
176 176 176 Index 176
177 177 177 Index 177
178 178 178 Index 178
179 179 179 Index 179
180 180 180 Index 180
181 181 181 Index 181
182 182 182 Index 182
183 183 183 Index 183
184 184 184 Index 184
185 185 185 Index 185
186 186 186 Index 186
187 187 187 Index 187
188 188 188 Index 188
189 189 189 Index 189
190 190 190 Index 190
191 191 191 Index 191
192 192 192 Index 192
193 193 193 Index 193
194 194 194 Index 194
195 195 195 Index 195
196 196 196 Index 196
197 197 197 Index 197
198 198 198 Index 198
199 199 199 Index 199
200 200 200 Index 200
201 201 201 Index 201
202 202 202 Index 202
203 203 203 Index 203
204 204 204 Index 204
205 205 205 Index 205
206 206 206 Index 206
207 207 207 Index 207
208 208 208 Index 208
209 209 209 Index 209
210 210 210 Index 210
211 211 211 Index 211
212 212 212 Index 212
213 213 213 Index 213
214 214 214 Index 214
215 215 215 Index 215
216 216 216 Index 216
217 217 217 Index 217
218 218 218 Index 218
219 219 219 Index 219
220 220 220 Index 220
221 221 221 Index 221
222 222 222 Index 222
223 223 223 Index 223
224 224 224 Index 224
225 225 225 Index 225
226 226 226 Index 226
227 227 227 Index 227
228 228 228 Index 228
229 229 229 Index 229
230 230 230 Index 230
231 231 231 Index 231
232 232 232 Index 232
233 233 233 Index 233
234 234 234 Index 234
235 235 235 Index 235
236 236 236 Index 236
237 237 237 Index 237
238 238 238 Index 238
239 239 239 Index 239
240 240 240 Index 240
241 241 241 Index 241
242 242 242 Index 242
243 243 243 Index 243
244 244 244 Index 244
245 245 245 Index 245
246 246 246 Index 246
247 247 247 Index 247
248 248 248 Index 248
249 249 249 Index 249
250 250 250 Index 250
251 251 251 Index 251
252 252 252 Index 252
253 253 253 Index 253
254 254 254 Index 254
255 255 255 Index 255

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 B

After

Width:  |  Height:  |  Size: 533 B

164
Tests/test_arrow.py Normal file
View File

@ -0,0 +1,164 @@
from __future__ import annotations
import pytest
from PIL import Image
from .helper import hopper
@pytest.mark.parametrize(
"mode, dest_modes",
(
("L", ["I", "F", "LA", "RGB", "RGBA", "RGBX", "CMYK", "YCbCr", "HSV"]),
("I", ["L", "F"]), # Technically I;32 can work for any 4x8bit storage.
("F", ["I", "L", "LA", "RGB", "RGBA", "RGBX", "CMYK", "YCbCr", "HSV"]),
("LA", ["L", "F"]),
("RGB", ["L", "F"]),
("RGBA", ["L", "F"]),
("RGBX", ["L", "F"]),
("CMYK", ["L", "F"]),
("YCbCr", ["L", "F"]),
("HSV", ["L", "F"]),
),
)
def test_invalid_array_type(mode: str, dest_modes: list[str]) -> None:
img = hopper(mode)
for dest_mode in dest_modes:
with pytest.raises(ValueError):
Image.fromarrow(img, dest_mode, img.size)
def test_invalid_array_size() -> None:
img = hopper("RGB")
assert img.size != (10, 10)
with pytest.raises(ValueError):
Image.fromarrow(img, "RGB", (10, 10))
def test_release_schema() -> None:
# these should not error out, valgrind should be clean
img = hopper("L")
schema = img.__arrow_c_schema__()
del schema
def test_release_array() -> None:
# these should not error out, valgrind should be clean
img = hopper("L")
array, schema = img.__arrow_c_array__()
del array
del schema
def test_readonly() -> None:
img = hopper("L")
reloaded = Image.fromarrow(img, img.mode, img.size)
assert reloaded.readonly == 1
reloaded._readonly = 0
assert reloaded.readonly == 1
def test_multiblock_l_image() -> None:
block_size = Image.core.get_block_size()
# check a 2 block image in single channel mode
size = (4096, 2 * block_size // 4096)
img = Image.new("L", size, 128)
with pytest.raises(ValueError):
(schema, arr) = img.__arrow_c_array__()
def test_multiblock_rgba_image() -> None:
block_size = Image.core.get_block_size()
# check a 2 block image in 4 channel mode
size = (4096, (block_size // 4096) // 2)
img = Image.new("RGBA", size, (128, 127, 126, 125))
with pytest.raises(ValueError):
(schema, arr) = img.__arrow_c_array__()
def test_multiblock_l_schema() -> None:
block_size = Image.core.get_block_size()
# check a 2 block image in single channel mode
size = (4096, 2 * block_size // 4096)
img = Image.new("L", size, 128)
with pytest.raises(ValueError):
img.__arrow_c_schema__()
def test_multiblock_rgba_schema() -> None:
block_size = Image.core.get_block_size()
# check a 2 block image in 4 channel mode
size = (4096, (block_size // 4096) // 2)
img = Image.new("RGBA", size, (128, 127, 126, 125))
with pytest.raises(ValueError):
img.__arrow_c_schema__()
def test_singleblock_l_image() -> None:
Image.core.set_use_block_allocator(1)
block_size = Image.core.get_block_size()
# check a 2 block image in 4 channel mode
size = (4096, 2 * (block_size // 4096))
img = Image.new("L", size, 128)
assert img.im.isblock()
(schema, arr) = img.__arrow_c_array__()
assert schema
assert arr
Image.core.set_use_block_allocator(0)
def test_singleblock_rgba_image() -> None:
Image.core.set_use_block_allocator(1)
block_size = Image.core.get_block_size()
# check a 2 block image in 4 channel mode
size = (4096, (block_size // 4096) // 2)
img = Image.new("RGBA", size, (128, 127, 126, 125))
assert img.im.isblock()
(schema, arr) = img.__arrow_c_array__()
assert schema
assert arr
Image.core.set_use_block_allocator(0)
def test_singleblock_l_schema() -> None:
Image.core.set_use_block_allocator(1)
block_size = Image.core.get_block_size()
# check a 2 block image in single channel mode
size = (4096, 2 * block_size // 4096)
img = Image.new("L", size, 128)
assert img.im.isblock()
schema = img.__arrow_c_schema__()
assert schema
Image.core.set_use_block_allocator(0)
def test_singleblock_rgba_schema() -> None:
Image.core.set_use_block_allocator(1)
block_size = Image.core.get_block_size()
# check a 2 block image in 4 channel mode
size = (4096, (block_size // 4096) // 2)
img = Image.new("RGBA", size, (128, 127, 126, 125))
assert img.im.isblock()
schema = img.__arrow_c_schema__()
assert schema
Image.core.set_use_block_allocator(0)

View File

@ -13,6 +13,7 @@ from PIL import Image, ImageSequence, PngImagePlugin
# (referenced from https://wiki.mozilla.org/APNG_Specification)
def test_apng_basic() -> None:
with Image.open("Tests/images/apng/single_frame.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
assert im.n_frames == 1
assert im.get_format_mimetype() == "image/apng"
@ -21,6 +22,7 @@ def test_apng_basic() -> None:
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/single_frame_default.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.is_animated
assert im.n_frames == 2
assert im.get_format_mimetype() == "image/apng"
@ -53,6 +55,7 @@ def test_apng_basic() -> None:
)
def test_apng_fdat(filename: str) -> None:
with Image.open(filename) as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
@ -60,31 +63,37 @@ def test_apng_fdat(filename: str) -> None:
def test_apng_dispose() -> None:
with Image.open("Tests/images/apng/dispose_op_none.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_background.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 0, 0)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
with Image.open("Tests/images/apng/dispose_op_background_final.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_previous.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_previous_final.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_previous_first.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 0, 0)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
@ -92,21 +101,25 @@ def test_apng_dispose() -> None:
def test_apng_dispose_region() -> None:
with Image.open("Tests/images/apng/dispose_op_none_region.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/dispose_op_background_before_region.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 0, 0)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
with Image.open("Tests/images/apng/dispose_op_background_region.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 255, 255)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
with Image.open("Tests/images/apng/dispose_op_previous_region.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
@ -133,6 +146,7 @@ def test_apng_dispose_op_previous_frame() -> None:
# ],
# )
with Image.open("Tests/images/apng/dispose_op_previous_frame.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (255, 0, 0, 255)
@ -146,26 +160,31 @@ def test_apng_dispose_op_background_p_mode() -> None:
def test_apng_blend() -> None:
with Image.open("Tests/images/apng/blend_op_source_solid.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/blend_op_source_transparent.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 0, 0)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
with Image.open("Tests/images/apng/blend_op_source_near_transparent.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 2)
assert im.getpixel((64, 32)) == (0, 255, 0, 2)
with Image.open("Tests/images/apng/blend_op_over.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/blend_op_over_near_transparent.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 97)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
@ -179,6 +198,7 @@ def test_apng_blend_transparency() -> None:
def test_apng_chunk_order() -> None:
with Image.open("Tests/images/apng/fctl_actl.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
@ -234,24 +254,28 @@ def test_apng_num_plays() -> None:
def test_apng_mode() -> None:
with Image.open("Tests/images/apng/mode_16bit.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "RGBA"
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (0, 0, 128, 191)
assert im.getpixel((64, 32)) == (0, 0, 128, 191)
with Image.open("Tests/images/apng/mode_grayscale.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "L"
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == 128
assert im.getpixel((64, 32)) == 255
with Image.open("Tests/images/apng/mode_grayscale_alpha.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "LA"
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (128, 191)
assert im.getpixel((64, 32)) == (128, 191)
with Image.open("Tests/images/apng/mode_palette.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "P"
im.seek(im.n_frames - 1)
im = im.convert("RGB")
@ -259,6 +283,7 @@ def test_apng_mode() -> None:
assert im.getpixel((64, 32)) == (0, 255, 0)
with Image.open("Tests/images/apng/mode_palette_alpha.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "P"
im.seek(im.n_frames - 1)
im = im.convert("RGBA")
@ -266,6 +291,7 @@ def test_apng_mode() -> None:
assert im.getpixel((64, 32)) == (0, 255, 0, 255)
with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.mode == "P"
im.seek(im.n_frames - 1)
im = im.convert("RGBA")
@ -275,25 +301,31 @@ def test_apng_mode() -> None:
def test_apng_chunk_errors() -> None:
with Image.open("Tests/images/apng/chunk_no_actl.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/chunk_multi_actl.png") as im:
im.load()
assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
with Image.open("Tests/images/apng/chunk_actl_after_idat.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
with Image.open("Tests/images/apng/chunk_no_fctl.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
with pytest.raises(SyntaxError):
im.seek(im.n_frames - 1)
with Image.open("Tests/images/apng/chunk_repeat_fctl.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
with pytest.raises(SyntaxError):
im.seek(im.n_frames - 1)
with Image.open("Tests/images/apng/chunk_no_fdat.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
with pytest.raises(SyntaxError):
im.seek(im.n_frames - 1)
@ -301,26 +333,31 @@ def test_apng_chunk_errors() -> None:
def test_apng_syntax_errors() -> None:
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
with pytest.raises(OSError):
im.load()
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
im.load()
# we can handle this case gracefully
with Image.open("Tests/images/apng/syntax_num_frames_low.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
with pytest.raises(OSError):
with Image.open("Tests/images/apng/syntax_num_frames_high.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
im.load()
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/syntax_num_frames_invalid.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert not im.is_animated
im.load()
@ -340,6 +377,7 @@ def test_apng_syntax_errors() -> None:
def test_apng_sequence_errors(test_file: str) -> None:
with pytest.raises(SyntaxError):
with Image.open(f"Tests/images/apng/{test_file}") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
im.load()
@ -350,6 +388,7 @@ def test_apng_save(tmp_path: Path) -> None:
im.save(test_file, save_all=True)
with Image.open(test_file) as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.load()
assert not im.is_animated
assert im.n_frames == 1
@ -365,6 +404,7 @@ def test_apng_save(tmp_path: Path) -> None:
)
with Image.open(test_file) as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.load()
assert im.is_animated
assert im.n_frames == 2
@ -404,6 +444,7 @@ def test_apng_save_split_fdat(tmp_path: Path) -> None:
append_images=frames,
)
with Image.open(test_file) as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
im.seek(im.n_frames - 1)
im.load()
@ -446,6 +487,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None:
test_file, save_all=True, append_images=[frame, frame], duration=[500, 100, 150]
)
with Image.open(test_file) as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.n_frames == 1
assert "duration" not in im.info
@ -457,6 +499,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None:
duration=[500, 100, 150],
)
with Image.open(test_file) as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.n_frames == 2
assert im.info["duration"] == 600
@ -467,6 +510,7 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None:
frame.info["duration"] = 300
frame.save(test_file, save_all=True, append_images=[frame, different_frame])
with Image.open(test_file) as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.n_frames == 2
assert im.info["duration"] == 600

779
Tests/test_file_avif.py Normal file
View File

@ -0,0 +1,779 @@
from __future__ import annotations
import gc
import os
import re
import warnings
from collections.abc import Generator, Sequence
from contextlib import contextmanager
from io import BytesIO
from pathlib import Path
from typing import Any
import pytest
from PIL import (
AvifImagePlugin,
Image,
ImageDraw,
ImageFile,
UnidentifiedImageError,
features,
)
from .helper import (
PillowLeakTestCase,
assert_image,
assert_image_similar,
assert_image_similar_tofile,
hopper,
skip_unless_feature,
)
try:
from PIL import _avif
HAVE_AVIF = True
except ImportError:
HAVE_AVIF = False
TEST_AVIF_FILE = "Tests/images/avif/hopper.avif"
def assert_xmp_orientation(xmp: bytes, expected: int) -> None:
assert int(xmp.split(b'tiff:Orientation="')[1].split(b'"')[0]) == expected
def roundtrip(im: ImageFile.ImageFile, **options: Any) -> ImageFile.ImageFile:
out = BytesIO()
im.save(out, "AVIF", **options)
return Image.open(out)
def skip_unless_avif_decoder(codec_name: str) -> pytest.MarkDecorator:
reason = f"{codec_name} decode not available"
return pytest.mark.skipif(
not HAVE_AVIF or not _avif.decoder_codec_available(codec_name), reason=reason
)
def skip_unless_avif_encoder(codec_name: str) -> pytest.MarkDecorator:
reason = f"{codec_name} encode not available"
return pytest.mark.skipif(
not HAVE_AVIF or not _avif.encoder_codec_available(codec_name), reason=reason
)
def is_docker_qemu() -> bool:
try:
init_proc_exe = os.readlink("/proc/1/exe")
except (FileNotFoundError, PermissionError):
return False
return "qemu" in init_proc_exe
class TestUnsupportedAvif:
def test_unsupported(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(AvifImagePlugin, "SUPPORTED", False)
with pytest.warns(UserWarning):
with pytest.raises(UnidentifiedImageError):
with Image.open(TEST_AVIF_FILE):
pass
def test_unsupported_open(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(AvifImagePlugin, "SUPPORTED", False)
with pytest.raises(SyntaxError):
AvifImagePlugin.AvifImageFile(TEST_AVIF_FILE)
@skip_unless_feature("avif")
class TestFileAvif:
def test_version(self) -> None:
version = features.version_module("avif")
assert version is not None
assert re.search(r"^\d+\.\d+\.\d+$", version)
def test_codec_version(self) -> None:
assert AvifImagePlugin.get_codec_version("unknown") is None
for codec_name in ("aom", "dav1d", "rav1e", "svt"):
codec_version = AvifImagePlugin.get_codec_version(codec_name)
if _avif.decoder_codec_available(
codec_name
) or _avif.encoder_codec_available(codec_name):
assert codec_version is not None
assert re.search(r"^v?\d+\.\d+\.\d+(-([a-z\d])+)*$", codec_version)
else:
assert codec_version is None
def test_read(self) -> None:
"""
Can we read an AVIF file without error?
Does it have the bits we expect?
"""
with Image.open(TEST_AVIF_FILE) as image:
assert image.mode == "RGB"
assert image.size == (128, 128)
assert image.format == "AVIF"
assert image.get_format_mimetype() == "image/avif"
image.getdata()
# generated with:
# avifdec hopper.avif hopper_avif_write.png
assert_image_similar_tofile(
image, "Tests/images/avif/hopper_avif_write.png", 11.5
)
def test_write_rgb(self, tmp_path: Path) -> None:
"""
Can we write a RGB mode file to avif without error?
Does it have the bits we expect?
"""
temp_file = tmp_path / "temp.avif"
im = hopper()
im.save(temp_file)
with Image.open(temp_file) as reloaded:
assert reloaded.mode == "RGB"
assert reloaded.size == (128, 128)
assert reloaded.format == "AVIF"
reloaded.getdata()
# avifdec hopper.avif avif/hopper_avif_write.png
assert_image_similar_tofile(
reloaded, "Tests/images/avif/hopper_avif_write.png", 6.02
)
# This test asserts that the images are similar. If the average pixel
# difference between the two images is less than the epsilon value,
# then we're going to accept that it's a reasonable lossy version of
# the image.
assert_image_similar(reloaded, im, 8.62)
def test_AvifEncoder_with_invalid_args(self) -> None:
"""
Calling encoder functions with no arguments should result in an error.
"""
with pytest.raises(TypeError):
_avif.AvifEncoder()
def test_AvifDecoder_with_invalid_args(self) -> None:
"""
Calling decoder functions with no arguments should result in an error.
"""
with pytest.raises(TypeError):
_avif.AvifDecoder()
def test_invalid_dimensions(self, tmp_path: Path) -> None:
test_file = tmp_path / "temp.avif"
im = Image.new("RGB", (0, 0))
with pytest.raises(ValueError):
im.save(test_file)
def test_encoder_finish_none_error(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Save should raise an OSError if AvifEncoder.finish returns None"""
class _mock_avif:
class AvifEncoder:
def __init__(self, *args: Any) -> None:
pass
def add(self, *args: Any) -> None:
pass
def finish(self) -> None:
return None
monkeypatch.setattr(AvifImagePlugin, "_avif", _mock_avif)
im = Image.new("RGB", (150, 150))
test_file = tmp_path / "temp.avif"
with pytest.raises(OSError):
im.save(test_file)
def test_no_resource_warning(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im:
with warnings.catch_warnings():
warnings.simplefilter("error")
im.save(tmp_path / "temp.avif")
@pytest.mark.parametrize("major_brand", [b"avif", b"avis", b"mif1", b"msf1"])
def test_accept_ftyp_brands(self, major_brand: bytes) -> None:
data = b"\x00\x00\x00\x1cftyp%s\x00\x00\x00\x00" % major_brand
assert AvifImagePlugin._accept(data) is True
def test_file_pointer_could_be_reused(self) -> None:
with open(TEST_AVIF_FILE, "rb") as blob:
with Image.open(blob) as im:
im.load()
with Image.open(blob) as im:
im.load()
def test_background_from_gif(self, tmp_path: Path) -> None:
with Image.open("Tests/images/chi.gif") as im:
original_value = im.convert("RGB").getpixel((1, 1))
# Save as AVIF
out_avif = tmp_path / "temp.avif"
im.save(out_avif, save_all=True)
# Save as GIF
out_gif = tmp_path / "temp.gif"
with Image.open(out_avif) as im:
im.save(out_gif)
with Image.open(out_gif) as reread:
reread_value = reread.convert("RGB").getpixel((1, 1))
difference = sum([abs(original_value[i] - reread_value[i]) for i in range(3)])
assert difference <= 3
def test_save_single_frame(self, tmp_path: Path) -> None:
temp_file = tmp_path / "temp.avif"
with Image.open("Tests/images/chi.gif") as im:
im.save(temp_file)
with Image.open(temp_file) as im:
assert im.n_frames == 1
def test_invalid_file(self) -> None:
invalid_file = "Tests/images/flower.jpg"
with pytest.raises(SyntaxError):
AvifImagePlugin.AvifImageFile(invalid_file)
def test_load_transparent_rgb(self) -> None:
test_file = "Tests/images/avif/transparency.avif"
with Image.open(test_file) as im:
assert_image(im, "RGBA", (64, 64))
# image has 876 transparent pixels
assert im.getchannel("A").getcolors()[0] == (876, 0)
def test_save_transparent(self, tmp_path: Path) -> None:
im = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
assert im.getcolors() == [(100, (0, 0, 0, 0))]
test_file = tmp_path / "temp.avif"
im.save(test_file)
# check if saved image contains the same transparency
with Image.open(test_file) as im:
assert_image(im, "RGBA", (10, 10))
assert im.getcolors() == [(100, (0, 0, 0, 0))]
def test_save_icc_profile(self) -> None:
with Image.open("Tests/images/avif/icc_profile_none.avif") as im:
assert "icc_profile" not in im.info
with Image.open("Tests/images/avif/icc_profile.avif") as with_icc:
expected_icc = with_icc.info["icc_profile"]
assert expected_icc is not None
im = roundtrip(im, icc_profile=expected_icc)
assert im.info["icc_profile"] == expected_icc
def test_discard_icc_profile(self) -> None:
with Image.open("Tests/images/avif/icc_profile.avif") as im:
im = roundtrip(im, icc_profile=None)
assert "icc_profile" not in im.info
def test_roundtrip_icc_profile(self) -> None:
with Image.open("Tests/images/avif/icc_profile.avif") as im:
expected_icc = im.info["icc_profile"]
im = roundtrip(im)
assert im.info["icc_profile"] == expected_icc
def test_roundtrip_no_icc_profile(self) -> None:
with Image.open("Tests/images/avif/icc_profile_none.avif") as im:
assert "icc_profile" not in im.info
im = roundtrip(im)
assert "icc_profile" not in im.info
def test_exif(self) -> None:
# With an EXIF chunk
with Image.open("Tests/images/avif/exif.avif") as im:
exif = im.getexif()
assert exif[274] == 1
with Image.open("Tests/images/avif/xmp_tags_orientation.avif") as im:
exif = im.getexif()
assert exif[274] == 3
@pytest.mark.parametrize("use_bytes", [True, False])
@pytest.mark.parametrize("orientation", [1, 2, 3, 4, 5, 6, 7, 8])
def test_exif_save(
self,
tmp_path: Path,
use_bytes: bool,
orientation: int,
) -> None:
exif = Image.Exif()
exif[274] = orientation
exif_data = exif.tobytes()
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
im.save(test_file, exif=exif_data if use_bytes else exif)
with Image.open(test_file) as reloaded:
if orientation == 1:
assert "exif" not in reloaded.info
else:
assert reloaded.getexif()[274] == orientation
assert reloaded.info["exif"] == exif_data
def test_exif_without_orientation(self, tmp_path: Path) -> None:
exif = Image.Exif()
exif[272] = b"test"
exif_data = exif.tobytes()
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
im.save(test_file, exif=exif)
with Image.open(test_file) as reloaded:
assert reloaded.info["exif"] == exif_data
def test_exif_invalid(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
with pytest.raises(SyntaxError):
im.save(test_file, exif=b"invalid")
@pytest.mark.parametrize(
"rot, mir, exif_orientation",
[
(0, 0, 4),
(0, 1, 2),
(1, 0, 5),
(1, 1, 7),
(2, 0, 2),
(2, 1, 4),
(3, 0, 7),
(3, 1, 5),
],
)
def test_rot_mir_exif(
self, rot: int, mir: int, exif_orientation: int, tmp_path: Path
) -> None:
with Image.open(f"Tests/images/avif/rot{rot}mir{mir}.avif") as im:
exif = im.getexif()
assert exif[274] == exif_orientation
test_file = tmp_path / "temp.avif"
im.save(test_file, exif=exif)
with Image.open(test_file) as reloaded:
assert reloaded.getexif()[274] == exif_orientation
def test_xmp(self) -> None:
with Image.open("Tests/images/avif/xmp_tags_orientation.avif") as im:
xmp = im.info["xmp"]
assert_xmp_orientation(xmp, 3)
def test_xmp_save(self, tmp_path: Path) -> None:
xmp_arg = "\n".join(
[
'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>',
'<x:xmpmeta xmlns:x="adobe:ns:meta/">',
' <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">',
' <rdf:Description rdf:about=""',
' xmlns:tiff="http://ns.adobe.com/tiff/1.0/"',
' tiff:Orientation="1"/>',
" </rdf:RDF>",
"</x:xmpmeta>",
'<?xpacket end="r"?>',
]
)
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
im.save(test_file, xmp=xmp_arg)
with Image.open(test_file) as reloaded:
xmp = reloaded.info["xmp"]
assert_xmp_orientation(xmp, 1)
def test_tell(self) -> None:
with Image.open(TEST_AVIF_FILE) as im:
assert im.tell() == 0
def test_seek(self) -> None:
with Image.open(TEST_AVIF_FILE) as im:
im.seek(0)
with pytest.raises(EOFError):
im.seek(1)
@pytest.mark.parametrize("subsampling", ["4:4:4", "4:2:2", "4:2:0", "4:0:0"])
def test_encoder_subsampling(self, tmp_path: Path, subsampling: str) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
im.save(test_file, subsampling=subsampling)
def test_encoder_subsampling_invalid(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
with pytest.raises(ValueError):
im.save(test_file, subsampling="foo")
@pytest.mark.parametrize("value", ["full", "limited"])
def test_encoder_range(self, tmp_path: Path, value: str) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
im.save(test_file, range=value)
def test_encoder_range_invalid(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
with pytest.raises(ValueError):
im.save(test_file, range="foo")
@skip_unless_avif_encoder("aom")
def test_encoder_codec_param(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
im.save(test_file, codec="aom")
def test_encoder_codec_invalid(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
with pytest.raises(ValueError):
im.save(test_file, codec="foo")
@skip_unless_avif_decoder("dav1d")
def test_decoder_codec_cannot_encode(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
with pytest.raises(ValueError):
im.save(test_file, codec="dav1d")
@skip_unless_avif_encoder("aom")
@pytest.mark.parametrize(
"advanced",
[
{
"aq-mode": "1",
"enable-chroma-deltaq": "1",
},
(("aq-mode", "1"), ("enable-chroma-deltaq", "1")),
[("aq-mode", "1"), ("enable-chroma-deltaq", "1")],
],
)
def test_encoder_advanced_codec_options(
self, advanced: dict[str, str] | Sequence[tuple[str, str]]
) -> None:
with Image.open(TEST_AVIF_FILE) as im:
ctrl_buf = BytesIO()
im.save(ctrl_buf, "AVIF", codec="aom")
test_buf = BytesIO()
im.save(
test_buf,
"AVIF",
codec="aom",
advanced=advanced,
)
assert ctrl_buf.getvalue() != test_buf.getvalue()
@skip_unless_avif_encoder("aom")
@pytest.mark.parametrize("advanced", [{"foo": "bar"}, {"foo": 1234}, 1234])
def test_encoder_advanced_codec_options_invalid(
self, tmp_path: Path, advanced: dict[str, str] | int
) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
with pytest.raises(ValueError):
im.save(test_file, codec="aom", advanced=advanced)
@skip_unless_avif_decoder("aom")
def test_decoder_codec_param(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(AvifImagePlugin, "DECODE_CODEC_CHOICE", "aom")
with Image.open(TEST_AVIF_FILE) as im:
assert im.size == (128, 128)
@skip_unless_avif_encoder("rav1e")
def test_encoder_codec_cannot_decode(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(AvifImagePlugin, "DECODE_CODEC_CHOICE", "rav1e")
with pytest.raises(ValueError):
with Image.open(TEST_AVIF_FILE):
pass
def test_decoder_codec_invalid(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(AvifImagePlugin, "DECODE_CODEC_CHOICE", "foo")
with pytest.raises(ValueError):
with Image.open(TEST_AVIF_FILE):
pass
@skip_unless_avif_encoder("aom")
def test_encoder_codec_available(self) -> None:
assert _avif.encoder_codec_available("aom") is True
def test_encoder_codec_available_bad_params(self) -> None:
with pytest.raises(TypeError):
_avif.encoder_codec_available()
@skip_unless_avif_decoder("dav1d")
def test_encoder_codec_available_cannot_decode(self) -> None:
assert _avif.encoder_codec_available("dav1d") is False
def test_encoder_codec_available_invalid(self) -> None:
assert _avif.encoder_codec_available("foo") is False
def test_encoder_quality_valueerror(self, tmp_path: Path) -> None:
with Image.open(TEST_AVIF_FILE) as im:
test_file = tmp_path / "temp.avif"
with pytest.raises(ValueError):
im.save(test_file, quality="invalid")
@skip_unless_avif_decoder("aom")
def test_decoder_codec_available(self) -> None:
assert _avif.decoder_codec_available("aom") is True
def test_decoder_codec_available_bad_params(self) -> None:
with pytest.raises(TypeError):
_avif.decoder_codec_available()
@skip_unless_avif_encoder("rav1e")
def test_decoder_codec_available_cannot_decode(self) -> None:
assert _avif.decoder_codec_available("rav1e") is False
def test_decoder_codec_available_invalid(self) -> None:
assert _avif.decoder_codec_available("foo") is False
def test_p_mode_transparency(self, tmp_path: Path) -> None:
im = Image.new("P", size=(64, 64))
draw = ImageDraw.Draw(im)
draw.rectangle(xy=[(0, 0), (32, 32)], fill=255)
draw.rectangle(xy=[(32, 32), (64, 64)], fill=255)
out_png = tmp_path / "temp.png"
im.save(out_png, transparency=0)
with Image.open(out_png) as im_png:
out_avif = tmp_path / "temp.avif"
im_png.save(out_avif, quality=100)
with Image.open(out_avif) as expected:
assert_image_similar(im_png.convert("RGBA"), expected, 0.17)
def test_decoder_strict_flags(self) -> None:
# This would fail if full avif strictFlags were enabled
with Image.open("Tests/images/avif/hopper-missing-pixi.avif") as im:
assert im.size == (128, 128)
@skip_unless_avif_encoder("aom")
@pytest.mark.parametrize("speed", [-1, 1, 11])
def test_aom_optimizations(self, tmp_path: Path, speed: int) -> None:
test_file = tmp_path / "temp.avif"
hopper().save(test_file, codec="aom", speed=speed)
@skip_unless_avif_encoder("svt")
def test_svt_optimizations(self, tmp_path: Path) -> None:
test_file = tmp_path / "temp.avif"
hopper().save(test_file, codec="svt", speed=1)
@skip_unless_feature("avif")
class TestAvifAnimation:
@contextmanager
def star_frames(self) -> Generator[list[Image.Image], None, None]:
with Image.open("Tests/images/avif/star.png") as f:
yield [f, f.rotate(90), f.rotate(180), f.rotate(270)]
def test_n_frames(self) -> None:
"""
Ensure that AVIF format sets n_frames and is_animated attributes
correctly.
"""
with Image.open(TEST_AVIF_FILE) as im:
assert im.n_frames == 1
assert not im.is_animated
with Image.open("Tests/images/avif/star.avifs") as im:
assert im.n_frames == 5
assert im.is_animated
def test_write_animation_P(self, tmp_path: Path) -> None:
"""
Convert an animated GIF to animated AVIF, then compare the frame
count, and ensure the frames are visually similar to the originals.
"""
with Image.open("Tests/images/avif/star.gif") as original:
assert original.n_frames > 1
temp_file = tmp_path / "temp.avif"
original.save(temp_file, save_all=True)
with Image.open(temp_file) as im:
assert im.n_frames == original.n_frames
# Compare first frame in P mode to frame from original GIF
assert_image_similar(im, original.convert("RGBA"), 2)
# Compare later frames in RGBA mode to frames from original GIF
for frame in range(1, original.n_frames):
original.seek(frame)
im.seek(frame)
assert_image_similar(im, original, 2.54)
def test_write_animation_RGBA(self, tmp_path: Path) -> None:
"""
Write an animated AVIF from RGBA frames, and ensure the frames
are visually similar to the originals.
"""
def check(temp_file: Path) -> None:
with Image.open(temp_file) as im:
assert im.n_frames == 4
# Compare first frame to original
assert_image_similar(im, frame1, 2.7)
# Compare second frame to original
im.seek(1)
assert_image_similar(im, frame2, 4.1)
with self.star_frames() as frames:
frame1 = frames[0]
frame2 = frames[1]
temp_file1 = tmp_path / "temp.avif"
frames[0].copy().save(temp_file1, save_all=True, append_images=frames[1:])
check(temp_file1)
# Test appending using a generator
def imGenerator(
ims: list[Image.Image],
) -> Generator[Image.Image, None, None]:
yield from ims
temp_file2 = tmp_path / "temp_generator.avif"
frames[0].copy().save(
temp_file2,
save_all=True,
append_images=imGenerator(frames[1:]),
)
check(temp_file2)
def test_sequence_dimension_mismatch_check(self, tmp_path: Path) -> None:
temp_file = tmp_path / "temp.avif"
frame1 = Image.new("RGB", (100, 100))
frame2 = Image.new("RGB", (150, 150))
with pytest.raises(ValueError):
frame1.save(temp_file, save_all=True, append_images=[frame2])
def test_heif_raises_unidentified_image_error(self) -> None:
with pytest.raises(UnidentifiedImageError):
with Image.open("Tests/images/avif/hopper.heif"):
pass
@pytest.mark.parametrize("alpha_premultiplied", [False, True])
def test_alpha_premultiplied(
self, tmp_path: Path, alpha_premultiplied: bool
) -> None:
temp_file = tmp_path / "temp.avif"
color = (200, 200, 200, 1)
im = Image.new("RGBA", (1, 1), color)
im.save(temp_file, alpha_premultiplied=alpha_premultiplied)
expected = (255, 255, 255, 1) if alpha_premultiplied else color
with Image.open(temp_file) as reloaded:
assert reloaded.getpixel((0, 0)) == expected
def test_timestamp_and_duration(self, tmp_path: Path) -> None:
"""
Try passing a list of durations, and make sure the encoded
timestamps and durations are correct.
"""
durations = [1, 10, 20, 30, 40]
temp_file = tmp_path / "temp.avif"
with self.star_frames() as frames:
frames[0].save(
temp_file,
save_all=True,
append_images=(frames[1:] + [frames[0]]),
duration=durations,
)
with Image.open(temp_file) as im:
assert im.n_frames == 5
assert im.is_animated
# Check that timestamps and durations match original values specified
timestamp = 0
for frame in range(im.n_frames):
im.seek(frame)
im.load()
assert im.info["duration"] == durations[frame]
assert im.info["timestamp"] == timestamp
timestamp += durations[frame]
def test_seeking(self, tmp_path: Path) -> None:
"""
Create an animated AVIF file, and then try seeking through frames in
reverse-order, verifying the timestamps and durations are correct.
"""
duration = 33
temp_file = tmp_path / "temp.avif"
with self.star_frames() as frames:
frames[0].save(
temp_file,
save_all=True,
append_images=(frames[1:] + [frames[0]]),
duration=duration,
)
with Image.open(temp_file) as im:
assert im.n_frames == 5
assert im.is_animated
# Traverse frames in reverse, checking timestamps and durations
timestamp = duration * (im.n_frames - 1)
for frame in reversed(range(im.n_frames)):
im.seek(frame)
im.load()
assert im.info["duration"] == duration
assert im.info["timestamp"] == timestamp
timestamp -= duration
def test_seek_errors(self) -> None:
with Image.open("Tests/images/avif/star.avifs") as im:
with pytest.raises(EOFError):
im.seek(-1)
with pytest.raises(EOFError):
im.seek(42)
MAX_THREADS = os.cpu_count() or 1
@skip_unless_feature("avif")
class TestAvifLeaks(PillowLeakTestCase):
mem_limit = MAX_THREADS * 3 * 1024
iterations = 100
@pytest.mark.skipif(
is_docker_qemu(), reason="Skipping on cross-architecture containers"
)
def test_leak_load(self) -> None:
with open(TEST_AVIF_FILE, "rb") as f:
im_data = f.read()
def core() -> None:
with Image.open(BytesIO(im_data)) as im:
im.load()
gc.collect()
self._test_leak(core)

View File

@ -15,25 +15,19 @@ from .helper import (
)
def test_sanity(tmp_path: Path) -> None:
def roundtrip(im: Image.Image) -> None:
outfile = tmp_path / "temp.bmp"
@pytest.mark.parametrize("mode", ("1", "L", "P", "RGB"))
def test_sanity(mode: str, tmp_path: Path) -> None:
outfile = tmp_path / "temp.bmp"
im.save(outfile, "BMP")
im = hopper(mode)
im.save(outfile, "BMP")
with Image.open(outfile) as reloaded:
reloaded.load()
assert im.mode == reloaded.mode
assert im.size == reloaded.size
assert reloaded.format == "BMP"
assert reloaded.get_format_mimetype() == "image/bmp"
roundtrip(hopper())
roundtrip(hopper("1"))
roundtrip(hopper("L"))
roundtrip(hopper("P"))
roundtrip(hopper("RGB"))
with Image.open(outfile) as reloaded:
reloaded.load()
assert im.mode == reloaded.mode
assert im.size == reloaded.size
assert reloaded.format == "BMP"
assert reloaded.get_format_mimetype() == "image/bmp"
def test_invalid_file() -> None:
@ -196,9 +190,9 @@ def test_rle8() -> None:
# Signal end of bitmap before the image is finished
with open("Tests/images/bmp/g/pal8rle.bmp", "rb") as fp:
data = fp.read(1063) + b"\x01"
with Image.open(io.BytesIO(data)) as im:
with pytest.raises(ValueError):
im.load()
with Image.open(io.BytesIO(data)) as im:
with pytest.raises(ValueError):
im.load()
def test_rle4() -> None:
@ -220,9 +214,9 @@ def test_rle4() -> None:
def test_rle8_eof(file_name: str, length: int) -> None:
with open(file_name, "rb") as fp:
data = fp.read(length)
with Image.open(io.BytesIO(data)) as im:
with pytest.raises(ValueError):
im.load()
with Image.open(io.BytesIO(data)) as im:
with pytest.raises(ValueError):
im.load()
def test_offset() -> None:
@ -230,3 +224,13 @@ def test_offset() -> None:
# to exclude the palette size from the pixel data offset
with Image.open("Tests/images/pal8_offset.bmp") as im:
assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp")
def test_use_raw_alpha(monkeypatch: pytest.MonkeyPatch) -> None:
with Image.open("Tests/images/bmp/g/rgb32.bmp") as im:
assert im.info["compression"] == BmpImagePlugin.BmpImageFile.COMPRESSIONS["RAW"]
assert im.mode == "RGB"
monkeypatch.setattr(BmpImagePlugin, "USE_RAW_ALPHA", True)
with Image.open("Tests/images/bmp/g/rgb32.bmp") as im:
assert im.mode == "RGBA"

View File

@ -69,12 +69,14 @@ def test_tell() -> None:
def test_n_frames() -> None:
with Image.open(TEST_FILE) as im:
assert isinstance(im, DcxImagePlugin.DcxImageFile)
assert im.n_frames == 1
assert not im.is_animated
def test_eoferror() -> None:
with Image.open(TEST_FILE) as im:
assert isinstance(im, DcxImagePlugin.DcxImageFile)
n_frames = im.n_frames
# Test seeking past the last frame

View File

@ -86,6 +86,8 @@ simple_eps_file_with_long_binary_data = (
def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None:
expected_size = tuple(s * scale for s in size)
with Image.open(filename) as image:
assert isinstance(image, EpsImagePlugin.EpsImageFile)
image.load(scale=scale)
assert image.mode == "RGB"
assert image.size == expected_size
@ -227,6 +229,8 @@ def test_showpage() -> None:
@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
def test_transparency() -> None:
with Image.open("Tests/images/eps/reqd_showpage.eps") as plot_image:
assert isinstance(plot_image, EpsImagePlugin.EpsImageFile)
plot_image.load(transparency=True)
assert plot_image.mode == "RGBA"
@ -308,6 +312,7 @@ def test_render_scale2() -> None:
# Zero bounding box
with Image.open(FILE1) as image1_scale2:
assert isinstance(image1_scale2, EpsImagePlugin.EpsImageFile)
image1_scale2.load(scale=2)
with Image.open(FILE1_COMPARE_SCALE2) as image1_scale2_compare:
image1_scale2_compare = image1_scale2_compare.convert("RGB")
@ -316,6 +321,7 @@ def test_render_scale2() -> None:
# Non-zero bounding box
with Image.open(FILE2) as image2_scale2:
assert isinstance(image2_scale2, EpsImagePlugin.EpsImageFile)
image2_scale2.load(scale=2)
with Image.open(FILE2_COMPARE_SCALE2) as image2_scale2_compare:
image2_scale2_compare = image2_scale2_compare.convert("RGB")

View File

@ -22,6 +22,8 @@ animated_test_file_with_prefix_chunk = "Tests/images/2422.flc"
def test_sanity() -> None:
with Image.open(static_test_file) as im:
assert isinstance(im, FliImagePlugin.FliImageFile)
im.load()
assert im.mode == "P"
assert im.size == (128, 128)
@ -29,6 +31,8 @@ def test_sanity() -> None:
assert not im.is_animated
with Image.open(animated_test_file) as im:
assert isinstance(im, FliImagePlugin.FliImageFile)
assert im.mode == "P"
assert im.size == (320, 200)
assert im.format == "FLI"
@ -112,16 +116,19 @@ def test_palette_chunk_second() -> None:
def test_n_frames() -> None:
with Image.open(static_test_file) as im:
assert isinstance(im, FliImagePlugin.FliImageFile)
assert im.n_frames == 1
assert not im.is_animated
with Image.open(animated_test_file) as im:
assert isinstance(im, FliImagePlugin.FliImageFile)
assert im.n_frames == 384
assert im.is_animated
def test_eoferror() -> None:
with Image.open(animated_test_file) as im:
assert isinstance(im, FliImagePlugin.FliImageFile)
n_frames = im.n_frames
# Test seeking past the last frame
@ -166,6 +173,7 @@ def test_seek_tell() -> None:
def test_seek() -> None:
with Image.open(animated_test_file) as im:
assert isinstance(im, FliImagePlugin.FliImageFile)
im.seek(50)
assert_image_equal_tofile(im, "Tests/images/a_fli.png")

View File

@ -22,10 +22,11 @@ def test_sanity() -> None:
def test_close() -> None:
with Image.open("Tests/images/input_bw_one_band.fpx") as im:
pass
assert isinstance(im, FpxImagePlugin.FpxImageFile)
assert im.ole.fp.closed
im = Image.open("Tests/images/input_bw_one_band.fpx")
assert isinstance(im, FpxImagePlugin.FpxImageFile)
im.close()
assert im.ole.fp.closed

View File

@ -450,6 +450,7 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None:
def test_seek() -> None:
with Image.open("Tests/images/dispose_none.gif") as img:
assert isinstance(img, GifImagePlugin.GifImageFile)
frame_count = 0
try:
while True:
@ -494,10 +495,12 @@ def test_seek_rewind() -> None:
def test_n_frames(path: str, n_frames: int) -> None:
# Test is_animated before n_frames
with Image.open(path) as im:
assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.is_animated == (n_frames != 1)
# Test is_animated after n_frames
with Image.open(path) as im:
assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.n_frames == n_frames
assert im.is_animated == (n_frames != 1)
@ -507,6 +510,7 @@ def test_no_change() -> None:
with Image.open("Tests/images/dispose_bgnd.gif") as im:
im.seek(1)
expected = im.copy()
assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.n_frames == 5
assert_image_equal(im, expected)
@ -514,17 +518,20 @@ def test_no_change() -> None:
with Image.open("Tests/images/dispose_bgnd.gif") as im:
im.seek(3)
expected = im.copy()
assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.is_animated
assert_image_equal(im, expected)
with Image.open("Tests/images/comment_after_only_frame.gif") as im:
expected = Image.new("P", (1, 1))
assert isinstance(im, GifImagePlugin.GifImageFile)
assert not im.is_animated
assert_image_equal(im, expected)
def test_eoferror() -> None:
with Image.open(TEST_GIF) as im:
assert isinstance(im, GifImagePlugin.GifImageFile)
n_frames = im.n_frames
# Test seeking past the last frame
@ -543,6 +550,7 @@ def test_first_frame_transparency() -> None:
def test_dispose_none() -> None:
with Image.open("Tests/images/dispose_none.gif") as img:
assert isinstance(img, GifImagePlugin.GifImageFile)
try:
while True:
img.seek(img.tell() + 1)
@ -566,6 +574,7 @@ def test_dispose_none_load_end() -> None:
def test_dispose_background() -> None:
with Image.open("Tests/images/dispose_bgnd.gif") as img:
assert isinstance(img, GifImagePlugin.GifImageFile)
try:
while True:
img.seek(img.tell() + 1)
@ -619,6 +628,7 @@ def test_transparent_dispose(
def test_dispose_previous() -> None:
with Image.open("Tests/images/dispose_prev.gif") as img:
assert isinstance(img, GifImagePlugin.GifImageFile)
try:
while True:
img.seek(img.tell() + 1)
@ -656,6 +666,7 @@ def test_save_dispose(tmp_path: Path) -> None:
for method in range(4):
im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=method)
with Image.open(out) as img:
assert isinstance(img, GifImagePlugin.GifImageFile)
for _ in range(2):
img.seek(img.tell() + 1)
assert img.disposal_method == method
@ -669,6 +680,7 @@ def test_save_dispose(tmp_path: Path) -> None:
)
with Image.open(out) as img:
assert isinstance(img, GifImagePlugin.GifImageFile)
for i in range(2):
img.seek(img.tell() + 1)
assert img.disposal_method == i + 1
@ -791,6 +803,7 @@ def test_dispose2_background_frame(tmp_path: Path) -> None:
im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=2)
with Image.open(out) as im:
assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.n_frames == 3
@ -972,6 +985,8 @@ def test_identical_frames(tmp_path: Path) -> None:
out, save_all=True, append_images=im_list[1:], duration=duration_list
)
with Image.open(out) as reread:
assert isinstance(reread, GifImagePlugin.GifImageFile)
# Assert that the first three frames were combined
assert reread.n_frames == 2
@ -1001,6 +1016,8 @@ def test_identical_frames_to_single_frame(
im_list[0].save(out, save_all=True, append_images=im_list[1:], duration=duration)
with Image.open(out) as reread:
assert isinstance(reread, GifImagePlugin.GifImageFile)
# Assert that all frames were combined
assert reread.n_frames == 1
@ -1187,6 +1204,14 @@ def test_append_images(tmp_path: Path) -> None:
im.copy().save(out, save_all=True, append_images=ims)
with Image.open(out) as reread:
assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 3
# Test append_images without save_all
im.copy().save(out, append_images=ims)
with Image.open(out) as reread:
assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 3
# Tests appending using a generator
@ -1196,6 +1221,7 @@ def test_append_images(tmp_path: Path) -> None:
im.save(out, save_all=True, append_images=im_generator(ims))
with Image.open(out) as reread:
assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 3
# Tests appending single and multiple frame images
@ -1204,6 +1230,7 @@ def test_append_images(tmp_path: Path) -> None:
im.save(out, save_all=True, append_images=[im2])
with Image.open(out) as reread:
assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 10
@ -1304,6 +1331,7 @@ def test_bbox(tmp_path: Path) -> None:
im.save(out, save_all=True, append_images=ims)
with Image.open(out) as reread:
assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 2
@ -1316,6 +1344,7 @@ def test_bbox_alpha(tmp_path: Path) -> None:
im.save(out, save_all=True, append_images=[im2])
with Image.open(out) as reread:
assert isinstance(reread, GifImagePlugin.GifImageFile)
assert reread.n_frames == 2
@ -1467,6 +1496,7 @@ def test_extents(
) -> None:
monkeypatch.setattr(GifImagePlugin, "LOADING_STRATEGY", loading_strategy)
with Image.open("Tests/images/" + test_file) as im:
assert isinstance(im, GifImagePlugin.GifImageFile)
assert im.size == (100, 100)
# Check that n_frames does not change the size
@ -1514,4 +1544,5 @@ def test_p_rgba(tmp_path: Path, params: dict[str, Any]) -> None:
im1.save(out, save_all=True, append_images=[im2], **params)
with Image.open(out) as reloaded:
assert isinstance(reloaded, GifImagePlugin.GifImageFile)
assert reloaded.n_frames == 2

View File

@ -1,5 +1,7 @@
from __future__ import annotations
from io import BytesIO
import pytest
from PIL.GimpPaletteFile import GimpPaletteFile
@ -14,17 +16,20 @@ def test_sanity() -> None:
GimpPaletteFile(fp)
with open("Tests/images/bad_palette_file.gpl", "rb") as fp:
with pytest.raises(SyntaxError):
with pytest.raises(SyntaxError, match="bad palette file"):
GimpPaletteFile(fp)
with open("Tests/images/bad_palette_entry.gpl", "rb") as fp:
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="bad palette entry"):
GimpPaletteFile(fp)
def test_get_palette() -> None:
@pytest.mark.parametrize(
"filename, size", (("custom_gimp_palette.gpl", 8), ("full_gimp_palette.gpl", 256))
)
def test_get_palette(filename: str, size: int) -> None:
# Arrange
with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp:
with open("Tests/images/" + filename, "rb") as fp:
palette_file = GimpPaletteFile(fp)
# Act
@ -32,4 +37,36 @@ def test_get_palette() -> None:
# Assert
assert mode == "RGB"
assert len(palette) / 3 == 8
assert len(palette) / 3 == size
def test_frombytes() -> None:
# Test that __init__ stops reading after 260 lines
with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp:
custom_data = fp.read()
custom_data += b"#\n" * 300 + b" 0 0 0 Index 12"
b = BytesIO(custom_data)
palette = GimpPaletteFile(b)
assert len(palette.palette) / 3 == 8
# Test that __init__ only reads 256 entries
with open("Tests/images/full_gimp_palette.gpl", "rb") as fp:
full_data = fp.read()
data = full_data.replace(b"#\n", b"") + b" 0 0 0 Index 256"
b = BytesIO(data)
palette = GimpPaletteFile(b)
assert len(palette.palette) / 3 == 256
# Test that frombytes() can read beyond that
palette = GimpPaletteFile.frombytes(data)
assert len(palette.palette) / 3 == 257
# Test that __init__ raises an error if a comment is too long
data = full_data[:-1] + b"a" * 100
b = BytesIO(data)
with pytest.raises(SyntaxError, match="bad palette file"):
palette = GimpPaletteFile(b)
# Test that frombytes() can read the data regardless
palette = GimpPaletteFile.frombytes(data)
assert len(palette.palette) / 3 == 256

View File

@ -43,7 +43,7 @@ def test_save() -> None:
# Arrange
with Image.open(TEST_FILE) as im:
dummy_fp = BytesIO()
dummy_filename = "dummy.filename"
dummy_filename = "dummy.h5"
# Act / Assert: stub cannot save without an implemented handler
with pytest.raises(OSError):

View File

@ -69,6 +69,7 @@ def test_save_append_images(tmp_path: Path) -> None:
assert_image_similar_tofile(im, temp_file, 1)
with Image.open(temp_file) as reread:
assert isinstance(reread, IcnsImagePlugin.IcnsImageFile)
reread.size = (16, 16)
reread.load(2)
assert_image_equal(reread, provided_im)
@ -90,6 +91,7 @@ def test_sizes() -> None:
# Check that we can load all of the sizes, and that the final pixel
# dimensions are as expected
with Image.open(TEST_FILE) as im:
assert isinstance(im, IcnsImagePlugin.IcnsImageFile)
for w, h, r in im.info["sizes"]:
wr = w * r
hr = h * r
@ -118,6 +120,7 @@ def test_older_icon() -> None:
wr = w * r
hr = h * r
with Image.open("Tests/images/pillow2.icns") as im2:
assert isinstance(im2, IcnsImagePlugin.IcnsImageFile)
im2.size = (w, h)
im2.load(r)
assert im2.mode == "RGBA"
@ -135,6 +138,7 @@ def test_jp2_icon() -> None:
wr = w * r
hr = h * r
with Image.open("Tests/images/pillow3.icns") as im2:
assert isinstance(im2, IcnsImagePlugin.IcnsImageFile)
im2.size = (w, h)
im2.load(r)
assert im2.mode == "RGBA"

View File

@ -77,6 +77,7 @@ def test_save_to_bytes() -> None:
# The other one
output.seek(0)
with Image.open(output) as reloaded:
assert isinstance(reloaded, IcoImagePlugin.IcoImageFile)
reloaded.size = (32, 32)
assert im.mode == reloaded.mode
@ -94,6 +95,7 @@ def test_getpixel(tmp_path: Path) -> None:
im.save(temp_file, "ico", sizes=[(32, 32), (64, 64)])
with Image.open(temp_file) as reloaded:
assert isinstance(reloaded, IcoImagePlugin.IcoImageFile)
reloaded.load()
reloaded.size = (32, 32)
@ -167,6 +169,7 @@ def test_save_to_bytes_bmp(mode: str) -> None:
# The other one
output.seek(0)
with Image.open(output) as reloaded:
assert isinstance(reloaded, IcoImagePlugin.IcoImageFile)
reloaded.size = (32, 32)
assert "RGBA" == reloaded.mode
@ -178,6 +181,7 @@ def test_save_to_bytes_bmp(mode: str) -> None:
def test_incorrect_size() -> None:
with Image.open(TEST_ICO_FILE) as im:
assert isinstance(im, IcoImagePlugin.IcoImageFile)
with pytest.raises(ValueError):
im.size = (1, 1)
@ -219,6 +223,7 @@ def test_save_append_images(tmp_path: Path) -> None:
im.save(outfile, sizes=[(32, 32), (128, 128)], append_images=[provided_im])
with Image.open(outfile) as reread:
assert isinstance(reread, IcoImagePlugin.IcoImageFile)
assert_image_equal(reread, hopper("RGBA"))
reread.size = (32, 32)

View File

@ -68,12 +68,14 @@ def test_tell() -> None:
def test_n_frames() -> None:
with Image.open(TEST_IM) as im:
assert isinstance(im, ImImagePlugin.ImImageFile)
assert im.n_frames == 1
assert not im.is_animated
def test_eoferror() -> None:
with Image.open(TEST_IM) as im:
assert isinstance(im, ImImagePlugin.ImImageFile)
n_frames = im.n_frames
# Test seeking past the last frame

View File

@ -91,6 +91,7 @@ class TestFileJpeg:
def test_app(self) -> None:
# Test APP/COM reader (@PIL135)
with Image.open(TEST_FILE) as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
assert im.applist[0] == ("APP0", b"JFIF\x00\x01\x01\x01\x00`\x00`\x00\x00")
assert im.applist[1] == (
"COM",
@ -316,6 +317,8 @@ class TestFileJpeg:
def test_exif_typeerror(self) -> None:
with Image.open("Tests/images/exif_typeerror.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
# Should not raise a TypeError
im._getexif()
@ -500,6 +503,7 @@ class TestFileJpeg:
def test_mp(self) -> None:
with Image.open("Tests/images/pil_sample_rgb.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
assert im._getmp() is None
def test_quality_keep(self, tmp_path: Path) -> None:
@ -558,12 +562,14 @@ class TestFileJpeg:
with Image.open(test_file) as im:
im.save(b, "JPEG", qtables=[[n] * 64] * n)
with Image.open(b) as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
assert len(im.quantization) == n
reloaded = self.roundtrip(im, qtables="keep")
assert im.quantization == reloaded.quantization
assert max(reloaded.quantization[0]) <= 255
with Image.open("Tests/images/hopper.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
qtables = im.quantization
reloaded = self.roundtrip(im, qtables=qtables, subsampling=0)
assert im.quantization == reloaded.quantization
@ -663,6 +669,7 @@ class TestFileJpeg:
def test_load_16bit_qtables(self) -> None:
with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
assert len(im.quantization) == 2
assert len(im.quantization[0]) == 64
assert max(im.quantization[0]) > 255
@ -705,6 +712,7 @@ class TestFileJpeg:
@pytest.mark.skipif(not djpeg_available(), reason="djpeg not available")
def test_load_djpeg(self) -> None:
with Image.open(TEST_FILE) as img:
assert isinstance(img, JpegImagePlugin.JpegImageFile)
img.load_djpeg()
assert_image_similar_tofile(img, TEST_FILE, 5)
@ -909,6 +917,7 @@ class TestFileJpeg:
def test_photoshop_malformed_and_multiple(self) -> None:
with Image.open("Tests/images/app13-multiple.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
assert "photoshop" in im.info
assert 24 == len(im.info["photoshop"])
apps_13_lengths = [len(v) for k, v in im.applist if k == "APP13"]
@ -1084,6 +1093,7 @@ class TestFileJpeg:
def test_deprecation(self) -> None:
with Image.open(TEST_FILE) as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
with pytest.warns(DeprecationWarning):
assert im.huffman_ac == {}
with pytest.warns(DeprecationWarning):

View File

@ -228,12 +228,14 @@ def test_layers(card: ImageFile.ImageFile) -> None:
out.seek(0)
with Image.open(out) as im:
assert isinstance(im, Jpeg2KImagePlugin.Jpeg2KImageFile)
im.layers = 1
im.load()
assert_image_similar(im, card, 13)
out.seek(0)
with Image.open(out) as im:
assert isinstance(im, Jpeg2KImagePlugin.Jpeg2KImageFile)
im.layers = 3
im.load()
assert_image_similar(im, card, 0.4)
@ -455,8 +457,8 @@ def test_comment() -> None:
# Test an image that is truncated partway through a codestream
with open("Tests/images/comment.jp2", "rb") as fp:
b = BytesIO(fp.read(130))
with Image.open(b) as im:
pass
with Image.open(b) as im:
pass
def test_save_comment(card: ImageFile.ImageFile) -> None:

View File

@ -36,6 +36,7 @@ class LibTiffTestCase:
im.load()
im.getdata()
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im._compression == "group4"
# can we write it back out, in a different form.
@ -80,7 +81,7 @@ class TestFileLibTiff(LibTiffTestCase):
s = io.BytesIO()
with open(test_file, "rb") as f:
s.write(f.read())
s.seek(0)
s.seek(0)
with Image.open(s) as im:
assert im.size == (500, 500)
self._assert_noerr(tmp_path, im)
@ -153,6 +154,7 @@ class TestFileLibTiff(LibTiffTestCase):
"""Test metadata writing through libtiff"""
f = tmp_path / "temp.tiff"
with Image.open("Tests/images/hopper_g4.tif") as img:
assert isinstance(img, TiffImagePlugin.TiffImageFile)
img.save(f, tiffinfo=img.tag)
if legacy_api:
@ -170,6 +172,7 @@ class TestFileLibTiff(LibTiffTestCase):
]
with Image.open(f) as loaded:
assert isinstance(loaded, TiffImagePlugin.TiffImageFile)
if legacy_api:
reloaded = loaded.tag.named()
else:
@ -212,6 +215,7 @@ class TestFileLibTiff(LibTiffTestCase):
# Exclude ones that have special meaning
# that we're already testing them
with Image.open("Tests/images/hopper_g4.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
for tag in im.tag_v2:
try:
del core_items[tag]
@ -317,6 +321,7 @@ class TestFileLibTiff(LibTiffTestCase):
im.save(out, tiffinfo=tiffinfo)
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
for tag, value in tiffinfo.items():
reloaded_value = reloaded.tag_v2[tag]
if (
@ -349,12 +354,14 @@ class TestFileLibTiff(LibTiffTestCase):
def test_osubfiletype(self, tmp_path: Path) -> None:
outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/g4_orientation_6.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.tag_v2[OSUBFILETYPE] = 1
im.save(outfile)
def test_subifd(self, tmp_path: Path) -> None:
outfile = tmp_path / "temp.tif"
with Image.open("Tests/images/g4_orientation_6.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.tag_v2[SUBIFD] = 10000
# Should not segfault
@ -369,6 +376,7 @@ class TestFileLibTiff(LibTiffTestCase):
hopper().save(out, tiffinfo={700: b"xmlpacket tag"})
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
if 700 in reloaded.tag_v2:
assert reloaded.tag_v2[700] == b"xmlpacket tag"
@ -430,12 +438,15 @@ class TestFileLibTiff(LibTiffTestCase):
"""Tests String data in info directory"""
test_file = "Tests/images/hopper_g4_500.tif"
with Image.open(test_file) as orig:
assert isinstance(orig, TiffImagePlugin.TiffImageFile)
out = tmp_path / "temp.tif"
orig.tag[269] = "temp.tif"
orig.save(out)
with Image.open(out) as reread:
assert isinstance(reread, TiffImagePlugin.TiffImageFile)
assert "temp.tif" == reread.tag_v2[269]
assert "temp.tif" == reread.tag[269][0]
@ -541,6 +552,7 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open(out) as reloaded:
# colormap/palette tag
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert len(reloaded.tag_v2[320]) == 768
@pytest.mark.parametrize("compression", ("tiff_ccitt", "group3", "group4"))
@ -572,6 +584,7 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open("Tests/images/multipage.tiff") as im:
# file is a multipage tiff, 10x10 green, 10x10 red, 20x20 blue
assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.seek(0)
assert im.size == (10, 10)
assert im.convert("RGB").getpixel((0, 0)) == (0, 128, 0)
@ -591,6 +604,7 @@ class TestFileLibTiff(LibTiffTestCase):
# issue #862
monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open("Tests/images/multipage.tiff") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
frames = im.n_frames
assert frames == 3
for _ in range(frames):
@ -610,6 +624,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test__next(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open("Tests/images/hopper.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert not im.tag.next
im.load()
assert not im.tag.next
@ -690,21 +705,25 @@ class TestFileLibTiff(LibTiffTestCase):
im.save(outfile, compression="jpeg")
with Image.open(outfile) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[530] == (1, 1)
assert reloaded.tag_v2[532] == (0, 255, 128, 255, 128, 255)
def test_exif_ifd(self) -> None:
out = io.BytesIO()
with Image.open("Tests/images/tiff_adobe_deflate.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2[34665] == 125456
im.save(out, "TIFF")
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert 34665 not in reloaded.tag_v2
im.save(out, "TIFF", tiffinfo={34665: 125456})
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
if Image.core.libtiff_support_custom_tags:
assert reloaded.tag_v2[34665] == 125456
@ -786,6 +805,7 @@ class TestFileLibTiff(LibTiffTestCase):
def test_multipage_compression(self) -> None:
with Image.open("Tests/images/compression.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.seek(0)
assert im._compression == "tiff_ccitt"
assert im.size == (10, 10)
@ -1026,6 +1046,17 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open("Tests/images/old-style-jpeg-compression.tif") as im:
assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png")
def test_old_style_jpeg_orientation(self) -> None:
with open("Tests/images/old-style-jpeg-compression.tif", "rb") as fp:
data = fp.read()
# Set EXIF Orientation to 2
data = data[:102] + b"\x02" + data[103:]
with Image.open(io.BytesIO(data)) as im:
im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png")
def test_open_missing_samplesperpixel(self) -> None:
with Image.open(
"Tests/images/old-style-jpeg-compression-no-samplesperpixel.tif"
@ -1079,6 +1110,7 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open("Tests/images/g4_orientation_1.tif") as base_im:
for i in range(2, 9):
with Image.open("Tests/images/g4_orientation_" + str(i) + ".tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert 274 in im.tag_v2
im.load()

View File

@ -32,7 +32,7 @@ class TestFileLibTiffSmall(LibTiffTestCase):
s = BytesIO()
with open(test_file, "rb") as f:
s.write(f.read())
s.seek(0)
s.seek(0)
with Image.open(s) as im:
assert im.size == (128, 128)
self._assert_noerr(tmp_path, im)

View File

@ -30,11 +30,13 @@ def test_sanity() -> None:
def test_n_frames() -> None:
with Image.open(TEST_FILE) as im:
assert isinstance(im, MicImagePlugin.MicImageFile)
assert im.n_frames == 1
def test_is_animated() -> None:
with Image.open(TEST_FILE) as im:
assert isinstance(im, MicImagePlugin.MicImageFile)
assert not im.is_animated
@ -55,10 +57,11 @@ def test_seek() -> None:
def test_close() -> None:
with Image.open(TEST_FILE) as im:
pass
assert isinstance(im, MicImagePlugin.MicImageFile)
assert im.ole.fp.closed
im = Image.open(TEST_FILE)
assert isinstance(im, MicImagePlugin.MicImageFile)
im.close()
assert im.ole.fp.closed

View File

@ -6,7 +6,7 @@ from typing import Any
import pytest
from PIL import Image, ImageFile, MpoImagePlugin
from PIL import Image, ImageFile, JpegImagePlugin, MpoImagePlugin
from .helper import (
assert_image_equal,
@ -80,6 +80,7 @@ def test_context_manager() -> None:
def test_app(test_file: str) -> None:
# Test APP/COM reader (@PIL135)
with Image.open(test_file) as im:
assert isinstance(im, MpoImagePlugin.MpoImageFile)
assert im.applist[0][0] == "APP1"
assert im.applist[1][0] == "APP2"
assert im.applist[1][1].startswith(
@ -220,12 +221,14 @@ def test_seek(test_file: str) -> None:
def test_n_frames() -> None:
with Image.open("Tests/images/sugarshack.mpo") as im:
assert isinstance(im, MpoImagePlugin.MpoImageFile)
assert im.n_frames == 2
assert im.is_animated
def test_eoferror() -> None:
with Image.open("Tests/images/sugarshack.mpo") as im:
assert isinstance(im, MpoImagePlugin.MpoImageFile)
n_frames = im.n_frames
# Test seeking past the last frame
@ -239,6 +242,8 @@ def test_eoferror() -> None:
def test_adopt_jpeg() -> None:
with Image.open("Tests/images/hopper.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
with pytest.raises(ValueError):
MpoImagePlugin.MpoImageFile.adopt(im)

View File

@ -43,6 +43,11 @@ def roundtrip(tmp_path: Path, mode: str) -> None:
im.save(outfile)
converted = open_with_magick(magick, tmp_path, outfile)
if mode == "P":
assert converted.mode == "P"
im = im.convert("RGB")
converted = converted.convert("RGB")
assert_image_equal(converted, im)
@ -55,7 +60,6 @@ def test_monochrome(tmp_path: Path) -> None:
roundtrip(tmp_path, mode)
@pytest.mark.xfail(reason="Palm P image is wrong")
def test_p_mode(tmp_path: Path) -> None:
# Arrange
mode = "P"

View File

@ -576,6 +576,7 @@ class TestFilePng:
def test_read_private_chunks(self) -> None:
with Image.open("Tests/images/exif.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.private_chunks == [(b"orNT", b"\x01")]
def test_roundtrip_private_chunk(self) -> None:
@ -598,6 +599,7 @@ class TestFilePng:
def test_textual_chunks_after_idat(self, monkeypatch: pytest.MonkeyPatch) -> None:
with Image.open("Tests/images/hopper.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert "comment" in im.text
for k, v in {
"date:create": "2014-09-04T09:37:08+03:00",
@ -607,15 +609,19 @@ class TestFilePng:
# Raises a SyntaxError in load_end
with Image.open("Tests/images/broken_data_stream.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
with pytest.raises(OSError):
assert isinstance(im.text, dict)
# Raises an EOFError in load_end
with Image.open("Tests/images/hopper_idat_after_image_end.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
assert im.text == {"TXT": "VALUE", "ZIP": "VALUE"}
# Raises a UnicodeDecodeError in load_end
with Image.open("Tests/images/truncated_image.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
# The file is truncated
with pytest.raises(OSError):
im.text
@ -726,6 +732,7 @@ class TestFilePng:
im.save(test_file)
with Image.open(test_file) as reloaded:
assert isinstance(reloaded, PngImagePlugin.PngImageFile)
assert reloaded._getexif() is None
# Test passing in exif

View File

@ -59,17 +59,21 @@ def test_invalid_file() -> None:
def test_n_frames() -> None:
with Image.open("Tests/images/hopper_merged.psd") as im:
assert isinstance(im, PsdImagePlugin.PsdImageFile)
assert im.n_frames == 1
assert not im.is_animated
for path in [test_file, "Tests/images/negative_layer_count.psd"]:
with Image.open(path) as im:
assert isinstance(im, PsdImagePlugin.PsdImageFile)
assert im.n_frames == 2
assert im.is_animated
def test_eoferror() -> None:
with Image.open(test_file) as im:
assert isinstance(im, PsdImagePlugin.PsdImageFile)
# PSD seek index starts at 1 rather than 0
n_frames = im.n_frames + 1
@ -119,11 +123,13 @@ def test_rgba() -> None:
def test_negative_top_left_layer() -> None:
with Image.open("Tests/images/negative_top_left_layer.psd") as im:
assert isinstance(im, PsdImagePlugin.PsdImageFile)
assert im.layers[0][2] == (-50, -50, 50, 50)
def test_layer_skip() -> None:
with Image.open("Tests/images/five_channels.psd") as im:
assert isinstance(im, PsdImagePlugin.PsdImageFile)
assert im.n_frames == 1
@ -175,5 +181,6 @@ def test_crashes(test_file: str, raises: type[Exception]) -> None:
def test_layer_crashes(test_file: str) -> None:
with open(test_file, "rb") as f:
with Image.open(f) as im:
assert isinstance(im, PsdImagePlugin.PsdImageFile)
with pytest.raises(SyntaxError):
im.layers

View File

@ -71,24 +71,26 @@ def test_invalid_file() -> None:
SgiImagePlugin.SgiImageFile(invalid_file)
def test_write(tmp_path: Path) -> None:
def roundtrip(img: Image.Image) -> None:
out = tmp_path / "temp.sgi"
img.save(out, format="sgi")
def roundtrip(img: Image.Image, tmp_path: Path) -> None:
out = tmp_path / "temp.sgi"
img.save(out, format="sgi")
assert_image_equal_tofile(img, out)
out = tmp_path / "fp.sgi"
with open(out, "wb") as fp:
img.save(fp)
assert_image_equal_tofile(img, out)
out = tmp_path / "fp.sgi"
with open(out, "wb") as fp:
img.save(fp)
assert_image_equal_tofile(img, out)
assert not fp.closed
assert not fp.closed
for mode in ("L", "RGB", "RGBA"):
roundtrip(hopper(mode))
@pytest.mark.parametrize("mode", ("L", "RGB", "RGBA"))
def test_write(mode: str, tmp_path: Path) -> None:
roundtrip(hopper(mode), tmp_path)
# Test 1 dimension for an L mode image
roundtrip(Image.new("L", (10, 1)))
def test_write_L_mode_1_dimension(tmp_path: Path) -> None:
roundtrip(Image.new("L", (10, 1)), tmp_path)
def test_write16(tmp_path: Path) -> None:

View File

@ -96,6 +96,7 @@ def test_tell() -> None:
def test_n_frames() -> None:
with Image.open(TEST_FILE) as im:
assert isinstance(im, SpiderImagePlugin.SpiderImageFile)
assert im.n_frames == 1
assert not im.is_animated

View File

@ -1,8 +1,6 @@
from __future__ import annotations
import os
from glob import glob
from itertools import product
from pathlib import Path
import pytest
@ -15,14 +13,27 @@ _TGA_DIR = os.path.join("Tests", "images", "tga")
_TGA_DIR_COMMON = os.path.join(_TGA_DIR, "common")
_MODES = ("L", "LA", "P", "RGB", "RGBA")
_ORIGINS = ("tl", "bl")
_ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1}
@pytest.mark.parametrize("mode", _MODES)
def test_sanity(mode: str, tmp_path: Path) -> None:
@pytest.mark.parametrize(
"size_mode",
(
("1x1", "L"),
("200x32", "L"),
("200x32", "LA"),
("200x32", "P"),
("200x32", "RGB"),
("200x32", "RGBA"),
),
)
@pytest.mark.parametrize("origin", _ORIGINS)
@pytest.mark.parametrize("rle", (True, False))
def test_sanity(
size_mode: tuple[str, str], origin: str, rle: str, tmp_path: Path
) -> None:
def roundtrip(original_im: Image.Image) -> None:
out = tmp_path / "temp.tga"
@ -36,33 +47,26 @@ def test_sanity(mode: str, tmp_path: Path) -> None:
assert_image_equal(saved_im, original_im)
png_paths = glob(os.path.join(_TGA_DIR_COMMON, f"*x*_{mode.lower()}.png"))
size, mode = size_mode
png_path = os.path.join(_TGA_DIR_COMMON, size + "_" + mode.lower() + ".png")
with Image.open(png_path) as reference_im:
assert reference_im.mode == mode
for png_path in png_paths:
with Image.open(png_path) as reference_im:
assert reference_im.mode == mode
path_no_ext = os.path.splitext(png_path)[0]
tga_path = "{}_{}_{}.tga".format(path_no_ext, origin, "rle" if rle else "raw")
path_no_ext = os.path.splitext(png_path)[0]
for origin, rle in product(_ORIGINS, (True, False)):
tga_path = "{}_{}_{}.tga".format(
path_no_ext, origin, "rle" if rle else "raw"
)
with Image.open(tga_path) as original_im:
assert original_im.format == "TGA"
assert original_im.get_format_mimetype() == "image/x-tga"
if rle:
assert original_im.info["compression"] == "tga_rle"
assert original_im.info["orientation"] == _ORIGIN_TO_ORIENTATION[origin]
if mode == "P":
assert original_im.getpalette() == reference_im.getpalette()
with Image.open(tga_path) as original_im:
assert original_im.format == "TGA"
assert original_im.get_format_mimetype() == "image/x-tga"
if rle:
assert original_im.info["compression"] == "tga_rle"
assert (
original_im.info["orientation"]
== _ORIGIN_TO_ORIENTATION[origin]
)
if mode == "P":
assert original_im.getpalette() == reference_im.getpalette()
assert_image_equal(original_im, reference_im)
assert_image_equal(original_im, reference_im)
roundtrip(original_im)
roundtrip(original_im)
def test_palette_depth_8() -> None:

View File

@ -9,7 +9,13 @@ from types import ModuleType
import pytest
from PIL import Image, ImageFile, TiffImagePlugin, UnidentifiedImageError
from PIL import (
Image,
ImageFile,
JpegImagePlugin,
TiffImagePlugin,
UnidentifiedImageError,
)
from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION
from .helper import (
@ -113,6 +119,7 @@ class TestFileTiff:
with Image.open("Tests/images/hopper_bigtiff.tif") as im:
outfile = tmp_path / "temp.tif"
assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2)
def test_bigtiff_save(self, tmp_path: Path) -> None:
@ -121,11 +128,13 @@ class TestFileTiff:
im.save(outfile, big_tiff=True)
with Image.open(outfile) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2._bigtiff is True
im.save(outfile, save_all=True, append_images=[im], big_tiff=True)
with Image.open(outfile) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2._bigtiff is True
def test_seek_too_large(self) -> None:
@ -140,6 +149,8 @@ class TestFileTiff:
def test_xyres_tiff(self) -> None:
filename = "Tests/images/pil168.tif"
with Image.open(filename) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
# legacy api
assert isinstance(im.tag[X_RESOLUTION][0], tuple)
assert isinstance(im.tag[Y_RESOLUTION][0], tuple)
@ -153,6 +164,8 @@ class TestFileTiff:
def test_xyres_fallback_tiff(self) -> None:
filename = "Tests/images/compression.tif"
with Image.open(filename) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
# v2 api
assert isinstance(im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational)
assert isinstance(im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational)
@ -167,6 +180,8 @@ class TestFileTiff:
def test_int_resolution(self) -> None:
filename = "Tests/images/pil168.tif"
with Image.open(filename) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
# Try to read a file where X,Y_RESOLUTION are ints
im.tag_v2[X_RESOLUTION] = 71
im.tag_v2[Y_RESOLUTION] = 71
@ -181,6 +196,7 @@ class TestFileTiff:
with Image.open(
"Tests/images/hopper_float_dpi_" + str(resolution_unit) + ".tif"
) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2.get(RESOLUTION_UNIT) == resolution_unit
assert im.info["dpi"] == (dpi, dpi)
@ -198,6 +214,7 @@ class TestFileTiff:
with Image.open("Tests/images/10ct_32bit_128.tiff") as im:
im.save(b, format="tiff", resolution=123.45)
with Image.open(b) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2[X_RESOLUTION] == 123.45
assert im.tag_v2[Y_RESOLUTION] == 123.45
@ -213,10 +230,12 @@ class TestFileTiff:
TiffImagePlugin.PREFIXES.pop()
def test_bad_exif(self) -> None:
with Image.open("Tests/images/hopper_bad_exif.jpg") as i:
with Image.open("Tests/images/hopper_bad_exif.jpg") as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
# Should not raise struct.error.
with pytest.warns(UserWarning):
i._getexif()
im._getexif()
def test_save_rgba(self, tmp_path: Path) -> None:
im = hopper("RGBA")
@ -307,11 +326,13 @@ class TestFileTiff:
)
def test_n_frames(self, path: str, n_frames: int) -> None:
with Image.open(path) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.n_frames == n_frames
assert im.is_animated == (n_frames != 1)
def test_eoferror(self) -> None:
with Image.open("Tests/images/multipage-lastframe.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
n_frames = im.n_frames
# Test seeking past the last frame
@ -355,19 +376,24 @@ class TestFileTiff:
def test_frame_order(self) -> None:
# A frame can't progress to itself after reading
with Image.open("Tests/images/multipage_single_frame_loop.tiff") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.n_frames == 1
# A frame can't progress to a frame that has already been read
with Image.open("Tests/images/multipage_multiple_frame_loop.tiff") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.n_frames == 2
# Frames don't have to be in sequence
with Image.open("Tests/images/multipage_out_of_order.tiff") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.n_frames == 3
def test___str__(self) -> None:
filename = "Tests/images/pil136.tiff"
with Image.open(filename) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
# Act
ret = str(im.ifd)
@ -378,6 +404,8 @@ class TestFileTiff:
# Arrange
filename = "Tests/images/pil136.tiff"
with Image.open(filename) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
# v2 interface
v2_tags = {
256: 55,
@ -417,6 +445,7 @@ class TestFileTiff:
def test__delitem__(self) -> None:
filename = "Tests/images/pil136.tiff"
with Image.open(filename) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
len_before = len(dict(im.ifd))
del im.ifd[256]
len_after = len(dict(im.ifd))
@ -449,6 +478,7 @@ class TestFileTiff:
def test_ifd_tag_type(self) -> None:
with Image.open("Tests/images/ifd_tag_type.tiff") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert 0x8825 in im.tag_v2
def test_exif(self, tmp_path: Path) -> None:
@ -537,6 +567,7 @@ class TestFileTiff:
im = hopper(mode)
im.save(filename, tiffinfo={262: 0})
with Image.open(filename) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[262] == 0
assert_image_equal(im, reloaded)
@ -615,6 +646,8 @@ class TestFileTiff:
filename = tmp_path / "temp.tif"
hopper("RGB").save(filename, "TIFF", **kwargs)
with Image.open(filename) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
# legacy interface
assert im.tag[X_RESOLUTION][0][0] == 72
assert im.tag[Y_RESOLUTION][0][0] == 36
@ -701,6 +734,7 @@ class TestFileTiff:
def test_planar_configuration_save(self, tmp_path: Path) -> None:
infile = "Tests/images/tiff_tiled_planar_raw.tif"
with Image.open(infile) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im._planar_configuration == 2
outfile = tmp_path / "temp.tif"
@ -733,6 +767,7 @@ class TestFileTiff:
mp.seek(0, os.SEEK_SET)
with Image.open(mp) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.n_frames == 3
# Test appending images
@ -743,6 +778,7 @@ class TestFileTiff:
mp.seek(0, os.SEEK_SET)
with Image.open(mp) as reread:
assert isinstance(reread, TiffImagePlugin.TiffImageFile)
assert reread.n_frames == 3
# Test appending using a generator
@ -754,6 +790,7 @@ class TestFileTiff:
mp.seek(0, os.SEEK_SET)
with Image.open(mp) as reread:
assert isinstance(reread, TiffImagePlugin.TiffImageFile)
assert reread.n_frames == 3
def test_save_all_progress(self) -> None:
@ -915,6 +952,7 @@ class TestFileTiff:
def test_get_photoshop_blocks(self) -> None:
with Image.open("Tests/images/lab.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert list(im.get_photoshop_blocks().keys()) == [
1061,
1002,

View File

@ -61,6 +61,7 @@ def test_rt_metadata(tmp_path: Path) -> None:
img.save(f, tiffinfo=info)
with Image.open(f) as loaded:
assert isinstance(loaded, TiffImagePlugin.TiffImageFile)
assert loaded.tag[ImageJMetaDataByteCounts] == (len(bin_data),)
assert loaded.tag_v2[ImageJMetaDataByteCounts] == (len(bin_data),)
@ -80,12 +81,14 @@ def test_rt_metadata(tmp_path: Path) -> None:
info[ImageJMetaDataByteCounts] = (8, len(bin_data) - 8)
img.save(f, tiffinfo=info)
with Image.open(f) as loaded:
assert isinstance(loaded, TiffImagePlugin.TiffImageFile)
assert loaded.tag[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8)
assert loaded.tag_v2[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8)
def test_read_metadata() -> None:
with Image.open("Tests/images/hopper_g4.tif") as img:
assert isinstance(img, TiffImagePlugin.TiffImageFile)
assert {
"YResolution": IFDRational(4294967295, 113653537),
"PlanarConfiguration": 1,
@ -128,6 +131,7 @@ def test_read_metadata() -> None:
def test_write_metadata(tmp_path: Path) -> None:
"""Test metadata writing through the python code"""
with Image.open("Tests/images/hopper.tif") as img:
assert isinstance(img, TiffImagePlugin.TiffImageFile)
f = tmp_path / "temp.tiff"
del img.tag[278]
img.save(f, tiffinfo=img.tag)
@ -135,6 +139,7 @@ def test_write_metadata(tmp_path: Path) -> None:
original = img.tag_v2.named()
with Image.open(f) as loaded:
assert isinstance(loaded, TiffImagePlugin.TiffImageFile)
reloaded = loaded.tag_v2.named()
ignored = ["StripByteCounts", "RowsPerStrip", "PageNumber", "StripOffsets"]
@ -165,6 +170,7 @@ def test_write_metadata(tmp_path: Path) -> None:
def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None:
out = tmp_path / "temp.tiff"
with Image.open("Tests/images/hopper.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
info = im.tag_v2
del info[278]
@ -178,6 +184,7 @@ def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None:
im.save(out, tiffinfo=info)
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] == TiffTags.LONG
@ -231,6 +238,7 @@ def test_writing_other_types_to_ascii(
im.save(out, tiffinfo=info)
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[271] == expected
@ -248,6 +256,7 @@ def test_writing_other_types_to_bytes(value: int | IFDRational, tmp_path: Path)
im.save(out, tiffinfo=info)
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[700] == b"\x01"
@ -267,6 +276,7 @@ def test_writing_other_types_to_undefined(
im.save(out, tiffinfo=info)
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[33723] == b"1"
@ -311,6 +321,7 @@ def test_iccprofile_binary() -> None:
# but probably won't be able to save it.
with Image.open("Tests/images/hopper.iccprofile_binary.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2.tagtype[34675] == 1
assert im.info["icc_profile"]
@ -336,6 +347,7 @@ def test_exif_div_zero(tmp_path: Path) -> None:
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert 0 == reloaded.tag_v2[41988].numerator
assert 0 == reloaded.tag_v2[41988].denominator
@ -355,6 +367,7 @@ def test_ifd_unsigned_rational(tmp_path: Path) -> None:
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert max_long == reloaded.tag_v2[41493].numerator
assert 1 == reloaded.tag_v2[41493].denominator
@ -367,6 +380,7 @@ def test_ifd_unsigned_rational(tmp_path: Path) -> None:
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert max_long == reloaded.tag_v2[41493].numerator
assert 1 == reloaded.tag_v2[41493].denominator
@ -385,6 +399,7 @@ def test_ifd_signed_rational(tmp_path: Path) -> None:
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert numerator == reloaded.tag_v2[37380].numerator
assert denominator == reloaded.tag_v2[37380].denominator
@ -397,6 +412,7 @@ def test_ifd_signed_rational(tmp_path: Path) -> None:
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert numerator == reloaded.tag_v2[37380].numerator
assert denominator == reloaded.tag_v2[37380].denominator
@ -410,6 +426,7 @@ def test_ifd_signed_rational(tmp_path: Path) -> None:
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert 2**31 - 1 == reloaded.tag_v2[37380].numerator
assert -1 == reloaded.tag_v2[37380].denominator
@ -424,6 +441,7 @@ def test_ifd_signed_long(tmp_path: Path) -> None:
im.save(out, tiffinfo=info, compression="raw")
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert reloaded.tag_v2[37000] == -60000
@ -444,11 +462,13 @@ def test_empty_values() -> None:
def test_photoshop_info(tmp_path: Path) -> None:
with Image.open("Tests/images/issue_2278.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert len(im.tag_v2[34377]) == 70
assert isinstance(im.tag_v2[34377], bytes)
out = tmp_path / "temp.tiff"
im.save(out)
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert len(reloaded.tag_v2[34377]) == 70
assert isinstance(reloaded.tag_v2[34377], bytes)

View File

@ -6,7 +6,7 @@ from pathlib import Path
import pytest
from packaging.version import parse as parse_version
from PIL import Image, features
from PIL import GifImagePlugin, Image, WebPImagePlugin, features
from .helper import (
assert_image_equal,
@ -22,10 +22,12 @@ def test_n_frames() -> None:
"""Ensure that WebP format sets n_frames and is_animated attributes correctly."""
with Image.open("Tests/images/hopper.webp") as im:
assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 1
assert not im.is_animated
with Image.open("Tests/images/iss634.webp") as im:
assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 42
assert im.is_animated
@ -37,11 +39,13 @@ def test_write_animation_L(tmp_path: Path) -> None:
"""
with Image.open("Tests/images/iss634.gif") as orig:
assert isinstance(orig, GifImagePlugin.GifImageFile)
assert orig.n_frames > 1
temp_file = tmp_path / "temp.webp"
orig.save(temp_file, save_all=True)
with Image.open(temp_file) as im:
assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == orig.n_frames
# Compare first and last frames to the original animated GIF
@ -69,6 +73,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
def check(temp_file: Path) -> None:
with Image.open(temp_file) as im:
assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 2
# Compare first frame to original
@ -127,6 +132,7 @@ def test_timestamp_and_duration(tmp_path: Path) -> None:
)
with Image.open(temp_file) as im:
assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 5
assert im.is_animated
@ -170,6 +176,7 @@ def test_seeking(tmp_path: Path) -> None:
)
with Image.open(temp_file) as im:
assert isinstance(im, WebPImagePlugin.WebPImageFile)
assert im.n_frames == 5
assert im.is_animated

View File

@ -6,7 +6,7 @@ from types import ModuleType
import pytest
from PIL import Image
from PIL import Image, WebPImagePlugin
from .helper import mark_if_feature_version, skip_unless_feature
@ -110,6 +110,7 @@ def test_read_no_exif() -> None:
test_buffer.seek(0)
with Image.open(test_buffer) as webp_image:
assert isinstance(webp_image, WebPImagePlugin.WebPImageFile)
assert not webp_image._getexif()

View File

@ -8,7 +8,7 @@ import pytest
from PIL import Image, ImageFile, WmfImagePlugin
from .helper import assert_image_similar_tofile, hopper
from .helper import assert_image_equal_tofile, assert_image_similar_tofile, hopper
def test_load_raw() -> None:
@ -44,6 +44,15 @@ def test_load_zero_inch() -> None:
pass
def test_render() -> None:
with open("Tests/images/drawing.emf", "rb") as fp:
data = fp.read()
b = BytesIO(data[:808] + b"\x00" + data[809:])
with Image.open(b) as im:
if hasattr(Image.core, "drawwmf"):
assert_image_equal_tofile(im, "Tests/images/drawing.emf")
def test_register_handler(tmp_path: Path) -> None:
class TestHandler(ImageFile.StubHandler):
methodCalled = False
@ -80,6 +89,7 @@ def test_load_float_dpi() -> None:
def test_load_set_dpi() -> None:
with Image.open("Tests/images/drawing.wmf") as im:
assert isinstance(im, WmfImagePlugin.WmfStubImageFile)
assert im.size == (82, 82)
if hasattr(Image.core, "drawwmf"):
@ -88,6 +98,22 @@ def test_load_set_dpi() -> None:
assert_image_similar_tofile(im, "Tests/images/drawing_wmf_ref_144.png", 2.1)
with Image.open("Tests/images/drawing.emf") as im:
assert im.size == (1625, 1625)
if not hasattr(Image.core, "drawwmf"):
return
assert isinstance(im, WmfImagePlugin.WmfStubImageFile)
im.load(im.info["dpi"])
assert im.size == (1625, 1625)
with Image.open("Tests/images/drawing.emf") as im:
assert isinstance(im, WmfImagePlugin.WmfStubImageFile)
im.load((72, 144))
assert im.size == (82, 164)
assert_image_equal_tofile(im, "Tests/images/drawing_emf_ref_72_144.png")
@pytest.mark.parametrize("ext", (".wmf", ".emf"))
def test_save(ext: str, tmp_path: Path) -> None:

View File

@ -30,6 +30,7 @@ def test_invalid_file() -> None:
def test_load_read() -> None:
# Arrange
with Image.open(TEST_FILE) as im:
assert isinstance(im, XpmImagePlugin.XpmImageFile)
dummy_bytes = 1
# Act

View File

@ -230,10 +230,10 @@ class TestImage:
assert_image_similar(im, reloaded, 20)
def test_unknown_extension(self, tmp_path: Path) -> None:
im = hopper()
temp_file = tmp_path / "temp.unknown"
with pytest.raises(ValueError):
im.save(temp_file)
with hopper() as im:
with pytest.raises(ValueError):
im.save(temp_file)
def test_internals(self) -> None:
im = Image.new("L", (100, 100))
@ -258,6 +258,15 @@ class TestImage:
assert im.readonly
im.save(temp_file)
def test_save_without_changing_readonly(self, tmp_path: Path) -> None:
temp_file = tmp_path / "temp.bmp"
with Image.open("Tests/images/rgb32bf-rgba.bmp") as im:
assert im.readonly
im.save(temp_file)
assert im.readonly
def test_dump(self, tmp_path: Path) -> None:
im = Image.new("L", (10, 10))
im._dump(str(tmp_path / "temp_L.ppm"))

View File

@ -1704,7 +1704,7 @@ def test_discontiguous_corners_polygon() -> None:
BLACK,
)
expected = os.path.join(IMAGES_PATH, "discontiguous_corners_polygon.png")
assert_image_similar_tofile(img, expected, 1)
assert_image_equal_tofile(img, expected)
def test_polygon2() -> None:

View File

@ -131,6 +131,26 @@ class TestImageFile:
assert_image_equal(im1, im2)
def test_tile_size(self) -> None:
with open("Tests/images/hopper.tif", "rb") as im_fp:
data = im_fp.read()
reads = []
class FP(BytesIO):
def read(self, size: int | None = None) -> bytes:
reads.append(size)
return super().read(size)
fp = FP(data)
with Image.open(fp) as im:
assert len(im.tile) == 7
im.load()
# Despite multiple tiles, assert only one tile caused a read of maxblock size
assert reads.count(im.decodermaxblock) == 1
def test_raise_oserror(self) -> None:
with pytest.warns(DeprecationWarning):
with pytest.raises(OSError):

View File

@ -40,8 +40,11 @@ class TestImageGrab:
@pytest.mark.skipif(Image.core.HAVE_XCB, reason="tests missing XCB")
def test_grab_no_xcb(self) -> None:
if sys.platform not in ("win32", "darwin") and not shutil.which(
"gnome-screenshot"
if (
sys.platform not in ("win32", "darwin")
and not shutil.which("gnome-screenshot")
and not shutil.which("grim")
and not shutil.which("spectacle")
):
with pytest.raises(OSError) as e:
ImageGrab.grab()
@ -57,6 +60,13 @@ class TestImageGrab:
ImageGrab.grab(xdisplay="error.test:0.0")
assert str(e.value).startswith("X connection failed")
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
def test_grab_invalid_handle(self) -> None:
with pytest.raises(OSError, match="unable to get device context for handle"):
ImageGrab.grab(window=-1)
with pytest.raises(OSError, match="screen grab failed"):
ImageGrab.grab(window=0)
def test_grabclipboard(self) -> None:
if sys.platform == "darwin":
subprocess.call(["screencapture", "-cx"])

View File

@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from PIL import Image, ImageSequence, TiffImagePlugin
from PIL import Image, ImageSequence, PsdImagePlugin, TiffImagePlugin
from .helper import assert_image_equal, hopper, skip_unless_feature
@ -31,6 +31,7 @@ def test_sanity(tmp_path: Path) -> None:
def test_iterator() -> None:
with Image.open("Tests/images/multipage.tiff") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
i = ImageSequence.Iterator(im)
for index in range(im.n_frames):
assert i[index] == next(i)
@ -42,6 +43,7 @@ def test_iterator() -> None:
def test_iterator_min_frame() -> None:
with Image.open("Tests/images/hopper.psd") as im:
assert isinstance(im, PsdImagePlugin.PsdImageFile)
i = ImageSequence.Iterator(im)
for index in range(1, im.n_frames):
assert i[index] == next(i)

View File

@ -81,6 +81,7 @@ def test_pickle_jpeg() -> None:
unpickled_image = pickle.loads(pickle.dumps(image))
# Assert
assert unpickled_image.filename == "Tests/images/hopper.jpg"
assert len(unpickled_image.layer) == 3
assert unpickled_image.layers == 3

112
Tests/test_pyarrow.py Normal file
View File

@ -0,0 +1,112 @@
from __future__ import annotations
from typing import Any # undone
import pytest
from PIL import Image
from .helper import (
assert_deep_equal,
assert_image_equal,
hopper,
)
pyarrow = pytest.importorskip("pyarrow", reason="PyArrow not installed")
TEST_IMAGE_SIZE = (10, 10)
def _test_img_equals_pyarray(
img: Image.Image, arr: Any, mask: list[int] | None
) -> None:
assert img.height * img.width == len(arr)
px = img.load()
assert px is not None
for x in range(0, img.size[0], int(img.size[0] / 10)):
for y in range(0, img.size[1], int(img.size[1] / 10)):
if mask:
for ix, elt in enumerate(mask):
pixel = px[x, y]
assert isinstance(pixel, tuple)
assert pixel[ix] == arr[y * img.width + x].as_py()[elt]
else:
assert_deep_equal(px[x, y], arr[y * img.width + x].as_py())
# really hard to get a non-nullable list type
fl_uint8_4_type = pyarrow.field(
"_", pyarrow.list_(pyarrow.field("_", pyarrow.uint8()).with_nullable(False), 4)
).type
@pytest.mark.parametrize(
"mode, dtype, mask",
(
("L", pyarrow.uint8(), None),
("I", pyarrow.int32(), None),
("F", pyarrow.float32(), None),
("LA", fl_uint8_4_type, [0, 3]),
("RGB", fl_uint8_4_type, [0, 1, 2]),
("RGBA", fl_uint8_4_type, None),
("RGBX", fl_uint8_4_type, None),
("CMYK", fl_uint8_4_type, None),
("YCbCr", fl_uint8_4_type, [0, 1, 2]),
("HSV", fl_uint8_4_type, [0, 1, 2]),
),
)
def test_to_array(mode: str, dtype: Any, mask: list[int] | None) -> None:
img = hopper(mode)
# Resize to non-square
img = img.crop((3, 0, 124, 127))
assert img.size == (121, 127)
arr = pyarrow.array(img)
_test_img_equals_pyarray(img, arr, mask)
assert arr.type == dtype
reloaded = Image.fromarrow(arr, mode, img.size)
assert reloaded
assert_image_equal(img, reloaded)
def test_lifetime() -> None:
# valgrind shouldn't error out here.
# arrays should be accessible after the image is deleted.
img = hopper("L")
arr_1 = pyarrow.array(img)
arr_2 = pyarrow.array(img)
del img
assert arr_1.sum().as_py() > 0
del arr_1
assert arr_2.sum().as_py() > 0
del arr_2
def test_lifetime2() -> None:
# valgrind shouldn't error out here.
# img should remain after the arrays are collected.
img = hopper("L")
arr_1 = pyarrow.array(img)
arr_2 = pyarrow.array(img)
assert arr_1.sum().as_py() > 0
del arr_1
assert arr_2.sum().as_py() > 0
del arr_2
img2 = img.copy()
px = img2.load()
assert px # make mypy happy
assert isinstance(px[0, 0], int)

View File

@ -23,5 +23,11 @@ def test_pyroma() -> None:
)
else:
# Should have a perfect score
assert rating == (10, [])
# Should have a perfect score, but pyroma does not support PEP 639 yet.
assert rating == (
9,
[
"Your package does neither have a license field "
"nor any license classifiers."
],
)

View File

@ -39,6 +39,7 @@ class TestShellInjection:
shutil.copy(TEST_JPG, src_file)
with Image.open(src_file) as im:
assert isinstance(im, JpegImagePlugin.JpegImageFile)
im.load_djpeg()
@pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available")

View File

@ -72,4 +72,5 @@ def test_ifd_rational_save(
im.save(out, dpi=(res, res), compression="raw")
with Image.open(out) as reloaded:
assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
assert float(IFDRational(301, 1)) == float(reloaded.tag_v2[282])

64
depends/install_libavif.sh Executable file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -eo pipefail
version=1.2.1
./download-and-extract.sh libavif-$version https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$version.tar.gz
pushd libavif-$version
if [ $(uname) == "Darwin" ] && [ -x "$(command -v brew)" ]; then
PREFIX=$(brew --prefix)
else
PREFIX=/usr
fi
PKGCONFIG=${PKGCONFIG:-pkg-config}
LIBAVIF_CMAKE_FLAGS=()
HAS_DECODER=0
HAS_ENCODER=0
if $PKGCONFIG --exists aom; then
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=SYSTEM)
HAS_ENCODER=1
HAS_DECODER=1
fi
if $PKGCONFIG --exists dav1d; then
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_DAV1D=SYSTEM)
HAS_DECODER=1
fi
if $PKGCONFIG --exists libgav1; then
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_LIBGAV1=SYSTEM)
HAS_DECODER=1
fi
if $PKGCONFIG --exists rav1e; then
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_RAV1E=SYSTEM)
HAS_ENCODER=1
fi
if $PKGCONFIG --exists SvtAv1Enc; then
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_SVT=SYSTEM)
HAS_ENCODER=1
fi
if [ "$HAS_ENCODER" != 1 ] || [ "$HAS_DECODER" != 1 ]; then
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=LOCAL)
fi
cmake \
-DCMAKE_INSTALL_PREFIX=$PREFIX \
-DCMAKE_INSTALL_NAME_DIR=$PREFIX/lib \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_MACOSX_RPATH=OFF \
-DAVIF_LIBSHARPYUV=LOCAL \
-DAVIF_LIBYUV=LOCAL \
"${LIBAVIF_CMAKE_FLAGS[@]}" \
.
sudo make install
popd

View File

@ -1,10 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
from livereload.compiler import shell
from livereload.task import Task
Task.add("*.rst", shell("make html"))
Task.add("*/*.rst", shell("make html"))
Task.add("Makefile", shell("make html"))
Task.add("conf.py", shell("make html"))

View File

@ -20,8 +20,8 @@ help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " htmlview to open the index page built by the html target in your browser"
@echo " htmllive to rebuild and reload HTML files in your browser"
@echo " serve to start a local server for viewing docs"
@echo " livehtml to start a local server for viewing docs and auto-reload on change"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@ -201,9 +201,10 @@ doctest:
htmlview: html
$(PYTHON) -c "import os, webbrowser; webbrowser.open('file://' + os.path.realpath('$(BUILDDIR)/html/index.html'))"
.PHONY: livehtml
livehtml: html
livereload $(BUILDDIR)/html -p 33233
.PHONY: htmllive
htmllive: SPHINXBUILD = $(PYTHON) -m sphinx_autobuild
htmllive: SPHINXOPTS = --open-browser --delay 0
htmllive: html
.PHONY: serve
serve:

View File

@ -186,7 +186,7 @@ ExifTags.IFD.Makernote
Image.Image.get_child_images()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.2.0
.. deprecated:: 11.2.1
``Image.Image.get_child_images()`` has been deprecated. and will be removed in Pillow
13 (2026-10-15). It will be moved to ``ImageFile.ImageFile.get_child_images()``. The

View File

@ -30,35 +30,35 @@ image. Each pixel uses the full range of the bit depth. So a 1-bit pixel has a r
INT32 and a 32-bit floating point pixel has the range of FLOAT32. The current release
supports the following standard modes:
* ``1`` (1-bit pixels, black and white, stored with one pixel per byte)
* ``L`` (8-bit pixels, grayscale)
* ``P`` (8-bit pixels, mapped to any other mode using a color palette)
* ``RGB`` (3x8-bit pixels, true color)
* ``RGBA`` (4x8-bit pixels, true color with transparency mask)
* ``CMYK`` (4x8-bit pixels, color separation)
* ``YCbCr`` (3x8-bit pixels, color video format)
* ``1`` (1-bit pixels, black and white, stored with one pixel per byte)
* ``L`` (8-bit pixels, grayscale)
* ``P`` (8-bit pixels, mapped to any other mode using a color palette)
* ``RGB`` (3x8-bit pixels, true color)
* ``RGBA`` (4x8-bit pixels, true color with transparency mask)
* ``CMYK`` (4x8-bit pixels, color separation)
* ``YCbCr`` (3x8-bit pixels, color video format)
* Note that this refers to the JPEG, and not the ITU-R BT.2020, standard
* Note that this refers to the JPEG, and not the ITU-R BT.2020, standard
* ``LAB`` (3x8-bit pixels, the L*a*b color space)
* ``HSV`` (3x8-bit pixels, Hue, Saturation, Value color space)
* ``LAB`` (3x8-bit pixels, the L*a*b color space)
* ``HSV`` (3x8-bit pixels, Hue, Saturation, Value color space)
* Hue's range of 0-255 is a scaled version of 0 degrees <= Hue < 360 degrees
* Hue's range of 0-255 is a scaled version of 0 degrees <= Hue < 360 degrees
* ``I`` (32-bit signed integer pixels)
* ``F`` (32-bit floating point pixels)
* ``I`` (32-bit signed integer pixels)
* ``F`` (32-bit floating point pixels)
Pillow also provides limited support for a few additional modes, including:
* ``LA`` (L with alpha)
* ``PA`` (P with alpha)
* ``RGBX`` (true color with padding)
* ``RGBa`` (true color with premultiplied alpha)
* ``La`` (L with premultiplied alpha)
* ``I;16`` (16-bit unsigned integer pixels)
* ``I;16L`` (16-bit little endian unsigned integer pixels)
* ``I;16B`` (16-bit big endian unsigned integer pixels)
* ``I;16N`` (16-bit native endian unsigned integer pixels)
* ``LA`` (L with alpha)
* ``PA`` (P with alpha)
* ``RGBX`` (true color with padding)
* ``RGBa`` (true color with premultiplied alpha)
* ``La`` (L with premultiplied alpha)
* ``I;16`` (16-bit unsigned integer pixels)
* ``I;16L`` (16-bit little endian unsigned integer pixels)
* ``I;16B`` (16-bit big endian unsigned integer pixels)
* ``I;16N`` (16-bit native endian unsigned integer pixels)
Premultiplied alpha is where the values for each other channel have been
multiplied by the alpha. For example, an RGBA pixel of ``(10, 20, 30, 127)``

View File

@ -24,6 +24,83 @@ present, and the :py:attr:`~PIL.Image.Image.format` attribute will be ``None``.
Fully supported formats
-----------------------
AVIF
^^^^
Pillow reads and writes AVIF files, including AVIF sequence images.
It is only possible to save 8-bit AVIF images, and all AVIF images are decoded
as 8-bit RGB(A).
The :py:meth:`~PIL.Image.Image.save` method supports the following options:
**quality**
Integer, 0-100, defaults to 75. 0 gives the smallest size and poorest
quality, 100 the largest size and best quality.
**subsampling**
If present, sets the subsampling for the encoder. Defaults to ``4:2:0``.
Options include:
* ``4:0:0``
* ``4:2:0``
* ``4:2:2``
* ``4:4:4``
**speed**
Quality/speed trade-off (0=slower/better, 10=fastest). Defaults to 6.
**max_threads**
Limit the number of active threads used. By default, there is no limit. If the aom
codec is used, there is a maximum of 64.
**range**
YUV range, either "full" or "limited". Defaults to "full".
**codec**
AV1 codec to use for encoding. Specific values are "aom", "rav1e", and
"svt", presuming the chosen codec is available. Defaults to "auto", which
will choose the first available codec in the order of the preceding list.
**tile_rows** / **tile_cols**
For tile encoding, the (log 2) number of tile rows and columns to use.
Valid values are 0-6, default 0. Ignored if "autotiling" is set to true.
**autotiling**
Split the image up to allow parallelization. Enabled automatically if "tile_rows"
and "tile_cols" both have their default values of zero.
**alpha_premultiplied**
Encode the image with premultiplied alpha. Defaults to ``False``.
**advanced**
Codec specific options.
**icc_profile**
The ICC Profile to include in the saved file.
**exif**
The exif data to include in the saved file.
**xmp**
The XMP data to include in the saved file.
Saving sequences
~~~~~~~~~~~~~~~~
When calling :py:meth:`~PIL.Image.Image.save` to write an AVIF file, by default
only the first frame of a multiframe image will be saved. If the ``save_all``
argument is present and true, then all frames will be saved, and the following
options will also be available.
**append_images**
A list of images to append as additional frames. Each of the
images in the list can be single or multiframe images.
**duration**
The display duration of each frame, in milliseconds. Pass a single
integer for a constant duration, or a list or tuple to set the
duration for each frame separately.
BLP
^^^
@ -93,7 +170,7 @@ DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode.
in ``P`` mode.
.. versionadded:: 11.2.0
.. versionadded:: 11.2.1
DXT1, DXT3, DXT5, BC2, BC3 and BC5 pixel formats can be saved::
im.save(out, pixel_format="DXT1")
@ -235,13 +312,14 @@ following options are available::
im.save(out, save_all=True, append_images=[im1, im2, ...])
**save_all**
If present and true, all frames of the image will be saved. If
not, then only the first frame of a multiframe image will be saved.
If present and true, or if ``append_images`` is not empty, all frames of
the image will be saved. Otherwise, only the first frame of a multiframe
image will be saved.
**append_images**
A list of images to append as additional frames. Each of the
images in the list can be single or multiframe images.
This is currently supported for GIF, PDF, PNG, TIFF, and WebP.
This is supported for AVIF, GIF, PDF, PNG, TIFF and WebP.
It is also supported for ICO and ICNS. If images are passed in of relevant
sizes, they will be used instead of scaling down the main image.
@ -723,8 +801,8 @@ Saving
When calling :py:meth:`~PIL.Image.Image.save` to write an MPO file, by default
only the first frame of a multiframe image will be saved. If the ``save_all``
argument is present and true, then all frames will be saved, and the following
option will also be available.
argument is present and true, or if ``append_images`` is not empty, all frames
will be saved.
**append_images**
A list of images to append as additional pictures. Each of the
@ -934,7 +1012,8 @@ Saving
When calling :py:meth:`~PIL.Image.Image.save`, by default only a single frame PNG file
will be saved. To save an APNG file (including a single frame APNG), the ``save_all``
parameter must be set to ``True``. The following parameters can also be set:
parameter should be set to ``True`` or ``append_images`` should not be empty. The
following parameters can also be set:
**default_image**
Boolean value, specifying whether or not the base image is a default image.
@ -1163,7 +1242,8 @@ Saving
The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments:
**save_all**
If true, Pillow will save all frames of the image to a multiframe tiff document.
If true, or if ``append_images`` is not empty, Pillow will save all frames of the
image to a multiframe tiff document.
.. versionadded:: 3.4.0
@ -1313,8 +1393,8 @@ Saving sequences
When calling :py:meth:`~PIL.Image.Image.save` to write a WebP file, by default
only the first frame of a multiframe image will be saved. If the ``save_all``
argument is present and true, then all frames will be saved, and the following
options will also be available.
argument is present and true, or if ``append_images`` is not empty, all frames
will be saved, and the following options will also be available.
**append_images**
A list of images to append as additional frames. Each of the
@ -1584,6 +1664,11 @@ The :py:meth:`~PIL.Image.open` method sets the following
Transparency color index. This key is omitted if the image is not
transparent.
XV Thumbnails
^^^^^^^^^^^^^
Pillow can read XV thumbnail files.
Write-only formats
------------------
@ -1616,15 +1701,14 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum
**save_all**
If a multiframe image is used, by default, only the first image will be saved.
To save all frames, each frame to a separate page of the PDF, the ``save_all``
parameter must be present and set to ``True``.
parameter should be present and set to ``True`` or ``append_images`` should not be
empty.
.. versionadded:: 3.0.0
**append_images**
A list of :py:class:`PIL.Image.Image` objects to append as additional pages. Each
of the images in the list can be single or multiframe images. The ``save_all``
parameter must be present and set to ``True`` in conjunction with
``append_images``.
of the images in the list can be single or multiframe images.
.. versionadded:: 4.2.0
@ -1690,11 +1774,6 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum
.. versionadded:: 5.3.0
XV Thumbnails
^^^^^^^^^^^^^
Pillow can read XV thumbnail files.
Identify-only formats
---------------------

View File

@ -534,7 +534,6 @@ You can create animated GIFs with Pillow, e.g.
# Save the images as an animated GIF
images[0].save(
"animated_hopper.gif",
save_all=True,
append_images=images[1:],
duration=500, # duration of each frame in milliseconds
loop=0, # loop forever

View File

@ -89,6 +89,14 @@ Many of Pillow's features require external libraries:
* **libxcb** provides X11 screengrab support.
* **libavif** provides support for the AVIF format.
* Pillow requires libavif version **1.0.0** or greater.
* libavif is merely an API that wraps AVIF codecs. If you are compiling
libavif from source, you will also need to install both an AVIF encoder
and decoder, such as rav1e and dav1d, or libaom, which both encodes and
decodes AVIF images.
.. tab:: Linux
If you didn't build Python from source, make sure you have Python's
@ -117,6 +125,12 @@ Many of Pillow's features require external libraries:
To install libraqm, ``sudo apt-get install meson`` and then see
``depends/install_raqm.sh``.
Build prerequisites for libavif on Ubuntu are installed with::
sudo apt-get install cmake ninja-build nasm
Then see ``depends/install_libavif.sh`` to build and install libavif.
Prerequisites are installed on recent **Red Hat**, **CentOS** or **Fedora** with::
sudo dnf install libtiff-devel libjpeg-devel openjpeg2-devel zlib-devel \
@ -148,7 +162,15 @@ Many of Pillow's features require external libraries:
The easiest way to install external libraries is via `Homebrew
<https://brew.sh/>`_. After you install Homebrew, run::
brew install libjpeg libraqm libtiff little-cms2 openjpeg webp
brew install libavif libjpeg libraqm libtiff little-cms2 openjpeg webp
If you would like to use libavif with more codecs than just aom, then
instead of installing libavif through Homebrew directly, you can use
Homebrew to install libavif's build dependencies::
brew install aom dav1d rav1e svt-av1
Then see ``depends/install_libavif.sh`` to install libavif.
.. tab:: Windows
@ -187,7 +209,8 @@ Many of Pillow's features require external libraries:
mingw-w64-x86_64-libwebp \
mingw-w64-x86_64-openjpeg2 \
mingw-w64-x86_64-libimagequant \
mingw-w64-x86_64-libraqm
mingw-w64-x86_64-libraqm \
mingw-w64-x86_64-libavif
.. tab:: FreeBSD
@ -199,7 +222,7 @@ Many of Pillow's features require external libraries:
Prerequisites are installed on **FreeBSD 10 or 11** with::
sudo pkg install jpeg-turbo tiff webp lcms2 freetype2 openjpeg harfbuzz fribidi libxcb
sudo pkg install jpeg-turbo tiff webp lcms2 freetype2 openjpeg harfbuzz fribidi libxcb libavif
Then see ``depends/install_raqm_cmake.sh`` to install libraqm.
@ -261,14 +284,16 @@ Build Options
* Config settings: ``-C zlib=disable``, ``-C jpeg=disable``,
``-C tiff=disable``, ``-C freetype=disable``, ``-C raqm=disable``,
``-C lcms=disable``, ``-C webp=disable``,
``-C jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``.
``-C jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``,
``-C avif=disable``.
Disable building the corresponding feature even if the development
libraries are present on the building machine.
* Config settings: ``-C zlib=enable``, ``-C jpeg=enable``,
``-C tiff=enable``, ``-C freetype=enable``, ``-C raqm=enable``,
``-C lcms=enable``, ``-C webp=enable``,
``-C jpeg2000=enable``, ``-C imagequant=enable``, ``-C xcb=enable``.
``-C jpeg2000=enable``, ``-C imagequant=enable``, ``-C xcb=enable``,
``-C avif=enable``.
Require that the corresponding feature is built. The build will raise
an exception if the libraries are not found. Tcl and Tk must be used
together.

View File

@ -23,7 +23,7 @@ These platforms are built and tested for every change.
+----------------------------------+----------------------------+---------------------+
| Amazon Linux 2023 | 3.9 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Arch | 3.12 | x86-64 |
| Arch | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| CentOS Stream 9 | 3.9 | x86-64 |
+----------------------------------+----------------------------+---------------------+
@ -31,10 +31,10 @@ These platforms are built and tested for every change.
+----------------------------------+----------------------------+---------------------+
| Debian 12 Bookworm | 3.11 | x86, x86-64 |
+----------------------------------+----------------------------+---------------------+
| Fedora 40 | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Fedora 41 | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Fedora 42 | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Gentoo | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| macOS 13 Ventura | 3.9 | x86-64 |
@ -73,7 +73,7 @@ These platforms have been reported to work at the versions mentioned.
| Operating system | | Tested Python | | Latest tested | | Tested |
| | | versions | | Pillow version | | processors |
+==================================+============================+==================+==============+
| macOS 15 Sequoia | 3.9, 3.10, 3.11, 3.12, 3.13| 11.1.0 |arm |
| macOS 15 Sequoia | 3.9, 3.10, 3.11, 3.12, 3.13| 11.2.1 |arm |
| +----------------------------+------------------+ |
| | 3.8 | 10.4.0 | |
+----------------------------------+----------------------------+------------------+--------------+

View File

@ -79,6 +79,7 @@ Constructing images
.. autofunction:: new
.. autofunction:: fromarray
.. autofunction:: fromarrow
.. autofunction:: frombytes
.. autofunction:: frombuffer
@ -370,6 +371,8 @@ Protocols
.. autoclass:: SupportsArrayInterface
:show-inheritance:
.. autoclass:: SupportsArrowArrayInterface
:show-inheritance:
.. autoclass:: SupportsGetData
:show-inheritance:

View File

@ -286,6 +286,14 @@ can be easily displayed in a chromaticity diagram, for example).
The value is in the format ``((X, Y, Z), (x, y, Y))``, if available.
.. py:attribute:: media_white_point
:type: tuple[tuple[float, float, float], tuple[float, float, float]] | None
This tag specifies the media white point and is used for
generating absolute colorimetry.
The value is in the format ``((X, Y, Z), (x, y, Y))``, if available.
.. py:attribute:: media_white_point_temperature
:type: float | None

View File

@ -391,7 +391,7 @@ Methods
the relative alignment of lines. Use the ``anchor`` parameter to
specify the alignment to ``xy``.
.. versionadded:: 11.2.0 ``"justify"``
.. versionadded:: 11.2.1 ``"justify"``
:param direction: Direction of the text. It can be ``"rtl"`` (right to
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.
@ -462,7 +462,7 @@ Methods
the relative alignment of lines. Use the ``anchor`` parameter to
specify the alignment to ``xy``.
.. versionadded:: 11.2.0 ``"justify"``
.. versionadded:: 11.2.1 ``"justify"``
:param direction: Direction of the text. It can be ``"rtl"`` (right to
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.
@ -609,7 +609,7 @@ Methods
the relative alignment of lines. Use the ``anchor`` parameter to
specify the alignment to ``xy``.
.. versionadded:: 11.2.0 ``"justify"``
.. versionadded:: 11.2.1 ``"justify"``
:param direction: Direction of the text. It can be ``"rtl"`` (right to
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.
@ -663,7 +663,7 @@ Methods
the relative alignment of lines. Use the ``anchor`` parameter to
specify the alignment to ``xy``.
.. versionadded:: 11.2.0 ``"justify"``
.. versionadded:: 11.2.1 ``"justify"``
:param direction: Direction of the text. It can be ``"rtl"`` (right to
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.

View File

@ -9,15 +9,16 @@ or the clipboard to a PIL image memory.
.. versionadded:: 1.1.3
.. py:function:: grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None)
.. py:function:: grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None, window=None)
Take a snapshot of the screen. The pixels inside the bounding box are returned as
an "RGBA" on macOS, or an "RGB" image otherwise. If the bounding box is omitted,
the entire screen is copied, and on macOS, it will be at 2x if on a Retina screen.
On Linux, if ``xdisplay`` is ``None`` and the default X11 display does not return
a snapshot of the screen, ``gnome-screenshot`` will be used as fallback if it is
installed. To disable this behaviour, pass ``xdisplay=""`` instead.
a snapshot of the screen, ``gnome-screenshot``, ``grim`` or ``spectacle`` will be
used as a fallback if they are installed. To disable this behaviour, pass
``xdisplay=""`` instead.
.. versionadded:: 1.1.3 (Windows), 3.0.0 (macOS), 7.1.0 (Linux)
@ -39,6 +40,11 @@ or the clipboard to a PIL image memory.
You can check X11 support using :py:func:`PIL.features.check_feature` with ``feature="xcb"``.
.. versionadded:: 7.1.0
:param window:
HWND, to capture a single window. Windows only.
.. versionadded:: 11.2.1
:return: An image
.. py:function:: grabclipboard()

View File

@ -0,0 +1,88 @@
.. _arrow-support:
=============
Arrow Support
=============
`Arrow <https://arrow.apache.org/>`__
is an in-memory data exchange format that is the spiritual
successor to the NumPy array interface. It provides for zero-copy
access to columnar data, which in our case is ``Image`` data.
The goal with Arrow is to provide native zero-copy interoperability
with any Arrow provider or consumer in the Python ecosystem.
.. warning:: Zero-copy does not mean zero allocation -- the internal
memory layout of Pillow images contains an allocation for row
pointers, so there is a non-zero, but significantly smaller than a
full-copy memory cost to reading an Arrow image.
Data Formats
============
Pillow currently supports exporting Arrow images in all modes
**except** for ``BGR;15``, ``BGR;16`` and ``BGR;24``. This is due to
line-length packing in these modes making for non-continuous memory.
For single-band images, the exported array is width*height elements,
with each pixel corresponding to the appropriate Arrow type.
For multiband images, the exported array is width*height fixed-length
four-element arrays of uint8. This is memory compatible with the raw
image storage of four bytes per pixel.
Mode ``1`` images are exported as one uint8 byte/pixel, as this is
consistent with the internal storage.
Pillow will accept, but not produce, one other format. For any
multichannel image with 32-bit storage per pixel, Pillow will accept
an array of width*height int32 elements, which will then be
interpreted using the mode-specific interpretation of the bytes.
The image mode must match the Arrow band format when reading single
channel images.
Memory Allocator
================
Pillow's default memory allocator, the :ref:`block_allocator`,
allocates up to a 16 MB block for images by default. Larger images
overflow into additional blocks. Arrow requires a single continuous
memory allocation, so images allocated in multiple blocks cannot be
exported in the Arrow format.
To enable the single block allocator::
from PIL import Image
Image.core.set_use_block_allocator(1)
Note that this is a global setting, not a per-image setting.
Unsupported Features
====================
* Table/dataframe protocol. We support a single array.
* Null markers, producing or consuming. Null values are inferred from
the mode, e.g. RGB images are stored in the first three bytes of
each 32-bit pixel, and the last byte is an implied null.
* Schema negotiation. There is an optional schema for the requested
datatype in the Arrow source interface. We ignore that
parameter.
* Array metadata.
Internal Details
================
Python Arrow C interface:
https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html
The memory that is exported from the Arrow interface is shared -- not
copied, so the lifetime of the memory allocation is no longer strictly
tied to the life of the Python object.
The core imaging struct now has a refcount associated with it, and the
lifetime of the core image struct is now divorced from the Python
image object. Creating an arrow reference to the image increments the
refcount, and the imaging struct is only released when the refcount
reaches zero.

View File

@ -1,3 +1,6 @@
.. _block_allocator:
Block Allocator
===============
@ -34,14 +37,14 @@ fresh allocation. This caching of free blocks is currently disabled by
default, but can be enabled and tweaked using three environment
variables:
* ``PILLOW_ALIGNMENT``, in bytes. Specifies the alignment of memory
allocations. Valid values are powers of 2 between 1 and
128, inclusive. Defaults to 1.
* ``PILLOW_ALIGNMENT``, in bytes. Specifies the alignment of memory
allocations. Valid values are powers of 2 between 1 and
128, inclusive. Defaults to 1.
* ``PILLOW_BLOCK_SIZE``, in bytes, K, or M. Specifies the maximum
block size for ``ImagingAllocateArray``. Valid values are
integers, with an optional ``k`` or ``m`` suffix. Defaults to 16M.
* ``PILLOW_BLOCK_SIZE``, in bytes, K, or M. Specifies the maximum
block size for ``ImagingAllocateArray``. Valid values are
integers, with an optional ``k`` or ``m`` suffix. Defaults to 16M.
* ``PILLOW_BLOCKS_MAX`` Specifies the number of freed blocks to
retain to fill future memory requests. Any freed blocks over this
threshold will be returned to the OS immediately. Defaults to 0.
* ``PILLOW_BLOCKS_MAX`` Specifies the number of freed blocks to
retain to fill future memory requests. Any freed blocks over this
threshold will be returned to the OS immediately. Defaults to 0.

View File

@ -21,6 +21,7 @@ Support for the following modules can be checked:
* ``freetype2``: FreeType font support via :py:func:`PIL.ImageFont.truetype`.
* ``littlecms2``: LittleCMS 2 support via :py:mod:`PIL.ImageCms`.
* ``webp``: WebP image support.
* ``avif``: AVIF image support.
.. autofunction:: PIL.features.check_module
.. autofunction:: PIL.features.version_module

View File

@ -9,3 +9,4 @@ Internal Reference
block_allocator
internal_modules
c_extension_debugging
arrow_support

View File

@ -1,6 +1,14 @@
Plugin reference
================
:mod:`~PIL.AvifImagePlugin` Module
----------------------------------
.. automodule:: PIL.AvifImagePlugin
:members:
:undoc-members:
:show-inheritance:
:mod:`~PIL.BmpImagePlugin` Module
---------------------------------

View File

@ -1,83 +0,0 @@
11.2.0
------
Security
========
TODO
^^^^
TODO
:cve:`YYYY-XXXXX`: TODO
^^^^^^^^^^^^^^^^^^^^^^^
TODO
Backwards Incompatible Changes
==============================
TODO
^^^^
Deprecations
============
Image.Image.get_child_images()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.2.0
``Image.Image.get_child_images()`` has been deprecated. and will be removed in Pillow
13 (2026-10-15). It will be moved to ``ImageFile.ImageFile.get_child_images()``. The
method uses an image's file pointer, and so child images could only be retrieved from
an :py:class:`PIL.ImageFile.ImageFile` instance.
API Changes
===========
TODO
^^^^
TODO
API Additions
=============
``"justify"`` multiline text alignment
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In addition to ``"left"``, ``"center"`` and ``"right"``, multiline text can also be
aligned using ``"justify"`` in :py:mod:`~PIL.ImageDraw`::
from PIL import Image, ImageDraw
im = Image.new("RGB", (50, 25))
draw = ImageDraw.Draw(im)
draw.multiline_text((0, 0), "Multiline\ntext 1", align="justify")
draw.multiline_textbbox((0, 0), "Multiline\ntext 2", align="justify")
Check for MozJPEG
^^^^^^^^^^^^^^^^^
You can check if Pillow has been built against the MozJPEG version of the
libjpeg library, and what version of MozJPEG is being used::
from PIL import features
features.check_feature("mozjpeg") # True or False
features.version_feature("mozjpeg") # "4.1.1" for example, or None
Saving compressed DDS images
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Compressed DDS images can now be saved using a ``pixel_format`` argument. DXT1, DXT3,
DXT5, BC2, BC3 and BC5 are supported::
im.save("out.dds", pixel_format="DXT1")
Other Changes
=============
TODO
^^^^
TODO

View File

@ -0,0 +1,118 @@
11.2.1
------
.. warning::
The release of Pillow *11.2.0* was halted prematurely, due to hitting PyPI's
project size limit and concern over the size of Pillow wheels containing libavif.
The PyPI limit has now been increased and Pillow *11.2.1* has been released
instead, without libavif included in the wheels.
To avoid confusion, the incomplete 11.2.0 release has been removed from PyPI.
Security
========
Undefined shift when loading compressed DDS images
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
When loading some compressed DDS formats, an integer was bitshifted by 24 places to
generate the 32 bits of the lookup table. This was undefined behaviour, and has been
present since Pillow 3.4.0.
Deprecations
============
Image.Image.get_child_images()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.2.1
``Image.Image.get_child_images()`` has been deprecated. and will be removed in Pillow
13 (2026-10-15). It will be moved to ``ImageFile.ImageFile.get_child_images()``. The
method uses an image's file pointer, and so child images could only be retrieved from
an :py:class:`PIL.ImageFile.ImageFile` instance.
API Changes
===========
``append_images`` no longer requires ``save_all``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Previously, ``save_all`` was required to in order to use ``append_images``. Now,
``save_all`` will default to ``True`` if ``append_images`` is not empty and the format
supports saving multiple frames::
im.save("out.gif", append_images=ims)
API Additions
=============
``"justify"`` multiline text alignment
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In addition to ``"left"``, ``"center"`` and ``"right"``, multiline text can also be
aligned using ``"justify"`` in :py:mod:`~PIL.ImageDraw`::
from PIL import Image, ImageDraw
im = Image.new("RGB", (50, 25))
draw = ImageDraw.Draw(im)
draw.multiline_text((0, 0), "Multiline\ntext 1", align="justify")
draw.multiline_textbbox((0, 0), "Multiline\ntext 2", align="justify")
Specify window in ImageGrab on Windows
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
When using :py:meth:`~PIL.ImageGrab.grab`, a specific window can be selected using the
HWND::
from PIL import ImageGrab
ImageGrab.grab(window=hwnd)
Check for MozJPEG
^^^^^^^^^^^^^^^^^
You can check if Pillow has been built against the MozJPEG version of the
libjpeg library, and what version of MozJPEG is being used::
from PIL import features
features.check_feature("mozjpeg") # True or False
features.version_feature("mozjpeg") # "4.1.1" for example, or None
Saving compressed DDS images
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Compressed DDS images can now be saved using a ``pixel_format`` argument. DXT1, DXT3,
DXT5, BC2, BC3 and BC5 are supported::
im.save("out.dds", pixel_format="DXT1")
Other Changes
=============
Arrow support
^^^^^^^^^^^^^
`Arrow <https://arrow.apache.org/>`__ is an in-memory data exchange format that is the
spiritual successor to the NumPy array interface. It provides for zero-copy access to
columnar data, which in our case is ``Image`` data.
To create an image with zero-copy shared memory from an object exporting the
arrow_c_array interface protocol::
from PIL import Image
import pyarrow as pa
arr = pa.array([0]*(5*5*4), type=pa.uint8())
im = Image.fromarrow(arr, 'RGBA', (5, 5))
Pillow images can also be converted to Arrow objects::
from PIL import Image
import pyarrow as pa
im = Image.open('hopper.jpg')
arr = pa.array(im)
Reading and writing AVIF images
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Pillow can now read and write AVIF images when built from source with libavif 1.0.0
or later.

View File

@ -14,7 +14,7 @@ expected to be backported to earlier versions.
.. toctree::
:maxdepth: 2
11.2.0
11.2.1
11.1.0
11.0.0
10.4.0

View File

@ -1,7 +1,7 @@
[build-system]
build-backend = "backend"
requires = [
"setuptools>=67.8",
"setuptools>=77",
]
backend-path = [
"_custom_build",
@ -14,14 +14,14 @@ readme = "README.md"
keywords = [
"Imaging",
]
license = { text = "MIT-CMU" }
license = "MIT-CMU"
license-files = [ "LICENSE" ]
authors = [
{ name = "Jeffrey A. Clark", email = "aclark@aclark.net" },
]
requires-python = ">=3.9"
classifiers = [
"Development Status :: 6 - Mature",
"License :: OSI Approved :: CMU License (MIT-CMU)",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
@ -44,6 +44,7 @@ optional-dependencies.docs = [
"furo",
"olefile",
"sphinx>=8.2",
"sphinx-autobuild",
"sphinx-copybutton",
"sphinx-inline-tabs",
"sphinxext-opengraph",
@ -54,6 +55,10 @@ optional-dependencies.fpx = [
optional-dependencies.mic = [
"olefile",
]
optional-dependencies.test-arrow = [
"pyarrow",
]
optional-dependencies.tests = [
"check-manifest",
"coverage>=7.4.2",
@ -67,6 +72,7 @@ optional-dependencies.tests = [
"pytest-timeout",
"trove-classifiers>=2024.10.12",
]
optional-dependencies.typing = [
"typing-extensions; python_version<'3.10'",
]

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