Wow... C'est un problème épineux... (Et il expose beaucoup de limitations dans le rendu de texte de matplotlib...)
Cela devrait (selon moi) être quelque chose que matplotlib a intégré, mais ce n'est pas le cas. Il y a eu quelques fils de discussion à ce sujet sur la liste de diffusion, mais aucune solution que j'ai pu trouver pour l'habillage automatique du texte.
Donc, tout d'abord, il n'y a aucun moyen de déterminer la taille (en pixels) de la chaîne de texte rendue avant qu'elle ne soit dessinée dans matplotlib. Ce n'est pas un gros problème, car nous pouvons simplement la dessiner, obtenir la taille, et ensuite redessiner le texte enveloppé. (C'est coûteux, mais pas excessivement mauvais).
Le problème suivant est que les caractères n'ont pas une largeur fixe en pixels, de sorte que l'enveloppement d'une chaîne de texte à un nombre donné de caractères ne reflétera pas nécessairement une largeur donnée lors du rendu. Ce n'est toutefois pas un problème majeur.
Au-delà de ça, on ne peut pas faire ça qu'une seule fois... Sinon, la figure sera correctement enveloppée lorsqu'elle sera dessinée la première fois (à l'écran, par exemple), mais pas si elle est dessinée à nouveau (lorsque la figure est redimensionnée ou enregistrée en tant qu'image avec un DPI différent de celui de l'écran). Ce n'est pas un gros problème, car nous pouvons simplement connecter une fonction de rappel à l'événement draw de matplotlib.
En tout état de cause, cette solution est imparfaite, mais elle devrait fonctionner dans la plupart des situations. Je n'essaie pas de prendre en compte les chaînes en rendu tex, les polices étirées ou les polices ayant un rapport d'aspect inhabituel. Cependant, elle devrait maintenant gérer correctement les textes tournés.
Cependant, il devrait tenter d'envelopper automatiquement tout objet texte dans plusieurs sous-plots, quelle que soit la figure à laquelle vous reliez l'élément on_draw
vers... Il sera imparfait dans de nombreux cas, mais il fait un travail décent.
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()