diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index 5b09ef64c9c..dcb3996e24e 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -6,6 +6,7 @@ numpy packaging pytest sphinx +types-atheris types-defusedxml types-olefile types-setuptools diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ab478e848a..14d75c6892e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.0 + rev: v0.6.3 hooks: - id: ruff args: [--exit-non-zero-on-fix] @@ -50,7 +50,7 @@ repos: exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.29.1 + rev: 0.29.2 hooks: - id: check-github-workflows - id: check-readthedocs diff --git a/CHANGES.rst b/CHANGES.rst index 9da76d82a12..b43466833bd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,33 @@ Changelog (Pillow) 11.0.0 (unreleased) ------------------- +- Return early from BoxBlur if either width or height is zero #8347 + [radarhere] + +- Check text is either string or bytes #8308 + [radarhere] + +- Added writing XMP bytes to JPEG #8286 + [radarhere] + +- Support JPEG2000 RGBA palettes #8256 + [radarhere] + +- Expand C image to match GIF frame image size #8237 + [radarhere] + +- Allow saving I;16 images as PPM #8231 + [radarhere] + +- When IFD is missing, connect get_ifd() dictionary to Exif #8230 + [radarhere] + +- Skip truncated ICO mask if LOAD_TRUNCATED_IMAGES is enabled #8180 + [radarhere] + +- Treat unknown JPEG2000 colorspace as unspecified #8343 + [radarhere] + - Updated error message when saving WebP with invalid width or height #8322 [radarhere, hugovk] diff --git a/Tests/images/test_extents_transparency.gif b/Tests/images/test_extents_transparency.gif new file mode 100644 index 00000000000..739c82ad47c Binary files /dev/null and b/Tests/images/test_extents_transparency.gif differ diff --git a/Tests/oss-fuzz/fuzz_font.py b/Tests/oss-fuzz/fuzz_font.py index 8788d7021d3..f4e40ea3671 100755 --- a/Tests/oss-fuzz/fuzz_font.py +++ b/Tests/oss-fuzz/fuzz_font.py @@ -16,8 +16,9 @@ import atheris +from atheris.import_hook import instrument_imports -with atheris.instrument_imports(): +with instrument_imports(): import sys import fuzzers diff --git a/Tests/oss-fuzz/fuzz_pillow.py b/Tests/oss-fuzz/fuzz_pillow.py index 9137391b656..d58a6f0150d 100644 --- a/Tests/oss-fuzz/fuzz_pillow.py +++ b/Tests/oss-fuzz/fuzz_pillow.py @@ -14,8 +14,9 @@ import atheris +from atheris.import_hook import instrument_imports -with atheris.instrument_imports(): +with instrument_imports(): import sys import fuzzers diff --git a/Tests/oss-fuzz/python.supp b/Tests/oss-fuzz/python.supp index 94cc87db97d..36385d67266 100644 --- a/Tests/oss-fuzz/python.supp +++ b/Tests/oss-fuzz/python.supp @@ -1,5 +1,5 @@ { - + Memcheck:Cond ... fun:encode_current_locale diff --git a/Tests/test_box_blur.py b/Tests/test_box_blur.py index 1f6ed61277a..cb267b2048f 100644 --- a/Tests/test_box_blur.py +++ b/Tests/test_box_blur.py @@ -71,6 +71,11 @@ def test_color_modes() -> None: box_blur(sample.convert("YCbCr")) +@pytest.mark.parametrize("size", ((0, 1), (1, 0))) +def test_zero_dimension(size: tuple[int, int]) -> None: + assert box_blur(Image.new("L", size)).size == size + + def test_radius_0() -> None: assert_blur( sample, diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 8cefdb628e6..571fe1b9ac0 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1378,16 +1378,39 @@ def test_lzw_bits() -> None: im.load() -def test_extents() -> None: - with Image.open("Tests/images/test_extents.gif") as im: - assert im.size == (100, 100) +@pytest.mark.parametrize( + "test_file, loading_strategy", + ( + ("test_extents.gif", GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST), + ( + "test_extents.gif", + GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY, + ), + ( + "test_extents_transparency.gif", + GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST, + ), + ), +) +def test_extents( + test_file: str, loading_strategy: GifImagePlugin.LoadingStrategy +) -> None: + GifImagePlugin.LOADING_STRATEGY = loading_strategy + try: + with Image.open("Tests/images/" + test_file) as im: + assert im.size == (100, 100) - # Check that n_frames does not change the size - assert im.n_frames == 2 - assert im.size == (100, 100) + # Check that n_frames does not change the size + assert im.n_frames == 2 + assert im.size == (100, 100) - im.seek(1) - assert im.size == (150, 150) + im.seek(1) + assert im.size == (150, 150) + + im.load() + assert im.im.size == (150, 150) + finally: + GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST def test_missing_background() -> None: diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index fa8c11d5a95..37770498a0a 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -6,7 +6,7 @@ import pytest -from PIL import IcoImagePlugin, Image, ImageDraw +from PIL import IcoImagePlugin, Image, ImageDraw, ImageFile from .helper import assert_image_equal, assert_image_equal_tofile, hopper @@ -241,3 +241,29 @@ def test_draw_reloaded(tmp_path: Path) -> None: with Image.open(outfile) as im: assert_image_equal_tofile(im, "Tests/images/hopper_draw.ico") + + +def test_truncated_mask() -> None: + # 1 bpp + with open("Tests/images/hopper_mask.ico", "rb") as fp: + data = fp.read() + + ImageFile.LOAD_TRUNCATED_IMAGES = True + data = data[:-3] + + try: + with Image.open(io.BytesIO(data)) as im: + with Image.open("Tests/images/hopper_mask.png") as expected: + assert im.mode == "1" + + # 32 bpp + output = io.BytesIO() + expected = hopper("RGBA") + expected.save(output, "ico", bitmap_format="bmp") + + data = output.getvalue()[:-1] + + with Image.open(io.BytesIO(data)) as im: + assert im.mode == "RGB" + finally: + ImageFile.LOAD_TRUNCATED_IMAGES = False diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 8e6221750e2..affc1862563 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -991,6 +991,21 @@ def test_getxmp_padded(self) -> None: else: assert im.getxmp() == {"xmpmeta": None} + def test_save_xmp(self, tmp_path: Path) -> None: + f = str(tmp_path / "temp.jpg") + im = hopper() + im.save(f, xmp=b"XMP test") + with Image.open(f) as reloaded: + assert reloaded.info["xmp"] == b"XMP test" + + im.info["xmp"] = b"1" * 65504 + im.save(f) + with Image.open(f) as reloaded: + assert reloaded.info["xmp"] == b"1" * 65504 + + with pytest.raises(ValueError): + im.save(f, xmp=b"1" * 65505) + @pytest.mark.timeout(timeout=1) def test_eof(self) -> None: # Even though this decoder never says that it is finished diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 5e11465ca1d..26b085601b6 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -182,6 +182,15 @@ def test_restricted_icc_profile() -> None: ImageFile.LOAD_TRUNCATED_IMAGES = False +@pytest.mark.skipif( + not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" +) +def test_unknown_colorspace() -> None: + with Image.open(f"{EXTRA_DIR}/file8.jp2") as im: + im.load() + assert im.mode == "L" + + def test_header_errors() -> None: for path in ( "Tests/images/invalid_header_length.jp2", @@ -391,6 +400,13 @@ def test_pclr() -> None: assert len(im.palette.colors) == 256 assert im.palette.colors[(255, 255, 255)] == 0 + with Image.open( + f"{EXTRA_DIR}/147af3f1083de4393666b7d99b01b58b_signal_sigsegv_130c531_6155_5136.jp2" + ) as im: + assert im.mode == "P" + assert len(im.palette.colors) == 139 + assert im.palette.colors[(0, 0, 0, 0)] == 0 + def test_comment() -> None: with Image.open("Tests/images/comment.jp2") as im: diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 0a61830a40a..fb08d613a56 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -95,7 +95,9 @@ def test_16bit_pgm_write(tmp_path: Path) -> None: with Image.open("Tests/images/16_bit_binary.pgm") as im: filename = str(tmp_path / "temp.pgm") im.save(filename, "PPM") + assert_image_equal_tofile(im, filename) + im.convert("I;16").save(filename, "PPM") assert_image_equal_tofile(im, filename) diff --git a/Tests/test_image.py b/Tests/test_image.py index 4e0840cf119..b0d71966779 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -775,6 +775,14 @@ def test_empty_exif(self) -> None: exif.load(b"Exif\x00\x00") assert not dict(exif) + def test_empty_get_ifd(self) -> None: + exif = Image.Exif() + ifd = exif.get_ifd(0x8769) + assert ifd == {} + + ifd[36864] = b"0220" + assert exif.get_ifd(0x8769) == {36864: b"0220"} + @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index 1d50dfa4096..eb2309e0ffe 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -24,7 +24,7 @@ def test(mode: str) -> tuple[tuple[int, ...], str, int]: def test_with_dtype(dtype: npt.DTypeLike) -> None: ai = numpy.array(im, dtype=dtype) - assert ai.dtype == dtype + assert ai.dtype.type is dtype # assert test("1") == ((100, 128), '|b1', 1600)) assert test("L") == ((100, 128), "|u1", 12800) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index a8bd798c12d..0985dce9cf1 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -238,7 +238,9 @@ def _open(self) -> None: self.rawmode = "RGBA" self._mode = "RGBA" self._size = (200, 200) - self.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None)] + self.tile = [ + ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 32, None) + ] class CodecsTest: @@ -268,7 +270,7 @@ def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [("MOCK", None, 32, None)] + im.tile = [ImageFile._Tile("MOCK", None, 32, None)] im.load() @@ -281,12 +283,12 @@ def test_negsize(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)] + im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)] with pytest.raises(ValueError): im.load() - im.tile = [("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)] + im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)] with pytest.raises(ValueError): im.load() @@ -294,12 +296,20 @@ def test_oversize(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [("MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None)] + im.tile = [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None + ) + ] with pytest.raises(ValueError): im.load() - im.tile = [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None)] + im.tile = [ + ImageFile._Tile( + "MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None + ) + ] with pytest.raises(ValueError): im.load() @@ -336,7 +346,7 @@ def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) - im.tile = [("MOCK", None, 32, None)] + im.tile = [ImageFile._Tile("MOCK", None, 32, None)] fp = BytesIO() ImageFile._save(im, fp, [ImageFile._Tile("MOCK", None, 0, "RGB")]) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 340cc47420b..953706010f6 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -1113,6 +1113,9 @@ def test_bytes(font: ImageFont.FreeTypeFont) -> None: ) assert font.getmask2(b"test")[1] == font.getmask2("test")[1] + with pytest.raises(TypeError): + font.getlength((0, 0)) # type: ignore[arg-type] + @pytest.mark.parametrize( "test_file", diff --git a/depends/install_imagequant.sh b/depends/install_imagequant.sh index 9dd7742ed34..867b31e4d19 100755 --- a/depends/install_imagequant.sh +++ b/depends/install_imagequant.sh @@ -2,7 +2,7 @@ # install libimagequant archive_name=libimagequant -archive_version=4.3.1 +archive_version=4.3.3 archive=$archive_name-$archive_version diff --git a/docs/example/DdsImagePlugin.py b/docs/example/DdsImagePlugin.py index caa852b1fe1..d8f53607697 100644 --- a/docs/example/DdsImagePlugin.py +++ b/docs/example/DdsImagePlugin.py @@ -246,7 +246,9 @@ def _open(self) -> None: msg = f"Unimplemented pixel format {repr(fourcc)}" raise NotImplementedError(msg) - self.tile = [(self.decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))] + self.tile = [ + ImageFile._Tile(self.decoder, (0, 0) + self.size, 0, (self.mode, 0, 1)) + ] def load_seek(self, pos: int) -> None: pass diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 71787311f35..4b517582786 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -64,7 +64,7 @@ Many of Pillow's features require external libraries: * **libimagequant** provides improved color quantization - * Pillow has been tested with libimagequant **2.6-4.3.1** + * Pillow has been tested with libimagequant **2.6-4.3.3** * Libimagequant is licensed GPLv3, which is more restrictive than the Pillow license, therefore we will not be distributing binaries with libimagequant support enabled. diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index 2fb4ff8c018..31d60cd832e 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -33,6 +33,10 @@ Internal Modules Provides a convenient way to import type hints that are not available on some Python versions. +.. py:class:: IntegralLike + + Typing alias. + .. py:class:: NumpyArray Typing alias. diff --git a/pyproject.toml b/pyproject.toml index e4ae73acf52..4042bd9ee20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,7 +163,3 @@ follow_imports = "silent" warn_redundant_casts = true warn_unreachable = true warn_unused_ignores = true -exclude = [ - '^Tests/oss-fuzz/fuzz_font.py$', - '^Tests/oss-fuzz/fuzz_pillow.py$', -] diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 1112276dc6e..8e29898dfc8 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -273,7 +273,7 @@ def _open(self) -> None: raise BLPFormatError(msg) self._mode = "RGBA" if self._blp_alpha_depth else "RGB" - self.tile = [(decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))] + self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))] class _BLPBaseDecoder(ImageFile.PyDecoder): @@ -372,7 +372,10 @@ def _decode_jpeg_stream(self) -> None: Image._decompression_bomb_check(image.size) if image.mode == "CMYK": decoder_name, extents, offset, args = image.tile[0] - image.tile = [(decoder_name, extents, offset, (args[0], "CMYK"))] + assert isinstance(args, tuple) + image.tile = [ + ImageFile._Tile(decoder_name, extents, offset, (args[0], "CMYK")) + ] r, g, b = image.convert("RGB").split() reversed_image = Image.merge("RGB", (b, g, r)) self.set_as_raw(reversed_image.tobytes()) diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index de441c6b5c2..16c8cf6ff04 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -296,7 +296,7 @@ def _bitmap(self, header: int = 0, offset: int = 0) -> None: args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3)) args.append(file_info["direction"]) self.tile = [ - ( + ImageFile._Tile( decoder_name, (0, 0, file_info["width"], file_info["height"]), offset or self.fp.tell(), @@ -387,7 +387,7 @@ def decode(self, buffer: bytes) -> tuple[int, int]: if self.fd.tell() % 2 != 0: self.fd.seek(1, os.SEEK_CUR) rawmode = "L" if self.mode == "L" else "P" - self.set_as_raw(bytes(data), (rawmode, 0, self.args[-1])) + self.set_as_raw(bytes(data), rawmode, (0, self.args[-1])) return -1, 0 diff --git a/src/PIL/CurImagePlugin.py b/src/PIL/CurImagePlugin.py index 85e2145e766..c4be0cecaf8 100644 --- a/src/PIL/CurImagePlugin.py +++ b/src/PIL/CurImagePlugin.py @@ -17,7 +17,7 @@ # from __future__ import annotations -from . import BmpImagePlugin, Image +from . import BmpImagePlugin, Image, ImageFile from ._binary import i16le as i16 from ._binary import i32le as i32 @@ -64,7 +64,7 @@ def _open(self) -> None: # patch up the bitmap height self._size = self.size[0], self.size[1] // 2 d, e, o, a = self.tile[0] - self.tile[0] = d, (0, 0) + self.size, o, a + self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a) # diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index a57e4aea2d5..6315324701a 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -367,7 +367,7 @@ def _open(self) -> None: mask_count = 3 masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4)) - self.tile = [("dds_rgb", extents, 0, (bitcount, masks))] + self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))] return elif pfflags & DDPF.LUMINANCE: if bitcount == 8: diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index 4846054b1e4..abacc5e67f2 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -67,7 +67,7 @@ def _open(self) -> None: raise ValueError(msg) offset += self.fp.tell() - 80 - self.tile = [(decoder_name, (0, 0) + self.size, offset, args)] + self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)] def _get_size( self, headers: dict[bytes, bytes], prefix: bytes diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index 52d1fce3167..666390be9ee 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -159,7 +159,7 @@ def _seek(self, frame: int) -> None: framesize = i32(s) self.decodermaxblock = framesize - self.tile = [("fli", (0, 0) + self.size, self.__offset, None)] + self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset, None)] self.__offset += framesize diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index 64d48635e8b..8fef51076b4 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -166,7 +166,7 @@ def _open_subimage(self, index: int = 1, subimage: int = 0) -> None: if compression == 0: self.tile.append( - ( + ImageFile._Tile( "raw", (x, y, x1, y1), i32(s, i) + 28, @@ -177,7 +177,7 @@ def _open_subimage(self, index: int = 1, subimage: int = 0) -> None: elif compression == 1: # FIXME: the fill decoder is not implemented self.tile.append( - ( + ImageFile._Tile( "fill", (x, y, x1, y1), i32(s, i) + 28, @@ -205,7 +205,7 @@ def _open_subimage(self, index: int = 1, subimage: int = 0) -> None: jpegmode = rawmode self.tile.append( - ( + ImageFile._Tile( "jpeg", (x, y, x1, y1), i32(s, i) + 28, diff --git a/src/PIL/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py index 5acbb4912f7..ddb469bc332 100644 --- a/src/PIL/FtexImagePlugin.py +++ b/src/PIL/FtexImagePlugin.py @@ -93,9 +93,9 @@ def _open(self) -> None: if format == Format.DXT1: self._mode = "RGBA" - self.tile = [("bcn", (0, 0) + self.size, 0, 1)] + self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))] elif format == Format.UNCOMPRESSED: - self.tile = [("raw", (0, 0) + self.size, 0, ("RGB", 0, 1))] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, ("RGB", 0, 1))] else: msg = f"Invalid texture compression format: {repr(format)}" raise ValueError(msg) diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py index 88b87a22cd6..f1b4969f2c4 100644 --- a/src/PIL/GdImageFile.py +++ b/src/PIL/GdImageFile.py @@ -72,7 +72,7 @@ def _open(self) -> None: ) self.tile = [ - ( + ImageFile._Tile( "raw", (0, 0) + self.size, 7 + true_color_offset + 4 + 256 * 4, diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index fa8137fb935..a27f7aae9dc 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -407,7 +407,7 @@ def _rgb(color: int) -> tuple[int, int, int]: elif self.mode not in ("RGB", "RGBA"): transparency = frame_transparency self.tile = [ - ( + ImageFile._Tile( "gif", (x0, y0, x1, y1), self.__offset, @@ -438,6 +438,13 @@ def load_prepare(self) -> None: self.im.putpalette("RGB", *self._frame_palette.getdata()) else: self._im = None + if not self._prev_im and self._im is not None and self.size != self.im.size: + expanded_im = Image.core.fill(self.im.mode, self.size) + if self._frame_palette: + expanded_im.putpalette("RGB", *self._frame_palette.getdata()) + expanded_im.paste(self.im, (0, 0) + self.im.size) + + self.im = expanded_im self._mode = temp_mode self._frame_palette = None @@ -455,6 +462,17 @@ def load_end(self) -> None: return if not self._prev_im: return + if self.size != self._prev_im.size: + if self._frame_transparency is not None: + expanded_im = Image.core.fill("RGBA", self.size) + else: + expanded_im = Image.core.fill("P", self.size) + expanded_im.putpalette("RGB", "RGB", self.im.getpalette()) + expanded_im = expanded_im.convert("RGB") + expanded_im.paste(self._prev_im, (0, 0) + self._prev_im.size) + + self._prev_im = expanded_im + assert self._prev_im is not None if self._frame_transparency is not None: self.im.putpalettealpha(self._frame_transparency, 0) frame_im = self.im.convert("RGBA") diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index d0605b75a46..e879f180154 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -228,7 +228,7 @@ def frame(self, idx: int) -> Image.Image: # change tile dimension to only encompass XOR image im._size = (im.size[0], int(im.size[1] / 2)) d, e, o, a = im.tile[0] - im.tile[0] = d, (0, 0) + im.size, o, a + im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a) # figure out where AND mask image starts if header.bpp == 32: @@ -243,13 +243,19 @@ def frame(self, idx: int) -> Image.Image: alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4] # convert to an 8bpp grayscale image - mask = Image.frombuffer( - "L", # 8bpp - im.size, # (w, h) - alpha_bytes, # source chars - "raw", # raw decoder - ("L", 0, -1), # 8bpp inverted, unpadded, reversed - ) + try: + mask = Image.frombuffer( + "L", # 8bpp + im.size, # (w, h) + alpha_bytes, # source chars + "raw", # raw decoder + ("L", 0, -1), # 8bpp inverted, unpadded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise else: # get AND image from end of bitmap w = im.size[0] @@ -267,19 +273,26 @@ def frame(self, idx: int) -> Image.Image: mask_data = self.buf.read(total_bytes) # convert raw data to image - mask = Image.frombuffer( - "1", # 1 bpp - im.size, # (w, h) - mask_data, # source chars - "raw", # raw decoder - ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed - ) + try: + mask = Image.frombuffer( + "1", # 1 bpp + im.size, # (w, h) + mask_data, # source chars + "raw", # raw decoder + ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise # now we have two images, im is XOR image and mask is AND image # apply mask image as alpha channel - im = im.convert("RGBA") - im.putalpha(mask) + if mask: + im = im.convert("RGBA") + im.putalpha(mask) return im diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index b9416508987..f9f47348c66 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -253,7 +253,11 @@ def _open(self) -> None: # use bit decoder (if necessary) bits = int(self.rawmode[2:]) if bits not in [8, 16, 32]: - self.tile = [("bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1))] + self.tile = [ + ImageFile._Tile( + "bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1) + ) + ] return except ValueError: pass @@ -263,13 +267,17 @@ def _open(self) -> None: # ever stumbled upon such a file ;-) size = self.size[0] * self.size[1] self.tile = [ - ("raw", (0, 0) + self.size, offs, ("G", 0, -1)), - ("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), - ("raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1)), + ImageFile._Tile("raw", (0, 0) + self.size, offs, ("G", 0, -1)), + ImageFile._Tile("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), + ImageFile._Tile( + "raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1) + ), ] else: # LabEye/IFUNC files - self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] @property def n_frames(self) -> int: @@ -295,7 +303,9 @@ def seek(self, frame: int) -> None: self.fp = self._fp - self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] def tell(self) -> int: return self.frame diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 95b4c64eef4..1c0cf293633 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -225,6 +225,11 @@ class Quantize(IntEnum): from . import ImageFile, ImageFilter, ImagePalette, ImageQt, TiffImagePlugin from ._typing import NumpyArray, StrOrBytesPath, TypeGuard + + if sys.version_info >= (3, 13): + from types import CapsuleType + else: + CapsuleType = object ID: list[str] = [] OPEN: dict[ str, @@ -1598,7 +1603,7 @@ def get_child_images(self) -> list[ImageFile.ImageFile]: self.fp.seek(offset) return child_images - def getim(self): + def getim(self) -> CapsuleType: """ Returns a capsule that points to the internal image memory. @@ -3641,7 +3646,10 @@ def merge(mode: str, bands: Sequence[Image]) -> Image: def register_open( id: str, - factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], + factory: ( + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile] + | type[ImageFile.ImageFile] + ), accept: Callable[[bytes], bool | str] | None = None, ) -> None: """ @@ -4136,7 +4144,7 @@ def get_ifd(self, tag: int) -> dict[int, Any]: ifd = self._get_ifd_dict(tag_data, tag) if ifd is not None: self._ifds[tag] = ifd - ifd = self._ifds.get(tag, {}) + ifd = self._ifds.setdefault(tag, {}) if tag == ExifTags.IFD.Exif and self._hidden_data: ifd = { k: v diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index a1c33f21974..615961ce508 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -34,12 +34,15 @@ import os import struct import sys -from typing import IO, Any, NamedTuple +from typing import IO, TYPE_CHECKING, Any, NamedTuple, cast from . import Image from ._deprecate import deprecate from ._util import is_path +if TYPE_CHECKING: + from ._typing import StrOrBytesPath + MAXBLOCK = 65536 SAFEBLOCK = 1024 * 1024 @@ -107,32 +110,34 @@ class _Tile(NamedTuple): class ImageFile(Image.Image): """Base class for image file format handlers.""" - def __init__(self, fp=None, filename=None): + def __init__( + self, fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None + ) -> None: super().__init__() self._min_frame = 0 - self.custom_mimetype = None + self.custom_mimetype: str | None = None - self.tile = None + self.tile: list[_Tile] = [] """ A list of tile descriptors, or ``None`` """ self.readonly = 1 # until we know better - self.decoderconfig = () + self.decoderconfig: tuple[Any, ...] = () self.decodermaxblock = MAXBLOCK if is_path(fp): # filename self.fp = open(fp, "rb") - self.filename = fp + self.filename = os.path.realpath(os.fspath(fp)) self._exclusive_fp = True else: # stream - self.fp = fp - self.filename = filename + self.fp = cast(IO[bytes], fp) + self.filename = filename if filename is not None else "" # can be overridden - self._exclusive_fp = None + self._exclusive_fp = False try: try: @@ -155,6 +160,9 @@ def __init__(self, fp=None, filename=None): self.fp.close() raise + def _open(self) -> None: + pass + def get_format_mimetype(self) -> str | None: if self.custom_mimetype: return self.custom_mimetype @@ -178,7 +186,7 @@ def verify(self) -> None: def load(self) -> Image.core.PixelAccess | None: """Load image data based on tile list""" - if self.tile is None: + if not self.tile and self._im is None: msg = "cannot load this image" raise OSError(msg) @@ -214,6 +222,7 @@ def load(self) -> Image.core.PixelAccess | None: args = (args, 0, 1) if ( decoder_name == "raw" + and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode and args[0] in Image._MAPMODES @@ -736,19 +745,22 @@ def decode(self, buffer: bytes) -> tuple[int, int]: msg = "unavailable in base decoder" raise NotImplementedError(msg) - def set_as_raw(self, data: bytes, rawmode=None) -> None: + def set_as_raw( + self, data: bytes, rawmode: str | None = None, extra: tuple[Any, ...] = () + ) -> None: """ Convenience method to set the internal image from a stream of raw data :param data: Bytes to be set :param rawmode: The rawmode to be used for the decoder. If not specified, it will default to the mode of the image + :param extra: Extra arguments for the decoder. :returns: None """ if not rawmode: rawmode = self.mode - d = Image._getdecoder(self.mode, "raw", rawmode) + d = Image._getdecoder(self.mode, "raw", rawmode, extra) assert self.im is not None d.setimage(self.im, self.state.extents()) s = d.decode(data) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 2ab65bfefe4..a82c36ba6b8 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -34,7 +34,7 @@ from enum import IntEnum from io import BytesIO from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict +from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict, cast from . import Image from ._typing import StrOrBytesPath @@ -212,7 +212,7 @@ class FreeTypeFont: def __init__( self, - font: StrOrBytesPath | BinaryIO | None = None, + font: StrOrBytesPath | BinaryIO, size: float = 10, index: int = 0, encoding: str = "", @@ -245,7 +245,7 @@ def __init__( self.layout_engine = layout_engine - def load_from_bytes(f) -> None: + def load_from_bytes(f: IO[bytes]) -> None: self.font_bytes = f.read() self.font = core.getfont( "", size, index, encoding, self.font_bytes, layout_engine @@ -267,7 +267,7 @@ def load_from_bytes(f) -> None: font, size, index, encoding, layout_engine=layout_engine ) else: - load_from_bytes(font) + load_from_bytes(cast(IO[bytes], font)) def __getstate__(self) -> list[Any]: return [self.path, self.size, self.index, self.encoding, self.layout_engine] @@ -781,7 +781,7 @@ def load(filename: str) -> ImageFont: def truetype( - font: StrOrBytesPath | BinaryIO | None = None, + font: StrOrBytesPath | BinaryIO, size: float = 10, index: int = 0, encoding: str = "", @@ -852,7 +852,7 @@ def truetype( :exception ValueError: If the font size is not greater than zero. """ - def freetype(font: StrOrBytesPath | BinaryIO | None) -> FreeTypeFont: + def freetype(font: StrOrBytesPath | BinaryIO) -> FreeTypeFont: return FreeTypeFont(font, size, index, encoding, layout_engine) try: diff --git a/src/PIL/ImtImagePlugin.py b/src/PIL/ImtImagePlugin.py index abb3fb762e7..594c56513cd 100644 --- a/src/PIL/ImtImagePlugin.py +++ b/src/PIL/ImtImagePlugin.py @@ -58,7 +58,7 @@ def _open(self) -> None: if s == b"\x0C": # image data begins self.tile = [ - ( + ImageFile._Tile( "raw", (0, 0) + self.size, self.fp.tell() - len(buffer), diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index a8cd38218f1..60ab7c83f37 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -147,7 +147,9 @@ def _open(self) -> None: # tile if tag == (8, 10): - self.tile = [("iptc", (0, 0) + self.size, offset, compression)] + self.tile = [ + ImageFile._Tile("iptc", (0, 0) + self.size, offset, compression) + ] def load(self) -> Image.core.PixelAccess | None: if len(self.tile) != 1 or self.tile[0][0] != "iptc": diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 02c2e48cfb4..b6ebd562be6 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -18,6 +18,7 @@ import io import os import struct +from collections.abc import Callable from typing import IO, cast from . import Image, ImageFile, ImagePalette, _binary @@ -205,7 +206,7 @@ def _parse_jp2_header( if bitdepth > max_bitdepth: max_bitdepth = bitdepth if max_bitdepth <= 8: - palette = ImagePalette.ImagePalette() + palette = ImagePalette.ImagePalette("RGBA" if npc == 4 else "RGB") for i in range(ne): color: list[int] = [] for value in header.read_fields(">" + ("B" * npc)): @@ -286,7 +287,7 @@ def _open(self) -> None: length = -1 self.tile = [ - ( + ImageFile._Tile( "jpeg2k", (0, 0) + self.size, 0, @@ -316,8 +317,13 @@ def _parse_comment(self) -> None: else: self.fp.seek(length - 2, os.SEEK_CUR) - @property - def reduce(self): + @property # type: ignore[override] + def reduce( + self, + ) -> ( + Callable[[int | tuple[int, int], tuple[int, int, int, int] | None], Image.Image] + | int + ): # https://github.com/python-pillow/Pillow/issues/4343 found that the # new Image 'reduce' method was shadowed by this plugin's 'reduce' # property. This attempts to allow for both scenarios @@ -338,8 +344,9 @@ def load(self) -> Image.core.PixelAccess | None: # Update the reduce and layers settings t = self.tile[0] + assert isinstance(t[3], tuple) t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) - self.tile = [(t[0], (0, 0) + self.size, t[2], t3)] + self.tile = [ImageFile._Tile(t[0], (0, 0) + self.size, t[2], t3)] return ImageFile.ImageFile.load(self) diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index bd4539be420..6510e072e5e 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -372,7 +372,9 @@ def _open(self) -> None: rawmode = self.mode if self.mode == "CMYK": rawmode = "CMYK;I" # assume adobe conventions - self.tile = [("jpeg", (0, 0) + self.size, 0, (rawmode, ""))] + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, 0, (rawmode, "")) + ] # self.__offset = self.fp.tell() break s = self.fp.read(1) @@ -423,6 +425,7 @@ def draft( scale = 1 original_size = self.size + assert isinstance(a, tuple) if a[0] == "RGB" and mode in ["L", "YCbCr"]: self._mode = mode a = mode, "" @@ -432,6 +435,7 @@ def draft( for s in [8, 4, 2, 1]: if scale >= s: break + assert e is not None e = ( e[0], e[1], @@ -441,7 +445,7 @@ def draft( self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s) scale = s - self.tile = [(d, e, o, a)] + self.tile = [ImageFile._Tile(d, e, o, a)] self.decoderconfig = (scale, 0) box = (0, 0, original_size[0] / scale, original_size[1] / scale) @@ -747,17 +751,27 @@ def validate_qtables( extra = info.get("extra", b"") MAX_BYTES_IN_MARKER = 65533 + xmp = info.get("xmp", im.info.get("xmp")) + if xmp: + overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00" + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len + if len(xmp) > max_data_bytes_in_marker: + msg = "XMP data is too long" + raise ValueError(msg) + size = o16(2 + overhead_len + len(xmp)) + extra += b"\xFF\xE1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp + icc_profile = info.get("icc_profile") if icc_profile: - ICC_OVERHEAD_LEN = 14 - MAX_DATA_BYTES_IN_MARKER = MAX_BYTES_IN_MARKER - ICC_OVERHEAD_LEN + overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers)) + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len markers = [] while icc_profile: - markers.append(icc_profile[:MAX_DATA_BYTES_IN_MARKER]) - icc_profile = icc_profile[MAX_DATA_BYTES_IN_MARKER:] + markers.append(icc_profile[:max_data_bytes_in_marker]) + icc_profile = icc_profile[max_data_bytes_in_marker:] i = 1 for marker in markers: - size = o16(2 + ICC_OVERHEAD_LEN + len(marker)) + size = o16(2 + overhead_len + len(marker)) extra += ( b"\xFF\xE2" + size @@ -844,7 +858,7 @@ def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ## # Factory for making JPEG and MPO instances def jpeg_factory( - fp: IO[bytes] | None = None, filename: str | bytes | None = None + fp: IO[bytes], filename: str | bytes | None = None ) -> JpegImageFile | MpoImageFile: im = JpegImageFile(fp, filename) try: diff --git a/src/PIL/McIdasImagePlugin.py b/src/PIL/McIdasImagePlugin.py index 27972236c0a..5dd031be369 100644 --- a/src/PIL/McIdasImagePlugin.py +++ b/src/PIL/McIdasImagePlugin.py @@ -67,7 +67,9 @@ def _open(self) -> None: offset = w[34] + w[15] stride = w[15] + w[10] * w[11] * w[14] - self.tile = [("raw", (0, 0) + self.size, offset, (rawmode, stride, 1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) + ] # -------------------------------------------------------------------- diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 5ed9f56a164..71f89a09a85 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -26,6 +26,7 @@ from . import ( Image, + ImageFile, ImageSequence, JpegImagePlugin, TiffImagePlugin, @@ -145,7 +146,9 @@ def seek(self, frame: int) -> None: if self.info.get("exif") != original_exif: self._reload_exif() - self.tile = [("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])] + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1]) + ] self.__frame = frame def tell(self) -> int: diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index 40e5fa435a8..1363ddd1f7c 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -70,9 +70,9 @@ def _open(self) -> None: self._size = i16(s, 4), i16(s, 6) if s[:4] == b"DanM": - self.tile = [("raw", (0, 0) + self.size, 32, ("1", 0, 1))] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, ("1", 0, 1))] else: - self.tile = [("MSP", (0, 0) + self.size, 32, None)] + self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32, None)] class MspDecoder(ImageFile.PyDecoder): @@ -152,7 +152,7 @@ def decode(self, buffer: bytes) -> tuple[int, int]: msg = f"Corrupted MSP file in row {x}" raise OSError(msg) from e - self.set_as_raw(img.getvalue(), ("1", 0, 1)) + self.set_as_raw(img.getvalue(), "1") return -1, 0 diff --git a/src/PIL/PcdImagePlugin.py b/src/PIL/PcdImagePlugin.py index d9970405c7a..e8ea800a42c 100644 --- a/src/PIL/PcdImagePlugin.py +++ b/src/PIL/PcdImagePlugin.py @@ -47,7 +47,7 @@ def _open(self) -> None: self._mode = "RGB" self._size = 768, 512 # FIXME: not correct for rotated images! - self.tile = [("pcd", (0, 0) + self.size, 96 * 2048, None)] + self.tile = [ImageFile._Tile("pcd", (0, 0) + self.size, 96 * 2048, None)] def load_end(self) -> None: if self.tile_post_rotate: diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index c3c9dd3f137..8445d5cc740 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -128,7 +128,9 @@ def _open(self) -> None: bbox = (0, 0) + self.size logger.debug("size: %sx%s", *self.size) - self.tile = [("pcx", bbox, self.fp.tell(), (rawmode, planes * stride))] + self.tile = [ + ImageFile._Tile("pcx", bbox, self.fp.tell(), (rawmode, planes * stride)) + ] # -------------------------------------------------------------------- diff --git a/src/PIL/PixarImagePlugin.py b/src/PIL/PixarImagePlugin.py index 887b6568bf7..36f565f1c20 100644 --- a/src/PIL/PixarImagePlugin.py +++ b/src/PIL/PixarImagePlugin.py @@ -61,7 +61,9 @@ def _open(self) -> None: # FIXME: to be continued... # create tile descriptor (assuming "dumped") - self.tile = [("raw", (0, 0) + self.size, 1024, (self.mode, 0, 1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, 1024, (self.mode, 0, 1)) + ] # diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index dc7face2f66..c268d7b1a28 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -258,7 +258,9 @@ class iTXt(str): tkey: str | bytes | None @staticmethod - def __new__(cls, text, lang=None, tkey=None): + def __new__( + cls, text: str, lang: str | None = None, tkey: str | None = None + ) -> iTXt: """ :param cls: the class to use when creating the instance :param text: value for this key @@ -368,21 +370,27 @@ def add_text( # PNG image stream (IHDR/IEND) +class _RewindState(NamedTuple): + info: dict[str | tuple[int, int], Any] + tile: list[ImageFile._Tile] + seq_num: int | None + + class PngStream(ChunkStream): - def __init__(self, fp): + def __init__(self, fp: IO[bytes]) -> None: super().__init__(fp) # local copies of Image attributes - self.im_info = {} - self.im_text = {} + self.im_info: dict[str | tuple[int, int], Any] = {} + self.im_text: dict[str, str | iTXt] = {} self.im_size = (0, 0) - self.im_mode = None - self.im_tile = None - self.im_palette = None - self.im_custom_mimetype = None - self.im_n_frames = None - self._seq_num = None - self.rewind_state = None + self.im_mode = "" + self.im_tile: list[ImageFile._Tile] = [] + self.im_palette: tuple[str, bytes] | None = None + self.im_custom_mimetype: str | None = None + self.im_n_frames: int | None = None + self._seq_num: int | None = None + self.rewind_state = _RewindState({}, [], None) self.text_memory = 0 @@ -396,16 +404,16 @@ def check_text_memory(self, chunklen: int) -> None: raise ValueError(msg) def save_rewind(self) -> None: - self.rewind_state = { - "info": self.im_info.copy(), - "tile": self.im_tile, - "seq_num": self._seq_num, - } + self.rewind_state = _RewindState( + self.im_info.copy(), + self.im_tile, + self._seq_num, + ) def rewind(self) -> None: - self.im_info = self.rewind_state["info"].copy() - self.im_tile = self.rewind_state["tile"] - self._seq_num = self.rewind_state["seq_num"] + self.im_info = self.rewind_state.info.copy() + self.im_tile = self.rewind_state.tile + self._seq_num = self.rewind_state.seq_num def chunk_iCCP(self, pos: int, length: int) -> bytes: # ICC profile @@ -459,11 +467,11 @@ def chunk_IHDR(self, pos: int, length: int) -> bytes: def chunk_IDAT(self, pos: int, length: int) -> NoReturn: # image data if "bbox" in self.im_info: - tile = [("zip", self.im_info["bbox"], pos, self.im_rawmode)] + tile = [ImageFile._Tile("zip", self.im_info["bbox"], pos, self.im_rawmode)] else: if self.im_n_frames is not None: self.im_info["default_image"] = True - tile = [("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] + tile = [ImageFile._Tile("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] self.im_tile = tile self.im_idat = length msg = "image data found" diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 7bdaa9fe740..3bda2bf28c0 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -151,7 +151,9 @@ def _open(self) -> None: decoder_name = "ppm" args = rawmode if decoder_name == "raw" else (rawmode, maxval) - self.tile = [(decoder_name, (0, 0) + self.size, self.fp.tell(), args)] + self.tile = [ + ImageFile._Tile(decoder_name, (0, 0) + self.size, self.fp.tell(), args) + ] # @@ -333,7 +335,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: rawmode, head = "1;I", b"P4" elif im.mode == "L": rawmode, head = "L", b"P5" - elif im.mode == "I": + elif im.mode in ("I", "I;16"): rawmode, head = "I;16B", b"P5" elif im.mode in ("RGB", "RGBA"): rawmode, head = "RGB", b"P6" diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index cc99cd6d8cb..8ff5e39088a 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -277,8 +277,8 @@ def read(size: int) -> bytes: def _maketile( file: IO[bytes], mode: str, bbox: tuple[int, int, int, int], channels: int -) -> list[ImageFile._Tile] | None: - tiles = None +) -> list[ImageFile._Tile]: + tiles = [] read = file.read compression = i16(read(2)) @@ -291,7 +291,6 @@ def _maketile( if compression == 0: # # raw compression - tiles = [] for channel in range(channels): layer = mode[channel] if mode == "CMYK": @@ -303,7 +302,6 @@ def _maketile( # # packbits compression i = 0 - tiles = [] bytecount = read(channels * ysize * 2) offset = file.tell() for channel in range(channels): diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index c81d381fb6c..eaf03140291 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -32,7 +32,7 @@ def _open(self) -> None: self._mode = "RGB" if channels == 3 else "RGBA" self.fp.seek(1, os.SEEK_CUR) # colorspace - self.tile = [("qoi", (0, 0) + self._size, self.fp.tell(), None)] + self.tile = [ImageFile._Tile("qoi", (0, 0) + self._size, self.fp.tell(), None)] class QoiDecoder(ImageFile.PyDecoder): diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index 50d97910932..2c205a926be 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -109,19 +109,28 @@ def _open(self) -> None: pagesize = xsize * ysize * bpc if bpc == 2: self.tile = [ - ("SGI16", (0, 0) + self.size, headlen, (self.mode, 0, orientation)) + ImageFile._Tile( + "SGI16", + (0, 0) + self.size, + headlen, + (self.mode, 0, orientation), + ) ] else: self.tile = [] offset = headlen for layer in self.mode: self.tile.append( - ("raw", (0, 0) + self.size, offset, (layer, 0, orientation)) + ImageFile._Tile( + "raw", (0, 0) + self.size, offset, (layer, 0, orientation) + ) ) offset += pagesize elif compression == 1: self.tile = [ - ("sgi_rle", (0, 0) + self.size, headlen, (rawmode, orientation, bpc)) + ImageFile._Tile( + "sgi_rle", (0, 0) + self.size, headlen, (rawmode, orientation, bpc) + ) ] diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 7045ab566de..075073f9fe3 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -154,7 +154,9 @@ def _open(self) -> None: self.rawmode = "F;32F" self._mode = "F" - self.tile = [("raw", (0, 0) + self.size, offset, (self.rawmode, 0, 1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (self.rawmode, 0, 1)) + ] self._fp = self.fp # FIXME: hack @property diff --git a/src/PIL/SunImagePlugin.py b/src/PIL/SunImagePlugin.py index 4e098474ab9..8912379ea3e 100644 --- a/src/PIL/SunImagePlugin.py +++ b/src/PIL/SunImagePlugin.py @@ -124,9 +124,13 @@ def _open(self) -> None: # (https://www.fileformat.info/format/sunraster/egff.htm) if file_type in (0, 1, 3, 4, 5): - self.tile = [("raw", (0, 0) + self.size, offset, (rawmode, stride))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride)) + ] elif file_type == 2: - self.tile = [("sun_rle", (0, 0) + self.size, offset, rawmode)] + self.tile = [ + ImageFile._Tile("sun_rle", (0, 0) + self.size, offset, rawmode) + ] else: msg = "Unsupported Sun Raster file type" raise SyntaxError(msg) diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 5aea363c064..90d5b5cf4ee 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -137,7 +137,7 @@ def _open(self) -> None: if imagetype & 8: # compressed self.tile = [ - ( + ImageFile._Tile( "tga_rle", (0, 0) + self.size, self.fp.tell(), @@ -146,7 +146,7 @@ def _open(self) -> None: ] else: self.tile = [ - ( + ImageFile._Tile( "raw", (0, 0) + self.size, self.fp.tell(), diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index ab23356d4f3..88f75ce8eef 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -61,6 +61,9 @@ from ._util import is_path from .TiffTags import TYPES +if TYPE_CHECKING: + from ._typing import IntegralLike + logger = logging.getLogger(__name__) # Set these to true to force use of libtiff for reading or writing. @@ -291,22 +294,24 @@ def _accept(prefix: bytes) -> bool: def _limit_rational( val: float | Fraction | IFDRational, max_val: int -) -> tuple[float, float]: +) -> tuple[IntegralLike, IntegralLike]: inv = abs(float(val)) > 1 n_d = IFDRational(1 / val if inv else val).limit_rational(max_val) return n_d[::-1] if inv else n_d -def _limit_signed_rational(val, max_val, min_val): +def _limit_signed_rational( + val: IFDRational, max_val: int, min_val: int +) -> tuple[IntegralLike, IntegralLike]: frac = Fraction(val) - n_d = frac.numerator, frac.denominator + n_d: tuple[IntegralLike, IntegralLike] = frac.numerator, frac.denominator - if min(n_d) < min_val: + if min(float(i) for i in n_d) < min_val: n_d = _limit_rational(val, abs(min_val)) - if max(n_d) > max_val: - val = Fraction(*n_d) - n_d = _limit_rational(val, max_val) + n_d_float = tuple(float(i) for i in n_d) + if max(n_d_float) > max_val: + n_d = _limit_rational(n_d_float[0] / n_d_float[1], max_val) return n_d @@ -318,8 +323,10 @@ def _limit_signed_rational(val, max_val, min_val): _write_dispatch = {} -def _delegate(op: str): - def delegate(self, *args): +def _delegate(op: str) -> Any: + def delegate( + self: IFDRational, *args: tuple[float, ...] + ) -> bool | float | Fraction: return getattr(self._val, op)(*args) return delegate @@ -358,7 +365,10 @@ def __init__( self._numerator = value.numerator self._denominator = value.denominator else: - self._numerator = value + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, value) + else: + self._numerator = value self._denominator = denominator if denominator == 0: @@ -371,14 +381,14 @@ def __init__( self._val = Fraction(value / denominator) @property - def numerator(self): + def numerator(self) -> IntegralLike: return self._numerator @property def denominator(self) -> int: return self._denominator - def limit_rational(self, max_denominator: int) -> tuple[float, int]: + def limit_rational(self, max_denominator: int) -> tuple[IntegralLike, int]: """ :param max_denominator: Integer, the maximum denominator value @@ -406,14 +416,18 @@ def __eq__(self, other: object) -> bool: val = float(val) return val == other - def __getstate__(self) -> list[float | Fraction]: + def __getstate__(self) -> list[float | Fraction | IntegralLike]: return [self._val, self._numerator, self._denominator] - def __setstate__(self, state: list[float | Fraction]) -> None: + def __setstate__(self, state: list[float | Fraction | IntegralLike]) -> None: IFDRational.__init__(self, 0) _val, _numerator, _denominator = state + assert isinstance(_val, (float, Fraction)) self._val = _val - self._numerator = _numerator + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, _numerator) + else: + self._numerator = _numerator assert isinstance(_denominator, int) self._denominator = _denominator @@ -471,8 +485,8 @@ def decorator(func: _LoaderFunc) -> _LoaderFunc: return decorator -def _register_writer(idx: int): - def decorator(func): +def _register_writer(idx: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: _write_dispatch[idx] = func # noqa: F821 return func @@ -1126,7 +1140,7 @@ class TiffImageFile(ImageFile.ImageFile): def __init__( self, - fp: StrOrBytesPath | IO[bytes] | None = None, + fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None, ) -> None: self.tag_v2: ImageFileDirectory_v2 @@ -1298,7 +1312,7 @@ def _load_libtiff(self) -> Image.core.PixelAccess | None: # (self._compression, (extents tuple), # 0, (rawmode, self._compression, fp)) extents = self.tile[0][1] - args = list(self.tile[0][3]) + args = self.tile[0][3] # To be nice on memory footprint, if there's a # file descriptor, use that instead of reading @@ -1316,11 +1330,12 @@ def _load_libtiff(self) -> Image.core.PixelAccess | None: fp = False if fp: - args[2] = fp + assert isinstance(args, tuple) + args_list = list(args) + args_list[2] = fp + args = tuple(args_list) - decoder = Image._getdecoder( - self.mode, "libtiff", tuple(args), self.decoderconfig - ) + decoder = Image._getdecoder(self.mode, "libtiff", args, self.decoderconfig) try: decoder.setimage(self.im, extents) except ValueError as e: @@ -1538,7 +1553,7 @@ def _setup(self) -> None: # Offset in the tile tuple is 0, we go from 0,0 to # w,h, and we only do this once -- eds a = (rawmode, self._compression, False, self.tag_v2.offset) - self.tile.append(("libtiff", (0, 0, xsize, ysize), 0, a)) + self.tile.append(ImageFile._Tile("libtiff", (0, 0, xsize, ysize), 0, a)) elif STRIPOFFSETS in self.tag_v2 or TILEOFFSETS in self.tag_v2: # striped image @@ -1571,7 +1586,7 @@ def _setup(self) -> None: args = (tile_rawmode, int(stride), 1) self.tile.append( - ( + ImageFile._Tile( self._compression, (x, y, min(x + w, xsize), min(y + h, ysize)), offset, diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index fc8f4fc2edc..f8d6168bafd 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -142,7 +142,7 @@ def load(self) -> Image.core.PixelAccess | None: if self.fp and self._exclusive_fp: self.fp.close() self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.rawmode)] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.rawmode)] return super().load() diff --git a/src/PIL/XVThumbImagePlugin.py b/src/PIL/XVThumbImagePlugin.py index c84adaca215..5d1f201a454 100644 --- a/src/PIL/XVThumbImagePlugin.py +++ b/src/PIL/XVThumbImagePlugin.py @@ -73,7 +73,11 @@ def _open(self) -> None: self.palette = ImagePalette.raw("RGB", PALETTE) - self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1))] + self.tile = [ + ImageFile._Tile( + "raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1) + ) + ] # -------------------------------------------------------------------- diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index 6c2e32804e3..f3d490a840f 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -67,7 +67,7 @@ def _open(self) -> None: self._mode = "1" self._size = xsize, ysize - self.tile = [("xbm", (0, 0) + self.size, m.end(), None)] + self.tile = [ImageFile._Tile("xbm", (0, 0) + self.size, m.end(), None)] def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index 8d56331e6aa..1fc6c0c39d5 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -101,7 +101,9 @@ def _open(self) -> None: self._mode = "P" self.palette = ImagePalette.raw("RGB", b"".join(palette)) - self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), ("P", 0, 1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, self.fp.tell(), ("P", 0, 1)) + ] def load_read(self, read_bytes: int) -> bytes: # diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index b6bb8d89a88..093778464da 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Any, Protocol, TypeVar, Union if TYPE_CHECKING: + from numbers import _IntegralLike as IntegralLike + try: import numpy.typing as npt @@ -38,4 +40,4 @@ def read(self, __length: int = ...) -> _T_co: ... StrOrBytesPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] -__all__ = ["TypeGuard", "StrOrBytesPath", "SupportsRead"] +__all__ = ["IntegralLike", "StrOrBytesPath", "SupportsRead", "TypeGuard"] diff --git a/src/_imagingft.c b/src/_imagingft.c index af5d80f87ce..c539ec2672c 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -272,7 +272,7 @@ text_layout_raqm( } set_text = raqm_set_text(rq, text, size); PyMem_Free(text); - } else { + } else if (PyBytes_Check(string)) { char *buffer; PyBytes_AsStringAndSize(string, &buffer, &size); if (!buffer || !size) { @@ -281,6 +281,9 @@ text_layout_raqm( goto failed; } set_text = raqm_set_text_utf8(rq, buffer, size); + } else { + PyErr_SetString(PyExc_TypeError, "expected string or bytes"); + goto failed; } if (!set_text) { PyErr_SetString(PyExc_ValueError, "raqm_set_text() failed"); @@ -425,8 +428,11 @@ text_layout_fallback( if (PyUnicode_Check(string)) { count = PyUnicode_GET_LENGTH(string); - } else { + } else if (PyBytes_Check(string)) { PyBytes_AsStringAndSize(string, &buffer, &count); + } else { + PyErr_SetString(PyExc_TypeError, "expected string or bytes"); + return 0; } if (count == 0) { return 0; diff --git a/src/libImaging/BoxBlur.c b/src/libImaging/BoxBlur.c index 51cb7e1024e..ed91541fed4 100644 --- a/src/libImaging/BoxBlur.c +++ b/src/libImaging/BoxBlur.c @@ -238,6 +238,9 @@ ImagingBoxBlur(Imaging imOut, Imaging imIn, float xradius, float yradius, int n) int i; Imaging imTransposed; + if (imOut->xsize == 0 || imOut->ysize == 0) { + return imOut; + } if (n < 1) { return ImagingError_ValueError("number of passes must be greater than zero"); } diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index 5b3d7ffc424..a81a14a09c0 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -698,8 +698,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { } /* Check that this image is something we can handle */ - if (image->numcomps < 1 || image->numcomps > 4 || - image->color_space == OPJ_CLRSPC_UNKNOWN) { + if (image->numcomps < 1 || image->numcomps > 4) { state->errcode = IMAGING_CODEC_BROKEN; state->state = J2K_STATE_FAILED; goto quick_exit; @@ -744,7 +743,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { /* Find the correct unpacker */ color_space = image->color_space; - if (color_space == OPJ_CLRSPC_UNSPECIFIED) { + if (color_space == OPJ_CLRSPC_UNKNOWN || color_space == OPJ_CLRSPC_UNSPECIFIED) { switch (image->numcomps) { case 1: case 2: diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 1021e4f229f..423aaa96607 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -110,9 +110,9 @@ def cmd_msbuild( V = { "BROTLI": "1.1.0", - "FREETYPE": "2.13.2", + "FREETYPE": "2.13.3", "FRIBIDI": "1.0.15", - "HARFBUZZ": "8.5.0", + "HARFBUZZ": "9.0.0", "JPEGTURBO": "3.0.3", "LCMS2": "2.16", "LIBPNG": "1.6.43", @@ -292,12 +292,8 @@ def cmd_msbuild( }, "build": [ cmd_rmdir("objs"), - cmd_msbuild( - r"builds\windows\vc2010\freetype.sln", "Release Static", "Clean" - ), - cmd_msbuild( - r"builds\windows\vc2010\freetype.sln", "Release Static", "Build" - ), + cmd_msbuild("MSBuild.sln", "Release Static", "Clean"), + cmd_msbuild("MSBuild.sln", "Release Static", "Build"), cmd_xcopy("include", "{inc_dir}"), ], "libs": [r"objs\{msbuild_arch}\Release Static\freetype.lib"],