2
\$\begingroup\$

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!

\$\endgroup\$
2
  • 1
    \$\begingroup\$ Please show complete context - how this function is called in a realistic setting. \$\endgroup\$
    – Reinderien
    Mar 19, 2022 at 14:18
  • \$\begingroup\$ @Reinderien Well, in my context, this function is called by a treemap function, which is a little complicated. What this function does is auto-adjusting the fontsize of a txt in a ax so that the txt fits into the box with the width and height at specific position xy. For simplicity, I add an example how this function works. \$\endgroup\$
    – Z-Y.L
    Mar 20, 2022 at 4:16

1 Answer 1

3
\$\begingroup\$

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()

example run

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()
\$\endgroup\$
2
  • \$\begingroup\$ Thanks for the new solution! But I don't understand why there needs subpixels when calculating the adjusted_fontsize. \$\endgroup\$
    – Z-Y.L
    Mar 27, 2022 at 14:08
  • \$\begingroup\$ It's what matplotlib does internally, and is a consequence of the font format. \$\endgroup\$
    – Reinderien
    Mar 27, 2022 at 14:20

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.