Merge branch 'main' into image_equals

This commit is contained in:
Andrew Murray 2025-03-18 23:16:36 +11:00 committed by GitHub
commit 198e3730be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 228 additions and 159 deletions

View File

@ -20,7 +20,7 @@ fi
set -e set -e
if [[ $(uname) != CYGWIN* ]]; then if [[ $(uname) != CYGWIN* ]]; then
sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\
ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\ ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\
cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ cmake meson imagemagick libharfbuzz-dev libfribidi-dev\
sway wl-clipboard libopenblas-dev sway wl-clipboard libopenblas-dev

View File

@ -1 +1 @@
cibuildwheel==2.23.0 cibuildwheel==2.23.1

View File

@ -16,6 +16,6 @@
} }
], ],
"schedule": [ "schedule": [
"on the 3rd day of the month" "* * 3 * *"
] ]
} }

View File

@ -35,6 +35,10 @@ jobs:
matrix: matrix:
os: ["ubuntu-latest"] os: ["ubuntu-latest"]
docker: [ docker: [
# Run slower jobs first to give them a headstart and reduce waiting time
ubuntu-24.04-noble-ppc64le,
ubuntu-24.04-noble-s390x,
# Then run the remainder
alpine, alpine,
amazon-2-amd64, amazon-2-amd64,
amazon-2023-amd64, amazon-2023-amd64,
@ -52,13 +56,9 @@ jobs:
dockerTag: [main] dockerTag: [main]
include: include:
- docker: "ubuntu-24.04-noble-ppc64le" - docker: "ubuntu-24.04-noble-ppc64le"
os: "ubuntu-22.04"
qemu-arch: "ppc64le" qemu-arch: "ppc64le"
dockerTag: main
- docker: "ubuntu-24.04-noble-s390x" - docker: "ubuntu-24.04-noble-s390x"
os: "ubuntu-22.04"
qemu-arch: "s390x" qemu-arch: "s390x"
dockerTag: main
- docker: "ubuntu-24.04-noble-arm64v8" - docker: "ubuntu-24.04-noble-arm64v8"
os: "ubuntu-24.04-arm" os: "ubuntu-24.04-arm"
dockerTag: main dockerTag: main
@ -75,8 +75,9 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
if: "matrix.qemu-arch" if: "matrix.qemu-arch"
run: | uses: docker/setup-qemu-action@v3
docker run --rm --privileged aptman/qus -s -- -p ${{ matrix.qemu-arch }} with:
platforms: ${{ matrix.qemu-arch }}
- name: Docker pull - name: Docker pull
run: | run: |

View File

@ -94,8 +94,8 @@ jobs:
choco install nasm --no-progress choco install nasm --no-progress
echo "C:\Program Files\NASM" >> $env:GITHUB_PATH echo "C:\Program Files\NASM" >> $env:GITHUB_PATH
choco install ghostscript --version=10.4.0 --no-progress choco install ghostscript --version=10.5.0 --no-progress
echo "C:\Program Files\gs\gs10.04.0\bin" >> $env:GITHUB_PATH echo "C:\Program Files\gs\gs10.05.0\bin" >> $env:GITHUB_PATH
# Install extra test images # Install extra test images
xcopy /S /Y Tests\test-images\* Tests\images xcopy /S /Y Tests\test-images\* Tests\images

View File

@ -43,7 +43,7 @@ LIBPNG_VERSION=1.6.47
JPEGTURBO_VERSION=3.1.0 JPEGTURBO_VERSION=3.1.0
OPENJPEG_VERSION=2.5.3 OPENJPEG_VERSION=2.5.3
XZ_VERSION=5.6.4 XZ_VERSION=5.6.4
TIFF_VERSION=4.6.0 TIFF_VERSION=4.7.0
LCMS2_VERSION=2.17 LCMS2_VERSION=2.17
ZLIB_NG_VERSION=2.2.4 ZLIB_NG_VERSION=2.2.4
LIBWEBP_VERSION=1.5.0 LIBWEBP_VERSION=1.5.0

View File

@ -34,8 +34,11 @@ def test_apng_basic() -> None:
with pytest.raises(EOFError): with pytest.raises(EOFError):
im.seek(2) im.seek(2)
# test rewind support
im.seek(0) im.seek(0)
with pytest.raises(ValueError, match="cannot seek to frame 2"):
im._seek(2)
# test rewind support
assert im.getpixel((0, 0)) == (255, 0, 0, 255) assert im.getpixel((0, 0)) == (255, 0, 0, 255)
assert im.getpixel((64, 32)) == (255, 0, 0, 255) assert im.getpixel((64, 32)) == (255, 0, 0, 255)
im.seek(1) im.seek(1)

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import io
import warnings import warnings
import pytest import pytest
@ -132,6 +133,15 @@ def test_eoferror() -> None:
im.seek(n_frames - 1) im.seek(n_frames - 1)
def test_missing_frame_size() -> None:
with open(animated_test_file, "rb") as fp:
data = fp.read()
data = data[:6188]
with Image.open(io.BytesIO(data)) as im:
with pytest.raises(EOFError, match="missing frame size"):
im.seek(1)
def test_seek_tell() -> None: def test_seek_tell() -> None:
with Image.open(animated_test_file) as im: with Image.open(animated_test_file) as im:
layer_number = im.tell() layer_number = im.tell()
@ -160,6 +170,9 @@ def test_seek() -> None:
assert_image_equal_tofile(im, "Tests/images/a_fli.png") assert_image_equal_tofile(im, "Tests/images/a_fli.png")
with pytest.raises(ValueError, match="cannot seek to frame 52"):
im._seek(52)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"test_file", "test_file",

View File

@ -410,6 +410,10 @@ def test_seek() -> None:
except EOFError: except EOFError:
assert frame_count == 5 assert frame_count == 5
img.seek(0)
with pytest.raises(ValueError, match="cannot seek to frame 2"):
img._seek(2)
def test_seek_info() -> None: def test_seek_info() -> None:
with Image.open("Tests/images/iss634.gif") as im: with Image.open("Tests/images/iss634.gif") as im:
@ -601,7 +605,7 @@ def test_save_dispose(tmp_path: Path) -> None:
Image.new("L", (100, 100), "#111"), Image.new("L", (100, 100), "#111"),
Image.new("L", (100, 100), "#222"), Image.new("L", (100, 100), "#222"),
] ]
for method in range(0, 4): for method in range(4):
im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=method) im_list[0].save(out, save_all=True, append_images=im_list[1:], disposal=method)
with Image.open(out) as img: with Image.open(out) as img:
for _ in range(2): for _ in range(2):

