Repository: python-pillow/Pillow

Problem statement:
getsize_multiline doesn't take into account characters that extend below the baseline
### What did you do?

I'm using `getsize_multiline` to compute the size of a text label that I render to an image. The image is exactly the size of the text label.

I noticed that my text labels where cropped; specifically, when there are letters like `p` or `g` (which extend below the baseline) on the last line of text, these letters get cropped.

The problem doesn't affect `getsize`, and can easily be exhibited by comparing the sizes reported by `getsize` vs `getsize_multiline` for a single line string like `g`.

Example:

```python
from PIL import ImageFont
font = ImageFont.truetype("DroidSans", 20)
print(font.getsize("g"))
print(font.getsize_multiline("g"))
```

### What did you expect to happen?

The `getsize` and `getsize_multiline` methods should return the same size.

### What actually happened?

With `DroidSans` in size 20:
- `getsize` returns (10,24)
- `getsize_multiline` returns (10,19)

The `g` actually extends below if I create a label of size 10,19 and draw the string `g` on it, the `g` gets cropped.

### What are your OS, Python and Pillow versions?

* OS: Arch Linux
* Python: 3.9
* Pillow: 8.4.0

```python
#!/usr/bin/env python

from PIL import Image, ImageFont, ImageDraw

font = ImageFont.truetype("DroidSans", 20)

text = "gÂp"

size = font.getsize(text)
image = Image.new("RGB", size)
draw = ImageDraw.Draw(image)
draw.text((0, 0), text, font=font)
image.save("getsize.png")

size = font.getsize_multiline(text)
image = Image.new("RGB", size)
draw = ImageDraw.Draw(image)
draw.text((0, 0), text, font=font)
image.save("getsize_multiline.png")
```

