【问题标题】:Text box with line wrapping in matplotlib?matplotlib中换行的文本框?
【发布时间】:2010-10-25 21:06:23
【问题描述】:

是否可以通过 Matplotlib 在框中显示文本,自动换行?通过使用pyplot.text(),我只能打印超出窗口边界的多行文本,这很烦人。事先不知道线条的大小......任何想法将不胜感激!

【问题讨论】:

    标签: python textbox matplotlib


    【解决方案1】:

    此答案的内容已合并到 https://github.com/matplotlib/matplotlib/pull/4342 的 mpl master 中,并将在下一个功能版本中。


    哇...这是一个棘手的问题...(并且它暴露了matplotlib的文本渲染中的很多限制...)

    这应该(i.m.o.)是 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()
    

    【讨论】:

    • +1。哇!令人印象深刻的 Matplotlib 掌握。 :) 使用您提供的代码,当我更改窗口大小时,宽度会越来越小,但似乎永远不会再变大(包括在窗口恢复到原始大小时达到原始大小)......
    • @Joe:您指出的话题也很有趣:LaTeX 包装可能是一个有用的选项。
    • @EOL - 谢谢!我添加了一个新版本来修复大小调整问题(并且还可以正确处理居中对齐的文本)。当图形变大和变小时,文本现在应该重排。 LaTeX 包装是一个不错的选择(而且绝对更简单!),但我似乎无法找到一种方法让它自动适应轴的大小......也许我错过了一些明显的东西?
    • @Joe:谢谢。这令人印象深刻。也许这是我的 Matplotlib 实现中的一个错误,但是当我放大然后回到原始大小时,一些文本中的空格会消失。奇怪……
    • 嗨,我很欣赏这篇文章!今天仍然要走的路还是有新颖的内置matplotlib优点?另外:当我用plt.savefig('test.png') 替换plt.show() 时,为什么这不起作用?
    【解决方案2】:

    大约五年过去了,但似乎仍然没有一个很好的方法来做到这一点。这是我接受的解决方案的版本。我的目标是允许将像素完美的包装选择性地应用于单个文本实例。我还创建了一个简单的 textBox() 函数,它将任何轴转换为具有自定义边距和对齐方式的文本框。

    我没有假设特定的字体纵横比或平均宽度,而是一次绘制一个单词并在达到阈值时插入换行符。与近似值相比,这速度非常慢,但对于

    # 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
    

    用法:

    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 字符来解决,或者将原始字符串存储在某处并在换行之间“重置”文本实例。

    【讨论】:

      【解决方案3】:

      通过在创建文本框时设置wrap = True,如下例所示。这可能会产生预期的效果。

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

      【讨论】:

      • 这是一个很好的近似解决方案(文本在边界框之外流动但不会太多)。
      • 请注意,此解决方案 (wrap=True) 与接受的答案基本相同,因为该答案是使用 wrap 时在幕后发生的事情。
      • 文本超出边界框对我来说是一个交易破坏者。我想知道为什么他们包含了如此糟糕的实现?
      猜你喜欢
      • 2013-06-09
      • 1970-01-01
      • 2011-11-04
      • 1970-01-01
      • 1970-01-01
      • 2010-10-27
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多