Added type hints

This commit is contained in:
Andrew Murray 2024-07-15 19:23:36 +10:00
parent 6944e9e183
commit 01529d8b09
10 changed files with 138 additions and 92 deletions

View File

@ -57,6 +57,9 @@ class TestImageWinDib:
# Assert # Assert
assert dib.size == (128, 128) assert dib.size == (128, 128)
with pytest.raises(ValueError):
ImageWin.Dib(mode)
def test_dib_paste(self) -> None: def test_dib_paste(self) -> None:
# Arrange # Arrange
im = hopper() im = hopper()

View File

@ -65,7 +65,7 @@ def has_ghostscript() -> bool:
return gs_binary is not False return gs_binary is not False
def Ghostscript(tile, size, fp, scale=1, transparency=False): def Ghostscript(tile, size, fp, scale=1, transparency: bool = False) -> Image.Image:
"""Render an image using Ghostscript""" """Render an image using Ghostscript"""
global gs_binary global gs_binary
if not has_ghostscript(): if not has_ghostscript():

View File

@ -25,7 +25,7 @@ from __future__ import annotations
import warnings import warnings
from io import BytesIO from io import BytesIO
from math import ceil, log from math import ceil, log
from typing import IO from typing import IO, NamedTuple
from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin
from ._binary import i16le as i16 from ._binary import i16le as i16
@ -119,8 +119,22 @@ def _accept(prefix: bytes) -> bool:
return prefix[:4] == _MAGIC return prefix[:4] == _MAGIC
class IconHeader(NamedTuple):
width: int
height: int
nb_color: int
reserved: int
planes: int
bpp: int
size: int
offset: int
dim: tuple[int, int]
square: int
color_depth: int
class IcoFile: class IcoFile:
def __init__(self, buf) -> None: def __init__(self, buf: IO[bytes]) -> None:
""" """
Parse image from file-like object containing ico file data Parse image from file-like object containing ico file data
""" """
@ -141,51 +155,44 @@ class IcoFile:
for i in range(self.nb_items): for i in range(self.nb_items):
s = buf.read(16) s = buf.read(16)
icon_header = {
"width": s[0],
"height": s[1],
"nb_color": s[2], # No. of colors in image (0 if >=8bpp)
"reserved": s[3],
"planes": i16(s, 4),
"bpp": i16(s, 6),
"size": i32(s, 8),
"offset": i32(s, 12),
}
# See Wikipedia # See Wikipedia
for j in ("width", "height"): width = s[0] or 256
if not icon_header[j]: height = s[1] or 256
icon_header[j] = 256
# See Wikipedia notes about color depth. # No. of colors in image (0 if >=8bpp)
# We need this just to differ images with equal sizes nb_color = s[2]
icon_header["color_depth"] = ( bpp = i16(s, 6)
icon_header["bpp"] icon_header = IconHeader(
or ( width=width,
icon_header["nb_color"] != 0 height=height,
and ceil(log(icon_header["nb_color"], 2)) nb_color=nb_color,
) reserved=s[3],
or 256 planes=i16(s, 4),
bpp=i16(s, 6),
size=i32(s, 8),
offset=i32(s, 12),
dim=(width, height),
square=width * height,
# See Wikipedia notes about color depth.
# We need this just to differ images with equal sizes
color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256,
) )
icon_header["dim"] = (icon_header["width"], icon_header["height"])
icon_header["square"] = icon_header["width"] * icon_header["height"]
self.entry.append(icon_header) self.entry.append(icon_header)
self.entry = sorted(self.entry, key=lambda x: x["color_depth"]) self.entry = sorted(self.entry, key=lambda x: x.color_depth)
# ICO images are usually squares # ICO images are usually squares
self.entry = sorted(self.entry, key=lambda x: x["square"], reverse=True) self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
def sizes(self) -> set[tuple[int, int]]: def sizes(self) -> set[tuple[int, int]]:
""" """
Get a list of all available icon sizes and color depths. Get a set of all available icon sizes and color depths.
""" """
return {(h["width"], h["height"]) for h in self.entry} return {(h.width, h.height) for h in self.entry}
def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int: def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int:
for i, h in enumerate(self.entry): for i, h in enumerate(self.entry):
if size == h["dim"] and (bpp is False or bpp == h["color_depth"]): if size == h.dim and (bpp is False or bpp == h.color_depth):
return i return i
return 0 return 0
@ -202,9 +209,9 @@ class IcoFile:
header = self.entry[idx] header = self.entry[idx]
self.buf.seek(header["offset"]) self.buf.seek(header.offset)
data = self.buf.read(8) data = self.buf.read(8)
self.buf.seek(header["offset"]) self.buf.seek(header.offset)
im: Image.Image im: Image.Image
if data[:8] == PngImagePlugin._MAGIC: if data[:8] == PngImagePlugin._MAGIC:
@ -222,8 +229,7 @@ class IcoFile:
im.tile[0] = d, (0, 0) + im.size, o, a im.tile[0] = d, (0, 0) + im.size, o, a
# figure out where AND mask image starts # figure out where AND mask image starts
bpp = header["bpp"] if 32 == header.bpp:
if 32 == bpp:
# 32-bit color depth icon image allows semitransparent areas # 32-bit color depth icon image allows semitransparent areas
# PIL's DIB format ignores transparency bits, recover them. # PIL's DIB format ignores transparency bits, recover them.
# The DIB is packed in BGRX byte order where X is the alpha # The DIB is packed in BGRX byte order where X is the alpha
@ -253,7 +259,7 @@ class IcoFile:
# padded row size * height / bits per char # padded row size * height / bits per char
total_bytes = int((w * im.size[1]) / 8) total_bytes = int((w * im.size[1]) / 8)
and_mask_offset = header["offset"] + header["size"] - total_bytes and_mask_offset = header.offset + header.size - total_bytes
self.buf.seek(and_mask_offset) self.buf.seek(and_mask_offset)
mask_data = self.buf.read(total_bytes) mask_data = self.buf.read(total_bytes)
@ -307,7 +313,7 @@ class IcoImageFile(ImageFile.ImageFile):
def _open(self) -> None: def _open(self) -> None:
self.ico = IcoFile(self.fp) self.ico = IcoFile(self.fp)
self.info["sizes"] = self.ico.sizes() self.info["sizes"] = self.ico.sizes()
self.size = self.ico.entry[0]["dim"] self.size = self.ico.entry[0].dim
self.load() self.load()
@property @property

