Skip to content

Commit 1448902

Browse files
authored
Support ttb multiline text (#8730)
2 parents c084bd7 + 26ae44e commit 1448902

File tree

3 files changed

+100
-79
lines changed

3 files changed

+100
-79
lines changed
3.95 KB
Loading

Tests/test_imagefontctl.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
from PIL import Image, ImageDraw, ImageFont
66

7-
from .helper import assert_image_similar_tofile, skip_unless_feature
7+
from .helper import (
8+
assert_image_equal_tofile,
9+
assert_image_similar_tofile,
10+
skip_unless_feature,
11+
)
812

913
FONT_SIZE = 20
1014
FONT_PATH = "Tests/fonts/DejaVuSans/DejaVuSans.ttf"
@@ -354,11 +358,27 @@ def test_combine_multiline(anchor: str, align: str) -> None:
354358
d.line(((200, 0), (200, 400)), "gray")
355359
bbox = d.multiline_textbbox((200, 200), text, anchor=anchor, font=f, align=align)
356360
d.rectangle(bbox, outline="red")
357-
d.multiline_text((200, 200), text, fill="black", anchor=anchor, font=f, align=align)
361+
d.multiline_text((200, 200), text, "black", anchor=anchor, font=f, align=align)
358362

359363
assert_image_similar_tofile(im, path, 0.015)
360364

361365

366+
def test_combine_multiline_ttb() -> None:
367+
path = "Tests/images/test_combine_multiline_ttb.png"
368+
f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
369+
text = "te\nxt"
370+
371+
im = Image.new("RGB", (400, 400), "white")
372+
d = ImageDraw.Draw(im)
373+
d.line(((0, 200), (400, 200)), "gray")
374+
d.line(((200, 0), (200, 400)), "gray")
375+
bbox = d.multiline_textbbox((200, 200), text, f, direction="ttb")
376+
d.rectangle(bbox, outline="red")
377+
d.multiline_text((200, 200), text, "black", f, direction="ttb")
378+
379+
assert_image_equal_tofile(im, path)
380+
381+
362382
def test_anchor_invalid_ttb() -> None:
363383
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
364384
im = Image.new("RGB", (100, 100), "white")
@@ -378,8 +398,3 @@ def test_anchor_invalid_ttb() -> None:
378398
d.multiline_text((0, 0), "foo\nbar", anchor=anchor, direction="ttb")
379399
with pytest.raises(ValueError):
380400
d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor, direction="ttb")
381-
# ttb multiline text does not support anchors at all
382-
with pytest.raises(ValueError):
383-
d.multiline_text((0, 0), "foo\nbar", anchor="mm", direction="ttb")
384-
with pytest.raises(ValueError):
385-
d.multiline_textbbox((0, 0), "foo\nbar", anchor="mm", direction="ttb")

src/PIL/ImageDraw.py

Lines changed: 78 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -692,100 +692,106 @@ def _prepare_multiline_text(
692692
ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont,
693693
list[tuple[tuple[float, float], str, AnyStr]],
694694
]:
695-
if direction == "ttb":
696-
msg = "ttb direction is unsupported for multiline text"
697-
raise ValueError(msg)
698-
699695
if anchor is None:
700-
anchor = "la"
696+
anchor = "lt" if direction == "ttb" else "la"
701697
elif len(anchor) != 2:
702698
msg = "anchor must be a 2 character string"
703699
raise ValueError(msg)
704-
elif anchor[1] in "tb":
700+
elif anchor[1] in "tb" and direction != "ttb":
705701
msg = "anchor not supported for multiline text"
706702
raise ValueError(msg)
707703

708704
if font is None:
709705
font = self._getfont(font_size)
710706

711-
widths = []
712-
max_width: float = 0
713707
lines = text.split("\n" if isinstance(text, str) else b"\n")
714708
line_spacing = (
715709
self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3]
716710
+ stroke_width
717711
+ spacing
718712
)
719713

