Skip to content

Commit

Permalink
Support ttb multiline text
Browse files Browse the repository at this point in the history
  • Loading branch information
radarhere committed Feb 6, 2025
1 parent b57b4e5 commit f056c25
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 71 deletions.
Binary file added Tests/images/test_combine_multiline_ttb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 22 additions & 7 deletions Tests/test_imagefontctl.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

from PIL import Image, ImageDraw, ImageFont

from .helper import assert_image_similar_tofile, skip_unless_feature
from .helper import (
assert_image_equal_tofile,
assert_image_similar_tofile,
skip_unless_feature,
)

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

assert_image_similar_tofile(im, path, 0.015)


def test_combine_multiline_ttb() -> None:
path = "Tests/images/test_combine_multiline_ttb.png"
f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48)
text = "te\nxt"

im = Image.new("RGB", (400, 400), "white")
d = ImageDraw.Draw(im)
d.line(((0, 200), (400, 200)), "gray")
d.line(((200, 0), (200, 400)), "gray")
bbox = d.multiline_textbbox((200, 200), text, f, direction="ttb")
d.rectangle(bbox, outline="red")
d.multiline_text((200, 200), text, "black", f, direction="ttb")

assert_image_equal_tofile(im, path)


def test_anchor_invalid_ttb() -> None:
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
im = Image.new("RGB", (100, 100), "white")
Expand All @@ -378,8 +398,3 @@ def test_anchor_invalid_ttb() -> None:
d.multiline_text((0, 0), "foo\nbar", anchor=anchor, direction="ttb")
with pytest.raises(ValueError):
d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor, direction="ttb")
# ttb multiline text does not support anchors at all
with pytest.raises(ValueError):
d.multiline_text((0, 0), "foo\nbar", anchor="mm", direction="ttb")
with pytest.raises(ValueError):
d.multiline_textbbox((0, 0), "foo\nbar", anchor="mm", direction="ttb")
130 changes: 66 additions & 64 deletions src/PIL/ImageDraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,92 +708,94 @@ def _prepare_multiline_text(
str,
list[tuple[tuple[float, float], AnyStr]],
]:
if direction == "ttb":
msg = "ttb direction is unsupported for multiline text"
raise ValueError(msg)

if anchor is None:
anchor = "la"
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":
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)

widths = []
max_width: float = 0
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
)

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)

top = xy[1]
if anchor[1] == "m":
top -= (len(lines) - 1) * line_spacing / 2.0
elif anchor[1] == "d":
top -= (len(lines) - 1) * line_spacing

parts = []
for idx, line in enumerate(lines):
if direction == "ttb":
left = xy[0]
width_difference = max_width - widths[idx]

# first align left by anchor
if anchor[0] == "m":
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference

# then 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:
words = line.split(" " if isinstance(text, str) else b" ")
word_widths = [
self.textlength(
word,
font,
direction=direction,
features=features,
language=language,
embedded_color=embedded_color,
)
for word in words
]
width_difference = max_width - sum(word_widths)
for i, word in enumerate(words):
parts.append(((left, top), word))
left += word_widths[i] + width_difference / (len(words) - 1)
else:
for line in lines:
parts.append(((left, top), 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]

# first align left by anchor
if anchor[0] == "m":
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference

# then 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:
words = line.split(" " if isinstance(text, str) else b" ")
word_widths = [
self.textlength(
word,
font,
direction=direction,
features=features,
language=language,
embedded_color=embedded_color,
)
for word in words
]
width_difference = max_width - sum(word_widths)
for i, word in enumerate(words):
parts.append(((left, top), word))
left += word_widths[i] + width_difference / (len(words) - 1)
else:
parts.append(((left, top), line))

top += line_spacing
top += line_spacing

return font, anchor, parts

Expand Down

0 comments on commit f056c25

Please sign in to comment.