View File

@ -3286,7 +3286,7 @@ def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image:
return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) return frombuffer(mode, size, obj, "raw", rawmode, 0, 1)
def fromqimage(im): def fromqimage(im) -> ImageFile.ImageFile:
"""Creates an image instance from a QImage image""" """Creates an image instance from a QImage image"""
from . import ImageQt from . import ImageQt
@ -3296,7 +3296,7 @@ def fromqimage(im):
return ImageQt.fromqimage(im) return ImageQt.fromqimage(im)
def fromqpixmap(im): def fromqpixmap(im) -> ImageFile.ImageFile:
"""Creates an image instance from a QPixmap image""" """Creates an image instance from a QPixmap image"""
from . import ImageQt from . import ImageQt
@ -3867,7 +3867,7 @@ class Exif(_ExifBase):
# returns a dict with any single item tuples/lists as individual values # returns a dict with any single item tuples/lists as individual values
return {k: self._fixup(v) for k, v in src_dict.items()} return {k: self._fixup(v) for k, v in src_dict.items()}
def _get_ifd_dict(self, offset, group=None): def _get_ifd_dict(self, offset: int, group=None):
try: try:
# an offset pointer to the location of the nested embedded IFD. # an offset pointer to the location of the nested embedded IFD.
# It should be a long, but may be corrupted. # It should be a long, but may be corrupted.
@ -3881,7 +3881,7 @@ class Exif(_ExifBase):
info.load(self.fp) info.load(self.fp)
return self._fixup_dict(info) return self._fixup_dict(info)
def _get_head(self): def _get_head(self) -> bytes:
version = b"\x2B" if self.bigtiff else b"\x2A" version = b"\x2B" if self.bigtiff else b"\x2A"
if self.endian == "<": if self.endian == "<":
head = b"II" + version + b"\x00" + o32le(8) head = b"II" + version + b"\x00" + o32le(8)
@ -4102,16 +4102,16 @@ class Exif(_ExifBase):
keys.update(self._info) keys.update(self._info)
return len(keys) return len(keys)
def __getitem__(self, tag): def __getitem__(self, tag: int):
if self._info is not None and tag not in self._data and tag in self._info: if self._info is not None and tag not in self._data and tag in self._info:
self._data[tag] = self._fixup(self._info[tag]) self._data[tag] = self._fixup(self._info[tag])
del self._info[tag] del self._info[tag]
return self._data[tag] return self._data[tag]
def __contains__(self, tag) -> bool: def __contains__(self, tag: object) -> bool:
return tag in self._data or (self._info is not None and tag in self._info) return tag in self._data or (self._info is not None and tag in self._info)
def __setitem__(self, tag, value) -> None: def __setitem__(self, tag: int, value) -> None:
if self._info is not None and tag in self._info: if self._info is not None and tag in self._info:
del self._info[tag] del self._info[tag]
self._data[tag] = value self._data[tag] = value

