Text vertical alignment within a FancyBboxPatch

Hello!

Today, some vertical alignment problems started to drive me crazy. I’m trying to make a TextBox class which will allow me to provide a text, a position and a fix width and which will create a FancyBboxPatch and Text objects accordingly, with line wrapping set to the desired width and with nice padding/alignments. The horizontal padding was no issue at all, but the vertical padding is driving me crazy.

Minimal working example (copy/paste and run):

from __future__ import annotations

import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch
from matplotlib.text import Text


class TextBox:
    """A text box object.

    To draw the text box on a matplotlib axes, call the :meth:`~TextBox.draw` method.

    Parameters
    ----------
    text : str
        The text content of the textbox.
    xy : tuple[float, float]
        The (x, y) position of the textbox. The anchor point is the bottom-left corner.
    width : float
        The width of the textbox.
    height : float | str
        The height of the textbox. If ``'auto'``, the height will be automatically
        adjusted based on the text content.
    bgcolor : str
        The background color of the textbox. See the matplotlib color documentation:
        https://matplotlib.org/stable/gallery/color/named_colors.html
    text_color : str
        The color of the text content. See the matplotlib color documentation:
        https://matplotlib.org/stable/gallery/color/named_colors.html
    shape : str
        The shape of the textbox. Either ``'rectangle'`` or ``'round'`` (rounded
        corners).
    font : str
        The font style of the text content.
    fontsize : int
        The font size of the text content.
    hpad : float
        The horizontal padding between the box and the text content, in data
        coordinates.
    text_alignment : str
        The text alignment within the box. Either ``'center'`` or ``'left'``.
    """

    def __init__(
        self,
        text: str,
        xy: tuple[float, float],
        width,
        height,
        bgcolor,
        text_color,
        shape,
        font: str,
        fontsize=12,
        hpad: float = 0.01,
        text_alignment: str = "center",
    ) -> None:
        """Initialize a TextBox instance."""
        self._text = text
        self._xy = xy
        self._width = width
        self._height = height
        self._bgcolor = bgcolor
        self._text_color = text_color
        self._shape = shape
        self._font = font
        self._fontsize = fontsize
        self._hpad = hpad
        self._text_alignment = text_alignment

        # create the associated boxstyle
        self._boxstyle = (
            "round,pad=0,rounding_size=0.05"
            if self._shape == "round"
            else "square,pad=0"
        )

    def _compute_auto_height(self, ax: plt.Axes) -> float:
        """Estimate the textbox height based on the text."""
        fig = ax.figure
        renderer = fig.canvas.get_renderer()
        # create a temporary Text object with wrap turned on
        temp_text = Text(
            x=0,
            y=0,
            text=self._text,
            fontsize=self._fontsize,
            fontname=self._font,
            wrap=True,  # make matplotlib do line-wrapping for us
        )

        # force a "wrap width" in pixels by overriding this private method
        def _wrap_width():
            return _get_width_in_pixels(self._xy, self._width, self._hpad, ax)

        temp_text._get_wrap_line_width = _wrap_width
        # associate the text with a figure and an axes, force a draw to get the size and
        # remove the temporary text
        ax.add_artist(temp_text)
        fig.canvas.draw()
        # measure in display coordinate and transform to data coordinate
        bbox_disp = temp_text.get_window_extent(renderer=renderer)
        bbox_data = bbox_disp.transformed(ax.transData.inverted())
        auto_height = bbox_data.height
        temp_text.remove()
        return auto_height

    def draw(self, ax: plt.Axes) -> None:
        """Draw the textbox on the provided matplotlib axes.

        Parameters
        ----------
        ax : Axes
            The matplotlib axes on which to draw the textbox.
        """
        height = (
            self._compute_auto_height(ax) if self._height == "auto" else self._height
        )
        # draw the FancyBboxPatch, with round or square corners
        textbox_patch = FancyBboxPatch(
            self._xy,  # bottom-left corner
            self._width,
            height,
            boxstyle=self._boxstyle,
            linewidth=1,
            facecolor=self._bgcolor,
            edgecolor="black",
        )
        ax.add_patch(textbox_patch)
        # draw the text, with the same logic as the auto height
        text_x = (
            self._xy[0] + self._width / 2
            if self._text_alignment == "center"
            else self._xy[0] + self._hpad
        )
        text_y = self._xy[1] + height / 2
        text = ax.text(
            text_x,
            text_y,
            self._text,
            ha=self._text_alignment,
            va="center",
            fontname=self._font,
            fontsize=self._fontsize,
            color=self._text_color,
            wrap=True,
            zorder=2,  # Keep it on top of patch if desired
        )

        # force a "wrap width" in pixels by overriding this private method
        def _wrap_width():
            return _get_width_in_pixels(self._xy, self._width, self._hpad, ax)

        text._get_wrap_line_width = _wrap_width


