diff --git a/Tests/images/test_combine_multiline_ttb.png b/Tests/images/test_combine_multiline_ttb.png new file mode 100644 index 000000000..d9c4aa2a1 Binary files /dev/null and b/Tests/images/test_combine_multiline_ttb.png differ diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index c85eb499c..5954de874 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -4,7 +4,11 @@ import pytest 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" @@ -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") @@ -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") diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 742b5f587..2cb9860b7 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -708,24 +708,18 @@ class ImageDraw: 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] @@ -733,67 +727,75 @@ class ImageDraw: + 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) - top += line_spacing + 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 return font, anchor, parts