Auto-wrapping text within a plot... Is there a simpler solution?

Hi folks,

First off, I apologize for the wall of text…

Spurred on by this Stack Overflow question, and by an itch I’ve been wanting to scratch lately, I put together a a callback function that (attempts, anyway) to auto-wrap text artists to the boundaries of the axis they’re in.

It is often useful to have text reflow/auto-wrap within the axis boundaries during interactive use (resizing a plot with lots of labeled points, for example…). It doesn’t really need to be precise, as long as it keeps words from being fully off the figure.

A “full” gui toolkit would be a better way of handling this, but I’ve gotten in the habit of slapping a few callback functions onto matplotlib figures to make (simple) interactive scripts that I can share across platforms. (Lab exercises, in my case.) Having a way to make text reflow within the axis boundaries during resizing of plots makes things a bit easier.

I’m aware that this isn’t really possible in a general, backend-independent fashion due to the subtleties of text rendering in matplotlib (e.g. things like latex, where the length of the raw string has nothing to do with it’s rendered size, and the general fact that the size of the text isn’t known until after it’s drawn). Even getting it approximately correct is still useful, though.

I have it working about as well as the approach I’m taking can do, I think, but I could use some help on a couple of points.

I have two specific questions, and one more general one…

First:

Is it possible to disconnect and then reconnect a callback function from
an event within the callback function, and without disconnecting all other callback functions from the event?

I’m redrawing the canvas within a “draw_event” callback function, and I’m currently avoiding recursion by disconnecting and reconnecting all callbacks to the draw event. It would be nice to disconnect only the function I’m inside, but I can’t find any way of getting its cid…

Alternatively, is there a way to redraw a figure’s canvas without triggering a draw event?

Second:
Is there any way to determine the average aspect ratio of a font in matplotlib?

I’m trying to approximate the length of a rendered text string based on it’s font size and the number of characters. Currently, I’m assuming that all fonts have an average aspect ratio of 0.5, which works decently for most non-monospaced fonts, but fails miserably with others.

Finally:
Is there a better way to do this? My current approach has tons of limitations but works in most situations. My biggest problem so far is that vertical alignment in matplotlib (quite reasonably) refers to an axis-aligned bounding box, rather than within a text aligned bounding box. This makes reflowing rotated text more difficult, and I’m only half-way dealing with rotated text in the code below. Any suggestions on any points are welcome!

Thanks!
-Joe

import matplotlib.pyplot as plt

def main():
fig = plt.figure()

plt.plot(range(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(7, 3, t, ha='center', rotation=30, va='center')
plt.text(5, 7, t, fontsize=18, ha='center')

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")
fig.canvas.mpl_connect('draw_event', on_draw)

plt.show()

def on_draw(event):

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](http://event.name)]
fig.canvas.callbacks.callbacks[[event.name](http://event.name)] = {}

# Re-draw the figure..
fig.canvas.draw()

# Reset the draw event callbacks
fig.canvas.callbacks.callbacks[[event.name](http://event.name)] = func_handles

def autowrap_text(textobj, renderer):
import textwrap

from math import sin, cos
# 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()

# Get the amount of space in the direction of rotation to the left and

# right of x0, y0
# (This doesn't try to correct for different vertical alignments, and will

# have issues with rotated text when va & ha are not 'center')
dx1, dx2 = x0 - clip.x0, clip.x1 - x0

dy1, dy2 = y0 - clip.y0, clip.y1 - y0
rotation = textobj.get_rotation()

left_space = min(abs(dx1 / cos(rotation)), abs(dy1 / sin(rotation)))
right_space = min(abs(dx2 / cos(rotation)), abs(dy2 / sin(rotation)))


# Determine the width (in pixels) of the new text

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)

if name == ‘main’:
main()

For whatever it’s worth, after a lot of wrangling, I think I solved most of my problems (though perhaps not in the most efficient way).

In case anyone else is looking for similar functionality, here’s a callback function that will autowrap text objects to the inside of the axis they’re plotted in, and should handle any font, rotation, etc that you throw at it. (The previous version had a lot of bugs).

Hope someone finds it useful, at any rate…
-Joe
E2G1j.png

import matplotlib.pyplot as plt

def main():

"""Draw some very long strings on a figure and have the auto-wrapped
to the axis boundaries. Try resizing the figure!!"""

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!"

t2 = r"Furthermore, if I put mathtext in here, it won't mutilate it,"\

    " but will treat it like a really long word. For example: "\
    r"$\frac{\sigma}{\gamma} - e^{\theta \pm 5}$ won't be mangled!"


plt.text(5, 10, t2, size=14, ha='center', va='top', family='monospace')

plt.text(3, 0, t, family='serif', style='italic', ha='right')

plt.text(4, 1, t, ha='left', family='Times New Roman', rotation=15)
plt.text(5, 3.5, t, ha='right', rotation=-15)

plt.title("This is a really long title that I want to have wrapped so"\

         r" it does not go outside the axis boundaries", ha='center')

# All we do to autowrap everything  is connect a callback function...
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](http://event.name)]

fig.canvas.callbacks.callbacks[[event.name](http://event.name)] = {}
# Re-draw the figure..

fig.canvas.draw()
# Reset the draw event callbacks

fig.canvas.callbacks.callbacks[[event.name](http://event.name)] = func_handles

def autowrap_text(textobj, renderer):
“”"Wraps the given matplotlib text object so that it doesn’t exceed the

boundaries of the axis it is plotted in."""
# 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 (nonsensical 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)
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 h-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)

# Convert to characters with a minimum width of 1 character

wrap_width = max(1, new_width // pixels_per_char(textobj))
try:

    wrapped_text = safewrap(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)

def pixels_per_char(textobj):

"""Determines the average width of a character of the given textobj
by drawing a test string and calculating it's length"""

test_text = 'Try something like a test'
orig_text = textobj.get_text()

textobj.set_text(test_text)
width = textobj.get_window_extent().width

textobj.set_text(orig_text)
return width / len(test_text)

def safewrap(text, width):
“”“Wraps text, but avoids putting linebreaks in tex strings”""

import textwrap
# If it's not a tex string, just wrap it as usual...

if '$' not in text:
    return textwrap.fill(text, width)

# Tex segments will be inside two "$"'s, so we want the odd items

segments = text.split('$')
tex = segments[1::2]

# Temporarily replace spaces and dashes inside tex segments so that

# they will be treated as long words by textwrap...
segments[1::2] = [x.replace(' ','').replace('-','') for x in tex]

# Rejoin the temp tex strings with the rest of the text and wrap it
temp_text = '$'.join(segments)

wrapped = textwrap.fill(temp_text, width, break_long_words=False)

# Put the original tex strings back in between $'s
segments = wrapped.split('$')

segments[1::2] = tex
return '$'.join(segments)

if name == ‘main’:

main()