def _get_width_in_pixels(
    xy: tuple[float, float], width: float, hpad: float, ax: plt.Axes
) -> float:
    """Convert the provided width to pixels."""
    x0_data = xy[0] + hpad
    x1_data = x0_data + (width - 2 * hpad)  # remove pad from both left & right
    x0_disp, _ = ax.transData.transform((x0_data, 0))
    x1_disp, _ = ax.transData.transform((x1_data, 0))
    return x1_disp - x0_disp


if __name__ == "__main__":
    fig, ax = plt.subplots(figsize=(8, 6))
    box2 = TextBox(
        text="Rounded corners textbox with longer text content. Let's make this text a bit longer and see what happens for the box. This box easily adapts.",
        xy=(0.1, 0.4),
        width=0.3,
        height="auto",
        bgcolor="lightgreen",
        text_color="black",
        shape="round",
        fontsize=12,
        font="DejaVu Sans",
        text_alignment="left",
    )
    box2.draw(ax)
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.axis("off")

    plt.tight_layout()
    plt.show()

My idea to fix the vertical padding was to set va="top" and to add a vpad argument to my object which would modify the height estimation:

auto_height = bbox_data.height + 2 * self._vpad

And the text Y position to:

text_y = self._xy[1] + height - self._vpad

That does not work at all, with the padding below being terrible:

Minimal working example (copy/paste and run):

from __future__ import annotations

import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch
from matplotlib.text import Text


