Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementation of Issue #3418 - Auto-wrapping text #4342

Merged
merged 6 commits into from May 22, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/users/whats_new/autowrap_text.rst
@@ -0,0 +1,7 @@
Auto-wrapping Text
------------------
Added the keyword argument "wrap" to Text, which automatically breaks long lines of text when being drawn.
Works for any rotated text, different modes of alignment, and for text that are either labels or titles.

Example:
plt.text(1, 1, "This is a really long string that should be wrapped so that it does not go outside the figure.", wrap=True)
19 changes: 19 additions & 0 deletions examples/text_labels_and_annotations/autowrap_demo.py
@@ -0,0 +1,19 @@
"""
Auto-wrapping text demo.
"""
import matplotlib.pyplot as plt

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, wrap=True)
plt.text(6, 5, t, ha='left', rotation=15, wrap=True)
plt.text(5, 5, t, ha='right', rotation=-15, wrap=True)
plt.text(5, 10, t, fontsize=18, style='oblique', ha='center',
va='top', wrap=True)
plt.text(3, 4, t, family='serif', style='italic', ha='right', wrap=True)
plt.text(-1, 0, t, ha='left', rotation=-15, wrap=True)

plt.show()
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 37 additions & 1 deletion lib/matplotlib/tests/test_text.py
Expand Up @@ -367,7 +367,8 @@ def test_text_with_arrow_annotation_get_window_extent():
headwidth = 21
fig, ax = plt.subplots(dpi=100)
txt = ax.text(s='test', x=0, y=0)
ann = ax.annotate('test',
ann = ax.annotate(
'test',
xy=(0.0, 50.0),
xytext=(50.0, 50.0), xycoords='figure pixels',
arrowprops={
Expand Down Expand Up @@ -441,3 +442,38 @@ def test_empty_annotation_get_window_extent():
eq_(points[1, 0], 0.0)
eq_(points[1, 1], 50.0)
eq_(points[0, 1], 50.0)


@image_comparison(baseline_images=['basictext_wrap'],
extensions=['png'])
def test_basic_wrap():
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, wrap=True)
plt.text(6, 5, t, ha='left', rotation=15, wrap=True)
plt.text(5, 5, t, ha='right', rotation=-15, wrap=True)
plt.text(5, 10, t, fontsize=18, style='oblique', ha='center',
va='top', wrap=True)
plt.text(3, 4, t, family='serif', style='italic', ha='right', wrap=True)
plt.text(-1, 0, t, ha='left', rotation=-15, wrap=True)


@image_comparison(baseline_images=['fonttext_wrap'],
extensions=['png'])
def test_font_wrap():
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, fontsize=18, family='serif', ha='left', rotation=15,
wrap=True)
plt.text(6, 5, t, family='sans serif', ha='left', rotation=15, wrap=True)
plt.text(5, 5, t, weight='light', ha='right', rotation=-15, wrap=True)
plt.text(5, 10, t, weight='heavy', ha='center', va='top', wrap=True)
plt.text(3, 4, t, family='monospace', ha='right', wrap=True)
plt.text(-1, 0, t, fontsize=14, style='italic', ha='left', rotation=-15,
wrap=True)
230 changes: 181 additions & 49 deletions lib/matplotlib/text.py
Expand Up @@ -10,6 +10,8 @@
import math
import warnings

import contextlib

import numpy as np

from matplotlib import cbook
Expand Down Expand Up @@ -42,6 +44,22 @@ def _process_text_args(override, fontdict=None, **kwargs):
return override


@contextlib.contextmanager
def _wrap_text(textobj):
"""
Temporarily inserts newlines to the text if the wrap option is enabled.
"""
if textobj.get_wrap():
old_text = textobj.get_text()
try:
textobj.set_text(textobj._get_wrapped_text())
yield textobj
finally:
textobj.set_text(old_text)
else:
yield textobj


# Extracted from Text's method to serve as a function
def get_rotation(rotation):
"""
Expand Down Expand Up @@ -105,6 +123,7 @@ def get_rotation(rotation):
visible [True | False]
weight or fontweight ['normal' | 'bold' | 'heavy' | 'light' |
'ultrabold' | 'ultralight']
wrap [True | False]
x float
y float
zorder any number
Expand Down Expand Up @@ -175,6 +194,7 @@ def __init__(self,
linespacing=None,
rotation_mode=None,
usetex=None, # defaults to rcParams['text.usetex']
wrap=False,
**kwargs
):
"""
Expand All @@ -198,6 +218,7 @@ def __init__(self,
self.set_text(text)
self.set_color(color)
self.set_usetex(usetex)
self.set_wrap(wrap)
self._verticalalignment = verticalalignment
self._horizontalalignment = horizontalalignment
self._multialignment = multialignment
Expand All @@ -211,7 +232,7 @@ def __init__(self,
self._linespacing = linespacing
self.set_rotation_mode(rotation_mode)
self.update(kwargs)
#self.set_bbox(dict(pad=0))
# self.set_bbox(dict(pad=0))

def __getstate__(self):
d = super(Text, self).__getstate__()
Expand Down Expand Up @@ -514,7 +535,7 @@ def update_bbox_position_size(self, renderer):
self._bbox_patch.set_transform(tr)
fontsize_in_pixel = renderer.points_to_pixels(self.get_size())
self._bbox_patch.set_mutation_scale(fontsize_in_pixel)
#self._bbox_patch.draw(renderer)
# self._bbox_patch.draw(renderer)

def _draw_bbox(self, renderer, posx, posy):

Expand Down Expand Up @@ -587,6 +608,115 @@ def set_clip_on(self, b):
super(Text, self).set_clip_on(b)
self._update_clip_properties()

def get_wrap(self):
"""
Returns the wrapping state for the text.
"""
return self._wrap

def set_wrap(self, wrap):
"""
Sets the wrapping state for the text.
"""
self._wrap = wrap

def _get_wrap_line_width(self):
"""
Returns the maximum line width for wrapping text based on the
current orientation.
"""
x0, y0 = self.get_transform().transform(self.get_position())
figure_box = self.get_figure().get_window_extent()

# Calculate available width based on text alignment
alignment = self.get_horizontalalignment()
self.set_rotation_mode('anchor')
rotation = self.get_rotation()

left = self._get_dist_to_box(rotation, x0, y0, figure_box)
right = self._get_dist_to_box(
(180 + rotation) % 360,
x0,
y0,
figure_box)

if alignment == 'left':
line_width = left
elif alignment == 'right':
line_width = right
else:
line_width = 2 * min(left, right)

return line_width

def _get_dist_to_box(self, rotation, x0, y0, figure_box):
"""
Returns the distance from the given points, to the boundaries
of a rotated box in pixels.
"""
if rotation > 270:
quad = rotation - 270
h1 = y0 / math.cos(math.radians(quad))
h2 = (figure_box.x1 - x0) / math.cos(math.radians(90 - quad))
elif rotation > 180:
quad = rotation - 180
h1 = x0 / math.cos(math.radians(quad))
h2 = y0 / math.cos(math.radians(90 - quad))
elif rotation > 90:
quad = rotation - 90
h1 = (figure_box.y1 - y0) / math.cos(math.radians(quad))
h2 = x0 / math.cos(math.radians(90 - quad))
else:
h1 = (figure_box.x1 - x0) / math.cos(math.radians(rotation))
h2 = (figure_box.y1 - y0) / math.cos(math.radians(90 - rotation))

return min(h1, h2)

def _get_rendered_text_width(self, text):
"""
Returns the width of a given text string, in pixels.
"""
w, h, d = self._renderer.get_text_width_height_descent(
text,
self.get_fontproperties(),
False)
return math.ceil(w)

def _get_wrapped_text(self):
"""
Return a copy of the text with new lines added, so that
the text is wrapped relative to the parent figure.
"""
# Not fit to handle breaking up latex syntax correctly, so
# ignore latex for now.
if self.get_usetex():
return self.get_text()

# Build the line incrementally, for a more accurate measure of length
line_width = self._get_wrap_line_width()
wrapped_str = ""
line = ""

for word in self.get_text().split(' '):
# New lines in the user's test need to force a split, so that it's
# not using the longest current line width in the line being built
sub_words = word.split('\n')
for i in range(len(sub_words)):
current_width = self._get_rendered_text_width(
line + ' ' + sub_words[i])

# Split long lines, and each newline found in the current word
if current_width > line_width or i > 0:
wrapped_str += line + '\n'
line = ""

if line == "":
line = sub_words[i]
else:
line += ' ' + sub_words[i]

return wrapped_str + line

@allow_rasterization
def draw(self, renderer):
"""
Expand All @@ -601,56 +731,58 @@ def draw(self, renderer):

renderer.open_group('text', self.get_gid())

bbox, info, descent = self._get_layout(renderer)
trans = self.get_transform()

# don't use self.get_position here, which refers to text position
# in Text, and dash position in TextWithDash:
posx = float(self.convert_xunits(self._x))
posy = float(self.convert_yunits(self._y))
with _wrap_text(self) as textobj:
bbox, info, descent = textobj._get_layout(renderer)
trans = textobj.get_transform()

posx, posy = trans.transform_point((posx, posy))
canvasw, canvash = renderer.get_canvas_width_height()
# don't use textobj.get_position here, which refers to text
# position in Text, and dash position in TextWithDash:
posx = float(textobj.convert_xunits(textobj._x))
posy = float(textobj.convert_yunits(textobj._y))

# draw the FancyBboxPatch
if self._bbox_patch:
self._draw_bbox(renderer, posx, posy)

gc = renderer.new_gc()
gc.set_foreground(self.get_color())
gc.set_alpha(self.get_alpha())
gc.set_url(self._url)
self._set_gc_clip(gc)

if self._bbox:
bbox_artist(self, renderer, self._bbox)
angle = self.get_rotation()

for line, wh, x, y in info:
if not np.isfinite(x) or not np.isfinite(y):
continue

mtext = self if len(info) == 1 else None
x = x + posx
y = y + posy
if renderer.flipy():
y = canvash - y
clean_line, ismath = self.is_math_text(line)

if self.get_path_effects():
from matplotlib.patheffects import PathEffectRenderer
textrenderer = PathEffectRenderer(self.get_path_effects(),
renderer)
else:
textrenderer = renderer
posx, posy = trans.transform_point((posx, posy))
canvasw, canvash = renderer.get_canvas_width_height()

# draw the FancyBboxPatch
if textobj._bbox_patch:
textobj._draw_bbox(renderer, posx, posy)

gc = renderer.new_gc()
gc.set_foreground(textobj.get_color())
gc.set_alpha(textobj.get_alpha())
gc.set_url(textobj._url)
textobj._set_gc_clip(gc)

if textobj._bbox:
bbox_artist(textobj, renderer, textobj._bbox)
angle = textobj.get_rotation()

for line, wh, x, y in info:
if not np.isfinite(x) or not np.isfinite(y):
continue

mtext = textobj if len(info) == 1 else None
x = x + posx
y = y + posy
if renderer.flipy():
y = canvash - y
clean_line, ismath = textobj.is_math_text(line)

if textobj.get_path_effects():
from matplotlib.patheffects import PathEffectRenderer
textrenderer = PathEffectRenderer(
textobj.get_path_effects(), renderer)
else:
textrenderer = renderer

if self.get_usetex():
textrenderer.draw_tex(gc, x, y, clean_line,
self._fontproperties, angle, mtext=mtext)
else:
textrenderer.draw_text(gc, x, y, clean_line,
self._fontproperties, angle,
ismath=ismath, mtext=mtext)
if textobj.get_usetex():
textrenderer.draw_tex(gc, x, y, clean_line,
textobj._fontproperties, angle,
mtext=mtext)
else:
textrenderer.draw_text(gc, x, y, clean_line,
textobj._fontproperties, angle,
ismath=ismath, mtext=mtext)

gc.restore()
renderer.close_group('text')
Expand Down