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