View File

@ -313,6 +313,18 @@ def test_rgba(ext: str) -> None:
assert im.mode == "RGBA" assert im.mode == "RGBA"
def test_grayscale_four_channels() -> None:
with open("Tests/images/rgb_trns_ycbc.jp2", "rb") as fp:
data = fp.read()
# Change color space to OPJ_CLRSPC_GRAY
data = data[:76] + b"\x11" + data[77:]
with Image.open(BytesIO(data)) as im:
im.load()
assert im.mode == "RGBA"
@pytest.mark.skipif( @pytest.mark.skipif(
not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" not os.path.exists(EXTRA_DIR), reason="Extra image files not installed"
) )

View File

@ -1140,11 +1140,9 @@ class TestFileLibTiff(LibTiffTestCase):
def test_realloc_overflow(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_realloc_overflow(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True) monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True)
with Image.open("Tests/images/tiff_overflow_rows_per_strip.tif") as im: with Image.open("Tests/images/tiff_overflow_rows_per_strip.tif") as im:
with pytest.raises(OSError) as e:
im.load()
# Assert that the error code is IMAGING_CODEC_MEMORY # Assert that the error code is IMAGING_CODEC_MEMORY
assert str(e.value) == "decoder error -9" with pytest.raises(OSError, match="decoder error -9"):
im.load()
@pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg"))
def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: def test_save_multistrip(self, compression: str, tmp_path: Path) -> None:

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import io
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -36,6 +37,28 @@ def test_sanity(tmp_path: Path) -> None:
im.save(f) im.save(f)
def test_bad_image_size() -> None:
with open("Tests/images/pil184.pcx", "rb") as fp:
data = fp.read()
data = data[:4] + b"\xff\xff" + data[6:]
b = io.BytesIO(data)
with pytest.raises(SyntaxError, match="bad PCX image size"):
with PcxImagePlugin.PcxImageFile(b):
pass
def test_unknown_mode() -> None:
with open("Tests/images/pil184.pcx", "rb") as fp:
data = fp.read()
data = data[:3] + b"\xff" + data[4:]
b = io.BytesIO(data)
with pytest.raises(OSError, match="unknown PCX mode"):
with Image.open(b):
pass
def test_invalid_file() -> None: def test_invalid_file() -> None:
invalid_file = "Tests/images/flower.jpg" invalid_file = "Tests/images/flower.jpg"

View File

@ -293,12 +293,10 @@ def test_header_token_too_long(tmp_path: Path) -> None:
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(b"P6\n 01234567890") f.write(b"P6\n 01234567890")
with pytest.raises(ValueError) as e: with pytest.raises(ValueError, match="Token too long in file header: 01234567890"):
with Image.open(path): with Image.open(path):
pass pass
assert str(e.value) == "Token too long in file header: 01234567890"
def test_truncated_file(tmp_path: Path) -> None: def test_truncated_file(tmp_path: Path) -> None:
# Test EOF in header # Test EOF in header
@ -306,12 +304,10 @@ def test_truncated_file(tmp_path: Path) -> None:
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(b"P6") f.write(b"P6")
with pytest.raises(ValueError) as e: with pytest.raises(ValueError, match="Reached EOF while reading header"):
with Image.open(path): with Image.open(path):
pass pass
assert str(e.value) == "Reached EOF while reading header"
# Test EOF for PyDecoder # Test EOF for PyDecoder
fp = BytesIO(b"P5 3 1 4") fp = BytesIO(b"P5 3 1 4")
with Image.open(fp) as im: with Image.open(fp) as im:
@ -335,12 +331,12 @@ def test_invalid_maxval(maxval: bytes, tmp_path: Path) -> None:
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(b"P6\n3 1 " + maxval) f.write(b"P6\n3 1 " + maxval)
with pytest.raises(ValueError) as e: with pytest.raises(
ValueError, match="maxval must be greater than 0 and less than 65536"
):
with Image.open(path): with Image.open(path):
pass pass
assert str(e.value) == "maxval must be greater than 0 and less than 65536"
def test_neg_ppm() -> None: def test_neg_ppm() -> None:
# Storage.c accepted negative values for xsize, ysize. the # Storage.c accepted negative values for xsize, ysize. the

View File

