From 3407f765cc81f5be9046fe51c36f7cf1dec6790b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 28 Feb 2025 10:28:48 +1100 Subject: [PATCH 01/19] Document using encoderinfo on subsequent frames from #8483 --- src/PIL/Image.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 6a2aa3e4c..d5bfda40e 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2475,7 +2475,21 @@ class Image: format to use is determined from the filename extension. If a file object was used instead of a filename, this parameter should always be used. - :param params: Extra parameters to the image writer. + :param params: Extra parameters to the image writer. These can also be + set on the image itself through ``encoderinfo``. This is useful when + saving multiple images:: + + # Saving XMP data to a single image + from PIL import Image + red = Image.new("RGB", (1, 1), "#f00") + red.save("out.mpo", xmp=b"test") + + # Saving XMP data to the second frame of an image + from PIL import Image + black = Image.new("RGB", (1, 1)) + red = Image.new("RGB", (1, 1), "#f00") + red.encoderinfo = {"xmp": b"test"} + black.save("out.mpo", save_all=True, append_images=[red]) :returns: None :exception ValueError: If the output format could not be determined from the file name. Use the format option to solve this. From 5c93145061953d8633397bb79ace396ab1e71eb5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 28 Feb 2025 22:16:52 +1100 Subject: [PATCH 02/19] Allow encoderconfig and encoderinfo to be set for appended TIFF images --- Tests/test_file_tiff.py | 12 ++++++++++++ docs/handbook/image-file-formats.rst | 4 +--- src/PIL/TiffImagePlugin.py | 15 ++++++--------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index a8a407963..dff961648 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -661,6 +661,18 @@ class TestFileTiff: assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im.tag_v2[278] == 256 + im = hopper() + im2 = Image.new("L", (128, 128)) + im2.encoderinfo = {"tiffinfo": {278: 256}} + im.save(outfile, save_all=True, append_images=[im2]) + + with Image.open(outfile) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + assert im.tag_v2[278] == 128 + + im.seek(1) + assert im.tag_v2[278] == 256 + def test_strip_raw(self) -> None: infile = "Tests/images/tiff_strip_raw.tif" with Image.open(infile) as im: diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index a915ee4e2..219a070f3 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -1162,9 +1162,7 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum **append_images** A list of images to append as additional frames. Each of the - images in the list can be single or multiframe images. Note however, that for - correct results, all the appended images should have the same - ``encoderinfo`` and ``encoderconfig`` properties. + images in the list can be single or multiframe images. .. versionadded:: 4.2.0 diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 0454038e8..4e6526be9 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -2295,9 +2295,7 @@ class AppendingTiffWriter(io.BytesIO): def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: - encoderinfo = im.encoderinfo.copy() - encoderconfig = im.encoderconfig - append_images = list(encoderinfo.get("append_images", [])) + append_images = list(im.encoderinfo.get("append_images", [])) if not hasattr(im, "n_frames") and not append_images: return _save(im, fp, filename) @@ -2305,12 +2303,11 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: with AppendingTiffWriter(fp) as tf: for ims in [im] + append_images: - ims.encoderinfo = encoderinfo - ims.encoderconfig = encoderconfig - if not hasattr(ims, "n_frames"): - nfr = 1 - else: - nfr = ims.n_frames + if not hasattr(ims, "encoderinfo"): + ims.encoderinfo = {} + if not hasattr(ims, "encoderconfig"): + ims.encoderconfig = () + nfr = getattr(ims, "n_frames", 1) for idx in range(nfr): ims.seek(idx) From 92cc9bf9027c4767967264a9622f8cde674e3c02 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 3 Mar 2025 08:46:20 +1100 Subject: [PATCH 03/19] Support reading grayscale images with 4 channels --- Tests/test_file_jpeg2k.py | 12 ++++++++++++ src/libImaging/Jpeg2KDecode.c | 1 + 2 files changed, 13 insertions(+) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 5748fa5a1..01172fdbb 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -313,6 +313,18 @@ def test_rgba(ext: str) -> None: assert im.mode == "RGBA" +def test_grayscale_four_channels() -> None: + with open("Tests/images/rgb_trns_ycbc.jp2", "rb") as fp: + data = fp.read() + + # Change color space to OPJ_CLRSPC_GRAY + data = data[:76] + b"\x11" + data[77:] + + with Image.open(BytesIO(data)) as im: + im.load() + assert im.mode == "RGBA" + + @pytest.mark.skipif( not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index 4f185b529..cc6955ca5 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -615,6 +615,7 @@ static const struct j2k_decode_unpacker j2k_unpackers[] = { {"RGBA", OPJ_CLRSPC_GRAY, 2, 0, j2ku_graya_la}, {"RGBA", OPJ_CLRSPC_SRGB, 3, 1, j2ku_srgb_rgb}, {"RGBA", OPJ_CLRSPC_SYCC, 3, 1, j2ku_sycc_rgb}, + {"RGBA", OPJ_CLRSPC_GRAY, 4, 1, j2ku_srgba_rgba}, {"RGBA", OPJ_CLRSPC_SRGB, 4, 1, j2ku_srgba_rgba}, {"RGBA", OPJ_CLRSPC_SYCC, 4, 1, j2ku_sycca_rgba}, {"CMYK", OPJ_CLRSPC_CMYK, 4, 1, j2ku_srgba_rgba}, From c0b5d013f6e3313456848f3969231e7ee3ee6031 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 4 Mar 2025 22:19:06 +1100 Subject: [PATCH 04/19] Test bad image size and unknown PCX mode --- Tests/test_file_pcx.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index b3f38c3e5..aa24189f4 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io from pathlib import Path import pytest @@ -36,6 +37,28 @@ def test_sanity(tmp_path: Path) -> None: im.save(f) +def test_bad_image_size() -> None: + with open("Tests/images/pil184.pcx", "rb") as fp: + data = fp.read() + data = data[:4] + b"\xff\xff" + data[6:] + + b = io.BytesIO(data) + with pytest.raises(SyntaxError, match="bad PCX image size"): + with PcxImagePlugin.PcxImageFile(b): + pass + + +def test_unknown_mode() -> None: + with open("Tests/images/pil184.pcx", "rb") as fp: + data = fp.read() + data = data[:3] + b"\xff" + data[4:] + + b = io.BytesIO(data) + with pytest.raises(OSError, match="unknown PCX mode"): + with Image.open(b): + pass + + def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" From 3607d1ade397fc5a5b41f2a0607a15927e2810fa Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 5 Mar 2025 00:03:37 +1100 Subject: [PATCH 05/19] Use match argument --- Tests/test_file_libtiff.py | 6 ++---- Tests/test_file_ppm.py | 14 +++++--------- Tests/test_file_tiff.py | 3 +-- Tests/test_file_webp.py | 3 +-- Tests/test_image.py | 3 +-- Tests/test_imagedraw.py | 7 +++---- Tests/test_imagefile.py | 3 +-- Tests/test_imagemorph.py | 26 ++++++++++---------------- Tests/test_imagepath.py | 12 ++---------- 9 files changed, 26 insertions(+), 51 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 369c2db1b..f284c3f2f 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1140,11 +1140,9 @@ class TestFileLibTiff(LibTiffTestCase): def test_realloc_overflow(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(TiffImagePlugin, "READ_LIBTIFF", True) with Image.open("Tests/images/tiff_overflow_rows_per_strip.tif") as im: - with pytest.raises(OSError) as e: - im.load() - # Assert that the error code is IMAGING_CODEC_MEMORY - assert str(e.value) == "decoder error -9" + with pytest.raises(OSError, match="decoder error -9"): + im.load() @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index d87192ca5..c93a8c73a 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -293,12 +293,10 @@ def test_header_token_too_long(tmp_path: Path) -> None: with open(path, "wb") as f: f.write(b"P6\n 01234567890") - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Token too long in file header: 01234567890"): with Image.open(path): pass - assert str(e.value) == "Token too long in file header: 01234567890" - def test_truncated_file(tmp_path: Path) -> None: # Test EOF in header @@ -306,12 +304,10 @@ def test_truncated_file(tmp_path: Path) -> None: with open(path, "wb") as f: f.write(b"P6") - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Reached EOF while reading header"): with Image.open(path): pass - assert str(e.value) == "Reached EOF while reading header" - # Test EOF for PyDecoder fp = BytesIO(b"P5 3 1 4") with Image.open(fp) as im: @@ -335,12 +331,12 @@ def test_invalid_maxval(maxval: bytes, tmp_path: Path) -> None: with open(path, "wb") as f: f.write(b"P6\n3 1 " + maxval) - with pytest.raises(ValueError) as e: + with pytest.raises( + ValueError, match="maxval must be greater than 0 and less than 65536" + ): with Image.open(path): pass - assert str(e.value) == "maxval must be greater than 0 and less than 65536" - def test_neg_ppm() -> None: # Storage.c accepted negative values for xsize, ysize. the diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index a8a407963..c1ccf3fe2 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -134,9 +134,8 @@ class TestFileTiff: def test_set_legacy_api(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="Not allowing setting of legacy api"): ifd.legacy_api = False - assert str(e.value) == "Not allowing setting of legacy api" def test_xyres_tiff(self) -> None: filename = "Tests/images/pil168.tif" diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index abe888241..d8c4eb589 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -154,9 +154,8 @@ class TestFileWebp: @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_write_encoding_error_message(self, tmp_path: Path) -> None: im = Image.new("RGB", (15000, 15000)) - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="encoding error 6"): im.save(tmp_path / "temp.webp", method=0) - assert str(e.value) == "encoding error 6" @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None: diff --git a/Tests/test_image.py b/Tests/test_image.py index 5474f951c..d64816b1e 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -65,9 +65,8 @@ class TestImage: @pytest.mark.parametrize("mode", ("", "bad", "very very long")) def test_image_modes_fail(self, mode: str) -> None: - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="unrecognized image mode"): Image.new(mode, (1, 1)) - assert str(e.value) == "unrecognized image mode" def test_exception_inheritance(self) -> None: assert issubclass(UnidentifiedImageError, OSError) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 232cbb16c..1af4455b8 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1626,7 +1626,7 @@ def test_compute_regular_polygon_vertices( 0, ValueError, "bounding_circle should contain 2D coordinates " - "and a radius (e.g. (x, y, r) or ((x, y), r) )", + r"and a radius \(e.g. \(x, y, r\) or \(\(x, y\), r\) \)", ), ( 3, @@ -1640,7 +1640,7 @@ def test_compute_regular_polygon_vertices( ((50, 50, 50), 25), 0, ValueError, - "bounding_circle centre should contain 2D coordinates (e.g. (x, y))", + r"bounding_circle centre should contain 2D coordinates \(e.g. \(x, y\)\)", ), ( 3, @@ -1665,9 +1665,8 @@ def test_compute_regular_polygon_vertices_input_error_handling( expected_error: type[Exception], error_message: str, ) -> None: - with pytest.raises(expected_error) as e: + with pytest.raises(expected_error, match=error_message): ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) # type: ignore[arg-type] - assert str(e.value) == error_message def test_continuous_horizontal_edges_polygon() -> None: diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index b05d29dae..c60a475a3 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -176,9 +176,8 @@ class TestImageFile: b"0" * ImageFile.SAFEBLOCK ) # only SAFEBLOCK bytes, so that the header is truncated ) - with pytest.raises(OSError) as e: + with pytest.raises(OSError, match="Truncated File Read"): BmpImagePlugin.BmpImageFile(b) - assert str(e.value) == "Truncated File Read" @skip_unless_feature("zlib") def test_truncated_with_errors(self) -> None: diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index 6180a7b5d..515e29cea 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -80,15 +80,12 @@ def test_lut(op: str) -> None: def test_no_operator_loaded() -> None: im = Image.new("L", (1, 1)) mop = ImageMorph.MorphOp() - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="No operator loaded"): mop.apply(im) - assert str(e.value) == "No operator loaded" - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="No operator loaded"): mop.match(im) - assert str(e.value) == "No operator loaded" - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="No operator loaded"): mop.save_lut("") - assert str(e.value) == "No operator loaded" # Test the named patterns @@ -238,15 +235,12 @@ def test_incorrect_mode() -> None: im = hopper("RGB") mop = ImageMorph.MorphOp(op_name="erosion8") - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Image mode must be L"): mop.apply(im) - assert str(e.value) == "Image mode must be L" - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Image mode must be L"): mop.match(im) - assert str(e.value) == "Image mode must be L" - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="Image mode must be L"): mop.get_on_pixels(im) - assert str(e.value) == "Image mode must be L" def test_add_patterns() -> None: @@ -279,9 +273,10 @@ def test_pattern_syntax_error() -> None: lb.add_patterns(new_patterns) # Act / Assert - with pytest.raises(Exception) as e: + with pytest.raises( + Exception, match='Syntax error in pattern "a pattern with a syntax error"' + ): lb.build_lut() - assert str(e.value) == 'Syntax error in pattern "a pattern with a syntax error"' def test_load_invalid_mrl() -> None: @@ -290,9 +285,8 @@ def test_load_invalid_mrl() -> None: mop = ImageMorph.MorphOp() # Act / Assert - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="Wrong size operator file!"): mop.load_lut(invalid_mrl) - assert str(e.value) == "Wrong size operator file!" def test_roundtrip_mrl(tmp_path: Path) -> None: diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index 1b1ee6bac..1ebf12d22 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -81,13 +81,9 @@ def test_path_constructors( def test_invalid_path_constructors( coords: tuple[str, str] | Sequence[Sequence[int]], ) -> None: - # Act - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="incorrect coordinate type"): ImagePath.Path(coords) - # Assert - assert str(e.value) == "incorrect coordinate type" - @pytest.mark.parametrize( "coords", @@ -99,13 +95,9 @@ def test_invalid_path_constructors( ), ) def test_path_odd_number_of_coordinates(coords: Sequence[int]) -> None: - # Act - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match="wrong number of coordinates"): ImagePath.Path(coords) - # Assert - assert str(e.value) == "wrong number of coordinates" - @pytest.mark.parametrize( "coords, expected", From 2309f0fa60bae05881907e374afffc2257376fbc Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 5 Mar 2025 21:30:24 +1100 Subject: [PATCH 06/19] Inherit classes with abstractmethod from ABC --- src/PIL/BlpImagePlugin.py | 2 +- src/PIL/Image.py | 4 ++-- src/PIL/ImageFile.py | 2 +- src/PIL/ImageFilter.py | 2 +- src/PIL/ImageShow.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 5747c1252..f7be7746d 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -291,7 +291,7 @@ class BlpImageFile(ImageFile.ImageFile): self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)] -class _BLPBaseDecoder(ImageFile.PyDecoder): +class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder): _pulls_fd = True def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 684c87c4d..c9c9c2e1b 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2966,7 +2966,7 @@ class Image: # Abstract handlers. -class ImagePointHandler: +class ImagePointHandler(abc.ABC): """ Used as a mixin by point transforms (for use with :py:meth:`~PIL.Image.Image.point`) @@ -2977,7 +2977,7 @@ class ImagePointHandler: pass -class ImageTransformHandler: +class ImageTransformHandler(abc.ABC): """ Used as a mixin by geometry transforms (for use with :py:meth:`~PIL.Image.Image.transform`) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index c3901d488..4bc70cc76 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -438,7 +438,7 @@ class ImageFile(Image.Image): return self.tell() != frame -class StubHandler: +class StubHandler(abc.ABC): def open(self, im: StubImageFile) -> None: pass diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 1c8b29b11..05829d0c6 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from ._typing import NumpyArray -class Filter: +class Filter(abc.ABC): @abc.abstractmethod def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: pass diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index d62893d9c..dd240fb55 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -192,7 +192,7 @@ if sys.platform == "darwin": register(MacViewer) -class UnixViewer(Viewer): +class UnixViewer(abc.ABC, Viewer): format = "PNG" options = {"compress_level": 1, "save_all": True} From d186a2a8d60ea1889d3c02c54da9c01076d233e1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 5 Mar 2025 21:50:09 +1100 Subject: [PATCH 07/19] Replace NotImplementedError with abstractmethod --- src/PIL/ImageFile.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 4bc70cc76..1bf8a7e5f 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -447,7 +447,7 @@ class StubHandler(abc.ABC): pass -class StubImageFile(ImageFile): +class StubImageFile(ImageFile, metaclass=abc.ABCMeta): """ Base class for stub image loaders. @@ -455,9 +455,9 @@ class StubImageFile(ImageFile): certain format, but relies on external code to load the file. """ + @abc.abstractmethod def _open(self) -> None: - msg = "StubImageFile subclass must implement _open" - raise NotImplementedError(msg) + pass def load(self) -> Image.core.PixelAccess | None: loader = self._load() @@ -471,10 +471,10 @@ class StubImageFile(ImageFile): self.__dict__ = image.__dict__ return image.load() + @abc.abstractmethod def _load(self) -> StubHandler | None: """(Hook) Find actual image loader.""" - msg = "StubImageFile subclass must implement _load" - raise NotImplementedError(msg) + pass class Parser: From 5ba72a9b54bd744724e4ec269268c16dd61bb472 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Thu, 6 Mar 2025 04:15:55 +1100 Subject: [PATCH 08/19] Merge pull request #8800 from radarhere/path_lists Allow coords to be sequence of lists --- Tests/test_imagedraw.py | 4 +++ Tests/test_imagepath.py | 17 ++------- src/path.c | 78 ++++++++++++++++++++++++++--------------- 3 files changed, 57 insertions(+), 42 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 967bd6738..2767418ea 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -39,6 +39,8 @@ BBOX = (((X0, Y0), (X1, Y1)), [(X0, Y0), (X1, Y1)], (X0, Y0, X1, Y1), [X0, Y0, X POINTS = ( ((10, 10), (20, 40), (30, 30)), [(10, 10), (20, 40), (30, 30)], + ([10, 10], [20, 40], [30, 30]), + [[10, 10], [20, 40], [30, 30]], (10, 10, 20, 40, 30, 30), [10, 10, 20, 40, 30, 30], ) @@ -46,6 +48,8 @@ POINTS = ( KITE_POINTS = ( ((10, 50), (70, 10), (90, 50), (70, 90), (10, 50)), [(10, 50), (70, 10), (90, 50), (70, 90), (10, 50)], + ([10, 50], [70, 10], [90, 50], [70, 90], [10, 50]), + [[10, 50], [70, 10], [90, 50], [70, 90], [10, 50]], ) diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index 1ebf12d22..ad8acde49 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -68,21 +68,10 @@ def test_path_constructors( assert list(p) == [(0.0, 1.0)] -@pytest.mark.parametrize( - "coords", - ( - ("a", "b"), - ([0, 1],), - [[0, 1]], - ([0.0, 1.0],), - [[0.0, 1.0]], - ), -) -def test_invalid_path_constructors( - coords: tuple[str, str] | Sequence[Sequence[int]], -) -> None: +def test_invalid_path_constructors() -> None: + # Arrange / Act with pytest.raises(ValueError, match="incorrect coordinate type"): - ImagePath.Path(coords) + ImagePath.Path(("a", "b")) @pytest.mark.parametrize( diff --git a/src/path.c b/src/path.c index 5affe3a1f..38300547c 100644 --- a/src/path.c +++ b/src/path.c @@ -109,6 +109,39 @@ path_dealloc(PyPathObject *path) { #define PyPath_Check(op) (Py_TYPE(op) == &PyPathType) +static int +assign_item_to_array(double *xy, Py_ssize_t j, PyObject *op) { + if (PyFloat_Check(op)) { + xy[j++] = PyFloat_AS_DOUBLE(op); + } else if (PyLong_Check(op)) { + xy[j++] = (float)PyLong_AS_LONG(op); + } else if (PyNumber_Check(op)) { + xy[j++] = PyFloat_AsDouble(op); + } else if (PyList_Check(op)) { + for (int k = 0; k < 2; k++) { + PyObject *op1 = PyList_GetItemRef(op, k); + if (op1 == NULL) { + return -1; + } + j = assign_item_to_array(xy, j, op1); + Py_DECREF(op1); + if (j == -1) { + return -1; + } + } + } else { + double x, y; + if (PyArg_ParseTuple(op, "dd", &x, &y)) { + xy[j++] = x; + xy[j++] = y; + } else { + PyErr_SetString(PyExc_ValueError, "incorrect coordinate type"); + return -1; + } + } + return j; +} + Py_ssize_t PyPath_Flatten(PyObject *data, double **pxy) { Py_ssize_t i, j, n; @@ -164,48 +197,32 @@ PyPath_Flatten(PyObject *data, double **pxy) { return -1; } -#define assign_item_to_array(op, decref) \ - if (PyFloat_Check(op)) { \ - xy[j++] = PyFloat_AS_DOUBLE(op); \ - } else if (PyLong_Check(op)) { \ - xy[j++] = (float)PyLong_AS_LONG(op); \ - } else if (PyNumber_Check(op)) { \ - xy[j++] = PyFloat_AsDouble(op); \ - } else if (PyArg_ParseTuple(op, "dd", &x, &y)) { \ - xy[j++] = x; \ - xy[j++] = y; \ - } else { \ - PyErr_SetString(PyExc_ValueError, "incorrect coordinate type"); \ - if (decref) { \ - Py_DECREF(op); \ - } \ - free(xy); \ - return -1; \ - } \ - if (decref) { \ - Py_DECREF(op); \ - } - /* Copy table to path array */ if (PyList_Check(data)) { for (i = 0; i < n; i++) { - double x, y; PyObject *op = PyList_GetItemRef(data, i); if (op == NULL) { free(xy); return -1; } - assign_item_to_array(op, 1); + j = assign_item_to_array(xy, j, op); + Py_DECREF(op); + if (j == -1) { + free(xy); + return -1; + } } } else if (PyTuple_Check(data)) { for (i = 0; i < n; i++) { - double x, y; PyObject *op = PyTuple_GET_ITEM(data, i); - assign_item_to_array(op, 0); + j = assign_item_to_array(xy, j, op); + if (j == -1) { + free(xy); + return -1; + } } } else { for (i = 0; i < n; i++) { - double x, y; PyObject *op = PySequence_GetItem(data, i); if (!op) { /* treat IndexError as end of sequence */ @@ -217,7 +234,12 @@ PyPath_Flatten(PyObject *data, double **pxy) { return -1; } } - assign_item_to_array(op, 1); + j = assign_item_to_array(xy, j, op); + Py_DECREF(op); + if (j == -1) { + free(xy); + return -1; + } } } From e946c7b14adc7a0aaa9ac883de217a3c0e556a81 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 7 Mar 2025 02:42:10 +1100 Subject: [PATCH 09/19] Test using _seek to skip frames (#8804) Co-authored-by: Andrew Murray --- Tests/test_file_apng.py | 5 ++++- Tests/test_file_fli.py | 3 +++ Tests/test_file_gif.py | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 9d5154fca..b9a036173 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -34,8 +34,11 @@ def test_apng_basic() -> None: with pytest.raises(EOFError): im.seek(2) - # test rewind support im.seek(0) + with pytest.raises(ValueError, match="cannot seek to frame 2"): + im._seek(2) + + # test rewind support assert im.getpixel((0, 0)) == (255, 0, 0, 255) assert im.getpixel((64, 32)) == (255, 0, 0, 255) im.seek(1) diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index 8adbd30f5..8a95af62d 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -160,6 +160,9 @@ def test_seek() -> None: assert_image_equal_tofile(im, "Tests/images/a_fli.png") + with pytest.raises(ValueError, match="cannot seek to frame 52"): + im._seek(52) + @pytest.mark.parametrize( "test_file", diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index d2592da97..dbbffc675 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -410,6 +410,10 @@ def test_seek() -> None: except EOFError: assert frame_count == 5 + img.seek(0) + with pytest.raises(ValueError, match="cannot seek to frame 2"): + img._seek(2) + def test_seek_info() -> None: with Image.open("Tests/images/iss634.gif") as im: From 5575c1d072a127e10cf0077ca9dcb7d54ea06c06 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 8 Mar 2025 09:56:00 +1100 Subject: [PATCH 10/19] Test missing frame size --- Tests/test_file_fli.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index 8a95af62d..2f39adc69 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import warnings import pytest @@ -132,6 +133,15 @@ def test_eoferror() -> None: im.seek(n_frames - 1) +def test_missing_frame_size() -> None: + with open(animated_test_file, "rb") as fp: + data = fp.read() + data = data[:6188] + with Image.open(io.BytesIO(data)) as im: + with pytest.raises(EOFError, match="missing frame size"): + im.seek(1) + + def test_seek_tell() -> None: with Image.open(animated_test_file) as im: layer_number = im.tell() From baa299a6f42d691653f3a6dcfb067be567319c27 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 8 Mar 2025 09:54:46 +1100 Subject: [PATCH 11/19] Moved code outside of context manager --- Tests/test_file_jpeg2k.py | 10 +++++----- Tests/test_file_pcx.py | 4 ++-- Tests/test_font_bdf.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 01172fdbb..e429610ad 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -317,12 +317,12 @@ 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:] + # 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" + with Image.open(BytesIO(data)) as im: + im.load() + assert im.mode == "RGBA" @pytest.mark.skipif( diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index aa24189f4..21c32268c 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -40,7 +40,7 @@ def test_sanity(tmp_path: Path) -> None: 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:] + data = data[:4] + b"\xff\xff" + data[6:] b = io.BytesIO(data) with pytest.raises(SyntaxError, match="bad PCX image size"): @@ -51,7 +51,7 @@ def test_bad_image_size() -> None: def test_unknown_mode() -> None: with open("Tests/images/pil184.pcx", "rb") as fp: data = fp.read() - data = data[:3] + b"\xff" + data[4:] + data = data[:3] + b"\xff" + data[4:] b = io.BytesIO(data) with pytest.raises(OSError, match="unknown PCX mode"): diff --git a/Tests/test_font_bdf.py b/Tests/test_font_bdf.py index 2ece5457a..b4155c879 100644 --- a/Tests/test_font_bdf.py +++ b/Tests/test_font_bdf.py @@ -20,8 +20,8 @@ def test_sanity() -> None: def test_zero_width_chars() -> None: with open(filename, "rb") as fp: data = fp.read() - data = data[:2650] + b"\x00\x00" + data[2652:] - BdfFontFile.BdfFontFile(io.BytesIO(data)) + data = data[:2650] + b"\x00\x00" + data[2652:] + BdfFontFile.BdfFontFile(io.BytesIO(data)) def test_invalid_file() -> None: From a38d4d2583c9ce6e944d44efdb4d5a7106d42ec5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 10 Mar 2025 22:44:13 +0100 Subject: [PATCH 12/19] Replace deprecated Renovate schedule with cron syntax --- .github/renovate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/renovate.json b/.github/renovate.json index f48b670ec..91fa0426f 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -16,6 +16,6 @@ } ], "schedule": [ - "on the 3rd day of the month" + "* * 3 * *" ] } From 4b9d9f55cdd567d90ea8d885a50489ffd2ccfe2b Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 12 Mar 2025 09:59:25 +1100 Subject: [PATCH 13/19] Updated libtiff to 4.7.0 (#8812) Co-authored-by: Andrew Murray --- .github/workflows/wheels-dependencies.sh | 2 +- docs/installation/building-from-source.rst | 2 +- winbuild/build_prepare.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 202a8935d..e9c54536e 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -43,7 +43,7 @@ LIBPNG_VERSION=1.6.47 JPEGTURBO_VERSION=3.1.0 OPENJPEG_VERSION=2.5.3 XZ_VERSION=5.6.4 -TIFF_VERSION=4.6.0 +TIFF_VERSION=4.7.0 LCMS2_VERSION=2.17 ZLIB_NG_VERSION=2.2.4 LIBWEBP_VERSION=1.5.0 diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index b400a3436..2790bc2e6 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -44,7 +44,7 @@ Many of Pillow's features require external libraries: * **libtiff** provides compressed TIFF functionality - * Pillow has been tested with libtiff versions **3.x** and **4.0-4.6.0** + * Pillow has been tested with libtiff versions **3.x** and **4.0-4.7.0** * **libfreetype** provides type related services diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index a645722d8..ea3d99394 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -120,7 +120,7 @@ V = { "LIBPNG": "1.6.47", "LIBWEBP": "1.5.0", "OPENJPEG": "2.5.3", - "TIFF": "4.6.0", + "TIFF": "4.7.0", "XZ": "5.6.4", "ZLIBNG": "2.2.4", } From d97441cb86f1f9187ee303210ccdd34b2f899f6b Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:49:59 +1100 Subject: [PATCH 14/19] Install libtiff-dev (#8816) Co-authored-by: Andrew Murray --- .ci/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/install.sh b/.ci/install.sh index e61752750..62677005e 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -20,7 +20,7 @@ fi set -e if [[ $(uname) != CYGWIN* ]]; then - sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ + sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\ ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\ cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ sway wl-clipboard libopenblas-dev From 5efcaa460372e91d9d18f2f812f590728903927a Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:50:28 +1100 Subject: [PATCH 15/19] Updated Ghostscript to 10.5.0 (#8814) Co-authored-by: Andrew Murray --- .github/workflows/test-windows.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index ef49ff332..a780c7835 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -94,8 +94,8 @@ jobs: choco install nasm --no-progress echo "C:\Program Files\NASM" >> $env:GITHUB_PATH - choco install ghostscript --version=10.4.0 --no-progress - echo "C:\Program Files\gs\gs10.04.0\bin" >> $env:GITHUB_PATH + choco install ghostscript --version=10.5.0 --no-progress + echo "C:\Program Files\gs\gs10.05.0\bin" >> $env:GITHUB_PATH # Install extra test images xcopy /S /Y Tests\test-images\* Tests\images From 7b725a8fc4e97f150793c8be529c9ae0c80dd64f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Mar 2025 13:04:26 +1100 Subject: [PATCH 16/19] DXT3 images are read in RGBA mode --- docs/handbook/image-file-formats.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index a1d93821f..b0e20fa84 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -68,7 +68,7 @@ by DirectX. DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode. .. versionadded:: 3.4.0 - DXT3 images can be read in ``RGB`` mode and DX10 images can be read in + DXT3 images can be read in ``RGBA`` mode and DX10 images can be read in ``RGB`` and ``RGBA`` mode. .. versionadded:: 6.0.0 From db30ef74232af617b331e2995cc25ac039def106 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 15 Mar 2025 19:49:03 +0000 Subject: [PATCH 17/19] Update dependency cibuildwheel to v2.23.1 --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 2fd3eb6ff..f2109ed61 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.23.0 +cibuildwheel==2.23.1 From 9953256bbf324458b4a7c4e5d070e5e1c4add0dd Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 16 Mar 2025 22:12:14 +1100 Subject: [PATCH 18/19] Revert "Use Ubuntu 22.04 for 24.04 ppc64le and s390x" This reverts commit e31441fc41ff54217317b61db395dfc9b5a0dc79. --- .github/workflows/test-docker.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index da5e191da..0d9033413 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -35,6 +35,10 @@ jobs: matrix: os: ["ubuntu-latest"] docker: [ + # Run slower jobs first to give them a headstart and reduce waiting time + ubuntu-24.04-noble-ppc64le, + ubuntu-24.04-noble-s390x, + # Then run the remainder alpine, amazon-2-amd64, amazon-2023-amd64, @@ -52,13 +56,9 @@ jobs: dockerTag: [main] include: - docker: "ubuntu-24.04-noble-ppc64le" - os: "ubuntu-22.04" qemu-arch: "ppc64le" - dockerTag: main - docker: "ubuntu-24.04-noble-s390x" - os: "ubuntu-22.04" qemu-arch: "s390x" - dockerTag: main - docker: "ubuntu-24.04-noble-arm64v8" os: "ubuntu-24.04-arm" dockerTag: main From 7767e83e6c40c01f0d61df480153bf9cf6d12a06 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 16 Mar 2025 22:24:13 +1100 Subject: [PATCH 19/19] Use action to setup qemu --- .github/workflows/test-docker.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 0d9033413..25aef55fb 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -75,8 +75,9 @@ jobs: - name: Set up QEMU if: "matrix.qemu-arch" - run: | - docker run --rm --privileged aptman/qus -s -- -p ${{ matrix.qemu-arch }} + uses: docker/setup-qemu-action@v3 + with: + platforms: ${{ matrix.qemu-arch }} - name: Docker pull run: |