View File

@ -553,7 +553,9 @@ def _save(im, fp, tile, bufsize: int = 0) -> None:
fp.flush() fp.flush()
def _encode_tile(im, fp, tile: list[_Tile], bufsize: int, fh, exc=None) -> None: def _encode_tile(
im, fp: IO[bytes], tile: list[_Tile], bufsize: int, fh, exc=None
) -> None:
for encoder_name, extents, offset, args in tile: for encoder_name, extents, offset, args in tile:
if offset > 0: if offset > 0:
fp.seek(offset) fp.seek(offset)
@ -653,7 +655,7 @@ class PyCodec:
""" """
pass pass
def setfd(self, fd) -> None: def setfd(self, fd: IO[bytes]) -> None:
""" """
Called from ImageFile to set the Python file-like object Called from ImageFile to set the Python file-like object

View File

@ -19,11 +19,14 @@ from __future__ import annotations
import sys import sys
from io import BytesIO from io import BytesIO
from typing import Callable from typing import TYPE_CHECKING, Callable
from . import Image from . import Image
from ._util import is_path from ._util import is_path
if TYPE_CHECKING:
from . import ImageFile
qt_version: str | None qt_version: str | None
qt_versions = [ qt_versions = [
["6", "PyQt6"], ["6", "PyQt6"],
@ -90,11 +93,11 @@ def fromqimage(im):
return Image.open(b) return Image.open(b)
def fromqpixmap(im): def fromqpixmap(im) -> ImageFile.ImageFile:
return fromqimage(im) return fromqimage(im)
def align8to32(bytes, width, mode): def align8to32(bytes: bytes, width: int, mode: str) -> bytes:
""" """
converts each scanline of data from 8 bit to 32 bit aligned converts each scanline of data from 8 bit to 32 bit aligned
""" """
@ -172,7 +175,7 @@ def _toqclass_helper(im):
if qt_is_installed: if qt_is_installed:
class ImageQt(QImage): class ImageQt(QImage):
def __init__(self, im): def __init__(self, im) -> None:
""" """
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
class. class.

View File

@ -70,11 +70,14 @@ class Dib:
""" """
def __init__( def __init__(
self, image: Image.Image | str, size: tuple[int, int] | list[int] | None = None self, image: Image.Image | str, size: tuple[int, int] | None = None
) -> None: ) -> None:
if isinstance(image, str): if isinstance(image, str):
mode = image mode = image
image = "" image = ""
if size is None:
msg = "If first argument is mode, size is required"
raise ValueError(msg)
else: else:
mode = image.mode mode = image.mode
size = image.size size = image.size
@ -105,7 +108,12 @@ class Dib:
result = self.image.expose(handle) result = self.image.expose(handle)
return result return result
def draw(self, handle, dst, src=None): def draw(
self,
handle,
dst: tuple[int, int, int, int],
src: tuple[int, int, int, int] | None = None,
):
""" """
Same as expose, but allows you to specify where to draw the image, and Same as expose, but allows you to specify where to draw the image, and
what part of it to draw. what part of it to draw.
@ -115,7 +123,7 @@ class Dib:
the destination have different sizes, the image is resized as the destination have different sizes, the image is resized as
necessary. necessary.
""" """
if not src: if src is None:
src = (0, 0) + self.size src = (0, 0) + self.size
if isinstance(handle, HWND): if isinstance(handle, HWND):
dc = self.image.getdc(handle) dc = self.image.getdc(handle)
@ -202,22 +210,22 @@ class Window:
title, self.__dispatcher, width or 0, height or 0 title, self.__dispatcher, width or 0, height or 0
) )
def __dispatcher(self, action, *args): def __dispatcher(self, action: str, *args):
return getattr(self, f"ui_handle_{action}")(*args) return getattr(self, f"ui_handle_{action}")(*args)
def ui_handle_clear(self, dc, x0, y0, x1, y1): def ui_handle_clear(self, dc, x0, y0, x1, y1) -> None:
pass pass
def ui_handle_damage(self, x0, y0, x1, y1): def ui_handle_damage(self, x0, y0, x1, y1) -> None:
pass pass
def ui_handle_destroy(self) -> None: def ui_handle_destroy(self) -> None:
pass pass
def ui_handle_repair(self, dc, x0, y0, x1, y1): def ui_handle_repair(self, dc, x0, y0, x1, y1) -> None:
pass pass
def ui_handle_resize(self, width, height): def ui_handle_resize(self, width, height) -> None:
pass pass
def mainloop(self) -> None: def mainloop(self) -> None:
@ -227,12 +235,12 @@ class Window:
class ImageWindow(Window): class ImageWindow(Window):
"""Create an image window which displays the given image.""" """Create an image window which displays the given image."""
def __init__(self, image, title="PIL"): def __init__(self, image, title: str = "PIL") -> None:
if not isinstance(image, Dib): if not isinstance(image, Dib):
image = Dib(image) image = Dib(image)
self.image = image self.image = image
width, height = image.size width, height = image.size
super().__init__(title, width=width, height=height) super().__init__(title, width=width, height=height)
def ui_handle_repair(self, dc, x0, y0, x1, y1): def ui_handle_repair(self, dc, x0, y0, x1, y1) -> None:
self.image.draw(dc, (x0, y0, x1, y1)) self.image.draw(dc, (x0, y0, x1, y1))

