diff --git a/Tests/fonts/LICENSE.txt b/Tests/fonts/LICENSE.txt index c4704dbda21..db3af7c34e1 100644 --- a/Tests/fonts/LICENSE.txt +++ b/Tests/fonts/LICENSE.txt @@ -11,6 +11,7 @@ BungeeColor-Regular_colr_Windows.ttf, from https://github.com/djrrb/bungee All of the above fonts are published under the SIL Open Font License (OFL) v1.1 (http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL), which allows you to copy, modify, and redistribute them if you need to. +OpenSansCondensed-LightItalic.tt, from https://fonts.google.com/specimen/Open+Sans, under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0) DejaVuSans-24-{1,2,4,8}-stripped.ttf are based on DejaVuSans.ttf converted using FontForge to add bitmap strikes and keep only the ASCII range. diff --git a/Tests/fonts/OpenSansCondensed-LightItalic.ttf b/Tests/fonts/OpenSansCondensed-LightItalic.ttf new file mode 100644 index 00000000000..b4ee4951f33 Binary files /dev/null and b/Tests/fonts/OpenSansCondensed-LightItalic.ttf differ diff --git a/Tests/images/test_combine_multiline_lm_center.png b/Tests/images/test_combine_multiline_lm_center.png new file mode 100644 index 00000000000..7b1e9c4e42f Binary files /dev/null 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 new file mode 100644 index 00000000000..a26996c2dbe Binary files /dev/null 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 new file mode 100644 index 00000000000..7caf5cb742a Binary files /dev/null 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 new file mode 100644 index 00000000000..a859e9570c8 Binary files /dev/null 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 new file mode 100644 index 00000000000..aadb5191f0e Binary files /dev/null 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 new file mode 100644 index 00000000000..8238d4ec8ca Binary files /dev/null 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 new file mode 100644 index 00000000000..7568dd63a33 Binary files /dev/null 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 new file mode 100644 index 00000000000..b8c3b5b143d Binary files /dev/null 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 new file mode 100644 index 00000000000..14c478a72d0 Binary files /dev/null and b/Tests/images/test_combine_multiline_rm_right.png differ diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 6fb62269e9a..7824b7f5364 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -41,6 +41,7 @@ class TestImageFont: "getters": (13, 16), "mask": (107, 13), "multiline-anchor": 6, + "getlength": (36, 27, 27, 33), }, (">=2.7",): { "multiline": 6.2, @@ -48,6 +49,7 @@ class TestImageFont: "getters": (12, 16), "mask": (108, 13), "multiline-anchor": 4, + "getlength": (36, 21, 24, 33), }, "Default": { "multiline": 0.5, @@ -55,6 +57,7 @@ class TestImageFont: "getters": (12, 16), "mask": (108, 13), "multiline-anchor": 4, + "getlength": (36, 24, 24, 33), }, } @@ -198,6 +201,37 @@ def test_textsize_equal(self): # Epsilon ~.5 fails with FreeType 2.7 assert_image_similar(im, target_img, self.metrics["textsize"]) + @pytest.mark.parametrize( + "text, mode, font, size, length_basic_index, length_raqm", + ( + # basic test + ("text", "L", "FreeMono.ttf", 15, 0, 36), + ("text", "1", "FreeMono.ttf", 15, 0, 36), + # issue 4177 + ("rrr", "L", "DejaVuSans.ttf", 18, 1, 22.21875), + ("rrr", "1", "DejaVuSans.ttf", 18, 2, 22.21875), + # test 'l' not including extra margin + # using exact value 2047 / 64 for raqm, checked with debugger + ("ill", "L", "OpenSansCondensed-LightItalic.ttf", 63, 3, 31.984375), + ("ill", "1", "OpenSansCondensed-LightItalic.ttf", 63, 3, 31.984375), + ), + ) + def test_getlength(self, text, mode, font, size, length_basic_index, length_raqm): + f = ImageFont.truetype( + "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 = d.textlength(text, f) + assert length == self.metrics["getlength"][length_basic_index] + else: + # disable kerning, kerning metrics changed + length = d.textlength(text, f, features=["-kern"]) + assert length == length_raqm + def test_render_multiline(self): im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -753,23 +787,36 @@ def test_variation_set_by_axes(self): self._check_text(font, "Tests/images/variation_tiny_axes.png", 32.5) @pytest.mark.parametrize( - "anchor", + "anchor, left, left_old, top", ( # test horizontal anchors - "ls", - "ms", - "rs", + ("ls", 0, 0, -36), + ("ms", -64, -65, -36), + ("rs", -128, -129, -36), # test vertical anchors - "ma", - "mt", - "mm", - "mb", - "md", + ("ma", -64, -65, 16), + ("mt", -64, -65, 0), + ("mm", -64, -65, -17), + ("mb", -64, -65, -44), + ("md", -64, -65, -51), ), + ids=("ls", "ms", "rs", "ma", "mt", "mm", "mb", "md"), ) - def test_anchor(self, anchor): + def test_anchor(self, anchor, left, left_old, top): name, text = "quick", "Quick" path = f"Tests/images/test_anchor_{name}_{anchor}.png" + + freetype = parse_version(features.version_module("freetype2")) + if freetype < parse_version("2.4"): + width, height = (129, 44) + left = left_old + elif self.LAYOUT_ENGINE == ImageFont.LAYOUT_RAQM: + width, height = (129, 44) + 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 ) @@ -780,11 +827,13 @@ def test_anchor(self, anchor): 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) @pytest.mark.parametrize( - "anchor,align", + "anchor, align", ( # test horizontal anchors ("lm", "left"), @@ -830,14 +879,26 @@ def test_anchor_invalid(self): for anchor in ["", "l", "a", "lax", "sa", "xa", "lx"]: 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("freetype2") @pytest.mark.parametrize("bpp", (1, 2, 4, 8)) @@ -870,7 +931,6 @@ def test_standard_embedded_color(self): assert_image_similar(im, expected, max(self.metrics["multiline"], 3)) @skip_unless_feature_version("freetype2", "2.5.0") - @pytest.mark.xfail(is_pypy(), reason="failing on PyPy with Raqm") def test_cbdt(self): try: font = ImageFont.truetype( @@ -891,7 +951,6 @@ def test_cbdt(self): pytest.skip("freetype compiled without libpng or unsupported") @skip_unless_feature_version("freetype2", "2.5.0") - @pytest.mark.xfail(is_pypy(), reason="failing on PyPy with Raqm") def test_cbdt_mask(self): try: font = ImageFont.truetype( diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index 3fa227e62c7..f538bf71bd1 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -213,6 +213,59 @@ def test_language(): assert_image_similar(im, target_img, 0.5) +@pytest.mark.parametrize("mode", ("L", "1")) +@pytest.mark.parametrize( + "text, direction, expected", + ( + ("سلطنة عمان Oman", None, 173.703125), + ("سلطنة عمان Oman", "ltr", 173.703125), + ("Oman سلطنة عمان", "rtl", 173.703125), + ("English عربي", "rtl", 123.796875), + ("test", "ttb", 80.0), + ), + ids=("None", "ltr", "rtl2", "rtl", "ttb"), +) +def test_getlength(mode, text, direction, expected): + ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) + im = Image.new(mode, (1, 1), 0) + d = ImageDraw.Draw(im) + + try: + assert d.textlength(text, ttf, direction) == expected + except ValueError as ex: + if ( + direction == "ttb" + and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction" + ): + pytest.skip("libraqm 0.7 or greater not available") + + +@pytest.mark.parametrize("mode", ("L", "1")) +@pytest.mark.parametrize("direction", ("ltr", "ttb")) +@pytest.mark.parametrize( + "text", + ("i" + ("\u030C" * 15) + "i", "i" + "\u032C" * 15 + "i", "\u035Cii", "i\u0305i"), + ids=("caron-above", "caron-below", "double-breve", "overline"), +) +def test_getlength_combine(mode, direction, text): + if text == "i\u0305i" and direction == "ttb": + pytest.skip("fails with this font") + + ttf = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) + + try: + target = ttf.getlength("ii", mode, direction) + actual = ttf.getlength(text, mode, direction) + + assert actual == target + except ValueError as ex: + if ( + direction == "ttb" + and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction" + ): + pytest.skip("libraqm 0.7 or greater not available") + + # FreeType 2.5.1 README: Miscellaneous Changes: # Improved computation of emulated vertical metrics for TrueType fonts. @skip_unless_feature_version( @@ -274,7 +327,7 @@ def test_anchor_ttb(anchor): # this tests various combining characters for anchor alignment and clipping @pytest.mark.parametrize( - "name,text,anchor,dir,epsilon", combine_tests, ids=[r[0] for r in combine_tests] + "name, text, anchor, dir, epsilon", combine_tests, ids=[r[0] for r in combine_tests] ) def test_combine(name, text, dir, anchor, epsilon): if ( @@ -302,6 +355,39 @@ def test_combine(name, text, dir, anchor, epsilon): assert_image_similar(im, expected, epsilon) +@pytest.mark.parametrize( + "anchor, align", + ( + ("lm", "left"), # pass with getsize + ("lm", "center"), # fail at 2.12 + ("lm", "right"), # fail at 2.57 + ("mm", "left"), # fail at 2.12 + ("mm", "center"), # pass with getsize + ("mm", "right"), # fail at 2.12 + ("rm", "left"), # fail at 2.57 + ("rm", "center"), # fail at 2.12 + ("rm", "right"), # pass with getsize + ), +) +def test_combine_multiline(anchor, align): + # test that multiline text uses getlength, not getsize or getbbox + + path = f"Tests/images/test_combine_multiline_{anchor}_{align}.png" + f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) + text = "i\u0305\u035C\ntext" # i with overline and double breve, and a word + + 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, 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: + assert_image_similar(im, expected, 0.015) + + def test_anchor_invalid_ttb(): font = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new("RGB", (100, 100), "white") @@ -312,17 +398,34 @@ def test_anchor_invalid_ttb(): pytest.raises( ValueError, lambda: font.getmask2("hello", anchor=anchor, direction="ttb") ) + pytest.raises( + ValueError, lambda: font.getbbox("hello", anchor=anchor, direction="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/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 3337688853b..6052d2833fb 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -421,6 +421,15 @@ Methods Return the size of the given string, in pixels. + Use :py:meth:`textlength()` to measure the offset of following text with + 1/64 pixel precision. + Use :py:meth:`textbbox()` to get the exact bounding box based on an anchor. + + .. note:: For historical reasons this function measures text height from + the ascender line instead of the top, see :ref:`text-anchors`. + If you wish to measure text height from the top, it is recommended + to use :meth:`textbbox` with ``anchor='lt'`` instead. + :param text: Text to be measured. If it contains any newline characters, the text is passed on to :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textsize`. :param font: An :py:class:`~PIL.ImageFont.ImageFont` instance. @@ -460,6 +469,16 @@ Methods Return the size of the given string, in pixels. + Use :py:meth:`textlength()` to measure the offset of following text with + 1/64 pixel precision. + Use :py:meth:`textbbox()` to get the exact bounding box based on an anchor. + + .. note:: For historical reasons this function measures text height as the + distance between the top ascender line and bottom descender line, + not the top and bottom of the text, see :ref:`text-anchors`. + If you wish to measure text height from the top to the bottom of text, + it is recommended to use :meth:`multiline_textbbox` instead. + :param text: Text to be measured. :param font: An :py:class:`~PIL.ImageFont.ImageFont` instance. :param spacing: The number of pixels between lines. @@ -494,6 +513,164 @@ Methods .. versionadded:: 6.2.0 +.. py:method:: ImageDraw.textlength(text, font=None, direction=None, features=None, language=None, embedded_color=False) + + Returns length (in pixels with 1/64 precision) of given text when rendered + in font with provided direction, features, and language. + + This is the amount by which following text should be offset. + Text bounding box may extend past the length in some fonts, + e.g. when using italics or accents. + + The result is returned as a float; it is a whole number if using basic layout. + + Note that the sum of two lengths may not equal the length of a concatenated + string due to kerning. If you need to adjust for kerning, include the following + character and subtract its length. + + For example, instead of + + .. code-block:: python + + hello = draw.textlength("Hello", font) + world = draw.textlength("World", font) + hello_world = hello + world # not adjusted for kerning + assert hello_world == draw.textlength("HelloWorld", font) # may fail + + use + + .. code-block:: python + + hello = draw.textlength("HelloW", font) - draw.textlength("W", font) # adjusted for kerning + world = draw.textlength("World", font) + hello_world = hello + world # adjusted for kerning + assert hello_world == draw.textlength("HelloWorld", font) # True + + or disable kerning with (requires libraqm) + + .. code-block:: python + + hello = draw.textlength("Hello", font, features=["-kern"]) + world = draw.textlength("World", font, features=["-kern"]) + hello_world = hello + world # kerning is disabled, no need to adjust + assert hello_world == draw.textlength("HelloWorld", font, features=["-kern"]) # True + + .. versionadded:: 8.0.0 + + :param text: Text to be measured. May not contain any newline characters. + :param font: An :py:class:`~PIL.ImageFont.ImageFont` instance. + :param direction: Direction of the text. It can be ``"rtl"`` (right to + left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). + Requires libraqm. + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example ``"dlig"`` or ``"ss01"``, but can be also + used to turn off default font features, for + example ``"-liga"`` to disable ligatures or ``"-kern"`` + to disable kerning. To get all supported + features, see `OpenType docs`_. + Requires libraqm. + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code`_. + Requires libraqm. + :param embedded_color: Whether to use font embedded color glyphs (COLR or CBDT). + +.. py:method:: ImageDraw.textbbox(xy, text, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, embedded_color=False) + + Returns bounding box (in pixels) of given text relative to given anchor + when rendered in font with provided direction, features, and language. + Only supported for TrueType fonts. + + Use :py:meth:`textlength` to get the offset of following text with + 1/64 pixel precision. The bounding box includes extra margins for + some fonts, e.g. italics or accents. + + .. versionadded:: 8.0.0 + + :param xy: The anchor coordinates of the text. + :param text: Text to be measured. If it contains any newline characters, + the text is passed on to + :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textbbox`. + :param font: A :py:class:`~PIL.ImageFont.FreeTypeFont` instance. + :param anchor: The text anchor alignment. Determines the relative location of + the anchor to the text. The default alignment is top left. + See :ref:`text-anchors` for valid values. This parameter is + ignored for non-TrueType fonts. + :param spacing: If the text is passed on to + :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textbbox`, + the number of pixels between lines. + :param align: If the text is passed on to + :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textbbox`, + ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. + Use the ``anchor`` parameter to specify the alignment to ``xy``. + :param direction: Direction of the text. It can be ``"rtl"`` (right to + left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). + Requires libraqm. + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example ``"dlig"`` or ``"ss01"``, but can be also + used to turn off default font features, for + example ``"-liga"`` to disable ligatures or ``"-kern"`` + to disable kerning. To get all supported + features, see `OpenType docs`_. + Requires libraqm. + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code`_. + Requires libraqm. + :param stroke_width: The width of the text stroke. + :param embedded_color: Whether to use font embedded color glyphs (COLR or CBDT). + +.. py:method:: ImageDraw.multiline_textbbox(xy, text, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, embedded_color=False) + + Returns bounding box (in pixels) of given text relative to given anchor + when rendered in font with provided direction, features, and language. + Only supported for TrueType fonts. + + Use :py:meth:`textlength` to get the offset of following text with + 1/64 pixel precision. The bounding box includes extra margins for + some fonts, e.g. italics or accents. + + .. versionadded:: 8.0.0 + + :param xy: The anchor coordinates of the text. + :param text: Text to be measured. + :param font: A :py:class:`~PIL.ImageFont.FreeTypeFont` instance. + :param anchor: The text anchor alignment. Determines the relative location of + the anchor to the text. The default alignment is top left. + See :ref:`text-anchors` for valid values. This parameter is + ignored for non-TrueType fonts. + :param spacing: The number of pixels between lines. + :param align: ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. + Use the ``anchor`` parameter to specify the alignment to ``xy``. + :param direction: Direction of the text. It can be ``"rtl"`` (right to + left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). + Requires libraqm. + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example ``"dlig"`` or ``"ss01"``, but can be also + used to turn off default font features, for + example ``"-liga"`` to disable ligatures or ``"-kern"`` + to disable kerning. To get all supported + features, see `OpenType docs`_. + Requires libraqm. + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code`_. + Requires libraqm. + :param stroke_width: The width of the text stroke. + :param embedded_color: Whether to use font embedded color glyphs (COLR or CBDT). + .. py:method:: getdraw(im=None, hints=None) .. warning:: This method is experimental. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 0ece087d70d..b823be9a2aa 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -411,13 +411,8 @@ def multiline_text( self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing ) for line in lines: - line_width, line_height = self.textsize( - line, - font, - direction=direction, - features=features, - language=language, - stroke_width=stroke_width, + line_width = self.textlength( + line, font, direction=direction, features=features, language=language ) widths.append(line_width) max_width = max(max_width, line_width) @@ -505,6 +500,172 @@ def multiline_textsize( 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, + embedded_color=False, + ): + """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 embedded_color and self.mode not in ("RGB", "RGBA"): + raise ValueError("Embedded color supported only in RGB and RGBA modes") + + if font is None: + font = self.getfont() + mode = "RGBA" if embedded_color else self.fontmode + try: + return font.getlength(text, mode, 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, + embedded_color=False, + ): + """Get the bounding box of a given string, in pixels.""" + if embedded_color and self.mode not in ("RGB", "RGBA"): + raise ValueError("Embedded color supported only in RGB and RGBA modes") + + if self._multiline_check(text): + return self.multiline_textbbox( + xy, + text, + font, + anchor, + spacing, + align, + direction, + features, + language, + stroke_width, + embedded_color, + ) + + if font is None: + font = self.getfont() + mode = "RGBA" if embedded_color else self.fontmode + bbox = font.getbbox( + text, mode, 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, + embedded_color=False, + ): + 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, + 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 + + 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, + embedded_color=embedded_color, + ) + 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): """ diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 187ef34cf5a..89d180ce8cb 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -215,6 +215,147 @@ def getmetrics(self): """ return self.font.ascent, self.font.descent + def getlength(self, text, mode="", direction=None, features=None, language=None): + """ + Returns length (in pixels with 1/64 precision) of given text when rendered + in font with provided direction, features, and language. + + This is the amount by which following text should be offset. + Text bounding box may extend past the length in some fonts, + e.g. when using italics or accents. + + The result is returned as a float; it is a whole number if using basic layout. + + Note that the sum of two lengths may not equal the length of a concatenated + string due to kerning. If you need to adjust for kerning, include the following + character and subtract its length. + + For example, instead of + + .. code-block:: python + + hello = font.getlength("Hello") + world = font.getlength("World") + hello_world = hello + world # not adjusted for kerning + assert hello_world == font.getlength("HelloWorld") # may fail + + use + + .. code-block:: python + + hello = font.getlength("HelloW") - font.getlength("W") # adjusted for kerning + world = font.getlength("World") + hello_world = hello + world # adjusted for kerning + assert hello_world == font.getlength("HelloWorld") # True + + or disable kerning with (requires libraqm) + + .. code-block:: python + + hello = draw.textlength("Hello", font, features=["-kern"]) + world = draw.textlength("World", font, features=["-kern"]) + hello_world = hello + world # kerning is disabled, no need to adjust + assert hello_world == draw.textlength("HelloWorld", font, features=["-kern"]) + + .. versionadded:: 8.0.0 + + :param text: Text to measure. + :param mode: Used by some graphics drivers to indicate what mode the + driver prefers; if empty, the renderer may return either + mode. Note that the mode is always a string, to simplify + C-level implementations. + + :param direction: Direction of the text. It can be 'rtl' (right to + left), 'ltr' (left to right) or 'ttb' (top to bottom). + Requires libraqm. + + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example 'dlig' or 'ss01', but can be also + used to turn off default font features for + example '-liga' to disable ligatures or '-kern' + to disable kerning. To get all supported + features, see + https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + Requires libraqm. + + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code + ` + Requires libraqm. + + :return: Width for horizontal, height for vertical text. + """ + return self.font.getlength(text, mode, direction, features, language) / 64 + + def getbbox( + self, + text, + mode="", + direction=None, + features=None, + language=None, + stroke_width=0, + anchor=None, + ): + """ + Returns bounding box (in pixels) of given text relative to given anchor + when rendered in font with provided direction, features, and language. + + Use :py:meth:`getlength()` to get the offset of following text with + 1/64 pixel precision. The bounding box includes extra margins for + some fonts, e.g. italics or accents. + + .. versionadded:: 8.0.0 + + :param text: Text to render. + :param mode: Used by some graphics drivers to indicate what mode the + driver prefers; if empty, the renderer may return either + mode. Note that the mode is always a string, to simplify + C-level implementations. + + :param direction: Direction of the text. It can be 'rtl' (right to + left), 'ltr' (left to right) or 'ttb' (top to bottom). + Requires libraqm. + + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example 'dlig' or 'ss01', but can be also + used to turn off default font features for + example '-liga' to disable ligatures or '-kern' + to disable kerning. To get all supported + features, see + https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + Requires libraqm. + + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code + ` + Requires libraqm. + + :param stroke_width: The width of the text stroke. + + :param anchor: The text anchor alignment. Determines the relative location of + the anchor to the text. The default alignment is top left. + See :ref:`text-anchors` for valid values. + + :return: ``(left, top, right, bottom)`` bounding box + """ + size, offset = self.font.getsize( + text, mode, direction, features, language, anchor + ) + left, top = offset[0] - stroke_width, offset[1] - stroke_width + width, height = size[0] + 2 * stroke_width, size[1] + 2 * stroke_width + return left, top, left + width, top + height + def getsize( self, text, direction=None, features=None, language=None, stroke_width=0 ): @@ -222,6 +363,15 @@ def getsize( Returns width and height (in pixels) of given text if rendered in font with provided direction, features, and language. + Use :py:meth:`getlength()` to measure the offset of following text with + 1/64 pixel precision. + Use :py:meth:`getbbox()` to get the exact bounding box based on an anchor. + + .. note:: For historical reasons this function measures text height from + the ascender line instead of the top, see :ref:`text-anchors`. + If you wish to measure text height from the top, it is recommended + to use the bottom value of :meth:`getbbox` with ``anchor='lt'`` instead. + :param text: Text to measure. :param direction: Direction of the text. It can be 'rtl' (right to diff --git a/src/_imagingft.c b/src/_imagingft.c index cb1ebdf06fd..150753de414 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -594,6 +594,54 @@ text_layout(PyObject* string, FontObject* self, const char* dir, PyObject *featu return count; } +static PyObject* +font_getlength(FontObject* self, PyObject* args) +{ + int length; /* length along primary axis, in 26.6 precision */ + GlyphInfo *glyph_info = NULL; /* computed text layout */ + size_t i, count; /* glyph_info index and length */ + int horizontal_dir; /* is primary axis horizontal? */ + int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ + int color = 0; /* is FT_LOAD_COLOR enabled? */ + const char *mode = NULL; + const char *dir = NULL; + const char *lang = NULL; + PyObject *features = Py_None; + PyObject *string; + + /* calculate size and bearing for a given string */ + + if (!PyArg_ParseTuple(args, "O|zzOz:getlength", &string, &mode, &dir, &features, &lang)) { + return NULL; + } + + horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; + + mask = mode && strcmp(mode, "1") == 0; + color = mode && strcmp(mode, "RGBA") == 0; + + count = text_layout(string, self, dir, features, lang, &glyph_info, mask, color); + if (PyErr_Occurred()) { + return NULL; + } + + length = 0; + for (i = 0; i < count; i++) { + if (horizontal_dir) { + length += glyph_info[i].x_advance; + } else { + length -= glyph_info[i].y_advance; + } + } + + if (glyph_info) { + PyMem_Free(glyph_info); + glyph_info = NULL; + } + + return PyLong_FromLong(length); +} + static PyObject* font_getsize(FontObject* self, PyObject* args) { @@ -1286,6 +1334,7 @@ font_dealloc(FontObject* self) static PyMethodDef font_methods[] = { {"render", (PyCFunction) font_render, METH_VARARGS}, {"getsize", (PyCFunction) font_getsize, METH_VARARGS}, + {"getlength", (PyCFunction) font_getlength, METH_VARARGS}, #if FREETYPE_MAJOR > 2 ||\ (FREETYPE_MAJOR == 2 && FREETYPE_MINOR > 9) ||\ (FREETYPE_MAJOR == 2 && FREETYPE_MINOR == 9 && FREETYPE_PATCH == 1)