mirror of
https://github.com/python-pillow/Pillow.git
synced 2026-02-03 22:15:52 +03:00
Merge 1918c6811d into 62aa42f9da
This commit is contained in:
commit
e04e3d55cc
|
|
@ -108,3 +108,123 @@ def test_stroke() -> None:
|
|||
assert_image_similar_tofile(
|
||||
im, "Tests/images/imagedraw_stroke_" + suffix + ".png", 3.1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data, width, expected",
|
||||
(
|
||||
("Hello World!", 100, "Hello World!"), # No wrap required
|
||||
("Hello World!", 50, "Hello\nWorld!"), # Wrap word to a new line
|
||||
# Keep multiple spaces within a line
|
||||
("Keep multiple spaces", 90, "Keep multiple\nspaces"),
|
||||
(" Keep\n leading space", 100, " Keep\n leading space"),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("string", (True, False))
|
||||
def test_wrap(data: str, width: int, expected: str, string: bool) -> None:
|
||||
if string:
|
||||
text = ImageText.Text(data)
|
||||
assert text.wrap(width) is None
|
||||
assert text.text == expected
|
||||
else:
|
||||
text_bytes = ImageText.Text(data.encode())
|
||||
assert text_bytes.wrap(width) is None
|
||||
assert text_bytes.text == expected.encode()
|
||||
|
||||
|
||||
def test_wrap_long_word() -> None:
|
||||
text = ImageText.Text("Hello World!")
|
||||
with pytest.raises(ValueError, match="Word does not fit within line"):
|
||||
text.wrap(25)
|
||||
|
||||
|
||||
def test_wrap_unsupported(font: ImageFont.FreeTypeFont) -> None:
|
||||
transposed_font = ImageFont.TransposedFont(font)
|
||||
text = ImageText.Text("Hello World!", transposed_font)
|
||||
with pytest.raises(ValueError, match="TransposedFont not supported"):
|
||||
text.wrap(50)
|
||||
|
||||
text = ImageText.Text("Hello World!", direction="ttb")
|
||||
with pytest.raises(ValueError, match="Only ltr direction supported"):
|
||||
text.wrap(50)
|
||||
|
||||
|
||||
def test_wrap_height() -> None:
|
||||
width = 50 if features.check_module("freetype2") else 60
|
||||
text = ImageText.Text("Text does not fit within height")
|
||||
wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40)
|
||||
assert wrapped is not None
|
||||
assert wrapped.text == " within height"
|
||||
assert text.text == "Text does\nnot fit"
|
||||
|
||||
text = ImageText.Text("Text does not fit\nwithin height")
|
||||
wrapped = text.wrap(width, 20)
|
||||
assert wrapped is not None
|
||||
assert wrapped.text == " not fit\nwithin height"
|
||||
assert text.text == "Text does"
|
||||
|
||||
text = ImageText.Text("Text does not fit\n\nwithin height")
|
||||
wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40)
|
||||
assert wrapped is not None
|
||||
assert wrapped.text == "\nwithin height"
|
||||
assert text.text == "Text does\nnot fit"
|
||||
|
||||
|
||||
def test_wrap_scaling_unsupported() -> None:
|
||||
font = ImageFont.load_default_imagefont()
|
||||
text = ImageText.Text("Hello World!", font)
|
||||
with pytest.raises(ValueError, match="'scaling' only supports FreeTypeFont"):
|
||||
text.wrap(50, scaling="shrink")
|
||||
|
||||
if features.check_module("freetype2"):
|
||||
text = ImageText.Text("Hello World!")
|
||||
with pytest.raises(ValueError, match="'scaling' requires 'height'"):
|
||||
text.wrap(50, scaling="shrink")
|
||||
|
||||
|
||||
@skip_unless_feature("freetype2")
|
||||
def test_wrap_shrink() -> None:
|
||||
# No scaling required
|
||||
text = ImageText.Text("Hello World!")
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 10
|
||||
assert text.wrap(50, 50, "shrink") is None
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 10
|
||||
|
||||
with pytest.raises(ValueError, match="Text could not be scaled"):
|
||||
text.wrap(50, 15, ("shrink", 9))
|
||||
|
||||
assert text.wrap(50, 15, "shrink") is None
|
||||
assert text.font.size == 8
|
||||
|
||||
text = ImageText.Text("Hello World!")
|
||||
assert text.wrap(50, 15, ("shrink", 7)) is None
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 8
|
||||
|
||||
|
||||
@skip_unless_feature("freetype2")
|
||||
def test_wrap_grow() -> None:
|
||||
# No scaling required
|
||||
text = ImageText.Text("Hello World!")
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 10
|
||||
assert text.wrap(58, 10, "grow") is None
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 10
|
||||
|
||||
with pytest.raises(ValueError, match="Text could not be scaled"):
|
||||
text.wrap(50, 50, ("grow", 12))
|
||||
|
||||
assert text.wrap(50, 50, "grow") is None
|
||||
assert text.font.size == 16
|
||||
|
||||
text = ImageText.Text("A\nB")
|
||||
with pytest.raises(ValueError, match="Text could not be scaled"):
|
||||
text.wrap(50, 10, "grow")
|
||||
|
||||
text = ImageText.Text("Hello World!")
|
||||
assert text.wrap(50, 50, ("grow", 18)) is None
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 16
|
||||
|
|
|
|||
75
docs/releasenotes/12.2.0.rst
Normal file
75
docs/releasenotes/12.2.0.rst
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
12.2.0
|
||||
------
|
||||
|
||||
Security
|
||||
========
|
||||
|
||||
TODO
|
||||
^^^^
|
||||
|
||||
TODO
|
||||
|
||||
:cve:`YYYY-XXXXX`: TODO
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
TODO
|
||||
|
||||
Backwards incompatible changes
|
||||
==============================
|
||||
|
||||
TODO
|
||||
^^^^
|
||||
|
||||
TODO
|
||||
|
||||
Deprecations
|
||||
============
|
||||
|
||||
TODO
|
||||
^^^^
|
||||
|
||||
TODO
|
||||
|
||||
API changes
|
||||
===========
|
||||
|
||||
TODO
|
||||
^^^^
|
||||
|
||||
TODO
|
||||
|
||||
API additions
|
||||
=============
|
||||
|
||||
ImageText.Text.wrap
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
:py:meth:`.ImageText.Text.wrap` has been added, to wrap text to fit within a given
|
||||
width::
|
||||
|
||||
from PIL import ImageText
|
||||
text = ImageText.Text("Hello World!")
|
||||
text.wrap(50)
|
||||
print(text.text) # "Hello\nWorld!"
|
||||
|
||||
or within a certain width and height, returning a new :py:class:`.ImageText.Text`
|
||||
instance if the text does not fit::
|
||||
|
||||
text = ImageText.Text("Text does not fit within height")
|
||||
print(text.wrap(50, 25).text == " within height")
|
||||
print(text.text) # "Text does\nnot fit"
|
||||
|
||||
or scaling, optionally with a font size limit::
|
||||
|
||||
text.wrap(50, 15, "shrink")
|
||||
text.wrap(50, 15, ("shrink", 7))
|
||||
text.wrap(58, 10, "grow")
|
||||
text.wrap(50, 50, ("grow", 12))
|
||||
|
||||
Other changes
|
||||
=============
|
||||
|
||||
TODO
|
||||
^^^^
|
||||
|
||||
TODO
|
||||
|
|
@ -15,6 +15,7 @@ expected to be backported to earlier versions.
|
|||
:maxdepth: 2
|
||||
|
||||
versioning
|
||||
12.2.0
|
||||
12.1.0
|
||||
12.0.0
|
||||
11.3.0
|
||||
|
|
|
|||
|
|
@ -538,7 +538,7 @@ class ImageDraw:
|
|||
def text(
|
||||
self,
|
||||
xy: tuple[float, float],
|
||||
text: AnyStr | ImageText.Text,
|
||||
text: AnyStr | ImageText.Text[AnyStr],
|
||||
fill: _Ink | None = None,
|
||||
font: (
|
||||
ImageFont.ImageFont
|
||||
|
|
@ -591,49 +591,49 @@ class ImageDraw:
|
|||
else ink
|
||||
)
|
||||
|
||||
for xy, anchor, line in image_text._split(xy, anchor, align):
|
||||
for line in image_text._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])
|
||||
x = int(line.x)
|
||||
y = int(line.y)
|
||||
start = (math.modf(line.x)[0], math.modf(line.y)[0])
|
||||
try:
|
||||
mask, offset = image_text.font.getmask2( # type: ignore[union-attr,misc]
|
||||
line,
|
||||
line.text,
|
||||
mode,
|
||||
direction=direction,
|
||||
features=features,
|
||||
language=language,
|
||||
stroke_width=stroke_width,
|
||||
stroke_filled=True,
|
||||
anchor=anchor,
|
||||
anchor=line.anchor,
|
||||
ink=ink,
|
||||
start=start,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
coord = [coord[0] + offset[0], coord[1] + offset[1]]
|
||||
x += offset[0]
|
||||
y += offset[1]
|
||||
except AttributeError:
|
||||
try:
|
||||
mask = image_text.font.getmask( # type: ignore[misc]
|
||||
line,
|
||||
line.text,
|
||||
mode,
|
||||
direction,
|
||||
features,
|
||||
language,
|
||||
stroke_width,
|
||||
anchor,
|
||||
line.anchor,
|
||||
ink,
|
||||
start=start,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
except TypeError:
|
||||
mask = image_text.font.getmask(line)
|
||||
mask = image_text.font.getmask(line.text)
|
||||
if mode == "RGBA":
|
||||
# image_text.font.getmask2(mode="RGBA")
|
||||
# returns color in RGB bands and mask in A
|
||||
|
|
@ -641,13 +641,12 @@ class ImageDraw:
|
|||
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)
|
||||
self.draw.draw_bitmap((x, y), mask, ink)
|
||||
|
||||
if stroke_ink is not None:
|
||||
# Draw stroked text
|
||||
|
|
|
|||
|
|
@ -1,19 +1,103 @@
|
|||
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
|
||||
|
||||
class Text:
|
||||
def __init__(
|
||||
self,
|
||||
text: str | bytes,
|
||||
font: (
|
||||
ImageFont.ImageFont
|
||||
| ImageFont.FreeTypeFont
|
||||
| ImageFont.TransposedFont
|
||||
| None
|
||||
) = None,
|
||||
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,
|
||||
|
|
@ -47,7 +131,7 @@ class Text:
|
|||
It should be a `BCP 47 language code`_.
|
||||
Requires libraqm.
|
||||
"""
|
||||
self.text = text
|
||||
self.text: AnyStr = text
|
||||
self.font = font or ImageFont.load_default()
|
||||
|
||||
self.mode = mode
|
||||
|
|
@ -88,6 +172,101 @@ class Text:
|
|||
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.
|
||||
|
|
@ -146,21 +325,26 @@ class Text:
|
|||
)
|
||||
|
||||
def _split(
|
||||
self, xy: tuple[float, float], anchor: str | None, align: str
|
||||
) -> list[tuple[tuple[float, float], str, str | bytes]]:
|
||||
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)
|
||||
|
||||
lines = (
|
||||
self.text.split("\n")
|
||||
if isinstance(self.text, str)
|
||||
else self.text.split(b"\n")
|
||||
)
|
||||
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 [(xy, anchor, self.text)]
|
||||
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"
|
||||
|
|
@ -185,7 +369,7 @@ class Text:
|
|||
if self.direction == "ttb":
|
||||
left = xy[0]
|
||||
for line in lines:
|
||||
parts.append(((left, top), anchor, line))
|
||||
parts.append(_Line(left, top, anchor, line))
|
||||
left += line_spacing
|
||||
else:
|
||||
widths = []
|
||||
|
|
@ -248,7 +432,7 @@ class Text:
|
|||
width_difference = max_width - sum(word_widths)
|
||||
i = 0
|
||||
for word in words:
|
||||
parts.append(((left, top), word_anchor, word))
|
||||
parts.append(_Line(left, top, word_anchor, word))
|
||||
left += word_widths[i] + width_difference / (len(words) - 1)
|
||||
i += 1
|
||||
top += line_spacing
|
||||
|
|
@ -259,11 +443,24 @@ class Text:
|
|||
left -= width_difference / 2.0
|
||||
elif anchor[0] == "r":
|
||||
left -= width_difference
|
||||
parts.append(((left, top), anchor, line))
|
||||
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),
|
||||
|
|
@ -289,22 +486,13 @@ class Text:
|
|||
: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,
|
||||
)
|
||||
for x, y, anchor, text in self._split(xy, anchor, align):
|
||||
bbox_line = self._get_bbox(text, anchor=anchor)
|
||||
bbox_line = (
|
||||
bbox_line[0] + xy[0],
|
||||
bbox_line[1] + xy[1],
|
||||
bbox_line[2] + xy[0],
|
||||
bbox_line[3] + xy[1],
|
||||
bbox_line[0] + x,
|
||||
bbox_line[1] + y,
|
||||
bbox_line[2] + x,
|
||||
bbox_line[3] + y,
|
||||
)
|
||||
if bbox is None:
|
||||
bbox = bbox_line
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user