From f056c259a7080a337b2ee09fee343dad1d3e48ab Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 4 Feb 2025 21:07:05 +1100 Subject: [PATCH] Support ttb multiline text --- Tests/images/test_combine_multiline_ttb.png | Bin 0 -> 4042 bytes Tests/test_imagefontctl.py | 29 +++-- src/PIL/ImageDraw.py | 130 ++++++++++---------- 3 files changed, 88 insertions(+), 71 deletions(-) create mode 100644 Tests/images/test_combine_multiline_ttb.png diff --git a/Tests/images/test_combine_multiline_ttb.png b/Tests/images/test_combine_multiline_ttb.png new file mode 100644 index 0000000000000000000000000000000000000000..d9c4aa2a1ae7e9c150c6ba0a53a78ccaacf6a865 GIT binary patch literal 4042 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 L&L>I7y_5e5ZOkoU literal 0 HcmV?d00001 diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index c85eb499cd0..5954de87429 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -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" @@ -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 742b5f58703..2cb9860b732 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -708,24 +708,18 @@ 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] @@ -733,67 +727,75 @@ def _prepare_multiline_text( + 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