@ -134,9 +134,8 @@ class TestFileTiff:
def test_set_legacy_api(self) -> None: def test_set_legacy_api(self) -> None:
ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd = TiffImagePlugin.ImageFileDirectory_v2()
with pytest.raises(Exception) as e: with pytest.raises(Exception, match="Not allowing setting of legacy api"):
ifd.legacy_api = False ifd.legacy_api = False
assert str(e.value) == "Not allowing setting of legacy api"
def test_xyres_tiff(self) -> None: def test_xyres_tiff(self) -> None:
filename = "Tests/images/pil168.tif" filename = "Tests/images/pil168.tif"
@ -661,6 +660,18 @@ class TestFileTiff:
assert isinstance(im, TiffImagePlugin.TiffImageFile) assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2[278] == 256 assert im.tag_v2[278] == 256
im = hopper()
im2 = Image.new("L", (128, 128))
im2.encoderinfo = {"tiffinfo": {278: 256}}
im.save(outfile, save_all=True, append_images=[im2])
with Image.open(outfile) as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
assert im.tag_v2[278] == 128
im.seek(1)
assert im.tag_v2[278] == 256
def test_strip_raw(self) -> None: def test_strip_raw(self) -> None:
infile = "Tests/images/tiff_strip_raw.tif" infile = "Tests/images/tiff_strip_raw.tif"
with Image.open(infile) as im: with Image.open(infile) as im:

View File

@ -154,9 +154,8 @@ class TestFileWebp:
@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system")
def test_write_encoding_error_message(self, tmp_path: Path) -> None: def test_write_encoding_error_message(self, tmp_path: Path) -> None:
im = Image.new("RGB", (15000, 15000)) im = Image.new("RGB", (15000, 15000))
with pytest.raises(ValueError) as e: with pytest.raises(ValueError, match="encoding error 6"):
im.save(tmp_path / "temp.webp", method=0) im.save(tmp_path / "temp.webp", method=0)
assert str(e.value) == "encoding error 6"
@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system")
def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None: def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None:
@ -231,7 +230,7 @@ class TestFileWebp:
with Image.open(out_gif) as reread: with Image.open(out_gif) as reread:
reread_value = reread.convert("RGB").getpixel((1, 1)) reread_value = reread.convert("RGB").getpixel((1, 1))
difference = sum(abs(original_value[i] - reread_value[i]) for i in range(0, 3)) difference = sum(abs(original_value[i] - reread_value[i]) for i in range(3))
assert difference < 5 assert difference < 5
def test_duration(self, tmp_path: Path) -> None: def test_duration(self, tmp_path: Path) -> None:

View File

@ -65,9 +65,8 @@ class TestImage:
@pytest.mark.parametrize("mode", ("", "bad", "very very long")) @pytest.mark.parametrize("mode", ("", "bad", "very very long"))
def test_image_modes_fail(self, mode: str) -> None: def test_image_modes_fail(self, mode: str) -> None:
with pytest.raises(ValueError) as e: with pytest.raises(ValueError, match="unrecognized image mode"):
Image.new(mode, (1, 1)) Image.new(mode, (1, 1))
assert str(e.value) == "unrecognized image mode"
def test_exception_inheritance(self) -> None: def test_exception_inheritance(self) -> None:
assert issubclass(UnidentifiedImageError, OSError) assert issubclass(UnidentifiedImageError, OSError)

View File

@ -39,6 +39,8 @@ BBOX = (((X0, Y0), (X1, Y1)), [(X0, Y0), (X1, Y1)], (X0, Y0, X1, Y1), [X0, Y0, X
POINTS = ( POINTS = (
((10, 10), (20, 40), (30, 30)), ((10, 10), (20, 40), (30, 30)),
[(10, 10), (20, 40), (30, 30)], [(10, 10), (20, 40), (30, 30)],
([10, 10], [20, 40], [30, 30]),
[[10, 10], [20, 40], [30, 30]],
(10, 10, 20, 40, 30, 30), (10, 10, 20, 40, 30, 30),
[10, 10, 20, 40, 30, 30], [10, 10, 20, 40, 30, 30],
) )
@ -46,6 +48,8 @@ POINTS = (
KITE_POINTS = ( KITE_POINTS = (
((10, 50), (70, 10), (90, 50), (70, 90), (10, 50)), ((10, 50), (70, 10), (90, 50), (70, 90), (10, 50)),
[(10, 50), (70, 10), (90, 50), (70, 90), (10, 50)], [(10, 50), (70, 10), (90, 50), (70, 90), (10, 50)],
([10, 50], [70, 10], [90, 50], [70, 90], [10, 50]),
[[10, 50], [70, 10], [90, 50], [70, 90], [10, 50]],
) )
@ -1044,8 +1048,8 @@ def create_base_image_draw(
background2: tuple[int, int, int] = GRAY, background2: tuple[int, int, int] = GRAY,
) -> tuple[Image.Image, ImageDraw.ImageDraw]: ) -> tuple[Image.Image, ImageDraw.ImageDraw]:
img = Image.new(mode, size, background1) img = Image.new(mode, size, background1)
for x in range(0, size[0]): for x in range(size[0]):
for y in range(0, size[1]): for y in range(size[1]):
if (x + y) % 2 == 0: if (x + y) % 2 == 0:
img.putpixel((x, y), background2) img.putpixel((x, y), background2)
return img, ImageDraw.Draw(img) return img, ImageDraw.Draw(img)
@ -1626,7 +1630,7 @@ def test_compute_regular_polygon_vertices(
0, 0,
ValueError, ValueError,
"bounding_circle should contain 2D coordinates " "bounding_circle should contain 2D coordinates "
"and a radius (e.g. (x, y, r) or ((x, y), r) )", r"and a radius \(e.g. \(x, y, r\) or \(\(x, y\), r\) \)",
), ),
( (
3, 3,
@ -1640,7 +1644,7 @@ def test_compute_regular_polygon_vertices(
((50, 50, 50), 25), ((50, 50, 50), 25),
0, 0,
ValueError, ValueError,
"bounding_circle centre should contain 2D coordinates (e.g. (x, y))", r"bounding_circle centre should contain 2D coordinates \(e.g. \(x, y\)\)",
), ),
( (
3, 3,
@ -1665,9 +1669,8 @@ def test_compute_regular_polygon_vertices_input_error_handling(
expected_error: type[Exception], expected_error: type[Exception],
error_message: str, error_message: str,
) -> None: ) -> None:
with pytest.raises(expected_error) as e: with pytest.raises(expected_error, match=error_message):
ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) # type: ignore[arg-type] ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) # type: ignore[arg-type]
assert str(e.value) == error_message
def test_continuous_horizontal_edges_polygon() -> None: def test_continuous_horizontal_edges_polygon() -> None:

View File

@ -176,9 +176,8 @@ class TestImageFile:
b"0" * ImageFile.SAFEBLOCK b"0" * ImageFile.SAFEBLOCK
) # only SAFEBLOCK bytes, so that the header is truncated ) # only SAFEBLOCK bytes, so that the header is truncated
) )
with pytest.raises(OSError) as e: with pytest.raises(OSError, match="Truncated File Read"):
BmpImagePlugin.BmpImageFile(b) BmpImagePlugin.BmpImageFile(b)
assert str(e.value) == "Truncated File Read"
@skip_unless_feature("zlib") @skip_unless_feature("zlib")
def test_truncated_with_errors(self) -> None: def test_truncated_with_errors(self) -> None:

