mirror of
https://github.com/python-pillow/Pillow.git
synced 2025-07-26 07:59:51 +03:00
Merge 969e468749
into 640f55a655
This commit is contained in:
commit
efab2b9996
72
Tests/test_imagetext.py
Normal file
72
Tests/test_imagetext.py
Normal file
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageText
|
||||
|
||||
from .helper import assert_image_similar_tofile, 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)
|
||||
|
||||
|
||||
def test_standard_embedded_color(layout_engine: ImageFont.Layout) -> None:
|
||||
font = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine)
|
||||
text = ImageText.ImageText("Hello World!", font)
|
||||
text.embed_color()
|
||||
|
||||
im = Image.new("RGB", (300, 64), "white")
|
||||
draw = ImageDraw.Draw(im)
|
||||
draw.text((10, 10), text, "#fa6")
|
||||
|
||||
assert_image_similar_tofile(im, "Tests/images/standard_embedded.png", 3.1)
|
||||
|
||||
|
||||
@skip_unless_feature("freetype2")
|
||||
def test_stroke() -> None:
|
||||
for suffix, stroke_fill in {"same": None, "different": "#0f0"}.items():
|
||||
# Arrange
|
||||
im = Image.new("RGB", (120, 130))
|
||||
draw = ImageDraw.Draw(im)
|
||||
font = ImageFont.truetype(FONT_PATH, 120)
|
||||
text = ImageText.ImageText("A", font)
|
||||
text.stroke(2, stroke_fill)
|
||||
|
||||
# Act
|
||||
draw.text((12, 12), text, "#f00")
|
||||
|
||||
# Assert
|
||||
assert_image_similar_tofile(
|
||||
im, "Tests/images/imagedraw_stroke_" + suffix + ".png", 3.1
|
||||
)
|
34
docs/reference/ImageText.rst
Normal file
34
docs/reference/ImageText.rst
Normal file
|
@ -0,0 +1,34 @@
|
|||
.. 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 or draw 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)
|
||||
|
||||
im = Image.new("RGB", text.get_bbox()[2:])
|
||||
d = ImageDraw.Draw(im)
|
||||
d.text((0, 0), text, "#f00")
|
||||
|
||||
Methods
|
||||
-------
|
||||
|
||||
.. autoclass:: PIL.ImageText.ImageText
|
||||
:members:
|
|
@ -24,6 +24,7 @@ Reference
|
|||
ImageSequence
|
||||
ImageShow
|
||||
ImageStat
|
||||
ImageText
|
||||
ImageTk
|
||||
ImageTransform
|
||||
ImageWin
|
||||
|
|
|
@ -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,15 +534,10 @@ 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],
|
||||
text: AnyStr,
|
||||
text: AnyStr | ImageText.ImageText,
|
||||
fill: _Ink | None = None,
|
||||
font: (
|
||||
ImageFont.ImageFont
|
||||
|
@ -565,29 +558,18 @@ 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,
|
||||
if isinstance(text, ImageText.ImageText):
|
||||
imagetext = text
|
||||
else:
|
||||
if font is None:
|
||||
font = self._getfont(kwargs.get("font_size"))
|
||||
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 +578,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 +659,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 +682,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 +694,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 +716,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 +752,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 +784,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 +796,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
318
src/PIL/ImageText.py
Normal 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
|
|
@ -38,6 +38,8 @@ else:
|
|||
return bool
|
||||
|
||||
|
||||
_Ink = Union[float, tuple[int, ...], str]
|
||||
|
||||
Coords = Union[Sequence[float], Sequence[Sequence[float]]]
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user