mirror of
https://github.com/python-pillow/Pillow.git
synced 2025-07-27 08:30:05 +03:00
Added ImageText
This commit is contained in:
parent
640f55a655
commit
24681a3927
41
Tests/test_imagetext.py
Normal file
41
Tests/test_imagetext.py
Normal 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)
|
30
docs/reference/ImageText.rst
Normal file
30
docs/reference/ImageText.rst
Normal 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:
|
|
@ -24,6 +24,7 @@ Reference
|
||||||
ImageSequence
|
ImageSequence
|
||||||
ImageShow
|
ImageShow
|
||||||
ImageStat
|
ImageStat
|
||||||
|
ImageText
|
||||||
ImageTk
|
ImageTk
|
||||||
ImageTransform
|
ImageTransform
|
||||||
ImageWin
|
ImageWin
|
||||||
|
|
|
@ -35,10 +35,10 @@ import math
|
||||||
import struct
|
import struct
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import Any, AnyStr, Callable, Union, cast
|
from typing import Any, AnyStr, Callable, cast
|
||||||
|
|
||||||
from . import Image, ImageColor
|
from . import Image, ImageColor, ImageText
|
||||||
from ._typing import Coords
|
from ._typing import Coords, _Ink
|
||||||
|
|
||||||
# experimental access to the outline API
|
# experimental access to the outline API
|
||||||
Outline: Callable[[], Image.core._Outline] = Image.core.outline
|
Outline: Callable[[], Image.core._Outline] = Image.core.outline
|
||||||
|
@ -47,8 +47,6 @@ TYPE_CHECKING = False
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from . import ImageDraw2, ImageFont
|
from . import ImageDraw2, ImageFont
|
||||||
|
|
||||||
_Ink = Union[float, tuple[int, ...], str]
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
A simple 2D drawing interface for PIL images.
|
A simple 2D drawing interface for PIL images.
|
||||||
<p>
|
<p>
|
||||||
|
@ -536,11 +534,6 @@ class ImageDraw:
|
||||||
right[3] -= r + 1
|
right[3] -= r + 1
|
||||||
self.draw.draw_rectangle(right, ink, 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(
|
def text(
|
||||||
self,
|
self,
|
||||||
xy: tuple[float, float],
|
xy: tuple[float, float],
|
||||||
|
@ -565,29 +558,15 @@ class ImageDraw:
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Draw text."""
|
"""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:
|
if font is None:
|
||||||
font = self._getfont(kwargs.get("font_size"))
|
font = self._getfont(kwargs.get("font_size"))
|
||||||
|
imagetext = ImageText.ImageText(
|
||||||
if self._multiline_check(text):
|
text, font, self.mode, spacing, direction, features, language
|
||||||
return self.multiline_text(
|
|
||||||
xy,
|
|
||||||
text,
|
|
||||||
fill,
|
|
||||||
font,
|
|
||||||
anchor,
|
|
||||||
spacing,
|
|
||||||
align,
|
|
||||||
direction,
|
|
||||||
features,
|
|
||||||
language,
|
|
||||||
stroke_width,
|
|
||||||
stroke_fill,
|
|
||||||
embedded_color,
|
|
||||||
)
|
)
|
||||||
|
if embedded_color:
|
||||||
|
imagetext.embed_color()
|
||||||
|
if stroke_width:
|
||||||
|
imagetext.stroke(stroke_width, stroke_fill)
|
||||||
|
|
||||||
def getink(fill: _Ink | None) -> int:
|
def getink(fill: _Ink | None) -> int:
|
||||||
ink, fill_ink = self._getink(fill)
|
ink, fill_ink = self._getink(fill)
|
||||||
|
@ -596,6 +575,20 @@ class ImageDraw:
|
||||||
return fill_ink
|
return fill_ink
|
||||||
return ink
|
return ink
|
||||||
|
|
||||||
|
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:
|
def draw_text(ink: int, stroke_width: float = 0) -> None:
|
||||||
mode = self.fontmode
|
mode = self.fontmode
|
||||||
if stroke_width == 0 and embedded_color:
|
if stroke_width == 0 and embedded_color:
|
||||||
|
@ -605,8 +598,8 @@ class ImageDraw:
|
||||||
coord.append(int(xy[i]))
|
coord.append(int(xy[i]))
|
||||||
start = (math.modf(xy[0])[0], math.modf(xy[1])[0])
|
start = (math.modf(xy[0])[0], math.modf(xy[1])[0])
|
||||||
try:
|
try:
|
||||||
mask, offset = font.getmask2( # type: ignore[union-attr,misc]
|
mask, offset = imagetext.font.getmask2( # type: ignore[union-attr,misc]
|
||||||
text,
|
line,
|
||||||
mode,
|
mode,
|
||||||
direction=direction,
|
direction=direction,
|
||||||
features=features,
|
features=features,
|
||||||
|
@ -622,8 +615,8 @@ class ImageDraw:
|
||||||
coord = [coord[0] + offset[0], coord[1] + offset[1]]
|
coord = [coord[0] + offset[0], coord[1] + offset[1]]
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
try:
|
try:
|
||||||
mask = font.getmask( # type: ignore[misc]
|
mask = imagetext.font.getmask( # type: ignore[misc]
|
||||||
text,
|
line,
|
||||||
mode,
|
mode,
|
||||||
direction,
|
direction,
|
||||||
features,
|
features,
|
||||||
|
@ -636,9 +629,10 @@ class ImageDraw:
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
mask = font.getmask(text)
|
mask = imagetext.font.getmask(line)
|
||||||
if mode == "RGBA":
|
if mode == "RGBA":
|
||||||
# font.getmask2(mode="RGBA") returns color in RGB bands and mask in A
|
# imagetext.font.getmask2(mode="RGBA")
|
||||||
|
# returns color in RGB bands and mask in A
|
||||||
# extract mask and set text alpha
|
# extract mask and set text alpha
|
||||||
color, mask = mask, mask.getband(3)
|
color, mask = mask, mask.getband(3)
|
||||||
ink_alpha = struct.pack("i", ink)[3]
|
ink_alpha = struct.pack("i", ink)[3]
|
||||||
|
@ -651,15 +645,9 @@ class ImageDraw:
|
||||||
else:
|
else:
|
||||||
self.draw.draw_bitmap(coord, mask, ink)
|
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
|
|
||||||
|
|
||||||
if stroke_ink is not None:
|
if stroke_ink is not None:
|
||||||
# Draw stroked text
|
# Draw stroked text
|
||||||
draw_text(stroke_ink, stroke_width)
|
draw_text(stroke_ink, imagetext.stroke_width)
|
||||||
|
|
||||||
# Draw normal text
|
# Draw normal text
|
||||||
if ink != stroke_ink:
|
if ink != stroke_ink:
|
||||||
|
@ -668,132 +656,6 @@ class ImageDraw:
|
||||||
# Only draw normal text
|
# Only draw normal text
|
||||||
draw_text(ink)
|
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(
|
def multiline_text(
|
||||||
self,
|
self,
|
||||||
xy: tuple[float, float],
|
xy: tuple[float, float],
|
||||||
|
@ -817,9 +679,10 @@ class ImageDraw:
|
||||||
*,
|
*,
|
||||||
font_size: float | None = None,
|
font_size: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
font, lines = self._prepare_multiline_text(
|
return self.text(
|
||||||
xy,
|
xy,
|
||||||
text,
|
text,
|
||||||
|
fill,
|
||||||
font,
|
font,
|
||||||
anchor,
|
anchor,
|
||||||
spacing,
|
spacing,
|
||||||
|
@ -828,23 +691,9 @@ class ImageDraw:
|
||||||
features,
|
features,
|
||||||
language,
|
language,
|
||||||
stroke_width,
|
stroke_width,
|
||||||
|
stroke_fill,
|
||||||
embedded_color,
|
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(
|
def textlength(
|
||||||
|
@ -864,17 +713,19 @@ class ImageDraw:
|
||||||
font_size: float | None = None,
|
font_size: float | None = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Get the length of a given string, in pixels with 1/64 precision."""
|
"""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:
|
if font is None:
|
||||||
font = self._getfont(font_size)
|
font = self._getfont(font_size)
|
||||||
mode = "RGBA" if embedded_color else self.fontmode
|
imagetext = ImageText.ImageText(
|
||||||
return font.getlength(text, mode, direction, features, language)
|
text,
|
||||||
|
font,
|
||||||
|
self.mode,
|
||||||
|
direction=direction,
|
||||||
|
features=features,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
if embedded_color:
|
||||||
|
imagetext.embed_color()
|
||||||
|
return imagetext.get_length()
|
||||||
|
|
||||||
def textbbox(
|
def textbbox(
|
||||||
self,
|
self,
|
||||||
|
@ -898,33 +749,16 @@ class ImageDraw:
|
||||||
font_size: float | None = None,
|
font_size: float | None = None,
|
||||||
) -> tuple[float, float, float, float]:
|
) -> tuple[float, float, float, float]:
|
||||||
"""Get the bounding box of a given string, in pixels."""
|
"""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:
|
if font is None:
|
||||||
font = self._getfont(font_size)
|
font = self._getfont(font_size)
|
||||||
|
imagetext = ImageText.ImageText(
|
||||||
if self._multiline_check(text):
|
text, font, self.mode, spacing, direction, features, language
|
||||||
return self.multiline_textbbox(
|
|
||||||
xy,
|
|
||||||
text,
|
|
||||||
font,
|
|
||||||
anchor,
|
|
||||||
spacing,
|
|
||||||
align,
|
|
||||||
direction,
|
|
||||||
features,
|
|
||||||
language,
|
|
||||||
stroke_width,
|
|
||||||
embedded_color,
|
|
||||||
)
|
)
|
||||||
|
if embedded_color:
|
||||||
mode = "RGBA" if embedded_color else self.fontmode
|
imagetext.embed_color()
|
||||||
bbox = font.getbbox(
|
if stroke_width:
|
||||||
text, mode, direction, features, language, stroke_width, anchor
|
imagetext.stroke(stroke_width)
|
||||||
)
|
return imagetext.get_bbox(xy, anchor, align)
|
||||||
return bbox[0] + xy[0], bbox[1] + xy[1], bbox[2] + xy[0], bbox[3] + xy[1]
|
|
||||||
|
|
||||||
def multiline_textbbox(
|
def multiline_textbbox(
|
||||||
self,
|
self,
|
||||||
|
@ -947,7 +781,7 @@ class ImageDraw:
|
||||||
*,
|
*,
|
||||||
font_size: float | None = None,
|
font_size: float | None = None,
|
||||||
) -> tuple[float, float, float, float]:
|
) -> tuple[float, float, float, float]:
|
||||||
font, lines = self._prepare_multiline_text(
|
return self.textbbox(
|
||||||
xy,
|
xy,
|
||||||
text,
|
text,
|
||||||
font,
|
font,
|
||||||
|
@ -959,37 +793,9 @@ class ImageDraw:
|
||||||
language,
|
language,
|
||||||
stroke_width,
|
stroke_width,
|
||||||
embedded_color,
|
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:
|
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
|
return bool
|
||||||
|
|
||||||
|
|
||||||
|
_Ink = Union[float, tuple[int, ...], str]
|
||||||
|
|
||||||
Coords = Union[Sequence[float], Sequence[Sequence[float]]]
|
Coords = Union[Sequence[float], Sequence[Sequence[float]]]
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user