Textfeld mit Zeilenumbruch in Matplotlib?

72

Ist es möglich, Text in einem Feld über Matplotlib mit automatischen Zeilenumbrüchen anzuzeigen ? Durch die Verwendung pyplot.text()konnte ich nur mehrzeiligen Text drucken, der über die Fenstergrenzen hinaus fließt, was ärgerlich ist. Die Größe der Linien ist nicht im Voraus bekannt ... Jede Idee wäre sehr dankbar!

Eric O Lebigot
quelle

Antworten:

119

Der Inhalt dieser Antwort wurde unter https://github.com/matplotlib/matplotlib/pull/4342 in mpl master zusammengeführt und wird in der nächsten Feature-Version enthalten sein.


Wow ... Dies ist ein heikles Problem ... (Und es weist viele Einschränkungen bei der Textwiedergabe von matplotlib auf ...)

Dies sollte (imo) etwas sein, das in matplotlib integriert ist, aber es ist nicht so. Es gibt ein paar gewesen Fäden darüber auf der Mailing - Liste, aber keine Lösung , dass ich in dem automatischen Textumbruch finden kann.

Zunächst einmal gibt es keine Möglichkeit, die Größe (in Pixel) der gerenderten Textzeichenfolge zu bestimmen, bevor sie in matplotlib gezeichnet wird. Dies ist kein allzu großes Problem, da wir es einfach zeichnen, die Größe ermitteln und dann den umbrochenen Text neu zeichnen können. (Es ist teuer, aber nicht zu schlecht)

Das nächste Problem besteht darin, dass Zeichen keine feste Breite in Pixel haben, sodass das Umbrechen einer Textzeichenfolge mit einer bestimmten Anzahl von Zeichen beim Rendern nicht unbedingt eine bestimmte Breite widerspiegelt. Dies ist jedoch kein großes Problem.

Darüber hinaus können wir dies nicht nur einmal tun ... Andernfalls wird es beim ersten Zeichnen (z. B. auf dem Bildschirm) korrekt verpackt, jedoch nicht beim erneuten Zeichnen (wenn die Größe der Figur geändert oder als gespeichert wird) Bild mit einer anderen DPI als der Bildschirm). Dies ist kein großes Problem, da wir einfach eine Rückruffunktion mit dem matplotlib-Zeichenereignis verbinden können.

In jedem Fall ist diese Lösung nicht perfekt, sollte aber in den meisten Situationen funktionieren. Ich versuche nicht, tex-gerenderte Zeichenfolgen, gestreckte Schriftarten oder Schriftarten mit einem ungewöhnlichen Seitenverhältnis zu berücksichtigen. Es sollte jetzt jedoch richtig mit gedrehtem Text umgehen.

Es sollte jedoch versucht werden, Textobjekte automatisch in mehrere Unterzeichnungen einzubinden, unabhängig davon, mit welchen Zahlen Sie den on_drawRückruf verbinden ... In vielen Fällen ist dies nicht perfekt, aber es leistet gute Arbeit.

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

Abbildung mit umbrochenem Text

Joe Kington
quelle
+1. Beeindruckend! Beeindruckendes Mastering von Matplotlib. :) Mit dem von Ihnen angegebenen Code werden die Breiten immer kleiner, wenn ich die Fenstergröße ändere, scheinen aber nie wieder größer zu werden (einschließlich des Erreichens ihrer ursprünglichen Größe, wenn das Fenster wieder auf seine ursprüngliche Größe zurückgesetzt wird)…
Eric O Lebigot
@ Joe: Der Thread, auf den Sie verweisen, ist ebenfalls interessant: LaTeX-Wrapping könnte eine nützliche Option sein.
Eric O Lebigot
@EOL - Danke! Ich habe eine neue Version hinzugefügt, die die Größenänderungsprobleme behebt (und auch mittig ausgerichteten Text richtig behandelt). Der Text sollte jetzt sowohl fließen, wenn die Figur größer als auch kleiner gemacht wird. LaTeX-Wrapping ist eine gute Option (und definitiv einfacher!), Aber ich kann anscheinend keinen Weg finden, es automatisch an die Größe der Achsen anzupassen ... Vielleicht fehlt mir etwas Offensichtliches?
Joe Kington
@ Joe: Danke. Das ist beeindruckend. Vielleicht ist dies ein Fehler in meiner Matplotlib-Implementierung, aber wenn ich zoome und dann zur ursprünglichen Größe zurückkehre, verschwinden in einigen Texten Leerzeichen. Seltsam…
Eric O Lebigot
11
Hallo, ich schätze diesen Beitrag! Ist heute noch der richtige Weg oder gibt es eine neuartige eingebaute Matplotlib-Güte? Außerdem: Warum funktioniert das nicht , wenn ich ersetzen plt.show()mit plt.savefig('test.png')?
Claus
6

Es ist ungefähr fünf Jahre her, aber es scheint immer noch keinen guten Weg zu geben, dies zu tun. Hier ist meine Version der akzeptierten Lösung. Mein Ziel war es, die pixelgenaue Umhüllung selektiv auf einzelne Textinstanzen anzuwenden. Ich habe auch eine einfache textBox () -Funktion erstellt, die alle Achsen in ein Textfeld mit benutzerdefinierten Rändern und Ausrichtungen konvertiert.

Anstatt ein bestimmtes Seitenverhältnis oder eine bestimmte durchschnittliche Breite anzunehmen, zeichne ich die Zeichenfolge tatsächlich wortweise und füge Zeilenumbrüche ein, sobald der Schwellenwert erreicht ist. Dies ist im Vergleich zu den Annäherungen schrecklich langsam, fühlt sich aber für Zeichenfolgen mit <200 Wörtern immer noch ziemlich bissig an.

# 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

Verwendung:

Geben Sie hier die Bildbeschreibung ein

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)

Ich habe einige Funktionen entfernt, die für mich nicht so wichtig waren. Die Größenänderung schlägt fehl, da bei jedem Aufruf von _wrap () zusätzliche Zeilenumbrüche in die Zeichenfolge eingefügt werden, diese jedoch nicht entfernt werden können. Dies kann gelöst werden, indem entweder alle \ n Zeichen in der _wrap-Funktion entfernt werden oder die ursprüngliche Zeichenfolge irgendwo gespeichert und die Textinstanz zwischen den Umbrüchen "zurückgesetzt" wird.

user65
quelle
5

Durch Einstellen wrap = Truebeim Erstellen des Textfelds wie im folgenden Beispiel. Dies kann den gewünschten Effekt haben.

plt.text(5, 5, t, ha='right', rotation=-15, wrap=True)
Daniel.Bourne
quelle
Das ist eine gute ungefähre Lösung (der Text fließt außerhalb des Begrenzungsrahmens, aber nicht zu viel).
Eric O Lebigot
2
Beachten Sie nur, dass diese Lösung ( wrap=True) im Wesentlichen dieselbe ist wie die akzeptierte Antwort, da diese Antwort bei der Verwendung hinter den Kulissen geschieht wrap.
ImportanceOfBeingErnest
2
Text, der außerhalb des Begrenzungsrahmens fließt, ist für mich ein Deal Breaker. Ich frage mich, warum sie eine so kaputte Implementierung enthalten haben.
Joe Coder