Skip to content

Commit

Permalink
(WIP) add type hints for ImageFont and _imagingft
Browse files Browse the repository at this point in the history
  • Loading branch information
nulano committed Dec 27, 2023
1 parent 7b7d603 commit 4d27431
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 64 deletions.
2 changes: 1 addition & 1 deletion src/PIL/Image.py
Original file line number Diff line number Diff line change
Expand Up @@ -3191,7 +3191,7 @@ def _decompression_bomb_check(size):
)


def open(fp, mode="r", formats=None):
def open(fp, mode="r", formats=None) -> Image:
"""
Opens and identifies the given image file.
Expand Down
152 changes: 93 additions & 59 deletions src/PIL/ImageFont.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from enum import IntEnum
from io import BytesIO
from pathlib import Path
from typing import BinaryIO
from typing import Any, BinaryIO, Sequence

from . import Image
from ._util import is_directory, is_path
Expand All @@ -45,7 +45,7 @@ class Layout(IntEnum):
RAQM = 1


MAX_STRING_LENGTH = 1_000_000
MAX_STRING_LENGTH: int | None = 1_000_000


try:
Expand All @@ -56,7 +56,7 @@ class Layout(IntEnum):
core = DeferredError.new(ex)


def _string_length_check(text):
def _string_length_check(text: str) -> None:
if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH:
msg = "too many characters in string"
raise ValueError(msg)
Expand All @@ -81,9 +81,9 @@ def _string_length_check(text):
class ImageFont:
"""PIL font wrapper"""

def _load_pilfont(self, filename):
def _load_pilfont(self, filename: str | Path):
with open(filename, "rb") as fp:
image = None
image: Image.Image | None = None
for ext in (".png", ".gif", ".pbm"):
if image:
image.close()
Expand All @@ -106,7 +106,7 @@ def _load_pilfont(self, filename):
self._load_pilfont_data(fp, image)
image.close()

def _load_pilfont_data(self, file, image):
def _load_pilfont_data(self, file: BinaryIO, image: Image.Image):
# read PILfont header
if file.readline() != b"PILfont\n":
msg = "Not a PILfont file"
Expand All @@ -131,7 +131,9 @@ def _load_pilfont_data(self, file, image):

self.font = Image.core.font(image.im, data)

def getmask(self, text, mode="", *args, **kwargs):
def getmask(
self, text: str, mode: str | None = "", *args, **kwargs
) -> Any: # TODO returns internal image type
"""
Create a bitmap for the text.
Expand All @@ -151,7 +153,7 @@ def getmask(self, text, mode="", *args, **kwargs):
"""
return self.font.getmask(text, mode)

def getbbox(self, text, *args, **kwargs):
def getbbox(self, text: str, *args, **kwargs) -> tuple[int, int, int, int]:
"""
Returns bounding box (in pixels) of given text.
Expand All @@ -169,7 +171,7 @@ def getbbox(self, text, *args, **kwargs):
width, height = self.font.getsize(text)
return 0, 0, width, height

def getlength(self, text, *args, **kwargs):
def getlength(self, text: str, *args, **kwargs) -> float:
"""
Returns length (in pixels) of given text.
This is the amount by which following text should be offset.
Expand All @@ -189,6 +191,8 @@ def getlength(self, text, *args, **kwargs):
class FreeTypeFont:
"""FreeType font wrapper (requires _imagingft service)"""

font: core.Font # TODO remove this

def __init__(
self,
font: bytes | str | Path | BinaryIO | None = None,
Expand Down Expand Up @@ -253,22 +257,29 @@ def __setstate__(self, state):
path, size, index, encoding, layout_engine = state
self.__init__(path, size, index, encoding, layout_engine)

def getname(self):
def getname(self) -> tuple[str, str]:
"""
:return: A tuple of the font family (e.g. Helvetica) and the font style
(e.g. Bold)
"""
return self.font.family, self.font.style

def getmetrics(self):
def getmetrics(self) -> tuple[int, int]:
"""
:return: A tuple of the font ascent (the distance from the baseline to
the highest outline point) and descent (the distance from the
baseline to the lowest outline point, a negative value)
"""
return self.font.ascent, self.font.descent

def getlength(self, text, mode="", direction=None, features=None, language=None):
def getlength(
self,
text: str,
mode: str | None = "",
direction: str | None = None,
features: Sequence[str] | None = None,
language: str | None = None,
) -> float:
"""
Returns length (in pixels with 1/64 precision) of given text when rendered
in font with provided direction, features, and language.
Expand Down Expand Up @@ -342,14 +353,14 @@ def getlength(self, text, mode="", direction=None, features=None, language=None)