View File

@ -80,15 +80,12 @@ def test_lut(op: str) -> None:
def test_no_operator_loaded() -> None: def test_no_operator_loaded() -> None:
im = Image.new("L", (1, 1)) im = Image.new("L", (1, 1))
mop = ImageMorph.MorphOp() mop = ImageMorph.MorphOp()
with pytest.raises(Exception) as e: with pytest.raises(Exception, match="No operator loaded"):
mop.apply(im) mop.apply(im)
assert str(e.value) == "No operator loaded" with pytest.raises(Exception, match="No operator loaded"):
with pytest.raises(Exception) as e:
mop.match(im) mop.match(im)
assert str(e.value) == "No operator loaded" with pytest.raises(Exception, match="No operator loaded"):
with pytest.raises(Exception) as e:
mop.save_lut("") mop.save_lut("")
assert str(e.value) == "No operator loaded"
# Test the named patterns # Test the named patterns
@ -238,15 +235,12 @@ def test_incorrect_mode() -> None:
im = hopper("RGB") im = hopper("RGB")
mop = ImageMorph.MorphOp(op_name="erosion8") mop = ImageMorph.MorphOp(op_name="erosion8")
with pytest.raises(ValueError) as e: with pytest.raises(ValueError, match="Image mode must be L"):
mop.apply(im) mop.apply(im)
assert str(e.value) == "Image mode must be L" with pytest.raises(ValueError, match="Image mode must be L"):
with pytest.raises(ValueError) as e:
mop.match(im) mop.match(im)
assert str(e.value) == "Image mode must be L" with pytest.raises(ValueError, match="Image mode must be L"):
with pytest.raises(ValueError) as e:
mop.get_on_pixels(im) mop.get_on_pixels(im)
assert str(e.value) == "Image mode must be L"
def test_add_patterns() -> None: def test_add_patterns() -> None:
@ -279,9 +273,10 @@ def test_pattern_syntax_error() -> None:
lb.add_patterns(new_patterns) lb.add_patterns(new_patterns)
# Act / Assert # Act / Assert
with pytest.raises(Exception) as e: with pytest.raises(
Exception, match='Syntax error in pattern "a pattern with a syntax error"'
):
lb.build_lut() lb.build_lut()
assert str(e.value) == 'Syntax error in pattern "a pattern with a syntax error"'
def test_load_invalid_mrl() -> None: def test_load_invalid_mrl() -> None:
@ -290,9 +285,8 @@ def test_load_invalid_mrl() -> None:
mop = ImageMorph.MorphOp() mop = ImageMorph.MorphOp()
# Act / Assert # Act / Assert
with pytest.raises(Exception) as e: with pytest.raises(Exception, match="Wrong size operator file!"):
mop.load_lut(invalid_mrl) mop.load_lut(invalid_mrl)
assert str(e.value) == "Wrong size operator file!"
def test_roundtrip_mrl(tmp_path: Path) -> None: def test_roundtrip_mrl(tmp_path: Path) -> None:

View File

@ -112,7 +112,7 @@ def test_make_linear_lut() -> None:
assert isinstance(lut, list) assert isinstance(lut, list)
assert len(lut) == 256 assert len(lut) == 256
# Check values # Check values
for i in range(0, len(lut)): for i in range(len(lut)):
assert lut[i] == i assert lut[i] == i

View File

@ -68,25 +68,10 @@ def test_path_constructors(
assert list(p) == [(0.0, 1.0)] assert list(p) == [(0.0, 1.0)]
@pytest.mark.parametrize( def test_invalid_path_constructors() -> None:
"coords", # Arrange / Act
( with pytest.raises(ValueError, match="incorrect coordinate type"):
("a", "b"), ImagePath.Path(("a", "b"))
([0, 1],),
[[0, 1]],
([0.0, 1.0],),
[[0.0, 1.0]],
),
)
def test_invalid_path_constructors(
coords: tuple[str, str] | Sequence[Sequence[int]],
) -> None:
# Act
with pytest.raises(ValueError) as e:
ImagePath.Path(coords)
# Assert
assert str(e.value) == "incorrect coordinate type"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -99,13 +84,9 @@ def test_invalid_path_constructors(
), ),
) )
def test_path_odd_number_of_coordinates(coords: Sequence[int]) -> None: def test_path_odd_number_of_coordinates(coords: Sequence[int]) -> None:
# Act with pytest.raises(ValueError, match="wrong number of coordinates"):
with pytest.raises(ValueError) as e:
ImagePath.Path(coords) ImagePath.Path(coords)
# Assert
assert str(e.value) == "wrong number of coordinates"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"coords, expected", "coords, expected",