View File

@ -18,6 +18,7 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from io import BytesIO from io import BytesIO
from typing import cast
from . import Image, ImageFile from . import Image, ImageFile
from ._binary import i16be as i16 from ._binary import i16be as i16
@ -184,7 +185,7 @@ Image.register_open(IptcImageFile.format, IptcImageFile)
Image.register_extension(IptcImageFile.format, ".iim") Image.register_extension(IptcImageFile.format, ".iim")
def getiptcinfo(im): def getiptcinfo(im: ImageFile.ImageFile):
""" """
Get IPTC information from TIFF, JPEG, or IPTC file. Get IPTC information from TIFF, JPEG, or IPTC file.
@ -221,16 +222,17 @@ def getiptcinfo(im):
class FakeImage: class FakeImage:
pass pass
im = FakeImage() fake_im = FakeImage()
im.__class__ = IptcImageFile fake_im.__class__ = IptcImageFile # type: ignore[assignment]
iptc_im = cast(IptcImageFile, fake_im)
# parse the IPTC information chunk # parse the IPTC information chunk
im.info = {} iptc_im.info = {}
im.fp = BytesIO(data) iptc_im.fp = BytesIO(data)
try: try:
im._open() iptc_im._open()
except (IndexError, KeyError): except (IndexError, KeyError):
pass # expected failure pass # expected failure
return im.info return iptc_im.info

View File

@ -685,7 +685,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
raise ValueError(msg) raise ValueError(msg)
subsampling = get_sampling(im) subsampling = get_sampling(im)
def validate_qtables(qtables): def validate_qtables(
qtables: (
str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None
)
) -> list[list[int]] | None:
if qtables is None: if qtables is None:
return qtables return qtables
if isinstance(qtables, str): if isinstance(qtables, str):
@ -715,12 +719,12 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if len(table) != 64: if len(table) != 64:
msg = "Invalid quantization table" msg = "Invalid quantization table"
raise TypeError(msg) raise TypeError(msg)
table = array.array("H", table) table_array = array.array("H", table)
except TypeError as e: except TypeError as e:
msg = "Invalid quantization table" msg = "Invalid quantization table"
raise ValueError(msg) from e raise ValueError(msg) from e
else: else:
qtables[idx] = list(table) qtables[idx] = list(table_array)
return qtables return qtables
if qtables == "keep": if qtables == "keep":

View File

