Text Box with Line Wrapping in Matplotlib

Text box with line wrapping in matplotlib?

The contents of this answer were merged into mpl master in https://github.com/matplotlib/matplotlib/pull/4342 and will be in the next feature release.


Wow... This is a thorny problem... (And it exposes a lot of limitations in matplotlib's text rendering...)

This should (i.m.o.) be something that matplotlib has built-in, but it doesn't. There have been a few threads about it on the mailing list, but no solution that I could find to automatic text wrapping.

So, first off, there's no way to determine the size (in pixels) of the rendered text string before it's drawn in matplotlib. This isn't too large of a problem, as we can just draw it, get the size, and then redraw the wrapped text. (It's expensive, but not too excessively bad)

The next problem is that characters don't have a fixed width in pixels, so wrapping a text string to a given number of characters won't necessarily reflect a given width when rendered. This isn't a huge problem, though.

Beyond that, we can't just do this once... Otherwise, it will be wrapped correctly when drawn the first time (on the screen, for example), but not if drawn again (when the figure is resized or saved as an image with a different DPI than the screen). This isn't a huge problem, as we can just connect a callback function to the matplotlib draw event.

At any rate this solution is imperfect, but it should work in most situations. I don't try to account for tex-rendered strings, any stretched fonts, or fonts with an unusual aspect ratio. However, it should now properly handle rotated text.

However, It should attempt automatically wrap any text objects in multiple subplots in whichever figures you connect the on_draw callback to... It will be imperfect in many cases, but it does a decent job.

import matplotlib.pyplot as plt

def main():
fig = plt.figure()
plt.axis([0, 10, 0, 10])

t = "This is a really long string that I'd rather have wrapped so that it"\
" doesn't go outside of the figure, but if it's long enough it will go"\
" off the top or bottom!"
plt.text(4, 1, t, ha='left', rotation=15)
plt.text(5, 3.5, t, ha='right', rotation=-15)
plt.text(5, 10, t, fontsize=18, ha='center', va='top')
plt.text(3, 0, t, family='serif', style='italic', ha='right')
plt.title("This is a really long title that I want to have wrapped so it"\
" does not go outside the figure boundaries", ha='center')

# Now make the text auto-wrap...
fig.canvas.mpl_connect('draw_event', on_draw)
plt.show()

def on_draw(event):
"""Auto-wraps all text objects in a figure at draw-time"""
import matplotlib as mpl
fig = event.canvas.figure

# Cycle through all artists in all the axes in the figure
for ax in fig.axes:
for artist in ax.get_children():
# If it's a text artist, wrap it...
if isinstance(artist, mpl.text.Text):
autowrap_text(artist, event.renderer)

# Temporarily disconnect any callbacks to the draw event...
# (To avoid recursion)
func_handles = fig.canvas.callbacks.callbacks[event.name]
fig.canvas.callbacks.callbacks[event.name] = {}
# Re-draw the figure..
fig.canvas.draw()
# Reset the draw event callbacks
fig.canvas.callbacks.callbacks[event.name] = func_handles

def autowrap_text(textobj, renderer):
"""Wraps the given matplotlib text object so that it exceed the boundaries
of the axis it is plotted in."""
import textwrap
# Get the starting position of the text in pixels...
x0, y0 = textobj.get_transform().transform(textobj.get_position())
# Get the extents of the current axis in pixels...
clip = textobj.get_axes().get_window_extent()
# Set the text to rotate about the left edge (doesn't make sense otherwise)
textobj.set_rotation_mode('anchor')

# Get the amount of space in the direction of rotation to the left and
# right of x0, y0 (left and right are relative to the rotation, as well)
rotation = textobj.get_rotation()
right_space = min_dist_inside((x0, y0), rotation, clip)
left_space = min_dist_inside((x0, y0), rotation - 180, clip)

# Use either the left or right distance depending on the horiz alignment.
alignment = textobj.get_horizontalalignment()
if alignment is 'left':
new_width = right_space
elif alignment is 'right':
new_width = left_space
else:
new_width = 2 * min(left_space, right_space)

# Estimate the width of the new size in characters...
aspect_ratio = 0.5 # This varies with the font!!
fontsize = textobj.get_size()
pixels_per_char = aspect_ratio * renderer.points_to_pixels(fontsize)

