Added type hints

This commit is contained in:
Andrew Murray 2024-07-25 22:55:49 +10:00
parent 6dd4b3c826
commit 726cdf5eed
10 changed files with 223 additions and 158 deletions

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import os
from pathlib import Path
from typing import AnyStr
import pytest
@ -92,7 +93,7 @@ def test_textsize(request: pytest.FixtureRequest, tmp_path: Path) -> None:
def _test_high_characters(
request: pytest.FixtureRequest, tmp_path: Path, message: str | bytes
request: pytest.FixtureRequest, tmp_path: Path, message: AnyStr
) -> None:
tempname = save_font(request, tmp_path)
font = ImageFont.load(tempname)

View File

@ -717,14 +717,14 @@ def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None:
font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf", 36)
_check_text(font, "Tests/images/variation_adobe.png", 11)
for name in ["Bold", b"Bold"]:
for name in ("Bold", b"Bold"):
font.set_variation_by_name(name)
assert font.getname()[1] == "Bold"
_check_text(font, "Tests/images/variation_adobe_name.png", 16)
font = ImageFont.truetype("Tests/fonts/TINY5x3GX.ttf", 36)
_check_text(font, "Tests/images/variation_tiny.png", 40)
for name in ["200", b"200"]:
for name in ("200", b"200"):
font.set_variation_by_name(name)
assert font.getname()[1] == "200"
_check_text(font, "Tests/images/variation_tiny_name.png", 40)

View File

@ -91,3 +91,11 @@ Constants
Set to 1,000,000, to protect against potential DOS attacks. Pillow will
raise a :py:exc:`ValueError` if the number of characters is over this limit. The
check can be disabled by setting ``ImageFont.MAX_STRING_LENGTH = None``.
Dictionaries
------------
.. autoclass:: Axis
:members:
:undoc-members:
:show-inheritance:

View File

@ -78,3 +78,7 @@ on some Python versions.
An internal interface module previously known as :mod:`~PIL._imaging`,
implemented in :file:`_imaging.c`.
.. py:class:: ImagingCore
A representation of the image data.

View File

@ -159,6 +159,4 @@ exclude = [
'^Tests/oss-fuzz/fuzz_font.py$',
'^Tests/oss-fuzz/fuzz_pillow.py$',
'^Tests/test_qt_image_qapplication.py$',
'^Tests/test_font_pcf_charsets.py$',
'^Tests/test_font_pcf.py$',
]

View File