@ -39,7 +39,7 @@ import struct
import warnings import warnings
import zlib import zlib
from enum import IntEnum from enum import IntEnum
from typing import IO, TYPE_CHECKING, Any, NoReturn from typing import IO, TYPE_CHECKING, Any, NamedTuple, NoReturn
from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
from ._binary import i16be as i16 from ._binary import i16be as i16
@ -1126,7 +1126,21 @@ class _fdat:
self.seq_num += 1 self.seq_num += 1
def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_images): 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],
chunk,
mode: str,
rawmode: str,
default_image: Image.Image | None,
append_images: list[Image.Image],
) -> Image.Image | None:
duration = im.encoderinfo.get("duration") duration = im.encoderinfo.get("duration")
loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) loop = im.encoderinfo.get("loop", im.info.get("loop", 0))
disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE))
@ -1137,7 +1151,7 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
else: else:
chain = itertools.chain([im], append_images) chain = itertools.chain([im], append_images)
im_frames = [] im_frames: list[_Frame] = []
frame_count = 0 frame_count = 0
for im_seq in chain: for im_seq in chain:
for im_frame in ImageSequence.Iterator(im_seq): for im_frame in ImageSequence.Iterator(im_seq):
@ -1158,24 +1172,24 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
if im_frames: if im_frames:
previous = im_frames[-1] previous = im_frames[-1]
prev_disposal = previous["encoderinfo"].get("disposal") prev_disposal = previous.encoderinfo.get("disposal")
prev_blend = previous["encoderinfo"].get("blend") prev_blend = previous.encoderinfo.get("blend")
if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2: if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2:
prev_disposal = Disposal.OP_BACKGROUND prev_disposal = Disposal.OP_BACKGROUND
if prev_disposal == Disposal.OP_BACKGROUND: if prev_disposal == Disposal.OP_BACKGROUND:
base_im = previous["im"].copy() base_im = previous.im.copy()
dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0)) dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0))
bbox = previous["bbox"] bbox = previous.bbox
if bbox: if bbox:
dispose = dispose.crop(bbox) dispose = dispose.crop(bbox)
else: else:
bbox = (0, 0) + im.size bbox = (0, 0) + im.size
base_im.paste(dispose, bbox) base_im.paste(dispose, bbox)
elif prev_disposal == Disposal.OP_PREVIOUS: elif prev_disposal == Disposal.OP_PREVIOUS:
base_im = im_frames[-2]["im"] base_im = im_frames[-2].im
else: else:
base_im = previous["im"] base_im = previous.im
delta = ImageChops.subtract_modulo( delta = ImageChops.subtract_modulo(
im_frame.convert("RGBA"), base_im.convert("RGBA") im_frame.convert("RGBA"), base_im.convert("RGBA")
) )
@ -1186,14 +1200,14 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
and prev_blend == encoderinfo.get("blend") and prev_blend == encoderinfo.get("blend")
and "duration" in encoderinfo and "duration" in encoderinfo
): ):
previous["encoderinfo"]["duration"] += encoderinfo["duration"] previous.encoderinfo["duration"] += encoderinfo["duration"]
continue continue
else: else:
bbox = None bbox = None
im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) im_frames.append(_Frame(im_frame, bbox, encoderinfo))
if len(im_frames) == 1 and not default_image: if len(im_frames) == 1 and not default_image:
return im_frames[0]["im"] return im_frames[0].im
# animation control # animation control
chunk( chunk(
@ -1211,14 +1225,14 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
seq_num = 0 seq_num = 0
for frame, frame_data in enumerate(im_frames): for frame, frame_data in enumerate(im_frames):
im_frame = frame_data["im"] im_frame = frame_data.im
if not frame_data["bbox"]: if not frame_data.bbox:
bbox = (0, 0) + im_frame.size bbox = (0, 0) + im_frame.size
else: else:
bbox = frame_data["bbox"] bbox = frame_data.bbox
im_frame = im_frame.crop(bbox) im_frame = im_frame.crop(bbox)
size = im_frame.size size = im_frame.size
encoderinfo = frame_data["encoderinfo"] encoderinfo = frame_data.encoderinfo
frame_duration = int(round(encoderinfo.get("duration", 0))) frame_duration = int(round(encoderinfo.get("duration", 0)))
frame_disposal = encoderinfo.get("disposal", disposal) frame_disposal = encoderinfo.get("disposal", disposal)
frame_blend = encoderinfo.get("blend", blend) frame_blend = encoderinfo.get("blend", blend)
@ -1253,6 +1267,7 @@ def _write_multiple_frames(im, fp, chunk, mode, rawmode, default_image, append_i
[("zip", (0, 0) + im_frame.size, 0, rawmode)], [("zip", (0, 0) + im_frame.size, 0, rawmode)],
) )
seq_num = fdat_chunks.seq_num seq_num = fdat_chunks.seq_num
return None
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
@ -1437,12 +1452,15 @@ def _save(
exif = exif[6:] exif = exif[6:]
chunk(fp, b"eXIf", exif) chunk(fp, b"eXIf", exif)
single_im: Image.Image | None = im
if save_all: if save_all:
im = _write_multiple_frames( single_im = _write_multiple_frames(
im, fp, chunk, mode, rawmode, default_image, append_images im, fp, chunk, mode, rawmode, default_image, append_images
) )
if im: if single_im:
ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) ImageFile._save(
single_im, _idat(fp, chunk), [("zip", (0, 0) + single_im.size, 0, rawmode)]
)
if info: if info:
for info_chunk in info.chunks: for info_chunk in info.chunks: