Added ImageText

This commit is contained in:
Andrew Murray 2025-07-21 17:09:26 +10:00
parent 640f55a655
commit 24681a3927
6 changed files with 490 additions and 292 deletions

41
Tests/test_imagetext.py Normal file
View File

@ -0,0 +1,41 @@
from __future__ import annotations
import pytest
from PIL import ImageFont, ImageText
from .helper import skip_unless_feature
FONT_PATH = "Tests/fonts/FreeMono.ttf"
@pytest.fixture(
scope="module",
params=[
pytest.param(ImageFont.Layout.BASIC),
pytest.param(ImageFont.Layout.RAQM, marks=skip_unless_feature("raqm")),
],
)
def layout_engine(request: pytest.FixtureRequest) -> ImageFont.Layout:
return request.param
@pytest.fixture(scope="module")
def font(layout_engine: ImageFont.Layout) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(FONT_PATH, 20, layout_engine=layout_engine)
def test_get_length(font: ImageFont.FreeTypeFont) -> None:
assert ImageText.ImageText("A", font).get_length() == 12
assert ImageText.ImageText("AB", font).get_length() == 24
assert ImageText.ImageText("M", font).get_length() == 12
assert ImageText.ImageText("y", font).get_length() == 12
assert ImageText.ImageText("a", font).get_length() == 12
def test_get_bbox(font: ImageFont.FreeTypeFont) -> None:
assert ImageText.ImageText("A", font).get_bbox() == (0, 4, 12, 16)
assert ImageText.ImageText("AB", font).get_bbox() == (0, 4, 24, 16)
assert ImageText.ImageText("M", font).get_bbox() == (0, 4, 12, 16)
assert ImageText.ImageText("y", font).get_bbox() == (0, 7, 12, 20)
assert ImageText.ImageText("a", font).get_bbox() == (0, 7, 12, 16)

View File

@ -0,0 +1,30 @@
.. py:module:: PIL.ImageText
.. py:currentmodule:: PIL.ImageText
:py:mod:`~PIL.ImageText` module
===============================
The :py:mod:`~PIL.ImageText` module defines a class with the same name. Instances of
this class provide a way to use fonts with text strings or bytes. The result is a
simple API to apply styling to pieces of text and measure them.
Example
-------
::
from PIL import Image, ImageDraw, ImageFont, ImageText
font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 24)
text = ImageText.ImageText("Hello world", font)
text.embed_color()
text.stroke(2, "#0f0")
print(text.get_length()) # 154.0
print(text.get_bbox()) # (-2, 3, 156, 22)
Methods
-------
.. autoclass:: PIL.ImageText.ImageText
:members:

View File

@ -24,6 +24,7 @@ Reference
ImageSequence
ImageShow
ImageStat
ImageText
ImageTk
ImageTransform
ImageWin

View File

@ -35,10 +35,10 @@ import math
import struct
from collections.abc import Sequence
from types import ModuleType
from typing import Any, AnyStr, Callable, Union, cast
from typing import Any, AnyStr, Callable, cast
from . import Image, ImageColor
from ._typing import Coords
from . import Image, ImageColor, ImageText
from ._typing import Coords, _Ink
# experimental access to the outline API
Outline: Callable[[], Image.core._Outline] = Image.core.outline
@ -47,8 +47,6 @@ TYPE_CHECKING = False
if TYPE_CHECKING:
from . import ImageDraw2, ImageFont
_Ink = Union[float, tuple[int, ...], str]
"""
A simple 2D drawing interface for PIL images.
<p>
@ -536,11 +534,6 @@ class ImageDraw:
right[3] -= r + 1
self.draw.draw_rectangle(right, ink, 1)
def _multiline_check(self, text: AnyStr) -> bool:
split_character = "\n" if isinstance(text, str) else b"\n"
return split_character in text
def text(
self,
xy: tuple[float, float],
@ -565,29 +558,15 @@ class ImageDraw:
**kwargs: Any,
) -> None:
"""Draw text."""
if embedded_color and self.mode not in ("RGB", "RGBA"):
msg = "Embedded color supported only in RGB and RGBA modes"
raise ValueError(msg)
if font is None:
font = self._getfont(kwargs.get("font_size"))
if self._multiline_check(text):
return self.multiline_text(
xy,
text,
fill,
font,
anchor,
spacing,
align,
direction,
features,
language,
stroke_width,
stroke_fill,
embedded_color,
)
imagetext = ImageText.ImageText(
text, font, self.mode, spacing, direction, features, language
)
if embedded_color:
imagetext.embed_color()
if stroke_width:
imagetext.stroke(stroke_width, stroke_fill)
def getink(fill: _Ink | None) -> int:
ink, fill_ink = self._getink(fill)
@ -596,70 +575,79 @@ class ImageDraw:
return fill_ink
return ink
def draw_text(ink: int, stroke_width: float = 0) -> None:
mode = self.fontmode
if stroke_width == 0 and embedded_color:
mode = "RGBA"
coord = []
for i in range(2):
coord.append(int(xy[i]))
start = (math.modf(xy[0])[0], math.modf(xy[1])[0])
try:
mask, offset = font.getmask2( # type: ignore[union-attr,misc]
text,
mode,
direction=direction,
features=features,
language=language,
stroke_width=stroke_width,
stroke_filled=True,
anchor=anchor,
ink=ink,
start=start,
*args,
**kwargs,
)
coord = [coord[0] + offset[0], coord[1] + offset[1]]
except AttributeError:
ink = getink(fill)
if ink is None:
return
stroke_ink = None
if imagetext.stroke_width:
stroke_ink = (
getink(imagetext.stroke_fill)
if imagetext.stroke_fill is not None
else ink
)
for xy, anchor, line in imagetext._split(xy, anchor, align):
def draw_text(ink: int, stroke_width: float = 0) -> None:
mode = self.fontmode
if stroke_width == 0 and embedded_color:
mode = "RGBA"
coord = []
for i in range(2):
coord.append(int(xy[i]))
start = (math.modf(xy[0])[0], math.modf(xy[1])[0])
try:
mask = font.getmask( # type: ignore[misc]
text,
mask, offset = imagetext.font.getmask2( # type: ignore[union-attr,misc]
line,
mode,
direction,
features,
language,
stroke_width,
anchor,
ink,
direction=direction,
features=features,
language=language,
stroke_width=stroke_width,
stroke_filled=True,
anchor=anchor,
ink=ink,
start=start,
*args,
**kwargs,
)
except TypeError:
mask = font.getmask(text)
if mode == "RGBA":
# font.getmask2(mode="RGBA") returns color in RGB bands and mask in A
# extract mask and set text alpha
color, mask = mask, mask.getband(3)
ink_alpha = struct.pack("i", ink)[3]
color.fillband(3, ink_alpha)
x, y = coord
if self.im is not None:
self.im.paste(
color, (x, y, x + mask.size[0], y + mask.size[1]), mask
)
else:
self.draw.draw_bitmap(coord, mask, ink)
ink = getink(fill)
if ink is not None:
stroke_ink = None
if stroke_width:
stroke_ink = getink(stroke_fill) if stroke_fill is not None else ink
coord = [coord[0] + offset[0], coord[1] + offset[1]]
except AttributeError:
try:
mask = imagetext.font.getmask( # type: ignore[misc]
line,
mode,
direction,
features,
language,
stroke_width,
anchor,
ink,
start=start,
*args,
**kwargs,
)
except TypeError:
mask = imagetext.font.getmask(line)
if mode == "RGBA":
# imagetext.font.getmask2(mode="RGBA")
# returns color in RGB bands and mask in A
# extract mask and set text alpha
color, mask = mask, mask.getband(3)
ink_alpha = struct.pack("i", ink)[3]
color.fillband(3, ink_alpha)
x, y = coord
if self.im is not None:
self.im.paste(
color, (x, y, x + mask.size[0], y + mask.size[1]), mask
)
else:
self.draw.draw_bitmap(coord, mask, ink)
if stroke_ink is not None:
# Draw stroked text
draw_text(stroke_ink, stroke_width)
draw_text(stroke_ink, imagetext.stroke_width)
# Draw normal text
if ink != stroke_ink:
@ -668,132 +656,6 @@ class ImageDraw:
# Only draw normal text
draw_text(ink)
def _prepare_multiline_text(
self,
xy: tuple[float, float],
text: AnyStr,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
),
anchor: str | None,
spacing: float,
align: str,
direction: str | None,
features: list[str] | None,
language: str | None,
stroke_width: float,
embedded_color: bool,
font_size: float | None,
) -> tuple[
ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont,
list[tuple[tuple[float, float], str, AnyStr]],
]:
if anchor is None:
anchor = "lt" if direction == "ttb" else "la"
elif len(anchor) != 2:
msg = "anchor must be a 2 character string"
raise ValueError(msg)
elif anchor[1] in "tb" and direction != "ttb":
msg = "anchor not supported for multiline text"
raise ValueError(msg)
if font is None:
font = self._getfont(font_size)
lines = text.split("\n" if isinstance(text, str) else b"\n")
line_spacing = (
self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3]
+ stroke_width
+ spacing
)
top = xy[1]
parts = []
if direction == "ttb":
left = xy[0]
for line in lines:
parts.append(((left, top), anchor, line))
left += line_spacing
else:
widths = []
max_width: float = 0
for line in lines:
line_width = self.textlength(
line,
font,
direction=direction,
features=features,
language=language,
embedded_color=embedded_color,
)
widths.append(line_width)
max_width = max(max_width, line_width)
if anchor[1] == "m":
top -= (len(lines) - 1) * line_spacing / 2.0
elif anchor[1] == "d":
top -= (len(lines) - 1) * line_spacing
for idx, line in enumerate(lines):
left = xy[0]
width_difference = max_width - widths[idx]
# align by align parameter
if align in ("left", "justify"):
pass
elif align == "center":
left += width_difference / 2.0
elif align == "right":
left += width_difference
else:
msg = 'align must be "left", "center", "right" or "justify"'
raise ValueError(msg)
if (
align == "justify"
and width_difference != 0
and idx != len(lines) - 1
):
words = line.split(" " if isinstance(text, str) else b" ")
if len(words) > 1:
# align left by anchor
if anchor[0] == "m":
left -= max_width / 2.0
elif anchor[0] == "r":
left -= max_width
word_widths = [
self.textlength(
word,
font,
direction=direction,
features=features,
language=language,
embedded_color=embedded_color,
)
for word in words
]
word_anchor = "l" + anchor[1]
width_difference = max_width - sum(word_widths)
for i, word in enumerate(words):
parts.append(((left, top), word_anchor, word))
left += word_widths[i] + width_difference / (len(words) - 1)
top += line_spacing
continue
# align left by anchor
if anchor[0] == "m":
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference
parts.append(((left, top), anchor, line))
top += line_spacing
return font, parts
def multiline_text(
self,
xy: tuple[float, float],
@ -817,9 +679,10 @@ class ImageDraw:
*,
font_size: float | None = None,
) -> None:
font, lines = self._prepare_multiline_text(
return self.text(
xy,
text,
fill,
font,
anchor,
spacing,
@ -828,25 +691,11 @@ class ImageDraw:
features,
language,
stroke_width,
stroke_fill,
embedded_color,
font_size,
font_size=font_size,
)
for xy, anchor, line in lines:
self.text(
xy,
line,
fill,
font,
anchor,
direction=direction,
features=features,
language=language,
stroke_width=stroke_width,
stroke_fill=stroke_fill,
embedded_color=embedded_color,
)
def textlength(
self,
text: AnyStr,
@ -864,17 +713,19 @@ class ImageDraw:
font_size: float | None = None,
) -> float:
"""Get the length of a given string, in pixels with 1/64 precision."""
if self._multiline_check(text):
msg = "can't measure length of multiline text"
raise ValueError(msg)
if embedded_color and self.mode not in ("RGB", "RGBA"):
msg = "Embedded color supported only in RGB and RGBA modes"
raise ValueError(msg)
if font is None:
font = self._getfont(font_size)
mode = "RGBA" if embedded_color else self.fontmode
return font.getlength(text, mode, direction, features, language)
imagetext = ImageText.ImageText(
text,
font,
self.mode,
direction=direction,
features=features,
language=language,
)
if embedded_color:
imagetext.embed_color()
return imagetext.get_length()
def textbbox(
self,
@ -898,33 +749,16 @@ class ImageDraw:
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"
raise ValueError(msg)
if font is None:
font = self._getfont(font_size)
if self._multiline_check(text):
return self.multiline_textbbox(
xy,
text,
font,
anchor,
spacing,
align,
direction,
features,
language,
stroke_width,
embedded_color,
)
mode = "RGBA" if embedded_color else self.fontmode
bbox = font.getbbox(
text, mode, direction, features, language, stroke_width, anchor
imagetext = ImageText.ImageText(
text, font, self.mode, spacing, direction, features, language
)
return bbox[0] + xy[0], bbox[1] + xy[1], bbox[2] + xy[0], bbox[3] + xy[1]
if embedded_color:
imagetext.embed_color()
if stroke_width:
imagetext.stroke(stroke_width)
return imagetext.get_bbox(xy, anchor, align)
def multiline_textbbox(
self,
@ -947,7 +781,7 @@ class ImageDraw:
*,
font_size: float | None = None,
) -> tuple[float, float, float, float]:
font, lines = self._prepare_multiline_text(
return self.textbbox(
xy,
text,
font,
@ -959,37 +793,9 @@ class ImageDraw:
language,
stroke_width,
embedded_color,
font_size,
font_size=font_size,
)
bbox: tuple[float, float, float, float] | None = None
for xy, anchor, line in lines:
bbox_line = self.textbbox(
xy,
line,
font,
anchor,
direction=direction,
features=features,
language=language,
stroke_width=stroke_width,
embedded_color=embedded_color,
)
if bbox is None:
bbox = bbox_line
else:
bbox = (
min(bbox[0], bbox_line[0]),
min(bbox[1], bbox_line[1]),
max(bbox[2], bbox_line[2]),
max(bbox[3], bbox_line[3]),
)
if bbox is None:
return xy[0], xy[1], xy[0], xy[1]
return bbox
def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw:
"""

318
src/PIL/ImageText.py Normal file
View File

@ -0,0 +1,318 @@
from __future__ import annotations
from . import ImageFont
from ._typing import _Ink
class ImageText:
def __init__(
self,
text: str | bytes,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
mode: str = "RGB",
spacing: float = 4,
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
) -> None:
"""
:param text: String to be drawn.
:param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance,
:py:class:`~PIL.ImageFont.FreeTypeFont` instance,
:py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If
``None``, the default font from :py:meth:`.ImageFont.load_default`
will be used.
:param mode: The image mode this will be used with.
:param spacing: The number of pixels between lines.
:param direction: Direction of the text. It can be ``"rtl"`` (right to left),
``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.
:param features: A list of OpenType font features to be used during text
layout. This is usually used to turn on optional font features
that are not enabled by default, for example ``"dlig"`` or
``"ss01"``, but can be also used to turn off default font
features, for example ``"-liga"`` to disable ligatures or
``"-kern"`` to disable kerning. To get all supported
features, see `OpenType docs`_.
Requires libraqm.
:param language: Language of the text. Different languages may use
different glyph shapes or ligatures. This parameter tells
the font which language the text is in, and to apply the
correct substitutions as appropriate, if available.
It should be a `BCP 47 language code`_.
Requires libraqm.
"""
self.text = text
self.font = font or ImageFont.load_default()
self.mode = mode
self.spacing = spacing
self.direction = direction
self.features = features
self.language = language
self.embedded_color = False
self.stroke_width: float = 0
self.stroke_fill: _Ink | None = None
def embed_color(self) -> None:
"""
Use embedded color glyphs (COLR, CBDT, SBIX).
"""
if self.mode not in ("RGB", "RGBA"):
msg = "Embedded color supported only in RGB and RGBA modes"
raise ValueError(msg)
self.embedded_color = True
def stroke(self, width: float = 0, fill: _Ink | None = None) -> None:
"""
:param width: The width of the text stroke.
:param fill: Color to use for the text stroke when drawing. If not given, will
default to the ``fill`` parameter from
:py:meth:`.ImageDraw.ImageDraw.text`.
"""
self.stroke_width = width
self.stroke_fill = fill
def _get_fontmode(self) -> str:
if self.mode in ("1", "P", "I", "F"):
return "1"
elif self.embedded_color:
return "RGBA"
else:
return "L"
def get_length(self):
"""
Returns length (in pixels with 1/64 precision) of text.
This is the amount by which following text should be offset.
Text bounding box may extend past the length in some fonts,
e.g. when using italics or accents.
The result is returned as a float; it is a whole number if using basic layout.
Note that the sum of two lengths may not equal the length of a concatenated
string due to kerning. If you need to adjust for kerning, include the following
character and subtract its length.
For example, instead of::
hello = ImageText.ImageText("Hello", font).get_length()
world = ImageText.ImageText("World", font).get_length()
helloworld = ImageText.ImageText("HelloWorld", font).get_length()
assert hello + world == helloworld
use::
hello = (
ImageText.ImageText("HelloW", font).get_length() -
ImageText.ImageText("W", font).get_length()
) # adjusted for kerning
world = ImageText.ImageText("World", font).get_length()
helloworld = ImageText.ImageText("HelloWorld", font).get_length()
assert hello + world == helloworld
or disable kerning with (requires libraqm)::
hello = ImageText.ImageText("Hello", font, features=["-kern"]).get_length()
world = ImageText.ImageText("World", font, features=["-kern"]).get_length()
helloworld = ImageText.ImageText(
"HelloWorld", font, features=["-kern"]
).get_length()
assert hello + world == helloworld
:return: Either width for horizontal text, or height for vertical text.
"""
split_character = "\n" if isinstance(self.text, str) else b"\n"
if split_character in self.text:
msg = "can't measure length of multiline text"
raise ValueError(msg)
return self.font.getlength(
self.text,
self._get_fontmode(),
self.direction,
self.features,
self.language,
)
def _split(
self, xy: tuple[float, float], anchor: str | None, align: str
) -> list[tuple[tuple[float, float], str, str | bytes]]:
if anchor is None:
anchor = "lt" if self.direction == "ttb" else "la"
elif len(anchor) != 2:
msg = "anchor must be a 2 character string"
raise ValueError(msg)
lines = (
self.text.split("\n")
if isinstance(self.text, str)
else self.text.split(b"\n")
)
if len(lines) == 1:
return [(xy, anchor, self.text)]
if anchor[1] in "tb" and self.direction != "ttb":
msg = "anchor not supported for multiline text"
raise ValueError(msg)
fontmode = self._get_fontmode()
line_spacing = (
self.font.getbbox(
"A",
fontmode,
None,
self.features,
self.language,
self.stroke_width,
)[3]
+ self.stroke_width
+ self.spacing
)
top = xy[1]
parts = []
if self.direction == "ttb":
left = xy[0]
for line in lines:
parts.append(((left, top), anchor, line))
left += line_spacing
else:
widths = []
max_width: float = 0
for line in lines:
line_width = self.font.getlength(
line, fontmode, self.direction, self.features, self.language
)
widths.append(line_width)
max_width = max(max_width, line_width)
if anchor[1] == "m":
top -= (len(lines) - 1) * line_spacing / 2.0
elif anchor[1] == "d":
top -= (len(lines) - 1) * line_spacing
idx = -1
for line in lines:
left = xy[0]
idx += 1
width_difference = max_width - widths[idx]
# align by align parameter
if align in ("left", "justify"):
pass
elif align == "center":
left += width_difference / 2.0
elif align == "right":
left += width_difference
else:
msg = 'align must be "left", "center", "right" or "justify"'
raise ValueError(msg)
if (
align == "justify"
and width_difference != 0
and idx != len(lines) - 1
):
words = (
line.split(" ") if isinstance(line, str) else line.split(b" ")
)
if len(words) > 1:
# align left by anchor
if anchor[0] == "m":
left -= max_width / 2.0
elif anchor[0] == "r":
left -= max_width
word_widths = [
self.font.getlength(
word,
fontmode,
self.direction,
self.features,
self.language,
)
for word in words
]
word_anchor = "l" + anchor[1]
width_difference = max_width - sum(word_widths)
i = 0
for word in words:
parts.append(((left, top), word_anchor, word))
left += word_widths[i] + width_difference / (len(words) - 1)
i += 1
top += line_spacing
continue
# align left by anchor
if anchor[0] == "m":
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference
parts.append(((left, top), anchor, line))
top += line_spacing
return parts
def get_bbox(
self,
xy: tuple[float, float] = (0, 0),
anchor: str | None = None,
align: str = "left",
) -> tuple[float, float, float, float]:
"""
Returns bounding box (in pixels) of text.
Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel
precision. The bounding box includes extra margins for some fonts, e.g. italics
or accents.
:param xy: The anchor coordinates of the text.
:param anchor: The text anchor alignment. Determines the relative location of
the anchor to the text. The default alignment is top left,
specifically ``la`` for horizontal text and ``lt`` for
vertical text. See :ref:`text-anchors` for details.
:param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or
``"justify"`` determines the relative alignment of lines. Use the
``anchor`` parameter to specify the alignment to ``xy``.
:return: ``(left, top, right, bottom)`` bounding box
"""
bbox: tuple[float, float, float, float] | None = None
fontmode = self._get_fontmode()
for xy, anchor, line in self._split(xy, anchor, align):
bbox_line = self.font.getbbox(
line,
fontmode,
self.direction,
self.features,
self.language,
self.stroke_width,
anchor,
)
bbox_line = (
bbox_line[0] + xy[0],
bbox_line[1] + xy[1],
bbox_line[2] + xy[0],
bbox_line[3] + xy[1],
)
if bbox is None:
bbox = bbox_line
else:
bbox = (
min(bbox[0], bbox_line[0]),
min(bbox[1], bbox_line[1]),
max(bbox[2], bbox_line[2]),
max(bbox[3], bbox_line[3]),
)
if bbox is None:
return xy[0], xy[1], xy[0], xy[1]
return bbox

View File

@ -38,6 +38,8 @@ else:
return bool
_Ink = Union[float, tuple[int, ...], str]
Coords = Union[Sequence[float], Sequence[Sequence[float]]]