74 votes

Boîte de texte avec retour à la ligne dans matplotlib ?

Est-il possible d'afficher du texte dans une boîte à travers Matplotlib, avec sauts de ligne automatiques ? En utilisant pyplot.text() Je n'ai pu imprimer que du texte de plusieurs lignes qui dépasse les limites de la fenêtre, ce qui est ennuyeux. La taille des lignes n'est pas connue à l'avance Toute idée serait très appréciée !

122voto

Joe Kington Points 68089

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

Figure with wrapped text

0 votes

+1. Wow ! Impressionnante maîtrise de Matplotlib :) Avec le code que vous fournissez, lorsque je modifie la taille de la fenêtre, les largeurs deviennent de plus en plus petites, mais semblent ne jamais redevenir plus grandes (y compris en retrouvant leur taille d'origine lorsque la fenêtre est remise à sa taille initiale)

0 votes

@Joe : Le fil de discussion que vous indiquez est également intéressant : L'habillage LaTeX pourrait être une option utile.

0 votes

@EOL - Merci ! J'ai ajouté une nouvelle version qui corrige les problèmes de redimensionnement (et gère aussi correctement le texte aligné au centre). Le texte devrait maintenant refluer à la fois lorsque la figure est agrandie et réduite. L'habillage LaTeX est une bonne option (et certainement plus simple !), mais je n'arrive pas à trouver un moyen de l'adapter automatiquement à la taille des axes... Peut-être ai-je raté quelque chose d'évident ?

7voto

user65 Points 343

Cela fait environ cinq ans, mais il ne semble toujours pas y avoir de moyen efficace de le faire. Voici ma version de la solution acceptée. Mon objectif était de permettre un habillage parfait au pixel près des instances de texte individuelles. J'ai également créé une simple fonction textBox() qui convertira n'importe quel axe en une zone de texte avec des marges et un alignement personnalisés.

Au lieu de supposer un rapport d'aspect de police particulier ou une largeur moyenne, je dessine en fait la chaîne un mot à la fois et j'insère des nouvelles lignes une fois le seuil atteint. C'est horriblement lent par rapport aux approximations, mais c'est quand même assez rapide pour les chaînes de <200 mots.

# 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

Utilisation :

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)

J'ai abandonné quelques fonctionnalités qui n'étaient pas aussi importantes pour moi. Le redimensionnement échouera car chaque appel à _wrap() insère des nouvelles lignes supplémentaires dans la chaîne de caractères mais n'a aucun moyen de les supprimer. Ce problème peut être résolu soit en supprimant tous les appels à \n dans la fonction _wrap, ou bien en stockant la chaîne originale quelque part et en "réinitialisant" l'instance de texte entre deux enveloppements.

5voto

Daniel.Bourne Points 83

En fixant wrap = True lors de la création de la zone de texte, comme dans l'exemple ci-dessous. Cela peut avoir l'effet désiré.

plt.text(5, 5, t, ha='right', rotation=-15, wrap=True)

0 votes

0 votes

C'est une bonne solution approximative (le texte sort de la boîte de délimitation, mais pas de trop).

3 votes

Notez simplement que cette solution ( wrap=True ) est essentiellement la même que la réponse acceptée, car cette réponse est ce qui se passe en coulisses lors de l'utilisation de l'option wrap .

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X