View File

@ -32,7 +32,7 @@ def test_sanity(tmp_path: Path) -> None:
def test_iterator() -> None: def test_iterator() -> None:
with Image.open("Tests/images/multipage.tiff") as im: with Image.open("Tests/images/multipage.tiff") as im:
i = ImageSequence.Iterator(im) i = ImageSequence.Iterator(im)
for index in range(0, im.n_frames): for index in range(im.n_frames):
assert i[index] == next(i) assert i[index] == next(i)
with pytest.raises(IndexError): with pytest.raises(IndexError):
i[index + 1] i[index + 1]

View File

@ -65,7 +65,7 @@ def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> Non
("Tests/images/itxt_chunks.png", None), ("Tests/images/itxt_chunks.png", None),
], ],
) )
@pytest.mark.parametrize("protocol", range(0, pickle.HIGHEST_PROTOCOL + 1)) @pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1))
def test_pickle_image( def test_pickle_image(
tmp_path: Path, test_file: str, test_mode: str | None, protocol: int tmp_path: Path, test_file: str, test_mode: str | None, protocol: int
) -> None: ) -> None:
@ -92,7 +92,7 @@ def test_pickle_la_mode_with_palette(tmp_path: Path) -> None:
im = im.convert("PA") im = im.convert("PA")
# Act / Assert # Act / Assert
for protocol in range(0, pickle.HIGHEST_PROTOCOL + 1): for protocol in range(pickle.HIGHEST_PROTOCOL + 1):
im._mode = "LA" im._mode = "LA"
with open(filename, "wb") as f: with open(filename, "wb") as f:
pickle.dump(im, f, protocol) pickle.dump(im, f, protocol)
@ -133,7 +133,7 @@ def helper_assert_pickled_font_images(
@skip_unless_feature("freetype2") @skip_unless_feature("freetype2")
@pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) @pytest.mark.parametrize("protocol", list(range(pickle.HIGHEST_PROTOCOL + 1)))
def test_pickle_font_string(protocol: int) -> None: def test_pickle_font_string(protocol: int) -> None:
# Arrange # Arrange
font = ImageFont.truetype(FONT_PATH, FONT_SIZE) font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
@ -147,7 +147,7 @@ def test_pickle_font_string(protocol: int) -> None:
@skip_unless_feature("freetype2") @skip_unless_feature("freetype2")
@pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) @pytest.mark.parametrize("protocol", list(range(pickle.HIGHEST_PROTOCOL + 1)))
def test_pickle_font_file(tmp_path: Path, protocol: int) -> None: def test_pickle_font_file(tmp_path: Path, protocol: int) -> None:
# Arrange # Arrange
font = ImageFont.truetype(FONT_PATH, FONT_SIZE) font = ImageFont.truetype(FONT_PATH, FONT_SIZE)

View File

@ -68,7 +68,7 @@ by DirectX.
DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode. DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode.
.. versionadded:: 3.4.0 .. versionadded:: 3.4.0
DXT3 images can be read in ``RGB`` mode and DX10 images can be read in DXT3 images can be read in ``RGBA`` mode and DX10 images can be read in
``RGB`` and ``RGBA`` mode. ``RGB`` and ``RGBA`` mode.
.. versionadded:: 6.0.0 .. versionadded:: 6.0.0
@ -1163,9 +1163,7 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum
**append_images** **append_images**
A list of images to append as additional frames. Each of the A list of images to append as additional frames. Each of the
images in the list can be single or multiframe images. Note however, that for images in the list can be single or multiframe images.
correct results, all the appended images should have the same
``encoderinfo`` and ``encoderconfig`` properties.
.. versionadded:: 4.2.0 .. versionadded:: 4.2.0

View File

@ -44,7 +44,7 @@ Many of Pillow's features require external libraries:
* **libtiff** provides compressed TIFF functionality * **libtiff** provides compressed TIFF functionality
* Pillow has been tested with libtiff versions **3.x** and **4.0-4.6.0** * Pillow has been tested with libtiff versions **3.x** and **4.0-4.7.0**
* **libfreetype** provides type related services * **libfreetype** provides type related services

View File

@ -121,6 +121,7 @@ lint.select = [
"ISC", # flake8-implicit-str-concat "ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging "LOG", # flake8-logging
"PGH", # pygrep-hooks "PGH", # pygrep-hooks
"PIE", # flake8-pie
"PT", # flake8-pytest-style "PT", # flake8-pytest-style
"PYI", # flake8-pyi "PYI", # flake8-pyi
"RUF100", # unused noqa (yesqa) "RUF100", # unused noqa (yesqa)
@ -133,6 +134,7 @@ lint.ignore = [
"E221", # Multiple spaces before operator "E221", # Multiple spaces before operator
"E226", # Missing whitespace around arithmetic operator "E226", # Missing whitespace around arithmetic operator
"E241", # Multiple spaces after ',' "E241", # Multiple spaces after ','
"PIE790", # flake8-pie: unnecessary-placeholder
"PT001", # pytest-fixture-incorrect-parentheses-style "PT001", # pytest-fixture-incorrect-parentheses-style
"PT007", # pytest-parametrize-values-wrong-type "PT007", # pytest-parametrize-values-wrong-type
"PT011", # pytest-raises-too-broad "PT011", # pytest-raises-too-broad

View File

@ -291,7 +291,7 @@ class BlpImageFile(ImageFile.ImageFile):
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)] self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
class _BLPBaseDecoder(ImageFile.PyDecoder): class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
_pulls_fd = True _pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:

View File

@ -123,8 +123,7 @@ def read_png_or_jpeg2000(
Image._decompression_bomb_check(im.size) Image._decompression_bomb_check(im.size)
return {"RGBA": im} return {"RGBA": im}
elif ( elif (
sig.startswith(b"\xff\x4f\xff\x51") sig.startswith((b"\xff\x4f\xff\x51", b"\x0d\x0a\x87\x0a"))
or sig.startswith(b"\x0d\x0a\x87\x0a")
or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"
): ):
if not enable_jpeg2k: if not enable_jpeg2k:

View File

@ -1005,7 +1005,7 @@ class Image:
elif len(mode) == 3: elif len(mode) == 3:
transparency = tuple( transparency = tuple(
convert_transparency(matrix[i * 4 : i * 4 + 4], transparency) convert_transparency(matrix[i * 4 : i * 4 + 4], transparency)
for i in range(0, len(transparency)) for i in range(len(transparency))
) )
new_im.info["transparency"] = transparency new_im.info["transparency"] = transparency
return new_im return new_im
@ -2479,7 +2479,21 @@ class Image:
format to use is determined from the filename extension. format to use is determined from the filename extension.
If a file object was used instead of a filename, this If a file object was used instead of a filename, this
parameter should always be used. parameter should always be used.
:param params: Extra parameters to the image writer. :param params: Extra parameters to the image writer. These can also be
set on the image itself through ``encoderinfo``. This is useful when
saving multiple images::
# Saving XMP data to a single image
from PIL import Image
red = Image.new("RGB", (1, 1), "#f00")
red.save("out.mpo", xmp=b"test")
# Saving XMP data to the second frame of an image
from PIL import Image
black = Image.new("RGB", (1, 1))
red = Image.new("RGB", (1, 1), "#f00")
red.encoderinfo = {"xmp": b"test"}
black.save("out.mpo", save_all=True, append_images=[red])
:returns: None :returns: None
:exception ValueError: If the output format could not be determined :exception ValueError: If the output format could not be determined
from the file name. Use the format option to solve this. from the file name. Use the format option to solve this.
@ -2970,7 +2984,7 @@ class Image:
# Abstract handlers. # Abstract handlers.
class ImagePointHandler: class ImagePointHandler(abc.ABC):
""" """
Used as a mixin by point transforms Used as a mixin by point transforms
(for use with :py:meth:`~PIL.Image.Image.point`) (for use with :py:meth:`~PIL.Image.Image.point`)
@ -2981,7 +2995,7 @@ class ImagePointHandler:
pass pass
class ImageTransformHandler: class ImageTransformHandler(abc.ABC):
""" """
Used as a mixin by geometry transforms Used as a mixin by geometry transforms
(for use with :py:meth:`~PIL.Image.Image.transform`) (for use with :py:meth:`~PIL.Image.Image.transform`)
@ -4007,7 +4021,7 @@ class Exif(_ExifBase):
ifd_data = tag_data[ifd_offset:] ifd_data = tag_data[ifd_offset:]
makernote = {} makernote = {}
for i in range(0, struct.unpack("<H", ifd_data[:2])[0]): for i in range(struct.unpack("<H", ifd_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack( ifd_tag, typ, count, data = struct.unpack(
"<HHL4s", ifd_data[i * 12 + 2 : (i + 1) * 12 + 2] "<HHL4s", ifd_data[i * 12 + 2 : (i + 1) * 12 + 2]
) )
@ -4042,7 +4056,7 @@ class Exif(_ExifBase):
self._ifds[tag] = dict(self._fixup_dict(makernote)) self._ifds[tag] = dict(self._fixup_dict(makernote))
elif self.get(0x010F) == "Nintendo": elif self.get(0x010F) == "Nintendo":
makernote = {} makernote = {}
for i in range(0, struct.unpack(">H", tag_data[:2])[0]): for i in range(struct.unpack(">H", tag_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack( ifd_tag, typ, count, data = struct.unpack(
">HHL4s", tag_data[i * 12 + 2 : (i + 1) * 12 + 2] ">HHL4s", tag_data[i * 12 + 2 : (i + 1) * 12 + 2]
) )

View File

@ -1204,7 +1204,7 @@ def _compute_regular_polygon_vertices(
degrees = 360 / n_sides degrees = 360 / n_sides
# Start with the bottom left polygon vertex # Start with the bottom left polygon vertex
current_angle = (270 - 0.5 * degrees) + rotation current_angle = (270 - 0.5 * degrees) + rotation
for _ in range(0, n_sides): for _ in range(n_sides):
angles.append(current_angle) angles.append(current_angle)
current_angle += degrees current_angle += degrees
if current_angle > 360: if current_angle > 360:
@ -1227,4 +1227,4 @@ def _color_diff(
first = color1 if isinstance(color1, tuple) else (color1,) first = color1 if isinstance(color1, tuple) else (color1,)
second = color2 if isinstance(color2, tuple) else (color2,) second = color2 if isinstance(color2, tuple) else (color2,)
return sum(abs(first[i] - second[i]) for i in range(0, len(second))) return sum(abs(first[i] - second[i]) for i in range(len(second)))

View File

@ -438,7 +438,7 @@ class ImageFile(Image.Image):
return self.tell() != frame return self.tell() != frame
class StubHandler: class StubHandler(abc.ABC):
def open(self, im: StubImageFile) -> None: def open(self, im: StubImageFile) -> None:
pass pass
@ -447,7 +447,7 @@ class StubHandler:
pass pass
class StubImageFile(ImageFile): class StubImageFile(ImageFile, metaclass=abc.ABCMeta):
""" """
Base class for stub image loaders. Base class for stub image loaders.
@ -455,9 +455,9 @@ class StubImageFile(ImageFile):
certain format, but relies on external code to load the file. certain format, but relies on external code to load the file.
""" """
@abc.abstractmethod
def _open(self) -> None: def _open(self) -> None:
msg = "StubImageFile subclass must implement _open" pass
raise NotImplementedError(msg)
def load(self) -> Image.core.PixelAccess | None: def load(self) -> Image.core.PixelAccess | None:
loader = self._load() loader = self._load()
@ -471,10 +471,10 @@ class StubImageFile(ImageFile):
self.__dict__ = image.__dict__ self.__dict__ = image.__dict__
return image.load() return image.load()
@abc.abstractmethod
def _load(self) -> StubHandler | None: def _load(self) -> StubHandler | None:
"""(Hook) Find actual image loader.""" """(Hook) Find actual image loader."""
msg = "StubImageFile subclass must implement _load" pass
raise NotImplementedError(msg)
class Parser: class Parser:

View File

@ -27,7 +27,7 @@ if TYPE_CHECKING:
from ._typing import NumpyArray from ._typing import NumpyArray
class Filter: class Filter(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
pass pass

View File

@ -213,14 +213,14 @@ def colorize(
blue = [] blue = []
# Create the low-end values # Create the low-end values
for i in range(0, blackpoint): for i in range(blackpoint):
red.append(rgb_black[0]) red.append(rgb_black[0])
green.append(rgb_black[1]) green.append(rgb_black[1])
blue.append(rgb_black[2]) blue.append(rgb_black[2])
# Create the mapping (2-color) # Create the mapping (2-color)
if rgb_mid is None: if rgb_mid is None:
range_map = range(0, whitepoint - blackpoint) range_map = range(whitepoint - blackpoint)
for i in range_map: for i in range_map:
red.append( red.append(
@ -235,8 +235,8 @@ def colorize(
# Create the mapping (3-color) # Create the mapping (3-color)
else: else:
range_map1 = range(0, midpoint - blackpoint) range_map1 = range(midpoint - blackpoint)
range_map2 = range(0, whitepoint - midpoint) range_map2 = range(whitepoint - midpoint)
for i in range_map1: for i in range_map1:
red.append( red.append(
@ -256,7 +256,7 @@ def colorize(
blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2)) blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2))
# Create the high-end values # Create the high-end values
for i in range(0, 256 - whitepoint): for i in range(256 - whitepoint):
red.append(rgb_white[0]) red.append(rgb_white[0])
green.append(rgb_white[1]) green.append(rgb_white[1])
blue.append(rgb_white[2]) blue.append(rgb_white[2])

View File

@ -192,7 +192,7 @@ if sys.platform == "darwin":
register(MacViewer) register(MacViewer)
class UnixViewer(Viewer): class UnixViewer(abc.ABC, Viewer):
format = "PNG" format = "PNG"
options = {"compress_level": 1, "save_all": True} options = {"compress_level": 1, "save_all": True}

View File

@ -569,7 +569,7 @@ def _getmp(self: JpegImageFile) -> dict[int, Any] | None:
mpentries = [] mpentries = []
try: try:
rawmpentries = mp[0xB002] rawmpentries = mp[0xB002]
for entrynum in range(0, quant): for entrynum in range(quant):
unpackedentry = struct.unpack_from( unpackedentry = struct.unpack_from(
f"{endianness}LLLHH", rawmpentries, entrynum * 16 f"{endianness}LLLHH", rawmpentries, entrynum * 16
) )

View File

@ -1584,7 +1584,7 @@ class TiffImageFile(ImageFile.ImageFile):
# byte order. # byte order.
elif rawmode == "I;16": elif rawmode == "I;16":
rawmode = "I;16N" rawmode = "I;16N"
elif rawmode.endswith(";16B") or rawmode.endswith(";16L"): elif rawmode.endswith((";16B", ";16L")):
rawmode = rawmode[:-1] + "N" rawmode = rawmode[:-1] + "N"
# Offset in the tile tuple is 0, we go from 0,0 to # Offset in the tile tuple is 0, we go from 0,0 to
@ -2295,9 +2295,7 @@ class AppendingTiffWriter(io.BytesIO):
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
encoderinfo = im.encoderinfo.copy() append_images = list(im.encoderinfo.get("append_images", []))
encoderconfig = im.encoderconfig
append_images = list(encoderinfo.get("append_images", []))
if not hasattr(im, "n_frames") and not append_images: if not hasattr(im, "n_frames") and not append_images:
return _save(im, fp, filename) return _save(im, fp, filename)
@ -2305,12 +2303,11 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
try: try:
with AppendingTiffWriter(fp) as tf: with AppendingTiffWriter(fp) as tf:
for ims in [im] + append_images: for ims in [im] + append_images:
ims.encoderinfo = encoderinfo if not hasattr(ims, "encoderinfo"):
ims.encoderconfig = encoderconfig ims.encoderinfo = {}
if not hasattr(ims, "n_frames"): if not hasattr(ims, "encoderconfig"):
nfr = 1 ims.encoderconfig = ()
else: nfr = getattr(ims, "n_frames", 1)
nfr = ims.n_frames
for idx in range(nfr): for idx in range(nfr):
ims.seek(idx) ims.seek(idx)

View File

@ -615,6 +615,7 @@ static const struct j2k_decode_unpacker j2k_unpackers[] = {
{"RGBA", OPJ_CLRSPC_GRAY, 2, 0, j2ku_graya_la}, {"RGBA", OPJ_CLRSPC_GRAY, 2, 0, j2ku_graya_la},
{"RGBA", OPJ_CLRSPC_SRGB, 3, 1, j2ku_srgb_rgb}, {"RGBA", OPJ_CLRSPC_SRGB, 3, 1, j2ku_srgb_rgb},
{"RGBA", OPJ_CLRSPC_SYCC, 3, 1, j2ku_sycc_rgb}, {"RGBA", OPJ_CLRSPC_SYCC, 3, 1, j2ku_sycc_rgb},
{"RGBA", OPJ_CLRSPC_GRAY, 4, 1, j2ku_srgba_rgba},
{"RGBA", OPJ_CLRSPC_SRGB, 4, 1, j2ku_srgba_rgba}, {"RGBA", OPJ_CLRSPC_SRGB, 4, 1, j2ku_srgba_rgba},
{"RGBA", OPJ_CLRSPC_SYCC, 4, 1, j2ku_sycca_rgba}, {"RGBA", OPJ_CLRSPC_SYCC, 4, 1, j2ku_sycca_rgba},
{"CMYK", OPJ_CLRSPC_CMYK, 4, 1, j2ku_srgba_rgba}, {"CMYK", OPJ_CLRSPC_CMYK, 4, 1, j2ku_srgba_rgba},

View File

@ -109,6 +109,39 @@ path_dealloc(PyPathObject *path) {
#define PyPath_Check(op) (Py_TYPE(op) == &PyPathType) #define PyPath_Check(op) (Py_TYPE(op) == &PyPathType)
static int
assign_item_to_array(double *xy, Py_ssize_t j, PyObject *op) {
if (PyFloat_Check(op)) {
xy[j++] = PyFloat_AS_DOUBLE(op);
} else if (PyLong_Check(op)) {
xy[j++] = (float)PyLong_AS_LONG(op);
} else if (PyNumber_Check(op)) {
xy[j++] = PyFloat_AsDouble(op);
} else if (PyList_Check(op)) {
for (int k = 0; k < 2; k++) {
PyObject *op1 = PyList_GetItemRef(op, k);
if (op1 == NULL) {
return -1;
}
j = assign_item_to_array(xy, j, op1);
Py_DECREF(op1);
if (j == -1) {
return -1;
}
}
} else {
double x, y;
if (PyArg_ParseTuple(op, "dd", &x, &y)) {
xy[j++] = x;
xy[j++] = y;
} else {
PyErr_SetString(PyExc_ValueError, "incorrect coordinate type");
return -1;
}
}
return j;
}
Py_ssize_t Py_ssize_t
PyPath_Flatten(PyObject *data, double **pxy) { PyPath_Flatten(PyObject *data, double **pxy) {
Py_ssize_t i, j, n; Py_ssize_t i, j, n;
@ -164,48 +197,32 @@ PyPath_Flatten(PyObject *data, double **pxy) {
return -1; return -1;
} }
#define assign_item_to_array(op, decref) \
if (PyFloat_Check(op)) { \
xy[j++] = PyFloat_AS_DOUBLE(op); \
} else if (PyLong_Check(op)) { \
xy[j++] = (float)PyLong_AS_LONG(op); \
} else if (PyNumber_Check(op)) { \
xy[j++] = PyFloat_AsDouble(op); \
} else if (PyArg_ParseTuple(op, "dd", &x, &y)) { \
xy[j++] = x; \
xy[j++] = y; \
} else { \
PyErr_SetString(PyExc_ValueError, "incorrect coordinate type"); \
if (decref) { \
Py_DECREF(op); \
} \
free(xy); \
return -1; \
} \
if (decref) { \
Py_DECREF(op); \
}
/* Copy table to path array */ /* Copy table to path array */
if (PyList_Check(data)) { if (PyList_Check(data)) {
for (i = 0; i < n; i++) { for (i = 0; i < n; i++) {
double x, y;
PyObject *op = PyList_GetItemRef(data, i); PyObject *op = PyList_GetItemRef(data, i);
if (op == NULL) { if (op == NULL) {
free(xy); free(xy);
return -1; return -1;
} }
assign_item_to_array(op, 1); j = assign_item_to_array(xy, j, op);
Py_DECREF(op);
if (j == -1) {
free(xy);
return -1;
}
} }
} else if (PyTuple_Check(data)) { } else if (PyTuple_Check(data)) {
for (i = 0; i < n; i++) { for (i = 0; i < n; i++) {
double x, y;
PyObject *op = PyTuple_GET_ITEM(data, i); PyObject *op = PyTuple_GET_ITEM(data, i);
assign_item_to_array(op, 0); j = assign_item_to_array(xy, j, op);
if (j == -1) {
free(xy);
return -1;
}
} }
} else { } else {
for (i = 0; i < n; i++) { for (i = 0; i < n; i++) {
double x, y;
PyObject *op = PySequence_GetItem(data, i); PyObject *op = PySequence_GetItem(data, i);
if (!op) { if (!op) {
/* treat IndexError as end of sequence */ /* treat IndexError as end of sequence */
@ -217,7 +234,12 @@ PyPath_Flatten(PyObject *data, double **pxy) {
return -1; return -1;
} }
} }
assign_item_to_array(op, 1); j = assign_item_to_array(xy, j, op);
Py_DECREF(op);
if (j == -1) {
free(xy);
return -1;
}
} }
} }

View File

@ -120,7 +120,7 @@ V = {
"LIBPNG": "1.6.47", "LIBPNG": "1.6.47",
"LIBWEBP": "1.5.0", "LIBWEBP": "1.5.0",
"OPENJPEG": "2.5.3", "OPENJPEG": "2.5.3",
"TIFF": "4.6.0", "TIFF": "4.7.0",
"XZ": "5.6.4", "XZ": "5.6.4",
"ZLIBNG": "2.2.4", "ZLIBNG": "2.2.4",
} }