@ -218,6 +218,8 @@ if hasattr(core, "DEFAULT_STRATEGY"):
# Registries
if TYPE_CHECKING:
from xml.etree.ElementTree import Element
from . import ImageFile, ImagePalette
from ._typing import NumpyArray, StrOrBytesPath, TypeGuard
ID: list[str] = []
@ -241,9 +243,9 @@ ENCODERS: dict[str, type[ImageFile.PyEncoder]] = {}
_ENDIAN = "<" if sys.byteorder == "little" else ">"
def _conv_type_shape(im):
def _conv_type_shape(im: Image) -> tuple[tuple[int, ...], str]:
m = ImageMode.getmode(im.mode)
shape = (im.height, im.width)
shape: tuple[int, ...] = (im.height, im.width)
extra = len(m.bands)
if extra != 1:
shape += (extra,)
@ -470,10 +472,10 @@ class _E:
self.scale = scale
self.offset = offset
def __neg__(self):
def __neg__(self) -> _E:
return _E(-self.scale, -self.offset)
def __add__(self, other):
def __add__(self, other) -> _E:
if isinstance(other, _E):
return _E(self.scale + other.scale, self.offset + other.offset)
return _E(self.scale, self.offset + other)
@ -486,14 +488,14 @@ class _E:
def __rsub__(self, other):
return other + -self
def __mul__(self, other):
def __mul__(self, other) -> _E:
if isinstance(other, _E):
return NotImplemented
return _E(self.scale * other, self.offset * other)
__rmul__ = __mul__
def __truediv__(self, other):
def __truediv__(self, other) -> _E:
if isinstance(other, _E):
return NotImplemented
return _E(self.scale / other, self.offset / other)
@ -718,9 +720,9 @@ class Image:
return self._repr_image("JPEG")
@property
def __array_interface__(self):
def __array_interface__(self) -> dict[str, str | bytes | int | tuple[int, ...]]:
# numpy array interface support
new = {"version": 3}
new: dict[str, str | bytes | int | tuple[int, ...]] = {"version": 3}
try:
if self.mode == "1":
# Binary images need to be extended from bits to bytes
@ -1418,7 +1420,7 @@ class Image:
return out
return self.im.getcolors(maxcolors)
def getdata(self, band: int | None = None):
def getdata(self, band: int | None = None) -> core.ImagingCore:
"""
Returns the contents of this image as a sequence object
containing pixel values. The sequence object is flattened, so
@ -1467,8 +1469,8 @@ class Image:
def get_name(tag: str) -> str:
return re.sub("^{[^}]+}", "", tag)
def get_value(element):
value = {get_name(k): v for k, v in element.attrib.items()}
def get_value(element: Element) -> str | dict[str, Any] | None:
value: dict[str, Any] = {get_name(k): v for k, v in element.attrib.items()}
children = list(element)
if children:
for child in children:
@ -1712,7 +1714,7 @@ class Image:
return self.im.histogram(extrema)
return self.im.histogram()
def entropy(self, mask=None, extrema=None):
def entropy(self, mask: Image | None = None, extrema=None):
"""
Calculates and returns the entropy for the image.
@ -1996,7 +1998,7 @@ class Image:
def putdata(
self,
data: Sequence[float] | Sequence[Sequence[int]] | NumpyArray,
data: Sequence[float] | Sequence[Sequence[int]] | core.ImagingCore | NumpyArray,
scale: float = 1.0,
offset: float = 0.0,
) -> None:
@ -2184,7 +2186,12 @@ class Image:
return m_im
def _get_safe_box(self, size, resample, box):
def _get_safe_box(
self,
size: tuple[int, int],
resample: Resampling,
box: tuple[float, float, float, float],
) -> tuple[int, int, int, int]:
"""Expands the box so it includes adjacent pixels
that may be used by resampling with the given resampling filter.
"""
@ -2294,7 +2301,7 @@ class Image:
factor_x = int((box[2] - box[0]) / size[0] / reducing_gap) or 1
factor_y = int((box[3] - box[1]) / size[1] / reducing_gap) or 1
if factor_x > 1 or factor_y > 1:
reduce_box = self._get_safe_box(size, resample, box)
reduce_box = self._get_safe_box(size, cast(Resampling, resample), box)
factor = (factor_x, factor_y)
self = (
self.reduce(factor, box=reduce_box)
@ -2430,7 +2437,7 @@ class Image:
0.0,
]
def transform(x, y, matrix):
def transform(x: float, y: float, matrix: list[float]) -> tuple[float, float]:
(a, b, c, d, e, f) = matrix
return a * x + b * y + c, d * x + e * y + f
@ -2445,9 +2452,9 @@ class Image:
xx = []
yy = []
for x, y in ((0, 0), (w, 0), (w, h), (0, h)):
x, y = transform(x, y, matrix)
xx.append(x)
yy.append(y)
transformed_x, transformed_y = transform(x, y, matrix)
xx.append(transformed_x)
yy.append(transformed_y)
nw = math.ceil(max(xx)) - math.floor(min(xx))
nh = math.ceil(max(yy)) - math.floor(min(yy))
@ -2705,7 +2712,7 @@ class Image:
provided_size = tuple(map(math.floor, size))
def preserve_aspect_ratio() -> tuple[int, int] | None:
def round_aspect(number, key):
def round_aspect(number: float, key: Callable[[int], float]) -> int:
return max(min(math.floor(number), math.ceil(number), key=key), 1)
x, y = provided_size
@ -2849,7 +2856,13 @@ class Image:
return im
def __transformer(
self, box, image, method, data, resample=Resampling.NEAREST, fill=1
self,
box: tuple[int, int, int, int],
image: Image,
method,
data,
resample: int = Resampling.NEAREST,
fill: bool = True,
):
w = box[2] - box[0]
h = box[3] - box[1]
@ -2899,11 +2912,12 @@ class Image:
Resampling.BICUBIC,
):
if resample in (Resampling.BOX, Resampling.HAMMING, Resampling.LANCZOS):
msg = {
unusable: dict[int, str] = {
Resampling.BOX: "Image.Resampling.BOX",
Resampling.HAMMING: "Image.Resampling.HAMMING",
Resampling.LANCZOS: "Image.Resampling.LANCZOS",
}[resample] + f" ({resample}) cannot be used."
}
msg = unusable[resample] + f" ({resample}) cannot be used."
else:
msg = f"Unknown resampling filter ({resample})."
@ -3843,7 +3857,7 @@ class Exif(_ExifBase):
print(gps_ifd[ExifTags.GPS.GPSDateStamp]) # 1999:99:99 99:99:99
"""
endian = None
endian: str | None = None
bigtiff = False
_loaded = False
@ -3892,7 +3906,7 @@ class Exif(_ExifBase):
head += b"\x00\x00\x00\x00"
return head
def load(self, data):
def load(self, data: bytes) -> None:
# Extract EXIF information. This is highly experimental,
# and is likely to be replaced with something better in a future
# version.
@ -3911,7 +3925,7 @@ class Exif(_ExifBase):
self._info = None
return
self.fp = io.BytesIO(data)
self.fp: IO[bytes] = io.BytesIO(data)
self.head = self.fp.read(8)
# process dictionary
from . import TiffImagePlugin
@ -3921,7 +3935,7 @@ class Exif(_ExifBase):
self.fp.seek(self._info.next)
self._info.load(self.fp)
def load_from_fp(self, fp, offset=None):
def load_from_fp(self, fp: IO[bytes], offset: int | None = None) -> None:
self._loaded_exif = None
self._data.clear()
self._hidden_data.clear()

View File

@ -36,7 +36,7 @@ import numbers
import struct
from collections.abc import Sequence
from types import ModuleType
from typing import TYPE_CHECKING, AnyStr, Callable, Union, cast
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Union, cast
from . import Image, ImageColor
from ._deprecate import deprecate
@ -561,7 +561,12 @@ class ImageDraw:
def _multiline_split(self, text: AnyStr) -> list[AnyStr]:
return text.split("\n" if isinstance(text, str) else b"\n")
def _multiline_spacing(self, font, spacing, stroke_width):
def _multiline_spacing(
self,
font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont,
spacing: float,
stroke_width: float,
) -> float:
return (
self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3]
+ stroke_width
@ -571,25 +576,25 @@ class ImageDraw:
def text(
self,
xy: tuple[float, float],
text: str,
fill=None,
text: AnyStr,
fill: _Ink | None = None,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
anchor=None,
spacing=4,
align="left",
direction=None,
features=None,
language=None,
stroke_width=0,
stroke_fill=None,
embedded_color=False,
*args,
**kwargs,
anchor: str | None = None,
spacing: float = 4,
align: str = "left",
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
stroke_width: float = 0,
stroke_fill: _Ink | None = None,
embedded_color: bool = False,
*args: Any,
**kwargs: Any,
) -> None:
"""Draw text."""
if embedded_color and self.mode not in ("RGB", "RGBA"):
@ -623,15 +628,14 @@ class ImageDraw:
return fill_ink
return ink
def draw_text(ink, stroke_width=0) -> None:
def draw_text(ink: int, stroke_width: float = 0) -> None:
mode = self.fontmode
if stroke_width == 0 and embedded_color:
mode = "RGBA"
coord = []
start = []
for i in range(2):
coord.append(int(xy[i]))
start.append(math.modf(xy[i])[0])
start = (math.modf(xy[0])[0], math.modf(xy[1])[0])
try:
mask, offset = font.getmask2( # type: ignore[union-attr,misc]
text,
@ -697,25 +701,25 @@ class ImageDraw:
def multiline_text(
self,
xy: tuple[float, float],
text: str,
fill=None,
text: AnyStr,
fill: _Ink | None = None,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
anchor=None,
spacing=4,
align="left",
direction=None,
features=None,
language=None,
stroke_width=0,
stroke_fill=None,
embedded_color=False,
anchor: str | None = None,
spacing: float = 4,
align: str = "left",
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
stroke_width: float = 0,
stroke_fill: _Ink | None = None,
embedded_color: bool = False,
*,
font_size=None,
font_size: float | None = None,
) -> None:
if direction == "ttb":
msg = "ttb direction is unsupported for multiline text"
@ -788,19 +792,19 @@ class ImageDraw:
def textlength(
self,
text: str,
text: AnyStr,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
direction=None,
features=None,
language=None,
embedded_color=False,
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
embedded_color: bool = False,
*,
font_size=None,
font_size: float | None = None,
) -> float:
"""Get the length of a given string, in pixels with 1/64 precision."""
if self._multiline_check(text):
@ -817,20 +821,25 @@ class ImageDraw:
def textbbox(
self,
xy,
text,
font=None,
anchor=None,
spacing=4,
align="left",
direction=None,
features=None,
language=None,
stroke_width=0,
embedded_color=False,
xy: tuple[float, float],
text: AnyStr,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
anchor: str | None = None,
spacing: float = 4,
align: str = "left",
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
stroke_width: float = 0,
embedded_color: bool = False,
*,
font_size=None,
) -> tuple[int, int, int, int]:
font_size: float | None = None,
) -> tuple[float, float, float, float]:
"""Get the bounding box of a given string, in pixels."""
if embedded_color and self.mode not in ("RGB", "RGBA"):
msg = "Embedded color supported only in RGB and RGBA modes"
@ -862,20 +871,25 @@ class ImageDraw:
def multiline_textbbox(
self,
xy,
text,
font=None,
anchor=None,
spacing=4,
align="left",
direction=None,
features=None,
language=None,
stroke_width=0,
embedded_color=False,
xy: tuple[float, float],
text: AnyStr,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
anchor: str | None = None,
spacing: float = 4,
align: str = "left",
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
stroke_width: float = 0,
embedded_color: bool = False,
*,
font_size=None,
) -> tuple[int, int, int, int]:
font_size: float | None = None,
) -> tuple[float, float, float, float]:
if direction == "ttb":
msg = "ttb direction is unsupported for multiline text"
raise ValueError(msg)
@ -914,7 +928,7 @@ class ImageDraw:
elif anchor[1] == "d":
top -= (len(lines) - 1) * line_spacing
bbox: tuple[int, int, int, int] | None = None
bbox: tuple[float, float, float, float] | None = None
for idx, line in enumerate(lines):
left = xy[0]

View File

@ -34,7 +34,7 @@ import warnings
from enum import IntEnum
from io import BytesIO
from types import ModuleType
from typing import IO, TYPE_CHECKING, Any, BinaryIO
from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict
from . import Image
from ._typing import StrOrBytesPath
@ -46,6 +46,13 @@ if TYPE_CHECKING:
from ._imagingft import Font
class Axis(TypedDict):
minimum: int | None
default: int | None
maximum: int | None
name: bytes | None
class Layout(IntEnum):
BASIC = 0
RAQM = 1
@ -138,7 +145,9 @@ class ImageFont:
self.font = Image.core.font(image.im, data)
def getmask(self, text, mode="", *args, **kwargs):
def getmask(
self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any
) -> Image.core.ImagingCore:
"""
Create a bitmap for the text.
@ -236,7 +245,7 @@ class FreeTypeFont:
self.layout_engine = layout_engine
def load_from_bytes(f):
def load_from_bytes(f) -> None:
self.font_bytes = f.read()
self.font = core.getfont(
"", size, index, encoding, self.font_bytes, layout_engine
@ -283,7 +292,12 @@ class FreeTypeFont:
return self.font.ascent, self.font.descent
def getlength(
self, text: str | bytes, mode="", direction=None, features=None, language=None
self,
text: str | bytes,
mode: str = "",
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
) -> float:
"""
Returns length (in pixels with 1/64 precision) of given text when rendered
@ -424,16 +438,16 @@ class FreeTypeFont:
def getmask(
self,
text,
mode="",
direction=None,
features=None,
language=None,
stroke_width=0,
anchor=None,
ink=0,
start=None,
):
text: str | bytes,
mode: str = "",
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
stroke_width: float = 0,
anchor: str | None = None,
ink: int = 0,
start: tuple[float, float] | None = None,
) -> Image.core.ImagingCore:
"""
Create a bitmap for the text.
@ -516,17 +530,17 @@ class FreeTypeFont:
def getmask2(
self,
text: str | bytes,
mode="",
direction=None,
features=None,
language=None,
stroke_width=0,
anchor=None,
ink=0,
start=None,
*args,
**kwargs,
):
mode: str = "",
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
stroke_width: float = 0,
anchor: str | None = None,
ink: int = 0,
start: tuple[float, float] | None = None,
*args: Any,
**kwargs: Any,
) -> tuple[Image.core.ImagingCore, tuple[int, int]]:
"""
Create a bitmap for the text.
@ -599,7 +613,7 @@ class FreeTypeFont:
if start is None:
start = (0, 0)
def fill(width, height):
def fill(width: int, height: int) -> Image.core.ImagingCore:
size = (width, height)
Image._decompression_bomb_check(size)
return Image.core.fill("RGBA" if mode == "RGBA" else "L", size)
@ -619,8 +633,13 @@ class FreeTypeFont:
)
def font_variant(
self, font=None, size=None, index=None, encoding=None, layout_engine=None
):
self,
font: StrOrBytesPath | BinaryIO | None = None,
size: float | None = None,
index: int | None = None,
encoding: str | None = None,
layout_engine: Layout | None = None,
) -> FreeTypeFont:
"""
Create a copy of this FreeTypeFont object,
using any specified arguments to override the settings.
@ -655,7 +674,7 @@ class FreeTypeFont:
raise NotImplementedError(msg) from e
return [name.replace(b"\x00", b"") for name in names]
def set_variation_by_name(self, name):
def set_variation_by_name(self, name: str | bytes) -> None:
"""
:param name: The name of the style.
:exception OSError: If the font is not a variation font.
@ -674,7 +693,7 @@ class FreeTypeFont:
self.font.setvarname(index)
def get_variation_axes(self):
def get_variation_axes(self) -> list[Axis]:
"""
:returns: A list of the axes in a variation font.
:exception OSError: If the font is not a variation font.
@ -704,7 +723,9 @@ class FreeTypeFont:
class TransposedFont:
"""Wrapper for writing rotated or mirrored text"""
def __init__(self, font, orientation=None):
def __init__(
self, font: ImageFont | FreeTypeFont, orientation: Image.Transpose | None = None
):
"""
Wrapper that creates a transposed font from any existing font
object.
@ -718,13 +739,17 @@ class TransposedFont:
self.font = font
self.orientation = orientation # any 'transpose' argument, or None
def getmask(self, text, mode="", *args, **kwargs):
def getmask(
self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any
) -> Image.core.ImagingCore:
im = self.font.getmask(text, mode, *args, **kwargs)
if self.orientation is not None:
return im.transpose(self.orientation)
return im
def getbbox(self, text, *args, **kwargs):
def getbbox(
self, text: str | bytes, *args: Any, **kwargs: Any
) -> tuple[int, int, float, float]:
# TransposedFont doesn't support getmask2, move top-left point to (0, 0)
# this has no effect on ImageFont and simulates anchor="lt" for FreeTypeFont
left, top, right, bottom = self.font.getbbox(text, *args, **kwargs)
@ -734,7 +759,7 @@ class TransposedFont:
return 0, 0, height, width
return 0, 0, width, height
def getlength(self, text: str | bytes, *args, **kwargs) -> float:
def getlength(self, text: str | bytes, *args: Any, **kwargs: Any) -> float:
if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270):
msg = "text length is undefined for text rotated by 90 or 270 degrees"
raise ValueError(msg)

View File

@ -1,6 +1,7 @@
from typing import Any
class ImagingCore:
def __getitem__(self, index: int) -> float: ...
def __getattr__(self, name: str) -> Any: ...
class ImagingFont:

View File

@ -1,12 +1,6 @@
from typing import Any, TypedDict
from typing import Any, Callable
from . import _imaging
class _Axis(TypedDict):
minimum: int | None
default: int | None
maximum: int | None
name: bytes | None
from . import ImageFont, _imaging
class Font:
@property
@ -28,42 +22,48 @@ class Font:
def render(
self,
string: str | bytes,
fill,
mode=...,
dir=...,
features=...,
lang=...,
stroke_width=...,
anchor=...,
foreground_ink_long=...,
x_start=...,
y_start=...,
fill: Callable[[int, int], _imaging.ImagingCore],
mode: str,
dir: str | None,
features: list[str] | None,
lang: str | None,
stroke_width: float,
anchor: str | None,
foreground_ink_long: int,
x_start: float,
y_start: float,
/,
) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ...
def getsize(
self,
string: str | bytes | bytearray,
mode=...,
dir=...,
features=...,
lang=...,
anchor=...,
mode: str,
dir: str | None,
features: list[str] | None,
lang: str | None,
anchor: str | None,
/,
) -> tuple[tuple[int, int], tuple[int, int]]: ...
def getlength(
self, string: str | bytes, mode=..., dir=..., features=..., lang=..., /
self,
string: str | bytes,
mode: str,
dir: str | None,
features: list[str] | None,
lang: str | None,
/,
) -> float: ...
def getvarnames(self) -> list[bytes]: ...
def getvaraxes(self) -> list[_Axis] | None: ...
def getvaraxes(self) -> list[ImageFont.Axis]: ...
def setvarname(self, instance_index: int, /) -> None: ...
def setvaraxes(self, axes: list[float], /) -> None: ...
def getfont(
filename: str | bytes,
size: float,
index=...,
encoding=...,
font_bytes=...,
layout_engine=...,
index: int,
encoding: str,
font_bytes: bytes,
layout_engine: int,
) -> Font: ...
def __getattr__(name: str) -> Any: ...