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 1 commit
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
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)
159 changes: 140 additions & 19 deletions lib/matplotlib/text.py
Expand Up @@ -105,6 +105,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 +176,7 @@ def __init__(self,
linespacing=None,
rotation_mode=None,
usetex=None, # defaults to rcParams['text.usetex']
wrap=False,
**kwargs
):
"""
Expand All @@ -198,6 +200,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 +214,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 @@ -315,18 +318,20 @@ def _get_layout(self, renderer):

# Find full vertical extent of font,
# including ascenders and descenders:
tmp, lp_h, lp_bl = renderer.get_text_width_height_descent('lp',
self._fontproperties,
ismath=False)
tmp, lp_h, lp_bl = renderer.get_text_width_height_descent(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you discard this change?

'lp',
self._fontproperties,
ismath=False)
offsety = (lp_h - lp_bl) * self._linespacing

baseline = 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same with this one?

for i, line in enumerate(lines):
clean_line, ismath = self.is_math_text(line)
if clean_line:
w, h, d = renderer.get_text_width_height_descent(clean_line,
self._fontproperties,
ismath=ismath)
w, h, d = renderer.get_text_width_height_descent(
clean_line,
self._fontproperties,
ismath=ismath)
else:
w, h, d = 0, 0, 0

Expand Down Expand Up @@ -468,12 +473,12 @@ def set_bbox(self, rectprops):
bbox_transmuter = props.pop("bbox_transmuter", None)

self._bbox_patch = FancyBboxPatch(
(0., 0.),
1., 1.,
boxstyle=boxstyle,
bbox_transmuter=bbox_transmuter,
transform=mtransforms.IdentityTransform(),
**props)
(0., 0.),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also this one.

1., 1.,
boxstyle=boxstyle,
bbox_transmuter=bbox_transmuter,
transform=mtransforms.IdentityTransform(),
**props)
self._bbox = None
else:
self._bbox_patch = None
Expand Down Expand Up @@ -514,7 +519,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 +592,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,6 +715,10 @@ def draw(self, renderer):

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

if self.get_wrap():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This maybe should be done with a context manager to make sure it is always restored correctly?

old_text = self.get_text()
self.set_text(self._get_wrapped_text())

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

Expand Down Expand Up @@ -652,6 +770,9 @@ def draw(self, renderer):
self._fontproperties, angle,
ismath=ismath, mtext=mtext)

if self.get_wrap():
self.set_text(old_text)

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

Expand Down Expand Up @@ -791,7 +912,7 @@ def get_window_extent(self, renderer=None, dpi=None):
the value used when saving the figure, then the value that
was used must be specified as the *dpi* argument.
'''
#return _unit_box
# return _unit_box
if not self.get_visible():
return Bbox.unit()
if dpi is not None:
Expand Down Expand Up @@ -1202,7 +1323,7 @@ def __init__(self,
self._dashpad = dashpad
self._dashpush = dashpush

#self.set_bbox(dict(pad=0))
# self.set_bbox(dict(pad=0))

def get_position(self):
"Return the position of the text as a tuple (*x*, *y*)"
Expand Down Expand Up @@ -1642,7 +1763,7 @@ def _get_ref_xy(self, renderer):
if isinstance(self.xycoords, tuple):
s1, s2 = self.xycoords
if ((is_string_like(s1) and s1.split()[0] == "offset") or
(is_string_like(s2) and s2.split()[0] == "offset")):
(is_string_like(s2) and s2.split()[0] == "offset")):
raise ValueError("xycoords should not be an offset coordinate")
x, y = self.xy
x1, y1 = self._get_xy(renderer, x, y, s1)
Expand All @@ -1654,7 +1775,7 @@ def _get_ref_xy(self, renderer):
else:
x, y = self.xy
return self._get_xy(renderer, x, y, self.xycoords)
#raise RuntimeError("must be defined by the derived class")
# raise RuntimeError("must be defined by the derived class")

# def _get_bbox(self, renderer):
# if hasattr(bbox, "bounds"):
Expand Down Expand Up @@ -1958,7 +2079,7 @@ def _update_position_xytext(self, renderer, xy_pixel):
# Use FancyArrowPatch if self.arrowprops has "arrowstyle" key.
# Otherwise, fallback to YAArrow.

#if d.has_key("arrowstyle"):
# if d.has_key("arrowstyle"):
if self.arrow_patch:

# adjust the starting point of the arrow relative to
Expand Down