def getbbox(
self,
text,
mode="",
direction=None,
features=None,
language=None,
stroke_width=0,
anchor=None,
):
text: str,
mode: str | None = "",
direction: str | None = None,
features: Sequence[str] | None = None,
language: str | None = None,
stroke_width: int = 0,
anchor: str | None = None,
) -> tuple[int, int, int, int]:
"""
Returns bounding box (in pixels) of given text relative to given anchor
when rendered in font with provided direction, features, and language.
Expand Down Expand Up @@ -408,16 +419,16 @@ def getbbox(

def getmask(
self,
text,
mode="",
direction=None,
features=None,
language=None,
stroke_width=0,
anchor=None,
ink=0,
start=None,
):
text: str,
mode: str | None = "",
direction: str | None = None,
features: Sequence[str] | None = None,
language: str | None = None,
stroke_width: int = 0,
anchor: str | None = None,
ink: int = 0,
start: tuple[float, float] | None = None,
) -> Any: # TODO returns internal image type
"""
Create a bitmap for the text.
Expand Down Expand Up @@ -499,18 +510,18 @@ def getmask(

def getmask2(
self,
text,
mode="",
direction=None,
features=None,
language=None,
stroke_width=0,
anchor=None,
ink=0,
start=None,
text: str,
mode: str | None = "",
direction: str | None = None,
features: Sequence[str] | None = None,
language: str | None = None,
stroke_width: int = 0,
anchor: str | None = None,
ink: int = 0,
start: tuple[float, float] | None = None,
*args,
**kwargs,
):
) -> tuple[Any, tuple[int, int]]: # TODO returns internal image type
"""
Create a bitmap for the text.
Expand Down Expand Up @@ -585,7 +596,7 @@ def getmask2(
im = None
size = None

def fill(width, height):
def fill(width: int, height: int) -> Any: # TODO returns internal image type
nonlocal im, size

size = (width, height)
Expand Down Expand Up @@ -614,8 +625,13 @@ def fill(width, height):
return im, offset

def font_variant(
self, font=None, size=None, index=None, encoding=None, layout_engine=None
):
self,
font: bytes | str | Path | 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.
Expand All @@ -638,7 +654,7 @@ def font_variant(
layout_engine=layout_engine or self.layout_engine,
)

def get_variation_names(self):
def get_variation_names(self) -> list[bytes]:
"""
:returns: A list of the named styles in a variation font.
:exception OSError: If the font is not a variation font.
Expand All @@ -650,7 +666,7 @@ def get_variation_names(self):
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):
"""
:param name: The name of the style.
:exception OSError: If the font is not a variation font.
Expand All @@ -669,7 +685,11 @@ def set_variation_by_name(self, name):

self.font.setvarname(index)

def get_variation_axes(self):
def get_variation_axes(
self,
) -> list[
core.FontVariationAxis
]: # TODO return type not available unless type checking
"""
:returns: A list of the axes in a variation font.
:exception OSError: If the font is not a variation font.
Expand All @@ -680,10 +700,11 @@ def get_variation_axes(self):
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
for axis in axes:
axis["name"] = axis["name"].replace(b"\x00", b"")
if axis["name"]:
axis["name"] = axis["name"].replace(b"\x00", b"")
return axes

def set_variation_by_axes(self, axes):
def set_variation_by_axes(self, axes: list[float]):
"""
:param axes: A list of values for each axis.
:exception OSError: If the font is not a variation font.
Expand All @@ -698,7 +719,11 @@ def set_variation_by_axes(self, axes):
class TransposedFont:
"""Wrapper for writing rotated or mirrored text"""

def __init__(self, font, orientation=None):
def __init__(
self,
font: ImageFont | FreeTypeFont | TransposedFont,
orientation: Image.Transpose | None = None,
) -> None:
"""
Wrapper that creates a transposed font from any existing font
object.
Expand All @@ -712,13 +737,15 @@ def __init__(self, font, orientation=None):
self.font = font
self.orientation = orientation # any 'transpose' argument, or None

def getmask(self, text, mode="", *args, **kwargs):
def getmask(
self, text: str, mode: str | None = "", *args, **kwargs
) -> Any: # TODO returns internal image type
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, *args, **kwargs) -> tuple[int, int, int, int]:
# 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)
Expand All @@ -728,14 +755,14 @@ def getbbox(self, text, *args, **kwargs):
return 0, 0, height, width
return 0, 0, width, height

def getlength(self, text, *args, **kwargs):
def getlength(self, text: str, *args, **kwargs) -> 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)
return self.font.getlength(text, *args, **kwargs)


def load(filename):
def load(filename: str | Path) -> ImageFont:
"""
Load a font file. This function loads a font object from the given
bitmap font file, and returns the corresponding font object.
Expand All @@ -749,7 +776,13 @@ def load(filename):
return f


def truetype(font=None, size=10, index=0, encoding="", layout_engine=None):
def truetype(
font: bytes | str | Path | BinaryIO | None = None,
size: float = 10,
index: int = 0,
encoding: str = "",
layout_engine: Layout | None = None,
) -> FreeTypeFont:
"""
Load a TrueType or OpenType font from a file or file-like object,
and create a font object.
Expand Down Expand Up @@ -860,7 +893,7 @@ def freetype(font):
raise


def load_path(filename):
def load_path(filename: str | bytes) -> ImageFont:
"""
Load font file. Same as :py:func:`~PIL.ImageFont.load`, but searches for a
bitmap font along the Python path.
Expand All @@ -874,14 +907,15 @@ def load_path(filename):
if not isinstance(filename, str):
filename = filename.decode("utf-8")
try:
return load(os.path.join(directory, filename))
# TODO sys.path might not be all str
return load(os.path.join(directory, filename)) # type: ignore[arg-type]
except OSError:
pass
msg = "cannot find font file"
raise OSError(msg)


def load_default(size=None):
def load_default(size: int | None = None) -> ImageFont | FreeTypeFont:
"""If FreeType support is available, load a version of Aileron Regular,
https://dotcolon.net/font/aileron, with a more limited character set.
Expand All @@ -896,7 +930,7 @@ def load_default(size=None):
:return: A font object.
"""
if core.__class__.__name__ == "module" or size is not None:
f = truetype(
return truetype(
BytesIO(
base64.b64decode(
b"""
Expand Down Expand Up @@ -1259,4 +1293,4 @@ def load_default(size=None):
)
),
)
return f
return f
Loading

0 comments on commit 4d27431

Please sign in to comment.