diff --git a/Tests/images/test_combine_multiline_lm_center.png b/Tests/images/test_combine_multiline_lm_center.png index f293f8d5f..7b1e9c4e4 100644 Binary files a/Tests/images/test_combine_multiline_lm_center.png and b/Tests/images/test_combine_multiline_lm_center.png differ diff --git a/Tests/images/test_combine_multiline_lm_left.png b/Tests/images/test_combine_multiline_lm_left.png index 2b96907ec..a26996c2d 100644 Binary files a/Tests/images/test_combine_multiline_lm_left.png and b/Tests/images/test_combine_multiline_lm_left.png differ diff --git a/Tests/images/test_combine_multiline_lm_right.png b/Tests/images/test_combine_multiline_lm_right.png index 73e2ca9d0..7caf5cb74 100644 Binary files a/Tests/images/test_combine_multiline_lm_right.png and b/Tests/images/test_combine_multiline_lm_right.png differ diff --git a/Tests/images/test_combine_multiline_mm_center.png b/Tests/images/test_combine_multiline_mm_center.png index 46ec20173..a859e9570 100644 Binary files a/Tests/images/test_combine_multiline_mm_center.png and b/Tests/images/test_combine_multiline_mm_center.png differ diff --git a/Tests/images/test_combine_multiline_mm_left.png b/Tests/images/test_combine_multiline_mm_left.png index ad2fa7a39..aadb5191f 100644 Binary files a/Tests/images/test_combine_multiline_mm_left.png and b/Tests/images/test_combine_multiline_mm_left.png differ diff --git a/Tests/images/test_combine_multiline_mm_right.png b/Tests/images/test_combine_multiline_mm_right.png index 838172b2d..8238d4ec8 100644 Binary files a/Tests/images/test_combine_multiline_mm_right.png and b/Tests/images/test_combine_multiline_mm_right.png differ diff --git a/Tests/images/test_combine_multiline_rm_center.png b/Tests/images/test_combine_multiline_rm_center.png index d5ad248ad..7568dd63a 100644 Binary files a/Tests/images/test_combine_multiline_rm_center.png and b/Tests/images/test_combine_multiline_rm_center.png differ diff --git a/Tests/images/test_combine_multiline_rm_left.png b/Tests/images/test_combine_multiline_rm_left.png index 901410fab..b8c3b5b14 100644 Binary files a/Tests/images/test_combine_multiline_rm_left.png and b/Tests/images/test_combine_multiline_rm_left.png differ diff --git a/Tests/images/test_combine_multiline_rm_right.png b/Tests/images/test_combine_multiline_rm_right.png index 82bfcd657..14c478a72 100644 Binary files a/Tests/images/test_combine_multiline_rm_right.png and b/Tests/images/test_combine_multiline_rm_right.png differ diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 5dd062459..adb75a1ff 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -221,12 +221,15 @@ class TestImageFont: "Tests/fonts/" + font, size, layout_engine=self.LAYOUT_ENGINE ) + im = Image.new(mode, (1, 1), 0) + d = ImageDraw.Draw(im) + if self.LAYOUT_ENGINE == ImageFont.LAYOUT_BASIC: - length = f.getlength(text, mode) + length = d.textlength(text, f) assert length == self.metrics["getlength"][length_basic_index] else: # disable kerning, kerning metrics changed - length = f.getlength(text, mode, features=["-kern"]) + length = d.textlength(text, f, features=["-kern"]) assert length == length_raqm def test_render_multiline(self): @@ -813,20 +816,20 @@ class TestImageFont: else: width, height = (128, 44) + bbox_expected = (left, top, left + width, top + height) + f = ImageFont.truetype( "Tests/fonts/NotoSans-Regular.ttf", 48, layout_engine=self.LAYOUT_ENGINE ) - # test getbbox - assert f.getbbox(text, anchor=anchor) == (left, top, left + width, top + height) - - # test render im = Image.new("RGB", (200, 200), "white") d = ImageDraw.Draw(im) d.line(((0, 100), (200, 100)), "gray") d.line(((100, 0), (100, 200)), "gray") d.text((100, 100), text, fill="black", anchor=anchor, font=f) + assert d.textbbox((0, 0), text, f, anchor=anchor) == bbox_expected + with Image.open(path) as expected: assert_image_similar(im, expected, 7) @@ -879,13 +882,24 @@ class TestImageFont: pytest.raises(ValueError, lambda: font.getmask2("hello", anchor=anchor)) pytest.raises(ValueError, lambda: font.getbbox("hello", anchor=anchor)) pytest.raises(ValueError, lambda: d.text((0, 0), "hello", anchor=anchor)) + pytest.raises( + ValueError, lambda: d.textbbox((0, 0), "hello", anchor=anchor) + ) pytest.raises( ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor) ) + pytest.raises( + ValueError, + lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor), + ) for anchor in ["lt", "lb"]: pytest.raises( ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor) ) + pytest.raises( + ValueError, + lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor=anchor), + ) @skip_unless_feature("raqm") diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index b585a2aa2..7fa5f76ab 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -222,10 +222,12 @@ def test_language(): ids=("None", "ltr", "rtl2", "rtl", "ttb"), ) def test_getlength(mode, text, direction, expected): - try: - ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) + ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) + im = Image.new(mode, (1, 1), 0) + d = ImageDraw.Draw(im) - assert ttf.getlength(text, mode, direction) == expected + try: + assert d.textlength(text, ttf, direction) == expected except ValueError as ex: if ( direction == "ttb" @@ -374,6 +376,8 @@ def test_combine_multiline(anchor, align): 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, 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) with Image.open(path) as expected: @@ -396,14 +400,28 @@ def test_anchor_invalid_ttb(): pytest.raises( ValueError, lambda: d.text((0, 0), "hello", anchor=anchor, direction="ttb") ) + pytest.raises( + ValueError, + lambda: d.textbbox((0, 0), "hello", anchor=anchor, direction="ttb"), + ) pytest.raises( ValueError, lambda: d.multiline_text( (0, 0), "foo\nbar", anchor=anchor, direction="ttb" ), ) + pytest.raises( + ValueError, + lambda: d.multiline_textbbox( + (0, 0), "foo\nbar", anchor=anchor, direction="ttb" + ), + ) # ttb multiline text does not support anchors at all pytest.raises( ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor="mm", direction="ttb"), ) + pytest.raises( + ValueError, + lambda: d.multiline_textbbox((0, 0), "foo\nbar", anchor="mm", direction="ttb"), + ) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 1379cf1f7..822ca3edf 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -385,9 +385,6 @@ class ImageDraw: elif anchor[1] in "tb": raise ValueError("anchor not supported for multiline text") - if font is None: - font = self.getfont() - widths = [] max_width = 0 lines = self._multiline_split(text) @@ -395,12 +392,8 @@ class ImageDraw: self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing ) for line in lines: - line_width = font.getlength( - line, - self.fontmode, - direction=direction, - features=features, - language=language, + line_width = self.textlength( + line, font, direction=direction, features=features, language=language ) widths.append(line_width) max_width = max(max_width, line_width) @@ -487,6 +480,148 @@ class ImageDraw: max_width = max(max_width, line_width) return max_width, len(lines) * line_spacing - spacing + def textlength(self, text, font=None, direction=None, features=None, language=None): + """Get the length of a given string, in pixels with 1/64 precision.""" + if self._multiline_check(text): + raise ValueError("can't measure length of multiline text") + + if font is None: + font = self.getfont() + try: + return font.getlength(text, self.fontmode, direction, features, language) + except AttributeError: + size = self.textsize( + text, font, direction=direction, features=features, language=language + ) + if direction == "ttb": + return size[1] + return size[0] + + def textbbox( + self, + xy, + text, + font=None, + anchor=None, + spacing=4, + align="left", + direction=None, + features=None, + language=None, + stroke_width=0, + ): + """Get the bounding box of a given string, in pixels.""" + if self._multiline_check(text): + return self.multiline_textbbox( + xy, + text, + font, + anchor, + spacing, + align, + direction, + features, + language, + stroke_width, + ) + + if font is None: + font = self.getfont() + bbox = font.getbbox( + text, self.fontmode, direction, features, language, stroke_width, anchor + ) + return bbox[0] + xy[0], bbox[1] + xy[1], bbox[2] + xy[0], bbox[3] + xy[1] + + def multiline_textbbox( + self, + xy, + text, + font=None, + anchor=None, + spacing=4, + align="left", + direction=None, + features=None, + language=None, + stroke_width=0, + ): + if direction == "ttb": + raise ValueError("ttb direction is unsupported for multiline text") + + if anchor is None: + anchor = "la" + elif len(anchor) != 2: + raise ValueError("anchor must be a 2 character string") + elif anchor[1] in "tb": + raise ValueError("anchor not supported for multiline text") + + widths = [] + max_width = 0 + lines = self._multiline_split(text) + line_spacing = ( + self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing + ) + for line in lines: + line_width = self.textlength( + line, font, direction=direction, features=features, language=language + ) + 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 + + bbox = None + + 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 == "left": + pass + elif align == "center": + left += width_difference / 2.0 + elif align == "right": + left += width_difference + else: + raise ValueError('align must be "left", "center" or "right"') + + bbox_line = self.textbbox( + (left, top), + line, + font, + anchor, + direction=direction, + features=features, + language=language, + stroke_width=stroke_width, + ) + if bbox is None: + bbox = bbox_line + else: + bbox = ( + min(bbox[0], bbox_line[0]), + min(bbox[1], bbox_line[1]), + max(bbox[2], bbox_line[2]), + max(bbox[3], bbox_line[3]), + ) + + top += line_spacing + + if bbox is None: + return xy[0], xy[1], xy[0], xy[1] + return bbox + def Draw(im, mode=None): """