# If wrap_width is < 1, just make it 1 character
wrap_width = max(1, new_width // pixels_per_char)
try:
wrapped_text = textwrap.fill(textobj.get_text(), wrap_width)
except TypeError:
# This appears to be a single word
wrapped_text = textobj.get_text()
textobj.set_text(wrapped_text)

def min_dist_inside(point, rotation, box):
"""Gets the space in a given direction from "point" to the boundaries of
"box" (where box is an object with x0, y0, x1, & y1 attributes, point is a
tuple of x,y, and rotation is the angle in degrees)"""
from math import sin, cos, radians
x0, y0 = point
rotation = radians(rotation)
distances = []
threshold = 0.0001
if cos(rotation) > threshold:
# Intersects the right axis
distances.append((box.x1 - x0) / cos(rotation))
if cos(rotation) < -threshold:
# Intersects the left axis
distances.append((box.x0 - x0) / cos(rotation))
if sin(rotation) > threshold:
# Intersects the top axis
distances.append((box.y1 - y0) / sin(rotation))
if sin(rotation) < -threshold:
# Intersects the bottom axis
distances.append((box.y0 - y0) / sin(rotation))
return min(distances)

if __name__ == '__main__':
main()

Figure with wrapped text

Wrapping text not working in matplotlib

Matplotlib is hardwired to use the figure box as the wrapping width. To get around this, you have to override the _get_wrap_line_width method, which returns how long the line can be in screen pixels. For example:

text = ('Lorem ipsum dolor sit amet, consectetur adipiscing elit, '
'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ')
txt = ax.text(.2, .8, text, ha='left', va='top', wrap=True,
bbox=dict(boxstyle='square', fc='w', ec='r'))
txt._get_wrap_line_width = lambda : 600. # wrap to 600 screen pixels

The lambda function is just an easy way to create a function/method without having to write a named one using def.

Obviously using a private method comes with risks, such as it being removed in future versions. And I haven't tested how well this works with rotations. If you want to make something more sophisticated, such as using data coordinates, you would have to subclass the Text class, and override the _get_wrap_line_width method explicitly.

import matplotlib.pyplot as plt
import matplotlib.text as mtext

class WrapText(mtext.Text):
def __init__(self,
x=0, y=0, text='',
width=0,
**kwargs):
mtext.Text.__init__(self,
x=x, y=y, text=text,
wrap=True,
**kwargs)
self.width = width # in screen pixels. You could do scaling first

def _get_wrap_line_width(self):
return self.width

fig = plt.figure(1, clear=True)
ax = fig.add_subplot(111)

text = ('Lorem ipsum dolor sit amet, consectetur adipiscing elit, '
'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ')

# Create artist object. Note clip_on is True by default
# The axes doesn't have this method, so the object is created separately
# and added afterwards.
wtxt = WrapText(.8, .4, text, width=500, va='top', clip_on=False,
bbox=dict(boxstyle='square', fc='w', ec='b'))
# Add artist to the axes
ax.add_artist(wtxt)

plt.show()

Matplotlib - Set the limits for text wrapping

As can be seen in the auto wrap demo, wrapping happens at the figure limits. While this is not very comfortable, and I can imagine many cases, where this would not help at all, here, it allows to wrap the text by choosing the correct alignment.

ax.text(0.49, 0.98, t, ha='right',va="top", wrap=True, 
fontsize=20, transform=ax.transAxes)
ax.text(0.51, 0.98, t, ha='left',va="top", wrap=True,
fontsize=20, transform=ax.transAxes)
ax.text(0.49, 0.49, t, ha='right',va="top", wrap=True,
fontsize=20, transform=ax.transAxes)

Sample Image

Text wrapping works only for vertical labels in matplotlib

Text wrapping doesn't wrap text to not overlap, it wraps text when it tries to go outside the figure.

For vertical labels it seems as matplotlib wraps the text to have nice labels but it just prevents the text to go outside the figure. You can verify this when you set a tight layout - no wrapping occurs, just the axes is being shrinked to accommodate for the labels so that there's no need to wrap them (provided it's possible).

If you make the horizontal label so long that they would go outside the figure they will be wrapped, but only those that would go outside the figure.
Sample Image

See also this source code comment in Text#_get_wrapped_text()

Return a copy of the text with new lines added, so that the text is
wrapped relative to the parent figure.

matplotlib - wrap text in legend

You can use textwrap.wrap in order to adjust your legend entries (found in this answer), then update them in the call to ax.legend().

import random
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
from textwrap import wrap

sns.set_style('darkgrid')

df = pd.DataFrame({'Year': [2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016],
'One legend label': [random.randint(1,15) for _ in range(10)],
'A much longer, much more inconvenient, annoying legend label': [random.randint(1, 15) for _ in range(10)]})

random.seed(22)
fig, ax = plt.subplots()

labels = [ '\n'.join(wrap(l, 20)) for l in df.columns]

df.plot.line(x='Year', ax=ax,)
ax.legend(labels, bbox_to_anchor=(1, 0.5))

plt.subplots_adjust(left=0.1, right = 0.7)
plt.show()

Which gives:

Sample Image

Update: As pointed out in the comments, the documentation says textwrap.fill() is shorthand for '\n'.join(wrap(text, ...)). Therefore you can instead use:

from textwrap import fill
labels = [fill(l, 20) for l in df.columns]

How do I get the height of a wrapped text in Matplotlib?

Thank you @ImportanceOfBeingErnest for pointing out that this is not really possible. Here is one workaround, that kind of works, by checking the number of lines the text is broken up into, and multiplying by the approximate line-height. This works when automatically inserted breaks are mixed with manually (i.e. there is an "\n" in the text), but will be off by a number of pixels. Any more precise suggestions welcome.

def get_text_height(fig, obj):
""" Get the approximate height of a text object.
"""
fig.canvas.draw() # Draw text to find out how big it is
t = obj.get_text()
r = fig.canvas.renderer
w, h, d = r.get_text_width_height_descent(t, obj._fontproperties,
ismath=obj.is_math_text(t))
num_lines = len(obj._get_wrapped_text().split("\n"))
return (h * num_lines)

text = "I'm a long text that will be wrapped automatically by Matplotlib, using wrap=True"
obj = fig.suptitle(text, wrap=True)
height = get_text_height(fig, obj)
print(height) # 28 <-- Close enough! (In reality 30)

Wrapping long y labels in matplotlib tight layout using setp

I have tried using textwrap on the labels and it works for me.

from textwrap import wrap
labels=['Really really really really really really long label 1',
'Really really really really really really long label 2',
'Really really really really really really long label 3']
labels = [ '\n'.join(wrap(l, 20)) for l in labels ]

Inserting this in your code gives us:

Wrapped labels



Related Topics



Leave a reply



Submit