From 9afe9d2769d9241385a4fd8f8e2376fbdca74981 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 10 Jun 2024 16:08:06 +1000 Subject: [PATCH] Added type hints --- Tests/test_file_gif.py | 9 +- src/PIL/GifImagePlugin.py | 204 +++++++++++++++++++++++--------------- src/PIL/Image.py | 20 ++-- src/PIL/ImageOps.py | 2 +- src/PIL/ImagePalette.py | 13 ++- 5 files changed, 153 insertions(+), 95 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 4e790926b..e19c88a47 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -53,6 +53,7 @@ def test_closed_file() -> None: def test_seek_after_close() -> None: im = Image.open("Tests/images/iss634.gif") + assert isinstance(im, GifImagePlugin.GifImageFile) im.load() im.close() @@ -377,7 +378,8 @@ def test_save_netpbm_bmp_mode(tmp_path: Path) -> None: img = img.convert("RGB") tempfile = str(tmp_path / "temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) + b = BytesIO() + GifImagePlugin._save_netpbm(img, b, tempfile) with Image.open(tempfile) as reloaded: assert_image_similar(img, reloaded.convert("RGB"), 0) @@ -388,7 +390,8 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None: img = img.convert("L") tempfile = str(tmp_path / "temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) + b = BytesIO() + GifImagePlugin._save_netpbm(img, b, tempfile) with Image.open(tempfile) as reloaded: assert_image_similar(img, reloaded.convert("L"), 0) @@ -648,7 +651,7 @@ def test_dispose2_palette(tmp_path: Path) -> None: assert rgb_img.getpixel((50, 50)) == circle # Check that frame transparency wasn't added unnecessarily - assert img._frame_transparency is None + assert getattr(img, "_frame_transparency") is None def test_dispose2_diff(tmp_path: Path) -> None: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index a540595b8..a305e8de6 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -29,9 +29,10 @@ import itertools import math import os import subprocess +import sys from enum import IntEnum from functools import cached_property -from typing import IO +from typing import IO, TYPE_CHECKING, Any, List, Literal, NamedTuple, Union from . import ( Image, @@ -46,6 +47,9 @@ from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 +if TYPE_CHECKING: + from . import _imaging + class LoadingStrategy(IntEnum): """.. versionadded:: 9.1.0""" @@ -118,7 +122,7 @@ class GifImageFile(ImageFile.ImageFile): self._seek(0) # get ready to read first frame @property - def n_frames(self): + def n_frames(self) -> int: if self._n_frames is None: current = self.tell() try: @@ -163,11 +167,11 @@ class GifImageFile(ImageFile.ImageFile): msg = "no more images in GIF file" raise EOFError(msg) from e - def _seek(self, frame, update_image=True): + def _seek(self, frame: int, update_image: bool = True) -> None: if frame == 0: # rewind self.__offset = 0 - self.dispose = None + self.dispose: _imaging.ImagingCore | None = None self.__frame = -1 self._fp.seek(self.__rewind) self.disposal_method = 0 @@ -195,9 +199,9 @@ class GifImageFile(ImageFile.ImageFile): msg = "no more images in GIF file" raise EOFError(msg) - palette = None + palette: ImagePalette.ImagePalette | Literal[False] | None = None - info = {} + info: dict[str, Any] = {} frame_transparency = None interlace = None frame_dispose_extent = None @@ -213,7 +217,7 @@ class GifImageFile(ImageFile.ImageFile): # s = self.fp.read(1) block = self.data() - if s[0] == 249: + if s[0] == 249 and block is not None: # # graphic control extension # @@ -249,14 +253,14 @@ class GifImageFile(ImageFile.ImageFile): info["comment"] = comment s = None continue - elif s[0] == 255 and frame == 0: + elif s[0] == 255 and frame == 0 and block is not None: # # application extension # info["extension"] = block, self.fp.tell() if block[:11] == b"NETSCAPE2.0": block = self.data() - if len(block) >= 3 and block[0] == 1: + if block and len(block) >= 3 and block[0] == 1: self.info["loop"] = i16(block, 1) while self.data(): pass @@ -345,51 +349,52 @@ class GifImageFile(ImageFile.ImageFile): else: return (color, color, color) + self.dispose = None self.dispose_extent = frame_dispose_extent - try: - if self.disposal_method < 2: - # do not dispose or none specified - self.dispose = None - elif self.disposal_method == 2: - # replace with background colour + if self.dispose_extent and self.disposal_method >= 2: + try: + if self.disposal_method == 2: + # replace with background colour - # only dispose the extent in this frame - x0, y0, x1, y1 = self.dispose_extent - dispose_size = (x1 - x0, y1 - y0) - - Image._decompression_bomb_check(dispose_size) - - # by convention, attempt to use transparency first - dispose_mode = "P" - color = self.info.get("transparency", frame_transparency) - if color is not None: - if self.mode in ("RGB", "RGBA"): - dispose_mode = "RGBA" - color = _rgb(color) + (0,) - else: - color = self.info.get("background", 0) - if self.mode in ("RGB", "RGBA"): - dispose_mode = "RGB" - color = _rgb(color) - self.dispose = Image.core.fill(dispose_mode, dispose_size, color) - else: - # replace with previous contents - if self.im is not None: # only dispose the extent in this frame - self.dispose = self._crop(self.im, self.dispose_extent) - elif frame_transparency is not None: x0, y0, x1, y1 = self.dispose_extent dispose_size = (x1 - x0, y1 - y0) Image._decompression_bomb_check(dispose_size) + + # by convention, attempt to use transparency first dispose_mode = "P" - color = frame_transparency - if self.mode in ("RGB", "RGBA"): - dispose_mode = "RGBA" - color = _rgb(frame_transparency) + (0,) + color = self.info.get("transparency", frame_transparency) + if color is not None: + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(color) + (0,) + else: + color = self.info.get("background", 0) + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGB" + color = _rgb(color) self.dispose = Image.core.fill(dispose_mode, dispose_size, color) - except AttributeError: - pass + else: + # replace with previous contents + if self.im is not None: + # only dispose the extent in this frame + self.dispose = self._crop(self.im, self.dispose_extent) + elif frame_transparency is not None: + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + dispose_mode = "P" + color = frame_transparency + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(frame_transparency) + (0,) + self.dispose = Image.core.fill( + dispose_mode, dispose_size, color + ) + except AttributeError: + pass if interlace is not None: transparency = -1 @@ -498,7 +503,12 @@ def _normalize_mode(im: Image.Image) -> Image.Image: return im.convert("L") -def _normalize_palette(im, palette, info): +_Palette = Union[bytes, bytearray, List[int], ImagePalette.ImagePalette] + + +def _normalize_palette( + im: Image.Image, palette: _Palette | None, info: dict[str, Any] +) -> Image.Image: """ Normalizes the palette for image. - Sets the palette to the incoming palette, if provided. @@ -526,8 +536,10 @@ def _normalize_palette(im, palette, info): source_palette = bytearray(i // 3 for i in range(768)) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) + used_palette_colors: list[int] | None if palette: used_palette_colors = [] + assert source_palette is not None for i in range(0, len(source_palette), 3): source_color = tuple(source_palette[i : i + 3]) index = im.palette.colors.get(source_color) @@ -561,7 +573,7 @@ def _normalize_palette(im, palette, info): def _write_single_frame( im: Image.Image, fp: IO[bytes], - palette: bytes | bytearray | list[int] | ImagePalette.ImagePalette, + palette: _Palette | None, ) -> None: im_out = _normalize_mode(im) for k, v in im_out.info.items(): @@ -585,7 +597,7 @@ def _write_single_frame( def _getbbox( base_im: Image.Image, im_frame: Image.Image -) -> tuple[Image.Image, tuple[int, int, int, int]]: +) -> tuple[Image.Image, tuple[int, int, int, int] | None]: if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): im_frame = im_frame.convert("RGBA") base_im = base_im.convert("RGBA") @@ -593,12 +605,20 @@ def _getbbox( return delta, delta.getbbox(alpha_only=False) -def _write_multiple_frames(im, fp, palette): +class _Frame(NamedTuple): + im: Image.Image + bbox: tuple[int, int, int, int] | None + encoderinfo: dict[str, Any] + + +def _write_multiple_frames( + im: Image.Image, fp: IO[bytes], palette: _Palette | None +) -> bool: duration = im.encoderinfo.get("duration") disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) - im_frames = [] - previous_im = None + im_frames: list[_Frame] = [] + previous_im: Image.Image | None = None frame_count = 0 background_im = None for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): @@ -624,24 +644,22 @@ def _write_multiple_frames(im, fp, palette): frame_count += 1 diff_frame = None - if im_frames: + if im_frames and previous_im: # delta frame delta, bbox = _getbbox(previous_im, im_frame) if not bbox: # This frame is identical to the previous frame if encoderinfo.get("duration"): - im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[ - "duration" - ] + im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"] continue - if im_frames[-1]["encoderinfo"].get("disposal") == 2: + if im_frames[-1].encoderinfo.get("disposal") == 2: if background_im is None: color = im.encoderinfo.get( "transparency", im.info.get("transparency", (0, 0, 0)) ) background = _get_background(im_frame, color) background_im = Image.new("P", im_frame.size, background) - background_im.putpalette(im_frames[0]["im"].palette) + background_im.putpalette(im_frames[0].im.palette) bbox = _getbbox(background_im, im_frame)[1] elif encoderinfo.get("optimize") and im_frame.mode != "1": if "transparency" not in encoderinfo: @@ -687,31 +705,29 @@ def _write_multiple_frames(im, fp, palette): else: bbox = None previous_im = im_frame - im_frames.append( - {"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo} - ) + im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo)) if len(im_frames) == 1: if "duration" in im.encoderinfo: # Since multiple frames will not be written, use the combined duration - im.encoderinfo["duration"] = im_frames[0]["encoderinfo"]["duration"] - return + im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"] + return False for frame_data in im_frames: - im_frame = frame_data["im"] - if not frame_data["bbox"]: + im_frame = frame_data.im + if not frame_data.bbox: # global header - for s in _get_global_header(im_frame, frame_data["encoderinfo"]): + for s in _get_global_header(im_frame, frame_data.encoderinfo): fp.write(s) offset = (0, 0) else: # compress difference if not palette: - frame_data["encoderinfo"]["include_color_table"] = True + frame_data.encoderinfo["include_color_table"] = True - im_frame = im_frame.crop(frame_data["bbox"]) - offset = frame_data["bbox"][:2] - _write_frame_data(fp, im_frame, offset, frame_data["encoderinfo"]) + im_frame = im_frame.crop(frame_data.bbox) + offset = frame_data.bbox[:2] + _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo) return True @@ -748,7 +764,9 @@ def get_interlace(im: Image.Image) -> int: return interlace -def _write_local_header(fp, im, offset, flags): +def _write_local_header( + fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int +) -> None: try: transparency = im.encoderinfo["transparency"] except KeyError: @@ -849,7 +867,7 @@ def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _FORCE_OPTIMIZE = False -def _get_optimize(im, info): +def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None: """ Palette optimization is a potentially expensive operation. @@ -893,6 +911,7 @@ def _get_optimize(im, info): and current_palette_size > 2 ): return used_palette_colors + return None def _get_color_table_size(palette_bytes: bytes) -> int: @@ -933,7 +952,10 @@ def _get_palette_bytes(im: Image.Image) -> bytes: return im.palette.palette if im.palette else b"" -def _get_background(im, info_background): +def _get_background( + im: Image.Image, + info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None, +) -> int: background = 0 if info_background: if isinstance(info_background, tuple): @@ -956,7 +978,7 @@ def _get_background(im, info_background): return background -def _get_global_header(im, info): +def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]: """Return a list of strings representing a GIF header""" # Header Block @@ -1018,7 +1040,12 @@ def _get_global_header(im, info): return header -def _write_frame_data(fp, im_frame, offset, params): +def _write_frame_data( + fp: IO[bytes], + im_frame: Image.Image, + offset: tuple[int, int], + params: dict[str, Any], +) -> None: try: im_frame.encoderinfo = params @@ -1038,7 +1065,9 @@ def _write_frame_data(fp, im_frame, offset, params): # Legacy GIF utilities -def getheader(im, palette=None, info=None): +def getheader( + im: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None +) -> tuple[list[bytes], list[int] | None]: """ Legacy Method to get Gif data from image. @@ -1050,11 +1079,11 @@ def getheader(im, palette=None, info=None): :returns: tuple of(list of header items, optimized palette) """ - used_palette_colors = _get_optimize(im, info) - if info is None: info = {} + used_palette_colors = _get_optimize(im, info) + if "background" not in info and "background" in im.info: info["background"] = im.info["background"] @@ -1066,7 +1095,9 @@ def getheader(im, palette=None, info=None): return header, used_palette_colors -def getdata(im, offset=(0, 0), **params): +def getdata( + im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any +) -> list[bytes]: """ Legacy Method @@ -1083,12 +1114,23 @@ def getdata(im, offset=(0, 0), **params): :returns: List of bytes containing GIF encoded frame data """ + from io import BytesIO - class Collector: + class Collector(BytesIO): data = [] - def write(self, data: bytes) -> None: - self.data.append(data) + if sys.version_info >= (3, 12): + from collections.abc import Buffer + + def write(self, data: Buffer) -> int: + self.data.append(data) + return len(data) + + else: + + def write(self, data: Any) -> int: + self.data.append(data) + return len(data) im.load() # make sure raster data is available diff --git a/src/PIL/Image.py b/src/PIL/Image.py index af1748610..bdd869ccc 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -41,7 +41,7 @@ import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast +from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, Tuple, cast # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -1367,7 +1367,7 @@ class Image: """ return ImageMode.getmode(self.mode).bands - def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int]: + def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None: """ Calculates the bounding box of the non-zero regions in the image. @@ -3029,12 +3029,18 @@ def new( color = ImageColor.getcolor(color, mode) im = Image() - if mode == "P" and isinstance(color, (list, tuple)) and len(color) in [3, 4]: - # RGB or RGBA value for a P image - from . import ImagePalette + if ( + mode == "P" + and isinstance(color, (list, tuple)) + and all(isinstance(i, int) for i in color) + ): + color_ints: tuple[int, ...] = cast(Tuple[int, ...], tuple(color)) + if len(color_ints) == 3 or len(color_ints) == 4: + # RGB or RGBA value for a P image + from . import ImagePalette - im.palette = ImagePalette.ImagePalette() - color = im.palette.getcolor(color) + im.palette = ImagePalette.ImagePalette() + color = im.palette.getcolor(color_ints) return im._new(core.fill(mode, size, color)) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 33db8fa50..cbe189cc9 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -497,7 +497,7 @@ def expand( color = _color(fill, image.mode) if image.palette: palette = ImagePalette.ImagePalette(palette=image.getpalette()) - if isinstance(color, tuple): + if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4): color = palette.getcolor(color) else: palette = None diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 057ccd1d7..1ff05a3ef 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -18,10 +18,13 @@ from __future__ import annotations import array -from typing import IO, Sequence +from typing import IO, TYPE_CHECKING, Sequence from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile +if TYPE_CHECKING: + from . import Image + class ImagePalette: """ @@ -128,7 +131,11 @@ class ImagePalette: raise ValueError(msg) from e return index - def getcolor(self, color, image=None) -> int: + def getcolor( + self, + color: tuple[int, int, int] | tuple[int, int, int, int], + image: Image.Image | None = None, + ) -> int: """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. @@ -163,7 +170,7 @@ class ImagePalette: self.dirty = 1 return index else: - msg = f"unknown color specifier: {repr(color)}" + msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable] raise ValueError(msg) def save(self, fp: str | IO[str]) -> None: