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()