È possibile visualizzare il testo in una casella tramite Matplotlib, con interruzioni di riga automatiche? Usando pyplot.text()
, ero solo in grado di stampare testo multilinea che scorre oltre i confini della finestra, il che è fastidioso. La dimensione delle linee non è nota in anticipo ... Qualsiasi idea sarebbe molto apprezzata!Casella di testo con ritorno a capo in matplotlib?
risposta
Il contenuto di questa risposta è stato unito in mpl master in https://github.com/matplotlib/matplotlib/pull/4342 e sarà nella prossima versione di funzionalità.
Wow ... Questo è un problema spinoso ... (Ed espone un sacco di limitazioni nel rendering del testo di matplotlib ...)
Questo dovrebbe (IMO) essere qualcosa che ha costruito matplotlib -in, ma non è così. Ci sono stati alcuni threads about it sulla mailing list, ma nessuna soluzione che ho trovato per il text wrapping automatico.
Quindi, prima di tutto, non c'è modo di determinare la dimensione (in pixel) della stringa di testo renderizzata prima che sia disegnata in matplotlib. Questo non è un problema troppo grande, in quanto possiamo solo disegnarlo, ottenere le dimensioni e quindi ridisegnare il testo avvolto. (È costoso, ma non eccessivamente cattivo)
Il prossimo problema è che i caratteri non hanno una larghezza fissa in pixel, quindi avvolgere una stringa di testo in un determinato numero di caratteri non necessariamente rifletterà una larghezza data quando reso. Questo non è un problema enorme, però.
Oltre a questo, non possiamo farlo solo una volta ... Altrimenti, verrà disegnato correttamente quando viene disegnato la prima volta (sullo schermo, per esempio), ma non se viene disegnato di nuovo (quando la figura viene ridimensionata o salvato come un'immagine con un DPI diverso dallo schermo). Questo non è un problema enorme, poiché possiamo semplicemente collegare una funzione di callback all'evento di disegno matplotlib.
In ogni caso questa soluzione è imperfetta, ma dovrebbe funzionare nella maggior parte delle situazioni. Non cerco di tenere conto delle stringhe tex-render, di font allungati o di font con proporzioni insolite. Tuttavia, ora dovrebbe gestire correttamente il testo ruotato.
Tuttavia, dovrebbe tentare di avvolgere automaticamente qualsiasi oggetto di testo in più sottotrame in qualsiasi cifra che si connette al callback on_draw
in ... In molti casi sarà imperfetto, ma fa un lavoro decente.
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()
suo stato circa cinque anni, ma c'è ancora non sembra essere un ottimo modo per fare questo. Ecco la mia versione della soluzione accettata. Il mio obiettivo era quello di permettere che il wrapping perfetto per i pixel fosse applicato in modo selettivo alle singole istanze di testo. Ho anche creato una semplice funzione textBox() che convertirà tutti gli assi in una casella di testo con margini e allineamento personalizzati.
Invece di assumere una particolare proporzione del carattere o larghezza media, in realtà disegno la stringa una parola alla volta e inserisco una nuova riga una volta che la soglia viene raggiunta. Questo è tremendamente lento rispetto alle approssimazioni, ma si sente ancora abbastanza scattante per le stringhe di < 200 parole.
# 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
Usage:
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)
ho lasciato cadere alcune caratteristiche che non erano così importanti per me. Il ridimensionamento fallirà poiché ogni chiamata a _wrap() inserisce nuove righe nella stringa ma non ha modo di rimuoverle. Questo può essere risolto eliminando tutti i caratteri \ n nella funzione _wrap, oppure memorizzando la stringa originale da qualche parte e "resettando" l'istanza di testo tra gli involucri.
+1. Wow! Impressionare la padronanza di Matplotlib. :) Con il codice che fornisci, quando cambio la dimensione della finestra, le larghezze diventano sempre più piccole, ma sembrano non diventare mai più grandi (incluso il raggiungimento della loro dimensione originale quando la finestra viene rimessa alle sue dimensioni originali) ... – EOL
@Joe: Anche il thread che hai indicato è interessante: il wrapping LaTeX potrebbe essere un'opzione utile. – EOL
@EOL - Grazie! Ho aggiunto una nuova versione che corregge i problemi di ridimensionamento (e gestisce anche il testo allineato al centro correttamente). Il testo dovrebbe ora riflettere sia quando la figura è ingrandita che più piccola. Il wrapping LaTeX è una buona opzione (e sicuramente più semplice!), Ma non riesco a trovare un modo per adattarlo automaticamente alla dimensione degli assi ... Forse mi manca qualcosa di ovvio? –