diff --git a/lib/matplotlib/cbook/__init__.py b/lib/matplotlib/cbook/__init__.py index 61e66f317fe8..2d6015e42c2b 100644 --- a/lib/matplotlib/cbook/__init__.py +++ b/lib/matplotlib/cbook/__init__.py @@ -2272,6 +2272,27 @@ def pts_to_midstep(x, *args): 'steps-mid': pts_to_midstep} +MEASUREMENT = re.compile( + r'''( # group match like scanf() token %e, %E, %f, %g + [-+]? # +/- or nothing for positive + (\d+(\.\d*)?|\.\d+) # match numbers: 1, 1., 1.1, .1 + ([eE][-+]?\d+)? # scientific notation: e(+/-)2 (*10^2) + ) + (\s*) # separator: white space or nothing + ( # unit of measure: like GB. also works for no units + \S*)''', re.VERBOSE) + + +def parse_measurement(value_sep_units): + measurement = re.match(MEASUREMENT, value_sep_units) + if measurement is None: + raise ValueError("Not a valid measurement value:" + " {!r}".format(value_sep_units)) + value = float(measurement.groups()[0]) + units = measurement.groups()[5] + return value, units + + def index_of(y): """ A helper function to get the index of an input to plot diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index 4e15176e060a..65598520fc02 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -494,7 +494,7 @@ def set_linewidth(self, lw): or a sequence; if it is a sequence the patches will cycle through the sequence - ACCEPTS: float or sequence of floats + ACCEPTS: float, string, or sequence of floats/strings """ if lw is None: lw = mpl.rcParams['patch.linewidth'] @@ -503,6 +503,10 @@ def set_linewidth(self, lw): # get the un-scaled/broadcast lw self._us_lw = np.atleast_1d(np.asarray(lw)) + # convert line widths to points + self._us_lw = np.array([mlines.linewidth2points(x) + for x in self._us_lw]) + # scale all of the dash patterns. self._linewidths, self._linestyles = self._bcast_lwls( self._us_lw, self._us_linestyles) @@ -839,6 +843,7 @@ def update_from(self, other): # self.update_dict = other.update_dict # do we need to copy this? -JJL self.stale = True + # these are not available for the object inspector until after the # class is built so we define an initial set here for the init # function and they will be overridden after object defn diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 96c1a888e04f..5f9cdf38b899 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -17,7 +17,7 @@ from .artist import Artist, allow_rasterization from .cbook import ( _to_unmasked_float_array, iterable, is_numlike, ls_mapper, ls_mapper_r, - STEP_LOOKUP_MAP) + STEP_LOOKUP_MAP, parse_measurement) from .markers import MarkerStyle from .path import Path from .transforms import Bbox, TransformedPath, IdentityTransform @@ -73,6 +73,75 @@ def _scale_dashes(offset, dashes, lw): return scaled_offset, scaled_dashes +def _convert2points(val, reference, lut): + """ + Convert relative size to points, using `reference` as the base value and + `lut` as the look-up table for qualitative size names. If `val` is None or + 'auto', then the `default` is returned. + """ + + try: + pts = reference*lut[val] + except KeyError: + try: + pts = float(val) + except (ValueError, TypeError): + pts, u = parse_measurement(val) + if u not in ['x', '%']: + raise ValueError('Unrecognized relative size value ' + '{!r}'.format(val)) + if u == '%': + pts /= 100. + + pts *= reference + + return pts + + +def _build_qualitative_scaling(labels, comparative=None, base=1.2): + + a, b = labels + if comparative is None: + ca, cb = a+"er", b+"er" + else: + ca, cb = comparative + + d = {'medium': 1., ca: base**-1, cb: base} + for k, m in enumerate(('', 'x-', 'xx-')): + d['{}{}'.format(m, a)] = base**(-k-1) + d['{}{}'.format(m, b)] = base**(k+1) + + return d + + +linewidth_scaling = _build_qualitative_scaling( + ('thin', 'thick'), ('thinner', 'thicker'), 1.5) +markersize_scaling = _build_qualitative_scaling( + ('small', 'large'), ('smaller', 'larger'), 1.5) + + +def linewidth2points(w): + """ + Convert a line width specification to points. + Line width can be either specified as float (absolute width in points), + a string representing a fraction of the default width in rcParams + or a string representing a relative qualitative width (e.g. 'x-thin') + """ + reference = rcParams['lines.linewidth'] + return _convert2points(w, reference, linewidth_scaling) + + +def markersize2points(s, default=None): + """ + Convert a marker size specification to points. + Marker size can be either specified as float (absolute size in points), + a string representing a fraction of the default size in rcParams + or a string representing a relative qualitative size (e.g. 'x-large') + """ + reference = rcParams['lines.markersize'] + return _convert2points(s, reference, markersize_scaling) + + def segment_hits(cx, cy, x, y, radius): """ Determine if any line segments are within radius of a @@ -1008,11 +1077,17 @@ def set_drawstyle(self, drawstyle): def set_linewidth(self, w): """ - Set the line width in points + Set the line width, either absolute width in points or + width relative to rc default. - ACCEPTS: float value in points + ACCEPTS: [float value in points | fraction as string | None | + 'xx-thin' | 'x-thin' | 'thin' | 'thinner' | 'medium' | + 'thicker' | 'thick' | 'x-thick' | 'xx-thick'] """ - w = float(w) + if w is None: + w = rcParams['lines.linewidth'] + + w = linewidth2points(w) if self._linewidth != w: self.stale = True @@ -1156,12 +1231,18 @@ def set_markeredgecolor(self, ec): def set_markeredgewidth(self, ew): """ - Set the marker edge width in points + Set the marker edge width, either absolute width in points or + width relative to lines.linewidth rc default. - ACCEPTS: float value in points + ACCEPTS: [float value in points | fraction as string | None | + 'xx-thin' | 'x-thin' | 'thin' | 'thinner' | 'medium' | + 'thicker' | 'thick' | 'x-thick' | 'xx-thick'] """ if ew is None: ew = rcParams['lines.markeredgewidth'] + + ew = linewidth2points(ew) + if self._markeredgewidth != ew: self.stale = True self._markeredgewidth = ew @@ -1192,11 +1273,19 @@ def set_markerfacecoloralt(self, fc): def set_markersize(self, sz): """ - Set the marker size in points + Set the line width, either absolute width in points or + width relative to rc default. - ACCEPTS: float + ACCEPTS: [float value in points | fraction as string | None | + 'xx-small' | 'x-small' | 'small' | 'smaller' | + 'medium' | 'larger' | 'large' | 'x-large' | + 'xx-large'] """ - sz = float(sz) + if sz is None: + sz = rcParams['lines.markersize'] + + sz = markersize2points(sz) + if self._markersize != sz: self.stale = True self._markersize = sz diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 6302fdf72078..2f82a5605f56 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -351,16 +351,21 @@ def set_alpha(self, alpha): def set_linewidth(self, w): """ - Set the patch linewidth in points + Set the path line width, either absolute width in points or + width relative to lines.linewidth rc default. - ACCEPTS: float or None for default + ACCEPTS: [float value in points | fraction as string | None | + 'xx-thin' | 'x-thin' | 'thin' | 'thinner' | 'medium' | + 'thicker' | 'thick' | 'x-thick' | 'xx-thick'] """ + if w is None: w = mpl.rcParams['patch.linewidth'] if w is None: w = mpl.rcParams['axes.linewidth'] - self._linewidth = float(w) + self._linewidth = mlines.linewidth2points(w) + # scale the dash pattern by the linewidth offset, ls = self._us_dashes self._dashoffset, self._dashes = mlines._scale_dashes( diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index eafc8d4eecf7..23e029f831c0 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -675,10 +675,46 @@ def validate_hatch(s): validate_hatchlist = _listify_validator(validate_hatch) validate_dashlist = _listify_validator(validate_nseq_float(allow_none=True)) + +def _validate_linewidth(w): + try: + w = float(w) + except (ValueError, TypeError): + if w in ['xx-thin', 'x-thin', 'thin', 'thinner', 'medium', 'thick', + 'thicker', 'x-thick', 'xx-thick']: + return w + else: + val, u = cbook.parse_measurement(w) + if u not in ['x', '%']: + raise ValueError("value {!r} is not a valid absolute" + "or relative width.".format(w)) + + return w + + +def _validate_markersize(sz): + try: + sz = float(sz) + except (ValueError, TypeError): + if sz in ['xx-small', 'x-small', 'small', 'smaller', 'medium', + 'large', 'larger', 'x-large', 'xx-large']: + return sz + else: + val, u = cbook.parse_measurement(sz) + if u not in ['x', '%']: + raise ValueError("value {!r} is not a valid absolute" + "or relative size.".format(sz)) + + return sz + + +validate_linewidthlist = _listify_validator(_validate_linewidth) +validate_markersizelist = _listify_validator(_validate_markersize) + _prop_validators = { 'color': _listify_validator(validate_color_for_prop_cycle, allow_stringlist=True), - 'linewidth': validate_floatlist, + 'linewidth': validate_linewidthlist, 'linestyle': validate_stringlist, 'facecolor': validate_colorlist, 'edgecolor': validate_colorlist, @@ -686,8 +722,8 @@ def validate_hatch(s): 'capstyle': validate_capstylelist, 'fillstyle': validate_fillstylelist, 'markerfacecolor': validate_colorlist, - 'markersize': validate_floatlist, - 'markeredgewidth': validate_floatlist, + 'markersize': validate_markersizelist, + 'markeredgewidth': validate_linewidthlist, 'markeredgecolor': validate_colorlist, 'alpha': validate_floatlist, 'marker': validate_stringlist, @@ -959,7 +995,7 @@ def _validate_linestyle(ls): 'lines.linestyle': ['-', _validate_linestyle], # solid line 'lines.color': ['C0', validate_color], # first color in color cycle 'lines.marker': ['None', validate_string], # marker name - 'lines.markeredgewidth': [1.0, validate_float], + 'lines.markeredgewidth': [1.0, _validate_linewidth], # width in points, or relative to lines.linewidth 'lines.markersize': [6, validate_float], # markersize, in points 'lines.antialiased': [True, validate_bool], # antialiased (no jaggies) 'lines.dash_joinstyle': ['round', validate_joinstyle], @@ -976,7 +1012,7 @@ def _validate_linestyle(ls): 'markers.fillstyle': ['full', validate_fillstyle], ## patch props - 'patch.linewidth': [1.0, validate_float], # line width in points + 'patch.linewidth': [1.0, _validate_linewidth], # line width in points, or relative to lines.linewidth 'patch.edgecolor': ['k', validate_color], 'patch.force_edgecolor' : [False, validate_bool], 'patch.facecolor': ['C0', validate_color], # first color in cycle @@ -1005,33 +1041,33 @@ def _validate_linestyle(ls): 'boxplot.flierprops.marker': ['o', validate_string], 'boxplot.flierprops.markerfacecolor': ['none', validate_color_or_auto], 'boxplot.flierprops.markeredgecolor': ['k', validate_color], - 'boxplot.flierprops.markersize': [6, validate_float], + 'boxplot.flierprops.markersize': [6, _validate_markersize], 'boxplot.flierprops.linestyle': ['none', _validate_linestyle], - 'boxplot.flierprops.linewidth': [1.0, validate_float], + 'boxplot.flierprops.linewidth': [1.0, _validate_linewidth], 'boxplot.boxprops.color': ['k', validate_color], - 'boxplot.boxprops.linewidth': [1.0, validate_float], + 'boxplot.boxprops.linewidth': [1.0, _validate_linewidth], 'boxplot.boxprops.linestyle': ['-', _validate_linestyle], 'boxplot.whiskerprops.color': ['k', validate_color], - 'boxplot.whiskerprops.linewidth': [1.0, validate_float], + 'boxplot.whiskerprops.linewidth': [1.0, _validate_linewidth], 'boxplot.whiskerprops.linestyle': ['-', _validate_linestyle], 'boxplot.capprops.color': ['k', validate_color], - 'boxplot.capprops.linewidth': [1.0, validate_float], + 'boxplot.capprops.linewidth': [1.0, _validate_linewidth], 'boxplot.capprops.linestyle': ['-', _validate_linestyle], 'boxplot.medianprops.color': ['C1', validate_color], - 'boxplot.medianprops.linewidth': [1.0, validate_float], + 'boxplot.medianprops.linewidth': [1.0, _validate_linewidth], 'boxplot.medianprops.linestyle': ['-', _validate_linestyle], 'boxplot.meanprops.color': ['C2', validate_color], 'boxplot.meanprops.marker': ['^', validate_string], 'boxplot.meanprops.markerfacecolor': ['C2', validate_color], 'boxplot.meanprops.markeredgecolor': ['C2', validate_color], - 'boxplot.meanprops.markersize': [6, validate_float], + 'boxplot.meanprops.markersize': [6, _validate_markersize], 'boxplot.meanprops.linestyle': ['--', _validate_linestyle], - 'boxplot.meanprops.linewidth': [1.0, validate_float], + 'boxplot.meanprops.linewidth': [1.0, _validate_linewidth], ## font props 'font.family': [['sans-serif'], validate_stringlist], # used by text object @@ -1107,7 +1143,7 @@ def _validate_linestyle(ls): 'axes.hold': [None, deprecate_axes_hold], 'axes.facecolor': ['w', validate_color], # background color; white 'axes.edgecolor': ['k', validate_color], # edge color; black - 'axes.linewidth': [0.8, validate_float], # edge linewidth + 'axes.linewidth': [0.8, _validate_linewidth], # edge linewidth 'axes.spines.left': [True, validate_bool], # Set visibility of axes 'axes.spines.right': [True, validate_bool], # 'spines', the lines @@ -1216,8 +1252,8 @@ def _validate_linestyle(ls): 'xtick.bottom': [True, validate_bool], # draw ticks on the bottom side 'xtick.major.size': [3.5, validate_float], # major xtick size in points 'xtick.minor.size': [2, validate_float], # minor xtick size in points - 'xtick.major.width': [0.8, validate_float], # major xtick width in points - 'xtick.minor.width': [0.6, validate_float], # minor xtick width in points + 'xtick.major.width': [0.8, _validate_linewidth], # major xtick width in points + 'xtick.minor.width': [0.6, _validate_linewidth], # minor xtick width in points 'xtick.major.pad': [3.5, validate_float], # distance to label in points 'xtick.minor.pad': [3.4, validate_float], # distance to label in points 'xtick.color': ['k', validate_color], # color of the xtick labels @@ -1236,8 +1272,8 @@ def _validate_linestyle(ls): 'ytick.right': [False, validate_bool], # draw ticks on the right side 'ytick.major.size': [3.5, validate_float], # major ytick size in points 'ytick.minor.size': [2, validate_float], # minor ytick size in points - 'ytick.major.width': [0.8, validate_float], # major ytick width in points - 'ytick.minor.width': [0.6, validate_float], # minor ytick width in points + 'ytick.major.width': [0.8, _validate_linewidth], # major ytick width in points + 'ytick.minor.width': [0.6, _validate_linewidth], # minor ytick width in points 'ytick.major.pad': [3.5, validate_float], # distance to label in points 'ytick.minor.pad': [3.4, validate_float], # distance to label in points 'ytick.color': ['k', validate_color], # color of the ytick labels @@ -1255,7 +1291,7 @@ def _validate_linestyle(ls): 'grid.color': ['#b0b0b0', validate_color], # grid color 'grid.linestyle': ['-', _validate_linestyle], # solid - 'grid.linewidth': [0.8, validate_float], # in points + 'grid.linewidth': [0.8, _validate_linewidth], # in points 'grid.alpha': [1.0, validate_float], diff --git a/lib/matplotlib/tests/test_lines.py b/lib/matplotlib/tests/test_lines.py index e0b6258e9ba5..da4e4c689fa6 100644 --- a/lib/matplotlib/tests/test_lines.py +++ b/lib/matplotlib/tests/test_lines.py @@ -184,3 +184,79 @@ def test_nan_is_sorted(): assert line._is_sorted(np.array([1, 2, 3])) assert line._is_sorted(np.array([1, np.nan, 3])) assert not line._is_sorted([3, 5] + [np.nan] * 100 + [0, 2]) + + +def test_relative_sizes(): + line = mlines.Line2D([0, 1], [0, 1]) + + reference = 1. + with matplotlib.rc_context( + rc={'lines.linewidth': reference, + 'lines.markersize': reference, + 'lines.markeredgewidth': reference}): + + # use default line with from rcParams + line.set(linewidth=None, markeredgewidth=None, markersize=None) + assert line.get_linewidth() == reference + assert line.get_markeredgewidth() == reference + assert line.get_markersize() == reference + + line.set(linewidth='2x', markeredgewidth='0.5x', markersize='2e1x') + assert line.get_linewidth() == 2*reference + assert line.get_markeredgewidth() == 0.5*reference + assert line.get_markersize() == 2e1*reference + + line.set(linewidth='50%', markeredgewidth='2e2 %', markersize='250%') + assert line.get_linewidth() == 0.5*reference + assert line.get_markeredgewidth() == 2*reference + assert line.get_markersize() == 2.5*reference + + # set qualitative relative widths + for w in ('xx-thin', 'x-thin', 'thin', 'thinner', + 'medium', 'thick', 'thicker', 'x-thick', 'xx-thick'): + line.set(linewidth=w, markeredgewidth=w) + assert (line.get_linewidth() == + mlines.linewidth_scaling[w]*reference) + assert (line.get_markeredgewidth() == + mlines.linewidth_scaling[w]*reference) + + # set qualitative relative size + for sz in ('xx-small', 'x-small', 'small', 'smaller', + 'medium', 'large', 'larger', 'x-large', 'xx-large'): + line.set_markersize(sz) + assert (line.get_markersize() == + mlines.markersize_scaling[sz]*reference) + + with pytest.raises(ValueError): + line.set_linewidth('large') + + with pytest.raises(ValueError): + line.set_markeredgewidth('six') + + with pytest.raises(ValueError): + line.set_markersize('tall') + + +def test_relative_sizes_rc(): + + # only absolute width for lines.linewidth + matplotlib.rcParams['lines.linewidth'] = 1. + matplotlib.rcParams['lines.markersize'] = 1. + + with pytest.raises(ValueError): + matplotlib.rcParams['lines.linewidth'] = '2x' + + with pytest.raises(ValueError): + matplotlib.rcParams['lines.markersize'] = '100%' + + # relative width/size + matplotlib.rcParams['lines.markeredgewidth'] = '0.5x' + matplotlib.rcParams['xtick.major.width'] = '2x' + matplotlib.rcParams['ytick.minor.width'] = '50%' + + with pytest.raises(ValueError): + matplotlib.rcParams['xtick.minor.width'] = 'big' + + with pytest.raises(ValueError): + matplotlib.rcParams['ytick.major.width'] = 'microscopic' +