Is it possible to display text in a box through Matplotlib, with automatic line breaks? By using pyplot.text()
, I was only able to print multi-line text that flows beyond the boundaries of the window, which is annoying. The size of the lines is not known in advance… Any idea would be much appreciated!
1 Answer. You can wrap the title using wrap() module.
wrap(text, width=70, **kwargs): This function wraps the input paragraph such that each line in the paragraph is at most width characters long. The wrap method returns a list of output lines. The returned list is empty if the wrapped output has no content.
If you want to wrap the labels manually you can insert a '\n' into the label name, which will break the label into two lines at the point you have the '\n'. You can see an example of this here. There also appears to be an autowrap function now that seems to do the trick nicely.
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()
Its been roughly five years but there still doesn't seem to be a great way to do this. Here is my version of the accepted solution. My goal was to allow pixel-perfect wrapping to be selectively applied to individual text instances. I have also created a simple textBox() function which will convert any axes into a text box with custom margins and alignment.
Instead of assuming a particular font aspect ratio or average width, I actually draw the string one word at a time and insert newlines once the threshold is hit. This is horrendously slow compared to the approximations, but still feels quite snappy for strings of <200 words.
# Text Wrapping # Defines wrapText which will attach an event to a given mpl.text object, # wrapping it within the parent axes object. Also defines a the convenience # function textBox() which effectively converts an axes to a text box. def wrapText(text, margin=4): """ Attaches an on-draw event to a given mpl.text object which will automatically wrap its string wthin the parent axes object. The margin argument controls the gap between the text and axes frame in points. """ ax = text.get_axes() margin = margin / 72 * ax.figure.get_dpi() def _wrap(event): """Wraps text within its parent axes.""" def _width(s): """Gets the length of a string in pixels.""" text.set_text(s) return text.get_window_extent().width # Find available space clip = ax.get_window_extent() x0, y0 = text.get_transform().transform(text.get_position()) if text.get_horizontalalignment() == 'left': width = clip.x1 - x0 - margin elif text.get_horizontalalignment() == 'right': width = x0 - clip.x0 - margin else: width = (min(clip.x1 - x0, x0 - clip.x0) - margin) * 2 # Wrap the text string words = [''] + _splitText(text.get_text())[::-1] wrapped = [] line = words.pop() while words: line = line if line else words.pop() lastLine = line while _width(line) <= width: if words: lastLine = line line += words.pop() # Add in any whitespace since it will not affect redraw width while words and (words[-1].strip() == ''): line += words.pop() else: lastLine = line break wrapped.append(lastLine) line = line[len(lastLine):] if not words and line: wrapped.append(line) text.set_text('\n'.join(wrapped)) # Draw wrapped string after disabling events to prevent recursion handles = ax.figure.canvas.callbacks.callbacks[event.name] ax.figure.canvas.callbacks.callbacks[event.name] = {} ax.figure.canvas.draw() ax.figure.canvas.callbacks.callbacks[event.name] = handles ax.figure.canvas.mpl_connect('draw_event', _wrap) def _splitText(text): """ Splits a string into its underlying chucks for wordwrapping. This mostly relies on the textwrap library but has some additional logic to avoid splitting latex/mathtext segments. """ import textwrap import re math_re = re.compile(r'(?<!\\)\$') textWrapper = textwrap.TextWrapper() if len(math_re.findall(text)) <= 1: return textWrapper._split(text) else: chunks = [] for n, segment in enumerate(math_re.split(text)): if segment and (n % 2): # Mathtext chunks.append('${}$'.format(segment)) else: chunks += textWrapper._split(segment) return chunks def textBox(text, axes, ha='left', fontsize=12, margin=None, frame=True, **kwargs): """ Converts an axes to a text box by removing its ticks and creating a wrapped annotation. """ if margin is None: margin = 6 if frame else 0 axes.set_xticks([]) axes.set_yticks([]) axes.set_frame_on(frame) an = axes.annotate(text, fontsize=fontsize, xy=({'left':0, 'right':1, 'center':0.5}[ha], 1), ha=ha, va='top', xytext=(margin, -margin), xycoords='axes fraction', textcoords='offset points', **kwargs) wrapText(an, margin=margin) return an
Usage:
ax = plot.plt.figure(figsize=(6, 6)).add_subplot(111) an = ax.annotate(t, fontsize=12, xy=(0.5, 1), ha='center', va='top', xytext=(0, -6), xycoords='axes fraction', textcoords='offset points') wrapText(an)
I dropped a few features which weren't as important to me. Resizing will fail as each call to _wrap() inserts additional newlines into the string but has no way of removing them. This can be solved by either stripping out all \n characters in the _wrap function, or storing the original string somewhere and "resetting" the text instance between wraps.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With