hand
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import AnyStr, Generic, NamedTuple
|
||||
|
||||
from . import ImageFont
|
||||
from ._typing import _Ink
|
||||
|
||||
Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont
|
||||
|
||||
|
||||
class _Line(NamedTuple):
|
||||
x: float
|
||||
y: float
|
||||
anchor: str
|
||||
text: str | bytes
|
||||
|
||||
|
||||
class _Wrap(Generic[AnyStr]):
|
||||
lines: list[AnyStr] = []
|
||||
position = 0
|
||||
offset = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: Text[AnyStr],
|
||||
width: int,
|
||||
height: int | None = None,
|
||||
font: Font | None = None,
|
||||
) -> None:
|
||||
self.text: Text[AnyStr] = text
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.font = font
|
||||
|
||||
input_text = self.text.text
|
||||
emptystring = "" if isinstance(input_text, str) else b""
|
||||
line = emptystring
|
||||
|
||||
for word in re.findall(
|
||||
r"\s*\S+" if isinstance(input_text, str) else rb"\s*\S+", input_text
|
||||
):
|
||||
newlines = re.findall(
|
||||
r"[^\S\n]*\n" if isinstance(input_text, str) else rb"[^\S\n]*\n", word
|
||||
)
|
||||
if newlines:
|
||||
if not self.add_line(line):
|
||||
break
|
||||
for i, line in enumerate(newlines):
|
||||
if i != 0 and not self.add_line(emptystring):
|
||||
break
|
||||
self.position += len(line)
|
||||
word = word[len(line) :]
|
||||
line = emptystring
|
||||
|
||||
new_line = line + word
|
||||
if self.text._get_bbox(new_line, self.font)[2] <= width:
|
||||
# This word fits on the line
|
||||
line = new_line
|
||||
continue
|
||||
|
||||
# This word does not fit on the line
|
||||
if line and not self.add_line(line):
|
||||
break
|
||||
|
||||
original_length = len(word)
|
||||
word = word.lstrip()
|
||||
self.offset = original_length - len(word)
|
||||
|
||||
if self.text._get_bbox(word, self.font)[2] > width:
|
||||
if font is None:
|
||||
msg = "Word does not fit within line"
|
||||
raise ValueError(msg)
|
||||
break
|
||||
line = word
|
||||
else:
|
||||
if line:
|
||||
self.add_line(line)
|
||||
self.remaining_text: AnyStr = input_text[self.position :]
|
||||
|
||||
def add_line(self, line: AnyStr) -> bool:
|
||||
lines = self.lines + [line]
|
||||
if self.height is not None:
|
||||
last_line_y = self.text._split(lines=lines)[-1].y
|
||||
last_line_height = self.text._get_bbox(line, self.font)[3]
|
||||
if last_line_y + last_line_height > self.height:
|
||||
return False
|
||||
|
||||
self.lines = lines
|
||||
self.position += len(line) + self.offset
|
||||
self.offset = 0
|
||||
return True
|
||||
|
||||
|
||||
class Text(Generic[AnyStr]):
|
||||
def __init__(
|
||||
self,
|
||||
text: AnyStr,
|
||||
font: Font | 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: AnyStr = 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 wrap(
|
||||
self,
|
||||
width: int,
|
||||
height: int | None = None,
|
||||
scaling: str | tuple[str, int] | None = None,
|
||||
) -> Text[AnyStr] | None:
|
||||
"""
|
||||
Wrap text to fit within a given width.
|
||||
|
||||
:param width: The width to fit within.
|
||||
:param height: An optional height limit. Any text that does not fit within this
|
||||
will be returned as a new :py:class:`.Text` object.
|
||||
:param scaling: An optional directive to scale the text, either "grow" as much
|
||||
as possible within the given dimensions, or "shrink" until it
|
||||
fits. It can also be a tuple of (direction, limit), with an
|
||||
integer limit to stop scaling at.
|
||||
|
||||
:returns: An :py:class:`.Text` object, or None.
|
||||
"""
|
||||
if isinstance(self.font, ImageFont.TransposedFont):
|
||||
msg = "TransposedFont not supported"
|
||||
raise ValueError(msg)
|
||||
if self.direction not in (None, "ltr"):
|
||||
msg = "Only ltr direction supported"
|
||||
raise ValueError(msg)
|
||||
|
||||
if scaling is None:
|
||||
wrap = _Wrap(self, width, height)
|
||||
else:
|
||||
if not isinstance(self.font, ImageFont.FreeTypeFont):
|
||||
msg = "'scaling' only supports FreeTypeFont"
|
||||
raise ValueError(msg)
|
||||
if height is None:
|
||||
msg = "'scaling' requires 'height'"
|
||||
raise ValueError(msg)
|
||||
|
||||
if isinstance(scaling, str):
|
||||
limit = 1
|
||||
else:
|
||||
scaling, limit = scaling
|
||||
|
||||
font = self.font
|
||||
wrap = _Wrap(self, width, height, font)
|
||||
if scaling == "shrink":
|
||||
if not wrap.remaining_text:
|
||||
return None
|
||||
|
||||
size = math.ceil(font.size)
|
||||
while wrap.remaining_text:
|
||||
if size == max(limit, 1):
|
||||
msg = "Text could not be scaled"
|
||||
raise ValueError(msg)
|
||||
size -= 1
|
||||
font = self.font.font_variant(size=size)
|
||||
wrap = _Wrap(self, width, height, font)
|
||||
self.font = font
|
||||
else:
|
||||
if wrap.remaining_text:
|
||||
msg = "Text could not be scaled"
|
||||
raise ValueError(msg)
|
||||
|
||||
size = math.floor(font.size)
|
||||
while not wrap.remaining_text:
|
||||
if size == limit:
|
||||
msg = "Text could not be scaled"
|
||||
raise ValueError(msg)
|
||||
size += 1
|
||||
font = self.font.font_variant(size=size)
|
||||
last_wrap = wrap
|
||||
wrap = _Wrap(self, width, height, font)
|
||||
size -= 1
|
||||
if size != self.font.size:
|
||||
self.font = self.font.font_variant(size=size)
|
||||
wrap = last_wrap
|
||||
|
||||
if wrap.remaining_text:
|
||||
text = Text(
|
||||
text=wrap.remaining_text,
|
||||
font=self.font,
|
||||
mode=self.mode,
|
||||
spacing=self.spacing,
|
||||
direction=self.direction,
|
||||
features=self.features,
|
||||
language=self.language,
|
||||
)
|
||||
text.embedded_color = self.embedded_color
|
||||
text.stroke_width = self.stroke_width
|
||||
text.stroke_fill = self.stroke_fill
|
||||
else:
|
||||
text = None
|
||||
|
||||
newline = "\n" if isinstance(self.text, str) else b"\n"
|
||||
self.text = newline.join(wrap.lines)
|
||||
return text
|
||||
|
||||
def get_length(self) -> float:
|
||||
"""
|
||||
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.Text("Hello", font).get_length()
|
||||
world = ImageText.Text("World", font).get_length()
|
||||
helloworld = ImageText.Text("HelloWorld", font).get_length()
|
||||
assert hello + world == helloworld
|
||||
|
||||
use::
|
||||
|
||||
hello = (
|
||||
ImageText.Text("HelloW", font).get_length() -
|
||||
ImageText.Text("W", font).get_length()
|
||||
) # adjusted for kerning
|
||||
world = ImageText.Text("World", font).get_length()
|
||||
helloworld = ImageText.Text("HelloWorld", font).get_length()
|
||||
assert hello + world == helloworld
|
||||
|
||||
or disable kerning with (requires libraqm)::
|
||||
|
||||
hello = ImageText.Text("Hello", font, features=["-kern"]).get_length()
|
||||
world = ImageText.Text("World", font, features=["-kern"]).get_length()
|
||||
helloworld = ImageText.Text(
|
||||
"HelloWorld", font, features=["-kern"]
|
||||
).get_length()
|
||||
assert hello + world == helloworld
|
||||
|
||||
:return: Either width for horizontal text, or height for vertical text.
|
||||
"""
|
||||
if isinstance(self.text, str):
|
||||
multiline = "\n" in self.text
|
||||
else:
|
||||
multiline = b"\n" in self.text
|
||||
if multiline:
|
||||
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] = (0, 0),
|
||||
anchor: str | None = None,
|
||||
align: str = "left",
|
||||
lines: list[str] | list[bytes] | None = None,
|
||||
) -> list[_Line]:
|
||||
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)
|
||||
|
||||
if lines is None:
|
||||
lines = (
|
||||
self.text.split("\n")
|
||||
if isinstance(self.text, str)
|
||||
else self.text.split(b"\n")
|
||||
)
|
||||
if len(lines) == 1:
|
||||
return [_Line(xy[0], xy[1], anchor, lines[0])]
|
||||
|
||||
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(_Line(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(_Line(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(_Line(left, top, anchor, line))
|
||||
top += line_spacing
|
||||
|
||||
return parts
|
||||
|
||||
def _get_bbox(
|
||||
self, text: str | bytes, font: Font | None = None, anchor: str | None = None
|
||||
) -> tuple[float, float, float, float]:
|
||||
return (font or self.font).getbbox(
|
||||
text,
|
||||
self._get_fontmode(),
|
||||
self.direction,
|
||||
self.features,
|
||||
self.language,
|
||||
self.stroke_width,
|
||||
anchor,
|
||||
)
|
||||
|
||||
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
|
||||
for x, y, anchor, text in self._split(xy, anchor, align):
|
||||
bbox_line = self._get_bbox(text, anchor=anchor)
|
||||
bbox_line = (
|
||||
bbox_line[0] + x,
|
||||
bbox_line[1] + y,
|
||||
bbox_line[2] + x,
|
||||
bbox_line[3] + y,
|
||||
)
|
||||
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]),
|
||||
)
|
||||
|
||||
assert bbox is not None
|
||||
return bbox
|
||||
Reference in New Issue
Block a user