daryl
daryl

Reputation: 1200

matplotlib: How to autoscale font-size so that text fits some bounding box

Problem

My matplotlib application generates user-defined dynamic images and so things like page title text can be of varying length. I want to be able to specify a bounding box to matplotlib and then have it auto-scale the font size so that the text fits within that bounding box. My application only uses the AGG backend.

My hack solution

I am the least sharp tool in the toolbox, but here is what I came up with for a solution to this problem. I brute force start at a fontsize of 50 and then iterate downward until I think I can fit the text into the box.

def fitbox(fig, text, x0, x1, y0, y1, **kwargs):
    """Fit text into a NDC box."""
    figbox = fig.get_window_extent().transformed(
        fig.dpi_scale_trans.inverted())
    # need some slop for decimal comparison below
    px0 = x0 * fig.dpi * figbox.width - 0.15
    px1 = x1 * fig.dpi * figbox.width + 0.15
    py0 = y0 * fig.dpi * figbox.height - 0.15
    py1 = y1 * fig.dpi * figbox.height + 0.15
    # print("px0: %s px1: %s py0: %s py1: %s" % (px0, px1, py0, py1))
    xanchor = x0
    if kwargs.get('ha', '') == 'center':
        xanchor = x0 + (x1 - x0) / 2.
    yanchor = y0
    if kwargs.get('va', '') == 'center':
        yanchor = y0 + (y1 - y0) / 2.
    txt = fig.text(
        xanchor, yanchor, text,
        fontsize=50, ha=kwargs.get('ha', 'left'),
        va=kwargs.get('va', 'bottom'),
        color=kwargs.get('color', 'k')
    )
    for fs in range(50, 1, -2):
        txt.set_fontsize(fs)
        tbox = txt.get_window_extent(fig.canvas.get_renderer())
        # print("fs: %s tbox: %s" % (fs, str(tbox)))
        if (tbox.x0 >= px0 and tbox.x1 < px1 and tbox.y0 >= py0 and
                tbox.y1 <= py1):
            break
    return txt

So then I can call this function like so

fitbox(fig, "Hello there, this is my title!", 0.1, 0.99, 0.95, 0.99)

Question/Feedback Request

  1. Does matplotlib offer a better built-in solution for this problem?
  2. Any significant downsides to this approach? The performance does not feel like a game breaker. I should likely make this function allow the specification of coordinates within a single axes and not the overall figure. Perhaps that already works :)

As an aside, I like how some other plotting applications allow the specifying of font-size in non-dimensional display coordinates. For example, PyNGL. So you can set it to fontsize=0.04 for example.

Thank you.

Upvotes: 1

Views: 1581

Answers (1)

Z-Y.L
Z-Y.L

Reputation: 1779

My implementation of auto-fit text in a box:

def text_with_autofit(self, txt, xy, width, height, *, 
                      transform=None, 
                      ha='center', va='center',
                      min_size=1, adjust=0,
                      **kwargs):
    if transform is None:
        if isinstance(self, Axes):
            transform = self.transData
        if isinstance(self, Figure):
            transform = self.transFigure
        
        
    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
    rect_width = x1 - x0
    rect_height = y1- y0
    
    fig = self.get_figure() if isinstance(self, Axes) else self
    dpi = fig.dpi
    rect_height_inch = rect_height / dpi
    fontsize = rect_height_inch * 72
    
    if isinstance(self, Figure):
        text = self.text(*xy, txt, ha=ha, va=va, transform=transform, 
                         **kwargs)
    if isinstance(self, Axes):
        text = self.annotate(txt, xy, ha=ha, va=va, xycoords=transform,
                             **kwargs)
    
    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 fig.get_constrained_layout():
        text.set_fontsize(fontsize + adjust + 0.5)
    if fig.get_tight_layout():
        text.set_fontsize(fontsize + adjust)
        
    return text

Upvotes: 1

Related Questions