720-
for line in lines:
721-
line_width = self.textlength(
722-
line,
723-
font,
724-
direction=direction,
725-
features=features,
726-
language=language,
727-
embedded_color=embedded_color,
728-
)
729-
widths.append(line_width)
730-
max_width = max(max_width, line_width)
731-
732714
top = xy[1]
733-
if anchor[1] == "m":
734-
top -= (len(lines) - 1) * line_spacing / 2.0
735-
elif anchor[1] == "d":
736-
top -= (len(lines) - 1) * line_spacing
737-
738715
parts = []
739-
for idx, line in enumerate(lines):
716+
if direction == "ttb":
740717
left = xy[0]
741-
width_difference = max_width - widths[idx]
742-
743-
# align by align parameter
744-
if align in ("left", "justify"):
745-
pass
746-
elif align == "center":
747-
left += width_difference / 2.0
748-
elif align == "right":
749-
left += width_difference
750-
else:
751-
msg = 'align must be "left", "center", "right" or "justify"'
752-
raise ValueError(msg)
718+
for line in lines:
719+
parts.append(((left, top), anchor, line))
720+
left += line_spacing
721+
else:
722+
widths = []
723+
max_width: float = 0
724+
for line in lines:
725+
line_width = self.textlength(
726+
line,
727+
font,
728+
direction=direction,
729+
features=features,
730+
language=language,
731+
embedded_color=embedded_color,
732+
)
733+
widths.append(line_width)
734+
max_width = max(max_width, line_width)
753735

754-
if align == "justify" and width_difference != 0 and idx != len(lines) - 1:
755-
words = line.split(" " if isinstance(text, str) else b" ")
756-
if len(words) > 1:
757-
# align left by anchor
758-
if anchor[0] == "m":
759-
left -= max_width / 2.0
760-
elif anchor[0] == "r":
761-
left -= max_width
762-
763-
word_widths = [
764-
self.textlength(
765-
word,
766-
font,
767-
direction=direction,
768-
features=features,
769-
language=language,
770-
embedded_color=embedded_color,
771-
)
772-
for word in words
773-
]
774-
word_anchor = "l" + anchor[1]
775-
width_difference = max_width - sum(word_widths)
776-
for i, word in enumerate(words):
777-
parts.append(((left, top), word_anchor, word))
778-
left += word_widths[i] + width_difference / (len(words) - 1)
779-
top += line_spacing
780-
continue
736+
if anchor[1] == "m":
737+
top -= (len(lines) - 1) * line_spacing / 2.0
738+
elif anchor[1] == "d":
739+
top -= (len(lines) - 1) * line_spacing
740+
741+
for idx, line in enumerate(lines):
742+
left = xy[0]
743+
width_difference = max_width - widths[idx]
744+
745+
# align by align parameter
746+
if align in ("left", "justify"):
747+
pass
748+
elif align == "center":
749+
left += width_difference / 2.0
750+
elif align == "right":
751+
left += width_difference
752+
else:
753+
msg = 'align must be "left", "center", "right" or "justify"'
754+
raise ValueError(msg)
755+
756+
if (
757+
align == "justify"
758+
and width_difference != 0
759+
and idx != len(lines) - 1
760+
):
761+
words = line.split(" " if isinstance(text, str) else b" ")
762+
if len(words) > 1:
763+
# align left by anchor
764+
if anchor[0] == "m":
765+
left -= max_width / 2.0
766+
elif anchor[0] == "r":
767+
left -= max_width
768+
769+
word_widths = [
770+
self.textlength(
771+
word,
772+
font,
773+
direction=direction,
774+
features=features,
775+
language=language,
776+
embedded_color=embedded_color,
777+
)
778+
for word in words
779+
]
780+
word_anchor = "l" + anchor[1]
781+
width_difference = max_width - sum(word_widths)
782+
for i, word in enumerate(words):
783+
parts.append(((left, top), word_anchor, word))
784+
left += word_widths[i] + width_difference / (len(words) - 1)
785+
top += line_spacing
786+
continue
781787

782-
# align left by anchor
783-
if anchor[0] == "m":
784-
left -= width_difference / 2.0
785-
elif anchor[0] == "r":
786-
left -= width_difference
787-
parts.append(((left, top), anchor, line))
788-
top += line_spacing
788+
# align left by anchor
789+
if anchor[0] == "m":
790+
left -= width_difference / 2.0
791+
elif anchor[0] == "r":
792+
left -= width_difference
793+
parts.append(((left, top), anchor, line))
794+
top += line_spacing
789795

790796
return font, parts
791797

0 commit comments

Comments
 (0)