getsize: ![getsize](https://user-images.githubusercontent.com/171481/140619714-defdcb34-edfb-46a9-8618-00fc3d3ac358.png)

getsize_multiline: ![getsize_multiline](https://user-images.githubusercontent.com/171481/140619717-1fdc4e28-f989-480d-844d-906b05376932.png)

I imagine that the logic in getsize_multiline doesn't take into accounts characters that extend below the baseline. For now I'm going to just add a bit of padding on the last line 😅 but I guess there has to be a better way.



Hints:
Of course, _immediately_ after opening that issue, I find a very promising method: `getmetrics()`, that precisely returns the ascent and the descend of a font. It looks like all I need to do is add the "descent" at the bottom of the label. I'll check that and if it works, I'll see if I can propose a patch for `getsize_multiline` and associated tests.
Both `getsize` or `getsize_multilne` are often inaccurate (especially with italics or accents), but unfortunately they might never get fixed due to backwards compatibility (also, the definition of size can change depending on your use case, so the expected return value is unclear). I would not recommend using them in new code. The [docs for `getsize`](https://pillow.readthedocs.io/en/stable/_modules/PIL/ImageFont.html#FreeTypeFont.getsize_multiline) have this warning:

> For historical reasons this function measures text height from the ascender line instead of the top, see Text anchors. If you wish to measure text height from the top, it is recommended to use the bottom value of getbbox() with anchor='lt' instead.

It seems I didn't add a warning to the multiline version, likely because I didn't add a `getbbox_multiline` function (it would be an exact copy-paste). However, in most cases you will be using a font to draw onto an image using `ImageDraw.text`, which provides the two equivalent functions `textsize` and `multiline_textsize`. The [docs for `multiline_textsize`](https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html#PIL.ImageDraw.ImageDraw.multiline_textsize) do have a similar warning:

> 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 Text anchors. If you wish to measure text height from the top to the bottom of text, it is recommended to use multiline_textbbox() instead.

In fact, I think this might not be entirely accurate due to the way Pillow computes line spacing - glancing at the source I suspect it is measuring to the bottom baseline instead in most cases and ignoring the last descender line like you mentioned.

Instead, I believe [`ImageDraw.multiline_textbbox`](https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html#PIL.ImageDraw.ImageDraw.multiline_textbbox) is the function you are looking for. It's documentation also provides a brief explanation:

> The bounding box includes extra margins for some fonts, e.g. italics or accents.

As for why `getsize` and `getsize_multiline` are not deprecated, IIRC I didn't propose this because the new functions work only with TrueType fonts, while the old functions work correctly with old "PIL" fonts. However, it might make sense to deprecate the old functions for TrueType fonts only.

Edit: For more information see #4959 and the linked issues.
> As for why `getsize` and `getsize_multiline` are not deprecated, IIRC I didn't propose this because the new functions work only with TrueType fonts, while the old functions work correctly with old "PIL" fonts. However, it might make sense to deprecate the old functions for TrueType fonts only.

This deprecation sounds reasonable.

What do others think? 

If adding warnings now (Pillow 9.0.0, 2022-01-02), it would be eligible for removal in Pillow 10.0.0 (2023-07-01), giving a nice long deprecation period. 
> Instead, I believe [`ImageDraw.multiline_textbbox`](https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html#PIL.ImageDraw.ImageDraw.multiline_textbbox) is the function you are looking for.

Yes indeed, thank you so much! I have one follow-up question: `ImageDraw.multiline_textbbox` requires an `ImageDraw` instance, but my program creates the image _after_ computing the text size (since the image holds the text label and nothing else). I did a little experiment and it looks like I can create an image of size (0,0) and use that to call `multiline_textbbox`, then create my final image with the proper size. Is that the right thing to do? Or might that break in the future?

Thank you so much!
I don't think that what you're describing should break in the future - and yes, it sounds like a reasonable way of doing things.
> As for why `getsize` and `getsize_multiline` are not deprecated, IIRC I didn't propose this because the new functions work only with TrueType fonts, while the old functions work correctly with old "PIL" fonts. However, it might make sense to deprecate the old functions for TrueType fonts only.

If we deprecate those, we should also then add `getbbox_multliline` and `TransposedFont.getbbox`, yes?
> If we deprecate those, we should also then add getbbox_multliline and TransposedFont.getbbox, yes?

Probably. That is what I was unsure about and why I didn't propose deprecating earlier.

I'll take a look at this as part of https://github.com/python-pillow/Pillow/pull/6195#discussion_r847410876.

Patch:
diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py
--- a/src/PIL/ImageDraw.py
+++ b/src/PIL/ImageDraw.py
@@ -32,8 +32,10 @@
 
 import math
 import numbers
+import warnings
 
 from . import Image, ImageColor
+from ._deprecate import deprecate
 
 """
 A simple 2D drawing interface for PIL images.
@@ -372,6 +374,19 @@ def _multiline_split(self, text):
 
         return text.split(split_character)
 
+    def _multiline_spacing(self, font, spacing, stroke_width):
+        # this can be replaced with self.textbbox(...)[3] when textsize is removed
+        with warnings.catch_warnings():
+            warnings.filterwarnings("ignore", category=DeprecationWarning)
+            return (
+                self.textsize(
+                    "A",
+                    font=font,
+                    stroke_width=stroke_width,
+                )[1]
+                + spacing
+            )
+
     def text(
         self,
         xy,
@@ -511,9 +526,7 @@ def multiline_text(
         widths = []
         max_width = 0
         lines = self._multiline_split(text)
-        line_spacing = (
-            self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing
-        )
+        line_spacing = self._multiline_spacing(font, spacing, stroke_width)
         for line in lines:
             line_width = self.textlength(
                 line, font, direction=direction, features=features, language=language
@@ -573,14 +586,31 @@ def textsize(
         stroke_width=0,
     ):
         """Get the size of a given string, in pixels."""
+        deprecate("textsize", 10, "textbbox or textlength")
         if self._multiline_check(text):
-            return self.multiline_textsize(
-                text, font, spacing, direction, features, language, stroke_width
-            )
+            with warnings.catch_warnings():
+                warnings.filterwarnings("ignore", category=DeprecationWarning)
+                return self.multiline_textsize(
+                    text,
+                    font,
+                    spacing,
+                    direction,
+                    features,
+                    language,
+                    stroke_width,
+                )
 
         if font is None:
             font = self.getfont()
-        return font.getsize(text, direction, features, language, stroke_width)
+        with warnings.catch_warnings():
+            warnings.filterwarnings("ignore", category=DeprecationWarning)
+            return font.getsize(
+                text,
+                direction,
+                features,
+                language,
+                stroke_width,
+            )
 
     def multiline_textsize(
         self,
@@ -592,16 +622,23 @@ def multiline_textsize(
         language=None,
         stroke_width=0,
     ):
+        deprecate("multiline_textsize", 10, "multiline_textbbox")
         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, line_height = self.textsize(
-                line, font, spacing, direction, features, language, stroke_width
-            )
-            max_width = max(max_width, line_width)
+        line_spacing = self._multiline_spacing(font, spacing, stroke_width)
+        with warnings.catch_warnings():
+            warnings.filterwarnings("ignore", category=DeprecationWarning)
+            for line in lines:
+                line_width, line_height = self.textsize(
+                    line,
+                    font,
+                    spacing,
+                    direction,
+                    features,
+                    language,
+                    stroke_width,
+                )
+                max_width = max(max_width, line_width)
         return max_width, len(lines) * line_spacing - spacing
 
     def textlength(
@@ -625,9 +662,16 @@ def textlength(
         try:
             return font.getlength(text, mode, direction, features, language)
         except AttributeError:
-            size = self.textsize(
-                text, font, direction=direction, features=features, language=language
-            )
+            deprecate("textlength support for fonts without getlength", 10)
+            with warnings.catch_warnings():
+                warnings.filterwarnings("ignore", category=DeprecationWarning)
+                size = self.textsize(
+                    text,
+                    font,
+                    direction=direction,
+                    features=features,
+                    language=language,
+                )
             if direction == "ttb":
                 return size[1]
             return size[0]
@@ -667,10 +711,6 @@ def textbbox(
 
         if font is None:
             font = self.getfont()
-        from . import ImageFont
-
-        if not isinstance(font, ImageFont.FreeTypeFont):
-            raise ValueError("Only supported for TrueType fonts")
         mode = "RGBA" if embedded_color else self.fontmode
         bbox = font.getbbox(
             text, mode, direction, features, language, stroke_width, anchor
@@ -704,9 +744,7 @@ def multiline_textbbox(
         widths = []
         max_width = 0
         lines = self._multiline_split(text)
-        line_spacing = (
-            self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing
-        )
+        line_spacing = self._multiline_spacing(font, spacing, stroke_width)
         for line in lines:
             line_width = self.textlength(
                 line,
diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py
--- a/src/PIL/ImageDraw2.py
+++ b/src/PIL/ImageDraw2.py
@@ -24,7 +24,10 @@
 """
 
 
+import warnings
+
 from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath
+from ._deprecate import deprecate
 
 
 class Pen:
@@ -172,8 +175,35 @@ def text(self, xy, text, font):
 
     def textsize(self, text, font):
         """
+        .. deprecated:: 9.2.0
+
         Return the size of the given string, in pixels.
 
         .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textsize`
         """
-        return self.draw.textsize(text, font=font.font)
+        deprecate("textsize", 10, "textbbox or textlength")
+        with warnings.catch_warnings():
+            warnings.filterwarnings("ignore", category=DeprecationWarning)
+            return self.draw.textsize(text, font=font.font)
+
+    def textbbox(self, xy, text, font):
+        """
+        Returns bounding box (in pixels) of given text.
+
+        :return: ``(left, top, right, bottom)`` bounding box
+
+        .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textbbox`
+        """
+        if self.transform:
+            xy = ImagePath.Path(xy)
+            xy.transform(self.transform)
+        return self.draw.textbbox(xy, text, font=font.font)
+
+    def textlength(self, text, font):
+        """
+        Returns length (in pixels) of given text.
+        This is the amount by which following text should be offset.
+
+        .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textlength`
+        """
+        return self.draw.textlength(text, font=font.font)
diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py
--- a/src/PIL/ImageFont.py
+++ b/src/PIL/ImageFont.py
@@ -137,12 +137,17 @@ def _load_pilfont_data(self, file, image):
 
     def getsize(self, text, *args, **kwargs):
         """
+        .. deprecated:: 9.2.0
+
+        Use :py:meth:`.getbbox` or :py:meth:`.getlength` instead.
+
         Returns width and height (in pixels) of given text.
 
         :param text: Text to measure.
 
         :return: (width, height)
         """
+        deprecate("getsize", 10, "getbbox or getlength")
         return self.font.getsize(text)
 
     def getmask(self, text, mode="", *args, **kwargs):
@@ -165,6 +170,33 @@ def getmask(self, text, mode="", *args, **kwargs):
         """
         return self.font.getmask(text, mode)
 
+    def getbbox(self, text, *args, **kwargs):
+        """
+        Returns bounding box (in pixels) of given text.
+
+        .. versionadded:: 9.2.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.
+
+        :return: ``(left, top, right, bottom)`` bounding box
+        """
+        width, height = self.font.getsize(text)
+        return 0, 0, width, height
+
+    def getlength(self, text, *args, **kwargs):
+        """
+        Returns length (in pixels) of given text.
+        This is the amount by which following text should be offset.
+
+        .. versionadded:: 9.2.0
+        """
+        width, height = self.font.getsize(text)
+        return width
+
 
 ##
 # Wrapper for FreeType fonts.  Application code should use the
@@ -386,16 +418,23 @@ def getbbox(
         return left, top, left + width, top + height
 
     def getsize(
-        self, text, direction=None, features=None, language=None, stroke_width=0
+        self,
+        text,
+        direction=None,
+        features=None,
+        language=None,
+        stroke_width=0,
     ):
         """
-        Returns width and height (in pixels) of given text if rendered in font with
-        provided direction, features, and language.
+        .. deprecated:: 9.2.0
 
         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.
 
+        Returns width and height (in pixels) of given text if rendered in font with
+        provided direction, features, and language.
+
         .. 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
@@ -438,6 +477,7 @@ def getsize(
 
         :return: (width, height)
         """
+        deprecate("getsize", 10, "getbbox or getlength")
         # vertical offset is added for historical reasons
         # see https://github.com/python-pillow/Pillow/pull/4910#discussion_r486682929
         size, offset = self.font.getsize(text, "L", direction, features, language)
@@ -456,6 +496,10 @@ def getsize_multiline(
         stroke_width=0,
     ):
         """
+        .. deprecated:: 9.2.0
+
+        Use :py:meth:`.ImageDraw.multiline_textbbox` instead.
+
         Returns width and height (in pixels) of given text if rendered in font
         with provided direction, features, and language, while respecting
         newline characters.
@@ -495,19 +539,26 @@ def getsize_multiline(
 
         :return: (width, height)
         """
+        deprecate("getsize_multiline", 10, "ImageDraw.multiline_textbbox")
         max_width = 0
         lines = self._multiline_split(text)
-        line_spacing = self.getsize("A", stroke_width=stroke_width)[1] + spacing
-        for line in lines:
-            line_width, line_height = self.getsize(
-                line, direction, features, language, stroke_width
-            )
-            max_width = max(max_width, line_width)
+        with warnings.catch_warnings():
+            warnings.filterwarnings("ignore", category=DeprecationWarning)
+            line_spacing = self.getsize("A", stroke_width=stroke_width)[1] + spacing
+            for line in lines:
+                line_width, line_height = self.getsize(
+                    line, direction, features, language, stroke_width
+                )
+                max_width = max(max_width, line_width)
 
         return max_width, len(lines) * line_spacing - spacing
 
     def getoffset(self, text):
         """
+        .. deprecated:: 9.2.0
+
+        Use :py:meth:`.getbbox` instead.
+
         Returns the offset of given text. This is the gap between the
         starting coordinate and the first marking. Note that this gap is
         included in the result of :py:func:`~PIL.ImageFont.FreeTypeFont.getsize`.
@@ -516,6 +567,7 @@ def getoffset(self, text):
 
         :return: A tuple of the x and y offset
         """
+        deprecate("getoffset", 10, "getbbox")
         return self.font.getsize(text)[1]
 
     def getmask(
@@ -796,7 +848,15 @@ def __init__(self, font, orientation=None):
         self.orientation = orientation  # any 'transpose' argument, or None
 
     def getsize(self, text, *args, **kwargs):
-        w, h = self.font.getsize(text)
+        """
+        .. deprecated:: 9.2.0
+
+        Use :py:meth:`.getbbox` or :py:meth:`.getlength` instead.
+        """
+        deprecate("getsize", 10, "getbbox or getlength")
+        with warnings.catch_warnings():
+            warnings.filterwarnings("ignore", category=DeprecationWarning)
+            w, h = self.font.getsize(text)
         if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270):
             return h, w
         return w, h
@@ -807,6 +867,23 @@ def getmask(self, text, mode="", *args, **kwargs):
             return im.transpose(self.orientation)
         return im
 
+    def getbbox(self, text, *args, **kwargs):
+        # TransposedFont doesn't support getmask2, move top-left point to (0, 0)
+        # this has no effect on ImageFont and simulates anchor="lt" for FreeTypeFont
+        left, top, right, bottom = self.font.getbbox(text, *args, **kwargs)
+        width = right - left
+        height = bottom - top
+        if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270):
+            return 0, 0, height, width
+        return 0, 0, width, height
+
+    def getlength(self, text, *args, **kwargs):
+        if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270):
+            raise ValueError(
+                "text length is undefined for text rotated by 90 or 270 degrees"
+            )
+        return self.font.getlength(text, *args, **kwargs)
+
 
 def load(filename):
     """


Test patch:
diff --git a/Tests/images/rectangle_surrounding_text.png b/Tests/images/rectangle_surrounding_text.png
Binary files a/Tests/images/rectangle_surrounding_text.png and b/Tests/images/rectangle_surrounding_text.png differ
diff --git a/Tests/oss-fuzz/fuzzers.py b/Tests/oss-fuzz/fuzzers.py
--- a/Tests/oss-fuzz/fuzzers.py
+++ b/Tests/oss-fuzz/fuzzers.py
@@ -33,9 +33,9 @@ def fuzz_font(data):
         # different font objects.
         return
 
-    font.getsize_multiline("ABC\nAaaa")
+    font.getbbox("ABC")
     font.getmask("test text")
     with Image.new(mode="RGBA", size=(200, 200)) as im:
         draw = ImageDraw.Draw(im)
-        draw.multiline_textsize("ABC\nAaaa", font, stroke_width=2)
+        draw.multiline_textbbox((10, 10), "ABC\nAaaa", font, stroke_width=2)
         draw.text((10, 10), "Test Text", font=font, fill="#000")
diff --git a/Tests/test_font_pcf.py b/Tests/test_font_pcf.py
--- a/Tests/test_font_pcf.py
+++ b/Tests/test_font_pcf.py
@@ -76,12 +76,19 @@ def test_textsize(request, tmp_path):
     tempname = save_font(request, tmp_path)
     font = ImageFont.load(tempname)
     for i in range(255):
-        (dx, dy) = font.getsize(chr(i))
+        (ox, oy, dx, dy) = font.getbbox(chr(i))
+        assert ox == 0
+        assert oy == 0
         assert dy == 20
         assert dx in (0, 10)
+        assert font.getlength(chr(i)) == dx
+        with pytest.warns(DeprecationWarning) as log:
+            assert font.getsize(chr(i)) == (dx, dy)
+        assert len(log) == 1
     for i in range(len(message)):
         msg = message[: i + 1]
-        assert font.getsize(msg) == (len(msg) * 10, 20)
+        assert font.getlength(msg) == len(msg) * 10
+        assert font.getbbox(msg) == (0, 0, len(msg) * 10, 20)
 
 
 def _test_high_characters(request, tmp_path, message):
diff --git a/Tests/test_font_pcf_charsets.py b/Tests/test_font_pcf_charsets.py
--- a/Tests/test_font_pcf_charsets.py
+++ b/Tests/test_font_pcf_charsets.py
@@ -101,13 +101,17 @@ def _test_textsize(request, tmp_path, encoding):
     tempname = save_font(request, tmp_path, encoding)
     font = ImageFont.load(tempname)
     for i in range(255):
-        (dx, dy) = font.getsize(bytearray([i]))
+        (ox, oy, dx, dy) = font.getbbox(bytearray([i]))
+        assert ox == 0
+        assert oy == 0
         assert dy == 20
         assert dx in (0, 10)
+        assert font.getlength(bytearray([i])) == dx
     message = charsets[encoding]["message"].encode(encoding)
     for i in range(len(message)):
         msg = message[: i + 1]
-        assert font.getsize(msg) == (len(msg) * 10, 20)
+        assert font.getlength(msg) == len(msg) * 10
+        assert font.getbbox(msg) == (0, 0, len(msg) * 10, 20)
 
 
 def test_textsize_iso8859_1(request, tmp_path):
diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py
--- a/Tests/test_imagedraw.py
+++ b/Tests/test_imagedraw.py
@@ -1232,21 +1232,39 @@ def test_textsize_empty_string():
     # Act
     # Should not cause 'SystemError: <built-in method getsize of
     # ImagingFont object at 0x...> returned NULL without setting an error'
-    draw.textsize("")
-    draw.textsize("\n")
-    draw.textsize("test\n")
+    draw.textbbox((0, 0), "")
+    draw.textbbox((0, 0), "\n")
+    draw.textbbox((0, 0), "test\n")
+    draw.textlength("")
 
 
 @skip_unless_feature("freetype2")
-def test_textsize_stroke():
+def test_textbbox_stroke():
     # Arrange
     im = Image.new("RGB", (W, H))
     draw = ImageDraw.Draw(im)
     font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 20)
 
     # Act / Assert
-    assert draw.textsize("A", font, stroke_width=2) == (16, 20)
-    assert draw.multiline_textsize("ABC\nAaaa", font, stroke_width=2) == (52, 44)
+    assert draw.textbbox((2, 2), "A", font, stroke_width=2) == (0, 4, 16, 20)
+    assert draw.textbbox((2, 2), "A", font, stroke_width=4) == (-2, 2, 18, 22)
+    assert draw.textbbox((2, 2), "ABC\nAaaa", font, stroke_width=2) == (0, 4, 52, 44)
+    assert draw.textbbox((2, 2), "ABC\nAaaa", font, stroke_width=4) == (-2, 2, 54, 50)
+
+
+def test_textsize_deprecation():
+    im = Image.new("RGB", (W, H))
+    draw = ImageDraw.Draw(im)
+
+    with pytest.warns(DeprecationWarning) as log:
+        draw.textsize("Hello")
+    assert len(log) == 1
+    with pytest.warns(DeprecationWarning) as log:
+        draw.textsize("Hello\nWorld")
+    assert len(log) == 1
+    with pytest.warns(DeprecationWarning) as log:
+        draw.multiline_textsize("Hello\nWorld")
+    assert len(log) == 1
 
 
 @skip_unless_feature("freetype2")
diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py
--- a/Tests/test_imagedraw2.py
+++ b/Tests/test_imagedraw2.py
@@ -1,5 +1,7 @@
 import os.path
 
+import pytest
+
 from PIL import Image, ImageDraw, ImageDraw2
 
 from .helper import (
@@ -205,7 +207,9 @@ def test_textsize():
     font = ImageDraw2.Font("white", FONT_PATH)
 
     # Act
-    size = draw.textsize("ImageDraw2", font)
+    with pytest.warns(DeprecationWarning) as log:
+        size = draw.textsize("ImageDraw2", font)
+    assert len(log) == 1
 
     # Assert
     assert size[1] == 12
@@ -221,9 +225,10 @@ def test_textsize_empty_string():
     # Act
     # Should not cause 'SystemError: <built-in method getsize of
     # ImagingFont object at 0x...> returned NULL without setting an error'
-    draw.textsize("", font)
-    draw.textsize("\n", font)
-    draw.textsize("test\n", font)
+    draw.textbbox((0, 0), "", font)
+    draw.textbbox((0, 0), "\n", font)
+    draw.textbbox((0, 0), "test\n", font)
+    draw.textlength("", font)
 
 
 @skip_unless_feature("freetype2")
diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py
--- a/Tests/test_imagefont.py
+++ b/Tests/test_imagefont.py
@@ -94,7 +94,7 @@ def test_non_ascii_path(self, tmp_path):
     def _render(self, font):
         txt = "Hello World!"
         ttf = ImageFont.truetype(font, FONT_SIZE, layout_engine=self.LAYOUT_ENGINE)
-        ttf.getsize(txt)
+        ttf.getbbox(txt)
 
         img = Image.new("RGB", (256, 64), "white")
         d = ImageDraw.Draw(img)
@@ -135,15 +135,15 @@ def test_I16(self):
         target = "Tests/images/transparent_background_text_L.png"
         assert_image_similar_tofile(im.convert("L"), target, 0.01)
 
-    def test_textsize_equal(self):
+    def test_textbbox_equal(self):
         im = Image.new(mode="RGB", size=(300, 100))
         draw = ImageDraw.Draw(im)
         ttf = self.get_font()
 
         txt = "Hello World!"
-        size = draw.textsize(txt, ttf)
+        bbox = draw.textbbox((10, 10), txt, ttf)
         draw.text((10, 10), txt, font=ttf)
-        draw.rectangle((10, 10, 10 + size[0], 10 + size[1]))
+        draw.rectangle(bbox)
 
         assert_image_similar_tofile(
             im, "Tests/images/rectangle_surrounding_text.png", 2.5
@@ -184,7 +184,7 @@ def test_render_multiline(self):
         im = Image.new(mode="RGB", size=(300, 100))
         draw = ImageDraw.Draw(im)
         ttf = self.get_font()
-        line_spacing = draw.textsize("A", font=ttf)[1] + 4
+        line_spacing = ttf.getbbox("A")[3] + 4
         lines = TEST_TEXT.split("\n")
         y = 0
         for line in lines:
@@ -245,19 +245,39 @@ def test_multiline_size(self):
         im = Image.new(mode="RGB", size=(300, 100))
         draw = ImageDraw.Draw(im)
 
-        # Test that textsize() correctly connects to multiline_textsize()
-        assert draw.textsize(TEST_TEXT, font=ttf) == draw.multiline_textsize(
-            TEST_TEXT, font=ttf
+        with pytest.warns(DeprecationWarning) as log:
+            # Test that textsize() correctly connects to multiline_textsize()
+            assert draw.textsize(TEST_TEXT, font=ttf) == draw.multiline_textsize(
+                TEST_TEXT, font=ttf
+            )
+
+            # Test that multiline_textsize corresponds to ImageFont.textsize()
+            # for single line text
+            assert ttf.getsize("A") == draw.multiline_textsize("A", font=ttf)
+
+            # Test that textsize() can pass on additional arguments
+            # to multiline_textsize()
+            draw.textsize(TEST_TEXT, font=ttf, spacing=4)
+            draw.textsize(TEST_TEXT, ttf, 4)
+        assert len(log) == 6
+
+    def test_multiline_bbox(self):
+        ttf = self.get_font()
+        im = Image.new(mode="RGB", size=(300, 100))
+        draw = ImageDraw.Draw(im)
+
+        # Test that textbbox() correctly connects to multiline_textbbox()
+        assert draw.textbbox((0, 0), TEST_TEXT, font=ttf) == draw.multiline_textbbox(
+            (0, 0), TEST_TEXT, font=ttf
         )
 
-        # Test that multiline_textsize corresponds to ImageFont.textsize()
+        # Test that multiline_textbbox corresponds to ImageFont.textbbox()
         # for single line text
-        assert ttf.getsize("A") == draw.multiline_textsize("A", font=ttf)
+        assert ttf.getbbox("A") == draw.multiline_textbbox((0, 0), "A", font=ttf)
 
-        # Test that textsize() can pass on additional arguments
-        # to multiline_textsize()
-        draw.textsize(TEST_TEXT, font=ttf, spacing=4)
-        draw.textsize(TEST_TEXT, ttf, 4)
+        # Test that textbbox() can pass on additional arguments
+        # to multiline_textbbox()
+        draw.textbbox((0, 0), TEST_TEXT, font=ttf, spacing=4)
 
     def test_multiline_width(self):
         ttf = self.get_font()
@@ -265,9 +285,15 @@ def test_multiline_width(self):
         draw = ImageDraw.Draw(im)
 
         assert (
-            draw.textsize("longest line", font=ttf)[0]
-            == draw.multiline_textsize("longest line\nline", font=ttf)[0]
+            draw.textbbox((0, 0), "longest line", font=ttf)[2]
+            == draw.multiline_textbbox((0, 0), "longest line\nline", font=ttf)[2]
         )
+        with pytest.warns(DeprecationWarning) as log:
+            assert (
+                draw.textsize("longest line", font=ttf)[0]
+                == draw.multiline_textsize("longest line\nline", font=ttf)[0]
+            )
+        assert len(log) == 2
 
     def test_multiline_spacing(self):
         ttf = self.get_font()
@@ -289,16 +315,33 @@ def test_rotated_transposed_font(self):
 
         # Original font
         draw.font = font
-        box_size_a = draw.textsize(word)
+        with pytest.warns(DeprecationWarning) as log:
+            box_size_a = draw.textsize(word)
+            assert box_size_a == font.getsize(word)
+        assert len(log) == 2
+        bbox_a = draw.textbbox((10, 10), word)
 
         # Rotated font
         draw.font = transposed_font
-        box_size_b = draw.textsize(word)
+        with pytest.warns(DeprecationWarning) as log:
+            box_size_b = draw.textsize(word)
+            assert box_size_b == transposed_font.getsize(word)
+        assert len(log) == 2
+        bbox_b = draw.textbbox((20, 20), word)
 
         # Check (w,h) of box a is (h,w) of box b
         assert box_size_a[0] == box_size_b[1]
         assert box_size_a[1] == box_size_b[0]
 
+        # Check bbox b is (20, 20, 20 + h, 20 + w)
+        assert bbox_b[0] == 20
+        assert bbox_b[1] == 20
+        assert bbox_b[2] == 20 + bbox_a[3] - bbox_a[1]
+        assert bbox_b[3] == 20 + bbox_a[2] - bbox_a[0]
+
+        # text length is undefined for vertical text
+        pytest.raises(ValueError, draw.textlength, word)
+
     def test_unrotated_transposed_font(self):
         img_grey = Image.new("L", (100, 100))
         draw = ImageDraw.Draw(img_grey)
@@ -310,15 +353,31 @@ def test_unrotated_transposed_font(self):
 
         # Original font
         draw.font = font
-        box_size_a = draw.textsize(word)
+        with pytest.warns(DeprecationWarning) as log:
+            box_size_a = draw.textsize(word)
+        assert len(log) == 1
+        bbox_a = draw.textbbox((10, 10), word)
+        length_a = draw.textlength(word)
 
         # Rotated font
         draw.font = transposed_font
-        box_size_b = draw.textsize(word)
+        with pytest.warns(DeprecationWarning) as log:
+            box_size_b = draw.textsize(word)
+        assert len(log) == 1
+        bbox_b = draw.textbbox((20, 20), word)
+        length_b = draw.textlength(word)
 
         # Check boxes a and b are same size
         assert box_size_a == box_size_b
 
+        # Check bbox b is (20, 20, 20 + w, 20 + h)
+        assert bbox_b[0] == 20
+        assert bbox_b[1] == 20
+        assert bbox_b[2] == 20 + bbox_a[2] - bbox_a[0]
+        assert bbox_b[3] == 20 + bbox_a[3] - bbox_a[1]
+
+        assert length_a == length_b
+
     def test_rotated_transposed_font_get_mask(self):
         # Arrange
         text = "mask this"
@@ -373,9 +432,11 @@ def test_free_type_font_get_offset(self):
         text = "offset this"
 
         # Act
-        offset = font.getoffset(text)
+        with pytest.warns(DeprecationWarning) as log:
+            offset = font.getoffset(text)
 
         # Assert
+        assert len(log) == 1
         assert offset == (0, 3)
 
     def test_free_type_font_get_mask(self):
@@ -417,11 +478,11 @@ def test_default_font(self):
         # Assert
         assert_image_equal_tofile(im, "Tests/images/default_font.png")
 
-    def test_getsize_empty(self):
+    def test_getbbox_empty(self):
         # issue #2614
         font = self.get_font()
         # should not crash.
-        assert (0, 0) == font.getsize("")
+        assert (0, 0, 0, 0) == font.getbbox("")
 
     def test_render_empty(self):
         # issue 2666
@@ -438,7 +499,7 @@ def test_unicode_pilfont(self):
         # issue #2826
         font = ImageFont.load_default()
         with pytest.raises(UnicodeEncodeError):
-            font.getsize("’")
+            font.getbbox("’")
 
     def test_unicode_extended(self):
         # issue #3777
@@ -563,17 +624,29 @@ def test_imagefont_getters(self):
         assert t.font.x_ppem == 20
         assert t.font.y_ppem == 20
         assert t.font.glyphs == 4177
-        assert t.getsize("A") == (12, 16)
-        assert t.getsize("AB") == (24, 16)
-        assert t.getsize("M") == (12, 16)
-        assert t.getsize("y") == (12, 20)
-        assert t.getsize("a") == (12, 16)
-        assert t.getsize_multiline("A") == (12, 16)
-        assert t.getsize_multiline("AB") == (24, 16)
-        assert t.getsize_multiline("a") == (12, 16)
-        assert t.getsize_multiline("ABC\n") == (36, 36)
-        assert t.getsize_multiline("ABC\nA") == (36, 36)
-        assert t.getsize_multiline("ABC\nAaaa") == (48, 36)
+        assert t.getbbox("A") == (0, 4, 12, 16)
+        assert t.getbbox("AB") == (0, 4, 24, 16)
+        assert t.getbbox("M") == (0, 4, 12, 16)
+        assert t.getbbox("y") == (0, 7, 12, 20)
+        assert t.getbbox("a") == (0, 7, 12, 16)
+        assert t.getlength("A") == 12
+        assert t.getlength("AB") == 24
+        assert t.getlength("M") == 12
+        assert t.getlength("y") == 12
+        assert t.getlength("a") == 12
+        with pytest.warns(DeprecationWarning) as log:
+            assert t.getsize("A") == (12, 16)
+            assert t.getsize("AB") == (24, 16)
+            assert t.getsize("M") == (12, 16)
+            assert t.getsize("y") == (12, 20)
+            assert t.getsize("a") == (12, 16)
+            assert t.getsize_multiline("A") == (12, 16)
+            assert t.getsize_multiline("AB") == (24, 16)
+            assert t.getsize_multiline("a") == (12, 16)
+            assert t.getsize_multiline("ABC\n") == (36, 36)
+            assert t.getsize_multiline("ABC\nA") == (36, 36)
+            assert t.getsize_multiline("ABC\nAaaa") == (48, 36)
+        assert len(log) == 11
 
     def test_getsize_stroke(self):
         # Arrange
@@ -581,14 +654,22 @@ def test_getsize_stroke(self):
 
         # Act / Assert
         for stroke_width in [0, 2]:
-            assert t.getsize("A", stroke_width=stroke_width) == (
-                12 + stroke_width * 2,
-                16 + stroke_width * 2,
-            )
-            assert t.getsize_multiline("ABC\nAaaa", stroke_width=stroke_width) == (
-                48 + stroke_width * 2,
-                36 + stroke_width * 4,
+            assert t.getbbox("A", stroke_width=stroke_width) == (
+                0 - stroke_width,
+                4 - stroke_width,
+                12 + stroke_width,
+                16 + stroke_width,
             )
+            with pytest.warns(DeprecationWarning) as log:
+                assert t.getsize("A", stroke_width=stroke_width) == (
+                    12 + stroke_width * 2,
+                    16 + stroke_width * 2,
+                )
+                assert t.getsize_multiline("ABC\nAaaa", stroke_width=stroke_width) == (
+                    48 + stroke_width * 2,
+                    36 + stroke_width * 4,
+                )
+            assert len(log) == 2
 
     def test_complex_font_settings(self):
         # Arrange
@@ -720,8 +801,11 @@ def test_textbbox_non_freetypefont(self):
         im = Image.new("RGB", (200, 200))
         d = ImageDraw.Draw(im)
         default_font = ImageFont.load_default()
-        with pytest.raises(ValueError):
-            d.textbbox((0, 0), "test", font=default_font)
+        with pytest.warns(DeprecationWarning) as log:
+            width, height = d.textsize("test", font=default_font)
+        assert len(log) == 1
+        assert d.textlength("test", font=default_font) == width
+        assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, width, height)
 
     @pytest.mark.parametrize(
         "anchor, left, top",
@@ -868,7 +952,7 @@ def test_bitmap_font_stroke(self):
     def test_standard_embedded_color(self):
         txt = "Hello World!"
         ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=self.LAYOUT_ENGINE)
-        ttf.getsize(txt)
+        ttf.getbbox(txt)
 
         im = Image.new("RGB", (300, 64), "white")
         d = ImageDraw.Draw(im)
diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py
--- a/Tests/test_imagefontctl.py
+++ b/Tests/test_imagefontctl.py
@@ -140,8 +140,8 @@ def test_ligature_features():
     target = "Tests/images/test_ligature_features.png"
     assert_image_similar_tofile(im, target, 0.5)
 
-    liga_size = ttf.getsize("fi", features=["-liga"])
-    assert liga_size == (13, 19)
+    liga_bbox = ttf.getbbox("fi", features=["-liga"])
+    assert liga_bbox == (0, 4, 13, 19)
 
 
 def test_kerning_features():