2010-10-25 173 views
59

是否可以通過Matplotlib,在自動換行符的框中顯示文本?通過使用pyplot.text(),我只能打印超出窗口邊界的多行文本,這很煩人。線的大小是不知道的...任何想法將不勝感激!用matplotlib換行的文本框?

回答

104

此答案的內容已合併到https://github.com/matplotlib/matplotlib/pull/4342的mpl主文件中,並將在下一個功能版本中發佈。


哇...這是一個棘手的問題......(它暴露在matplotlib的文本呈現很大的侷限性...)

這應該(IMO)是matplotlib已建成的東西在,但它沒有。郵件列表上有一些threads about it,但沒有找到自動文本換行的解決方案。

因此,首先,在matplotlib中繪製之前,無法確定呈現文本字符串的大小(以像素爲單位)。這不是太大的問題,因爲我們可以繪製它,獲取大小,然後重新繪製包裝的文本。 (價格昂貴,但不是太差)

接下來的問題是,字符沒有固定的像素寬度,因此將文本字符串包裝爲給定數量的字符不一定會反映給定的寬度渲染。但這並不是一個大問題。除此之外,我們不能僅僅這樣做一次......否則,它將在第一次繪製(例如在屏幕上)時被正確地包裝,但是如果再次繪製(當圖形被調整大小時)將不會被正確包裝或保存爲DPI不同於屏幕的圖像)。這不是一個大問題,因爲我們可以將回調函數連接到matplotlib繪製事件。

無論如何這個解決方案是不完善的,但它應該在大多數情況下工作。我不會考慮tex渲染的字符串,任何拉伸的字體或帶有不尋常縱橫比的字體。但是,它現在應該正確處理旋轉的文本。

然而,它應該會自動嘗試在多個次要情節包裝任何文本對象中任何一個數字,你連接on_draw回調...這將是在許多情況下不完美的,但它確實一份體面的工作。

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

+1。哇!令人印象深刻的Matplotlib掌握。 :)隨着你提供的代碼,當我改變窗口大小,寬度變得越來越小,但似乎再也不會變大(包括當窗口恢復到原始尺寸時達到原始大小)... – EOL 2010-10-30 10:22:34

+0

@Joe:您指向的線程也很有趣:LaTeX包裝可能是一個有用的選項。 – EOL 2010-10-30 10:24:18

+0

@EOL - 謝謝!我添加了一個修復調整大小問題的新版本(並且還正確處理了中心對齊的文本)。當數字變得越來越小時,文本現在應該重新流動。乳膠包裝是一個不錯的選擇(並且絕對簡單!),但我似乎無法找到一種方法使它自動適合軸的大小......也許我錯過了明顯的東西? – 2010-10-31 15:51:12

4

它已經大約5年,但仍有似乎並沒有來做到這一點的好方法。這是我接受的解決方案的版本。我的目標是讓像素完美的包裝可以選擇性地應用於單個文本實例。我還創建了將任何軸轉換爲文本框自定義邊距和對齊一個簡單的textBox()函數。

相反假設特定的字體的縱橫比或平均寬度的,我實際上每次繪製字符串一個字,一旦閾值被擊中插入換行。相比近似這是窘況緩慢,但仍覺得爲< 200字串的很快。

# 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 

用法:

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) 

我放棄了這是不是對我很重要的幾個特點。調整大小將失敗,因爲每次調用_wrap()都會在字符串中插入額外的換行符,但無法刪除它們。這可以通過刪除_wrap函數中的所有\ n字符,或者將原始字符串存儲在某個地方以及在兩次換行之間「重置」文本實例來解決。