Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Text box with line wrapping in matplotlib?

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!

like image 235
Eric O Lebigot Avatar asked Oct 25 '10 21:10

Eric O Lebigot


People also ask

How do I wrap a text title in Matplotlib?

1 Answer. You can wrap the title using wrap() module.

How do you wrap text in Python?

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.

How do you wrap Xlabel in Python?

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.


2 Answers

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

like image 193
Joe Kington Avatar answered Sep 21 '22 10:09

Joe Kington


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:

enter image description here

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.

like image 23
user65 Avatar answered Sep 21 '22 10:09

user65