Skip to content

Commit 2c2c2a1

Browse files
authored
Add ImageText.Text.wrap() to wrap text (#9286)
2 parents d66a772 + a69b4ec commit 2c2c2a1

4 files changed

Lines changed: 381 additions & 47 deletions

File tree

Tests/test_imagetext.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,123 @@ def test_stroke() -> None:
108108
assert_image_similar_tofile(
109109
im, "Tests/images/imagedraw_stroke_" + suffix + ".png", 3.1
110110
)
111+
112+
113+
@pytest.mark.parametrize(
114+
"data, width, expected",
115+
(
116+
("Hello World!", 100, "Hello World!"), # No wrap required
117+
("Hello World!", 50, "Hello\nWorld!"), # Wrap word to a new line
118+
# Keep multiple spaces within a line
119+
("Keep multiple spaces", 90, "Keep multiple\nspaces"),
120+
(" Keep\n leading space", 100, " Keep\n leading space"),
121+
),
122+
)
123+
@pytest.mark.parametrize("string", (True, False))
124+
def test_wrap(data: str, width: int, expected: str, string: bool) -> None:
125+
if string:
126+
text = ImageText.Text(data)
127+
assert text.wrap(width) is None
128+
assert text.text == expected
129+
else:
130+
text_bytes = ImageText.Text(data.encode())
131+
assert text_bytes.wrap(width) is None
132+
assert text_bytes.text == expected.encode()
133+
134+
135+
def test_wrap_long_word() -> None:
136+
text = ImageText.Text("Hello World!")
137+
with pytest.raises(ValueError, match="Word does not fit within line"):
138+
text.wrap(25)
139+
140+
141+
def test_wrap_unsupported(font: ImageFont.FreeTypeFont) -> None:
142+
transposed_font = ImageFont.TransposedFont(font)
143+
text = ImageText.Text("Hello World!", transposed_font)
144+
with pytest.raises(ValueError, match="TransposedFont not supported"):
145+
text.wrap(50)
146+
147+
text = ImageText.Text("Hello World!", direction="ttb")
148+
with pytest.raises(ValueError, match="Only ltr direction supported"):
149+
text.wrap(50)
150+
151+
152+
def test_wrap_height() -> None:
153+
width = 50 if features.check_module("freetype2") else 60
154+
text = ImageText.Text("Text does not fit within height")
155+
wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40)
156+
assert wrapped is not None
157+
assert wrapped.text == " within height"
158+
assert text.text == "Text does\nnot fit"
159+
160+
text = ImageText.Text("Text does not fit\nwithin height")
161+
wrapped = text.wrap(width, 20)
162+
assert wrapped is not None
163+
assert wrapped.text == " not fit\nwithin height"
164+
assert text.text == "Text does"
165+
166+
text = ImageText.Text("Text does not fit\n\nwithin height")
167+
wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40)
168+
assert wrapped is not None
169+
assert wrapped.text == "\nwithin height"
170+
assert text.text == "Text does\nnot fit"
171+
172+
173+
def test_wrap_scaling_unsupported() -> None:
174+
font = ImageFont.load_default_imagefont()
175+
text = ImageText.Text("Hello World!", font)
176+
with pytest.raises(ValueError, match="'scaling' only supports FreeTypeFont"):
177+
text.wrap(50, scaling="shrink")
178+
179+
if features.check_module("freetype2"):
180+
text = ImageText.Text("Hello World!")
181+
with pytest.raises(ValueError, match="'scaling' requires 'height'"):
182+
text.wrap(50, scaling="shrink")
183+
184+
185+
@skip_unless_feature("freetype2")
186+
def test_wrap_shrink() -> None:
187+
# No scaling required
188+
text = ImageText.Text("Hello World!")
189+
assert isinstance(text.font, ImageFont.FreeTypeFont)
190+
assert text.font.size == 10
191+
assert text.wrap(50, 50, "shrink") is None
192+
assert isinstance(text.font, ImageFont.FreeTypeFont)
193+
assert text.font.size == 10
194+
195+
with pytest.raises(ValueError, match="Text could not be scaled"):
196+
text.wrap(50, 15, ("shrink", 9))
197+
198+
assert text.wrap(50, 15, "shrink") is None
199+
assert text.font.size == 8
200+
201+
text = ImageText.Text("Hello World!")
202+
assert text.wrap(50, 15, ("shrink", 7)) is None
203+
assert isinstance(text.font, ImageFont.FreeTypeFont)
204+
assert text.font.size == 8
205+
206+
207+
@skip_unless_feature("freetype2")
208+
def test_wrap_grow() -> None:
209+
# No scaling required
210+
text = ImageText.Text("Hello World!")
211+
assert isinstance(text.font, ImageFont.FreeTypeFont)
212+
assert text.font.size == 10
213+
assert text.wrap(58, 10, "grow") is None
214+
assert isinstance(text.font, ImageFont.FreeTypeFont)
215+
assert text.font.size == 10
216+
217+
with pytest.raises(ValueError, match="Text could not be scaled"):
218+
text.wrap(50, 50, ("grow", 12))
219+
220+
assert text.wrap(50, 50, "grow") is None
221+
assert text.font.size == 16
222+
223+
text = ImageText.Text("A\nB")
224+
with pytest.raises(ValueError, match="Text could not be scaled"):
225+
text.wrap(50, 10, "grow")
226+
227+
text = ImageText.Text("Hello World!")
228+
assert text.wrap(50, 50, ("grow", 18)) is None
229+
assert isinstance(text.font, ImageFont.FreeTypeFont)
230+
assert text.font.size == 16

