Task
I tried to code a treemap function in matplotlib, and one of the challenges is auto-fitting text into boxes of different sizes in the treemap, similar to R's package ggfittext.
My Code
My implementation is as follows:
import matplotlib.patches as mpatches
def text_with_autofit(ax, txt, xy, width, height, *,
transform=None,
ha='center', va='center',
min_size=1, show_rect=False,
**kwargs):
if transform is None:
transform = ax.transData
# Different alignments gives different bottom left and top right anchors.
x_data = {'center': (xy[0] - width/2, xy[0] + width/2),
'left': (xy[0], xy[0] + width),
'right': (xy[0] - width, xy[0])}
y_data = {'center': (xy[1] - height/2, xy[1] + height/2),
'bottom': (xy[1], xy[1] + height),
'top': (xy[1] - height, xy[1])}
(x0, y0) = transform.transform((x_data[ha][0], y_data[va][0]))
(x1, y1) = transform.transform((x_data[ha][1], y_data[va][1]))
# rectange region size to constrain the text in pixel
rect_width = x1 - x0
rect_height = y1- y0
fig = ax.get_figure()
dpi = fig.dpi
rect_height_inch = rect_height / dpi
# Initial fontsize according to the height of boxes
fontsize = rect_height_inch * 72
text = ax.annotate(txt, xy, ha=ha, va=va, xycoords=transform,
**kwargs)
# Adjust the fontsize according to the box size, and this is the slow part.
while fontsize > min_size:
text.set_fontsize(fontsize)
bbox = text.get_window_extent(fig.canvas.get_renderer())
if bbox.width < rect_width:
break;
fontsize -= 1
if show_rect:
rect = mpatches.Rectangle((x_data[ha][0], y_data[va][0]),
width, height, fill=False, ls='--')
ax.add_patch(rect)
return text
Examples
import matplotlib.pyplot as plt
fig, ax = plt.subplots(2, 1)
# In the box with the width of 0.4 and the height of 0.4 at (0.5, 0.5), add the text.
text_with_autofit(ax[0], "Hello, World! How are you?", (0.5, 0.5), 0.4, 0.4, show_rect=True)
# In the box with the width of 0.6 and the height of 0.4 at (0.5, 0.5), add the text.
text_with_autofit(ax[1], "Hello, World! How are you?", (0.5, 0.5), 0.6, 0.4, show_rect=True)
plt.show()
The resulting figures are as follows: enter image description here
Problem
The slow part is the while
loop, which sets the font size, compares text width with the box's width and decreases the font size until the text width is less than box's width.
With this code, when I plotted a treemap with 15 items, it took about several seconds, while without auto-fitting, the plotting is fast.
I can't accept the performance. I wonder whether there are some ways to make the code more efficient. Any help is appreciated! Thanks!
1 Answer 1
You don't need to loop. Once you have an initial rendered width from get_window_extent
, you can scale the font proportionally to fit the bounding box. This is potentially more accurate as it is not constrained to an integer quantity. In the following example run, the adjusted font size was found to be 10.53:
from typing import Optional, Literal
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from matplotlib.text import Annotation
from matplotlib.transforms import Transform, Bbox
def text_with_autofit(
ax: plt.Axes,
txt: str,
xy: tuple[float, float],
width: float, height: float,
*,
transform: Optional[Transform] = None,
ha: Literal['left', 'center', 'right'] = 'center',
va: Literal['bottom', 'center', 'top'] = 'center',
show_rect: bool = False,
**kwargs,
):
if transform is None:
transform = ax.transData
# Different alignments give different bottom left and top right anchors.
x, y = xy
xa0, xa1 = {
'center': (x - width / 2, x + width / 2),
'left': (x, x + width),
'right': (x - width, x),
}[ha]
ya0, ya1 = {
'center': (y - height / 2, y + height / 2),
'bottom': (y, y + height),
'top': (y - height, y),
}[va]
a0 = xa0, ya0
a1 = xa1, ya1
x0, y0 = transform.transform(a0)
x1, y1 = transform.transform(a1)
# rectangle region size to constrain the text in pixel
rect_width = x1 - x0
rect_height = y1 - y0
fig: plt.Figure = ax.get_figure()
dpi = fig.dpi
rect_height_inch = rect_height / dpi
# Initial fontsize according to the height of boxes
fontsize = rect_height_inch * 72
text: Annotation = ax.annotate(txt, xy, ha=ha, va=va, xycoords=transform, **kwargs)
# Adjust the fontsize according to the box size.
text.set_fontsize(fontsize)
bbox: Bbox = text.get_window_extent(fig.canvas.get_renderer())
adjusted_size = fontsize * rect_width / bbox.width
text.set_fontsize(adjusted_size)
if show_rect:
rect = mpatches.Rectangle(a0, width, height, fill=False, ls='--')
ax.add_patch(rect)
return text
def main() -> None:
fig, ax = plt.subplots(2, 1)
# In the box with the width of 0.4 and the height of 0.4 at (0.5, 0.5), add the text.
text_with_autofit(ax[0], "Hello, World! How are you?", (0.5, 0.5), 0.4, 0.4, show_rect=True)
# In the box with the width of 0.6 and the height of 0.4 at (0.5, 0.5), add the text.
text_with_autofit(ax[1], "Hello, World! How are you?", (0.5, 0.5), 0.6, 0.4, show_rect=True)
plt.show()
if __name__ == '__main__':
main()
It's so accurate, in fact, that you may want to introduce a padding quantity.
A more direct, possibly faster method that doesn't necessarily have as much feature support (tex, etc.) is:
from typing import Optional, Literal
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties, findfont, get_font
from matplotlib.text import Annotation
from matplotlib.transforms import Transform
from matplotlib.backends.backend_agg import get_hinting_flag
def text_with_autofit(
ax: plt.Axes,
txt: str,
xy: tuple[float, float],
width: float, height: float,
*,
transform: Optional[Transform] = None,
ha: Literal['left', 'center', 'right'] = 'center',
va: Literal['bottom', 'center', 'top'] = 'center',
show_rect: bool = False,
**kwargs,
) -> Annotation:
if transform is None:
transform = ax.transData
# Different alignments give different bottom left and top right anchors.
x, y = xy
xa0, xa1 = {
'center': (x - width / 2, x + width / 2),
'left': (x, x + width),
'right': (x - width, x),
}[ha]
ya0, ya1 = {
'center': (y - height / 2, y + height / 2),
'bottom': (y, y + height),
'top': (y - height, y),
}[va]
a0 = xa0, ya0
a1 = xa1, ya1
x0, _ = transform.transform(a0)
x1, _ = transform.transform(a1)
# rectangle region size to constrain the text in pixel
rect_width = x1 - x0
fig: plt.Figure = ax.get_figure()
props = FontProperties()
font = get_font(findfont(props))
font.set_size(props.get_size_in_points(), fig.dpi)
angle = 0
font.set_text(txt, angle, flags=get_hinting_flag())
w, _ = font.get_width_height()
subpixels = 64
adjusted_size = props.get_size_in_points() * rect_width / w * subpixels
props.set_size(adjusted_size)
text: Annotation = ax.annotate(txt, xy, ha=ha, va=va, xycoords=transform, fontproperties=props, **kwargs)
if show_rect:
rect = mpatches.Rectangle(a0, width, height, fill=False, ls='--')
ax.add_patch(rect)
return text
def main() -> None:
fig, ax = plt.subplots(2, 1)
# In the box with the width of 0.4 and the height of 0.4 at (0.5, 0.5), add the text.
text_with_autofit(ax[0], "Hello, World! How are you?", (0.5, 0.5), 0.4, 0.4, show_rect=True)
# In the box with the width of 0.6 and the height of 0.4 at (0.5, 0.5), add the text.
text_with_autofit(ax[1], "Hello, World! How are you?", (0.5, 0.5), 0.6, 0.4, show_rect=True)
plt.show()
if __name__ == '__main__':
main()
-
\$\begingroup\$ Thanks for the new solution! But I don't understand why there needs
subpixels
when calculating theadjusted_fontsize
. \$\endgroup\$Z-Y.L– Z-Y.L2022年03月27日 14:08:02 +00:00Commented Mar 27, 2022 at 14:08 -
\$\begingroup\$ It's what matplotlib does internally, and is a consequence of the font format. \$\endgroup\$Reinderien– Reinderien2022年03月27日 14:20:05 +00:00Commented Mar 27, 2022 at 14:20
treemap
function, which is a little complicated. What this function does is auto-adjusting the fontsize of atxt
in aax
so that thetxt
fits into the box with thewidth
andheight
at specific positionxy
. For simplicity, I add an example how this function works. \$\endgroup\$