class TextBox:
    """A text box object.

    To draw the text box on a matplotlib axes, call the :meth:`~TextBox.draw` method.

    Parameters
    ----------
    text : str
        The text content of the textbox.
    xy : tuple[float, float]
        The (x, y) position of the textbox. The anchor point is the bottom-left corner.
    width : float
        The width of the textbox.
    height : float | str
        The height of the textbox. If ``'auto'``, the height will be automatically
        adjusted based on the text content.
    bgcolor : str
        The background color of the textbox. See the matplotlib color documentation:
        https://matplotlib.org/stable/gallery/color/named_colors.html
    text_color : str
        The color of the text content. See the matplotlib color documentation:
        https://matplotlib.org/stable/gallery/color/named_colors.html
    shape : str
        The shape of the textbox. Either ``'rectangle'`` or ``'round'`` (rounded
        corners).
    font : str
        The font style of the text content.
    fontsize : int
        The font size of the text content.
    hpad : float
        The horizontal padding between the box and the text content, in data
        coordinates.
    vpad : float
        The vertical padding between the box and the text content, in data
        coordinates
    text_alignment : str
        The text alignment within the box. Either ``'center'`` or ``'left'``.
    """

    def __init__(
        self,
        text: str,
        xy: tuple[float, float],
        width,
        height,
        bgcolor,
        text_color,
        shape,
        font: str,
        fontsize=12,
        hpad: float = 0.01,
        vpad: float = 0.01,
        text_alignment: str = "center",
    ) -> None:
        """Initialize a TextBox instance."""
        self._text = text
        self._xy = xy
        self._width = width
        self._height = height
        self._bgcolor = bgcolor
        self._text_color = text_color
        self._shape = shape
        self._font = font
        self._fontsize = fontsize
        self._hpad = hpad
        self._vpad = vpad
        self._text_alignment = text_alignment

        # create the associated boxstyle
        self._boxstyle = (
            "round,pad=0,rounding_size=0.05"
            if self._shape == "round"
            else "square,pad=0"
        )

    def _compute_auto_height(self, ax: plt.Axes) -> float:
        """Estimate the textbox height based on the text."""
        fig = ax.figure
        renderer = fig.canvas.get_renderer()
        # create a temporary Text object with wrap turned on
        temp_text = Text(
            x=0,
            y=0,
            text=self._text,
            fontsize=self._fontsize,
            fontname=self._font,
            wrap=True,  # make matplotlib do line-wrapping for us
            va="top",
        )

        # force a "wrap width" in pixels by overriding this private method
        def _wrap_width():
            return _get_width_in_pixels(self._xy, self._width, self._hpad, ax)

        temp_text._get_wrap_line_width = _wrap_width
        # associate the text with a figure and an axes, force a draw to get the size and
        # remove the temporary text
        ax.add_artist(temp_text)
        fig.canvas.draw()
        # measure in display coordinate and transform to data coordinate
        bbox_disp = temp_text.get_window_extent(renderer=renderer)
        bbox_data = bbox_disp.transformed(ax.transData.inverted())
        auto_height = bbox_data.height + 2 * self._vpad
        temp_text.remove()
        return auto_height

    def draw(self, ax: plt.Axes) -> None:
        """Draw the textbox on the provided matplotlib axes.

        Parameters
        ----------
        ax : Axes
            The matplotlib axes on which to draw the textbox.
        """
        height = (
            self._compute_auto_height(ax) if self._height == "auto" else self._height
        )
        # draw the FancyBboxPatch, with round or square corners
        textbox_patch = FancyBboxPatch(
            self._xy,  # bottom-left corner
            self._width,
            height,
            boxstyle=self._boxstyle,
            linewidth=1,
            facecolor=self._bgcolor,
            edgecolor="black",
        )
        ax.add_patch(textbox_patch)
        # draw the text, with the same logic as the auto height
        text_x = (
            self._xy[0] + self._width / 2
            if self._text_alignment == "center"
            else self._xy[0] + self._hpad
        )
        text_y = self._xy[1] + height - self._vpad
        text = ax.text(
            text_x,
            text_y,
            self._text,
            ha=self._text_alignment,
            va="top",
            fontname=self._font,
            fontsize=self._fontsize,
            color=self._text_color,
            wrap=True,
            zorder=2,  # Keep it on top of patch if desired
        )

        # force a "wrap width" in pixels by overriding this private method
        def _wrap_width():
            return _get_width_in_pixels(self._xy, self._width, self._hpad, ax)

        text._get_wrap_line_width = _wrap_width


def _get_width_in_pixels(
    xy: tuple[float, float], width: float, hpad: float, ax: plt.Axes
) -> float:
    """Convert the provided width to pixels."""
    x0_data = xy[0] + hpad
    x1_data = x0_data + (width - 2 * hpad)  # remove pad from both left & right
    x0_disp, _ = ax.transData.transform((x0_data, 0))
    x1_disp, _ = ax.transData.transform((x1_data, 0))
    return x1_disp - x0_disp


if __name__ == "__main__":
    fig, ax = plt.subplots(figsize=(8, 6))
    box2 = TextBox(
        text="Rounded corners textbox with longer text content. Let's make this text a bit longer and see what happens for the box. This box easily adapts.",
        xy=(0.1, 0.4),
        width=0.3,
        height="auto",
        bgcolor="lightgreen",
        text_color="black",
        shape="round",
        fontsize=12,
        font="DejaVu Sans",
        text_alignment="left",
    )
    box2.draw(ax)
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.axis("off")

    plt.tight_layout()
    plt.show()

Help appreciated!

Mathieu