docs/releasenotes/12.2.0.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,31 @@ FontFile.to_imagefont()
4242
...
4343
<PIL.ImageFont.ImageFont object at 0x10457bb80>
4444

45+
ImageText.Text.wrap
46+
^^^^^^^^^^^^^^^^^^^
47+
48+
:py:meth:`.ImageText.Text.wrap` has been added, to wrap text to fit within a given
49+
width::
50+
51+
from PIL import ImageText
52+
text = ImageText.Text("Hello World!")
53+
text.wrap(50)
54+
print(text.text) # "Hello\nWorld!"
55+
56+
or within a certain width and height, returning a new :py:class:`.ImageText.Text`
57+
instance if the text does not fit::
58+
59+
text = ImageText.Text("Text does not fit within height")
60+
print(text.wrap(50, 25).text == " within height")
61+
print(text.text) # "Text does\nnot fit"
62+
63+
or scaling, optionally with a font size limit::
64+
65+
text.wrap(50, 15, "shrink")
66+
text.wrap(50, 15, ("shrink", 7))
67+
text.wrap(58, 10, "grow")
68+
text.wrap(50, 50, ("grow", 12))
69+
4570
Other changes
4671
=============
4772

src/PIL/ImageDraw.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ def draw_corners(pieslice: bool) -> None:
538538
def text(
539539
self,
540540
xy: tuple[float, float],
541-
text: AnyStr | ImageText.Text,
541+
text: AnyStr | ImageText.Text[AnyStr],
542542
fill: _Ink | None = None,
543543
font: (
544544
ImageFont.ImageFont
@@ -591,61 +591,62 @@ def getink(fill: _Ink | None) -> int:
591591
else ink
592592
)
593593

594-
for xy, anchor, line in image_text._split(xy, anchor, align):
594+
for line in image_text._split(xy, anchor, align):
595595

596596
def draw_text(ink: int, stroke_width: float = 0) -> None:
597597
mode = self.fontmode
598598
if stroke_width == 0 and embedded_color:
599599
mode = "RGBA"
600-
coord = [int(xy[i]) for i in range(2)]
601-
start = (math.modf(xy[0])[0], math.modf(xy[1])[0])
600+
x = int(line.x)
601+
y = int(line.y)
602+
start = (math.modf(line.x)[0], math.modf(line.y)[0])
602603
try:
603604
mask, offset = image_text.font.getmask2( # type: ignore[union-attr,misc]
604-
line,
605+
line.text,
605606
mode,
606607
direction=direction,
607608
features=features,
608609
language=language,
609610
stroke_width=stroke_width,
610611
stroke_filled=True,
611-
anchor=anchor,
612+
anchor=line.anchor,
612613
ink=ink,
613614
start=start,
614615
*args,
615616
**kwargs,
616617
)
617-
coord = [coord[0] + offset[0], coord[1] + offset[1]]
618+
x += offset[0]
619+
y += offset[1]
618620
except AttributeError:
619621
try:
620622
mask = image_text.font.getmask( # type: ignore[misc]
621-
line,
623+
line.text,
622624
mode,
623625
direction,
624626
features,
625627
language,
626628
stroke_width,
627-
anchor,
629+
line.anchor,
628630
ink,
629631
start=start,
630632
*args,
631633
**kwargs,
632634
)
633635
except TypeError:
634-
mask = image_text.font.getmask(line)
636+
mask = image_text.font.getmask(line.text)
635637
if mode == "RGBA":
636638
# image_text.font.getmask2(mode="RGBA")
637639
# returns color in RGB bands and mask in A
638640
# extract mask and set text alpha
639641
color, mask = mask, mask.getband(3)
640642
ink_alpha = struct.pack("i", ink)[3]
641643
color.fillband(3, ink_alpha)
642-
x, y = coord
643644
if self.im is not None:
644645
self.im.paste(
645646
color, (x, y, x + mask.size[0], y + mask.size[1]), mask
646647
)
647648
else:
648-
self.draw.draw_bitmap(coord, mask, ink)
649+
self.draw.draw_bitmap((x, y), mask, ink)
649650

650651
if stroke_ink is not None:
651652
# Draw stroked text

0 commit comments

Comments
 (0)