diff --git a/doc/_static/mpl.css b/doc/_static/mpl.css index 0d99cf930ef2..a6499284b64e 100644 --- a/doc/_static/mpl.css +++ b/doc/_static/mpl.css @@ -89,7 +89,7 @@ table.highlighttable td { padding: 0 0.5em 0 0.5em; } -cite, code, tt { +cite, code, tt, dl.value-list dt { font-family: 'Consolas', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; font-size: 0.95em; letter-spacing: 0.01em; @@ -730,7 +730,6 @@ td.field-body table.property-table tr:last-of-type td { border-bottom-color: #888; } - /* function and class description */ .descclassname { color: #aaa; @@ -806,6 +805,22 @@ dl.class > dd { font-size: 14px; } +/* custom tables for lists of allowed values in "mpl._types" */ +dl.value-list { + display: grid; +} + +dl.value-list dt { + grid-column: 1; + margin: 4px 0; +} + +dl.value-list dd { + grid-column: 2; + margin: 4px 0 4px 20px; + padding: 0; +} + /* parameter section table */ table.docutils.field-list { width: 100%; @@ -1257,4 +1272,4 @@ div.bullet-box li { div#gallery.section .sphx-glr-clear:first-of-type, div#tutorials.section .sphx-glr-clear:first-of-type{ display: none; -} \ No newline at end of file +} diff --git a/doc/api/_types.rst b/doc/api/_types.rst new file mode 100644 index 000000000000..88ded801768c --- /dev/null +++ b/doc/api/_types.rst @@ -0,0 +1,15 @@ +********************** +``matplotlib._types`` +********************** + +.. automodule:: matplotlib._types + :no-members: + + .. autoclass:: JoinStyle + :members: demo + :exclude-members: bevel, miter, round, input_description + + .. autoclass:: CapStyle + :members: demo + :exclude-members: butt, round, projecting, input_description + diff --git a/doc/api/hatch_api.rst b/doc/api/hatch_api.rst new file mode 100644 index 000000000000..70b0db9be6c1 --- /dev/null +++ b/doc/api/hatch_api.rst @@ -0,0 +1,9 @@ +********************* +``matplotlib.hatch`` +********************* + +.. automodule:: matplotlib.hatch + :members: + :undoc-members: + :show-inheritance: + diff --git a/doc/api/index.rst b/doc/api/index.rst index dba86c35ad0a..ec5b80e6d2a6 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -93,6 +93,7 @@ Matplotlib consists of the following submodules: font_manager_api.rst fontconfig_pattern_api.rst gridspec_api.rst + hatch_api.rst image_api.rst legend_api.rst legend_handler_api.rst @@ -124,6 +125,7 @@ Matplotlib consists of the following submodules: transformations.rst tri_api.rst type1font.rst + _types.rst units_api.rst widgets_api.rst _api_api.rst diff --git a/examples/lines_bars_and_markers/joinstyle.py b/examples/lines_bars_and_markers/joinstyle.py deleted file mode 100644 index dcc47105d12f..000000000000 --- a/examples/lines_bars_and_markers/joinstyle.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -========================== -Join styles and cap styles -========================== - -This example demonstrates the available join styles and cap styles. - -Both are used in `.Line2D` and various ``Collections`` from -`matplotlib.collections` as well as some functions that create these, e.g. -`~matplotlib.pyplot.plot`. - - -Join styles -=========== - -Join styles define how the connection between two line segments is drawn. - -See the respective ``solid_joinstyle``, ``dash_joinstyle`` or ``joinstyle`` -parameters. -""" - -import numpy as np -import matplotlib.pyplot as plt - - -def plot_angle(ax, x, y, angle, style): - phi = np.radians(angle) - xx = [x + .5, x, x + .5*np.cos(phi)] - yy = [y, y, y + .5*np.sin(phi)] - ax.plot(xx, yy, lw=12, color='tab:blue', solid_joinstyle=style) - ax.plot(xx, yy, lw=1, color='black') - ax.plot(xx[1], yy[1], 'o', color='tab:red', markersize=3) - - -fig, ax = plt.subplots(figsize=(8, 6)) -ax.set_title('Join style') - -for x, style in enumerate(['miter', 'round', 'bevel']): - ax.text(x, 5, style) - for y, angle in enumerate([20, 45, 60, 90, 120]): - plot_angle(ax, x, y, angle, style) - if x == 0: - ax.text(-1.3, y, f'{angle} degrees') -ax.text(1, 4.7, '(default)') - -ax.set_xlim(-1.5, 2.75) -ax.set_ylim(-.5, 5.5) -ax.set_axis_off() -plt.show() - - -############################################################################# -# -# Cap styles -# ========== -# -# Cap styles define how the the end of a line is drawn. -# -# See the respective ``solid_capstyle``, ``dash_capstyle`` or ``capstyle`` -# parameters. - -fig, ax = plt.subplots(figsize=(8, 2)) -ax.set_title('Cap style') - -for x, style in enumerate(['butt', 'round', 'projecting']): - ax.text(x+0.25, 1, style, ha='center') - xx = [x, x+0.5] - yy = [0, 0] - ax.plot(xx, yy, lw=12, color='tab:blue', solid_capstyle=style) - ax.plot(xx, yy, lw=1, color='black') - ax.plot(xx, yy, 'o', color='tab:red', markersize=3) -ax.text(2.25, 0.7, '(default)', ha='center') - -ax.set_ylim(-.5, 1.5) -ax.set_axis_off() - - -############################################################################# -# -# ------------ -# -# References -# """""""""" -# -# The use of the following functions, methods, classes and modules is shown -# in this example: - -import matplotlib -matplotlib.axes.Axes.plot -matplotlib.pyplot.plot diff --git a/lib/matplotlib/_types.py b/lib/matplotlib/_types.py new file mode 100644 index 000000000000..06c05c4049da --- /dev/null +++ b/lib/matplotlib/_types.py @@ -0,0 +1,204 @@ +""" +Concepts used by the matplotlib API that do not yet have a dedicated class. + +Matplotlib often uses simple data types like strings or tuples to define a +concept; e.g. the line capstyle can be specified as one of 'butt', 'round', +or 'projecting'. The classes in this module are used internally and document +these concepts formally. + +As an end-user you will not use these classes directly, but only the values +they define. +""" + +from enum import Enum, auto +from matplotlib import cbook, docstring + + +class _AutoStringNameEnum(Enum): + """Automate the ``name = 'name'`` part of making a (str, Enum).""" + def _generate_next_value_(name, start, count, last_values): + return name + + +def _deprecate_case_insensitive_join_cap(s): + s_low = s.lower() + if s != s_low: + if s_low in ['miter', 'round', 'bevel']: + cbook.warn_deprecated( + "3.3", message="Case-insensitive capstyles are deprecated " + "since %(since)s and support for them will be removed " + "%(removal)s; please pass them in lowercase.") + elif s_low in ['butt', 'round', 'projecting']: + cbook.warn_deprecated( + "3.3", message="Case-insensitive joinstyles are deprecated " + "since %(since)s and support for them will be removed " + "%(removal)s; please pass them in lowercase.") + # Else, error out at the check_in_list stage. + return s_low + + +class JoinStyle(str, _AutoStringNameEnum): + """ + Define how the connection between two line segments is drawn. + + For a visual impression of each *JoinStyle*, `view these docs online + `, or run `JoinStyle.demo`. + + Lines in Matplotlib are typically defined by a 1D `~.path.Path` and a + finite ``linewidth``, where the underlying 1D `~.path.Path` represents the + center of the stroked line. + + By default, `~.backend_bases.GraphicsContextBase` defines the boundaries of + a stroked line to simply be every point within some radius, + ``linewidth/2``, away from any point of the center line. However, this + results in corners appearing "rounded", which may not be the desired + behavior if you are drawing, for example, a polygon or pointed star. + + **Supported values:** + + .. rst-class:: value-list + + 'miter' + the "arrow-tip" style. Each boundary of the filled-in area will + extend in a straight line parallel to the tangent vector of the + centerline at the point it meets the corner, until they meet in a + sharp point. + 'round' + stokes every point within a radius of ``linewidth/2`` of the center + lines. + 'bevel' + the "squared-off" style. It can be thought of as a rounded corner + where the "circular" part of the corner has been cut off. + + .. note:: + + Very long miter tips are cut off (to form a *bevel*) after a + backend-dependent limit called the "miter limit", which specifies the + maximum allowed ratio of miter length to line width. For example, the + PDF backend uses the default value of 10 specified by the PDF standard, + while the SVG backend does not even specify the miter limit, resulting + in a default value of 4 per the SVG specification. Matplotlib does not + currently allow the user to adjust this parameter. + + A more detailed description of the effect of a miter limit can be found + in the `Mozilla Developer Docs + `_ + + .. plot:: + :alt: Demo of possible JoinStyle's + + from matplotlib._types import JoinStyle + JoinStyle.demo() + + """ + + miter = auto() + round = auto() + bevel = auto() + + def __init__(self, s): + s = _deprecate_case_insensitive_join_cap(s) + Enum.__init__(self) + + @staticmethod + def demo(): + """Demonstrate how each JoinStyle looks for various join angles.""" + import numpy as np + import matplotlib.pyplot as plt + + def plot_angle(ax, x, y, angle, style): + phi = np.radians(angle) + xx = [x + .5, x, x + .5*np.cos(phi)] + yy = [y, y, y + .5*np.sin(phi)] + ax.plot(xx, yy, lw=12, color='tab:blue', solid_joinstyle=style) + ax.plot(xx, yy, lw=1, color='black') + ax.plot(xx[1], yy[1], 'o', color='tab:red', markersize=3) + + fig, ax = plt.subplots(figsize=(5, 4), constrained_layout=True) + ax.set_title('Join style') + for x, style in enumerate(['miter', 'round', 'bevel']): + ax.text(x, 5, style) + for y, angle in enumerate([20, 45, 60, 90, 120]): + plot_angle(ax, x, y, angle, style) + if x == 0: + ax.text(-1.3, y, f'{angle} degrees') + ax.set_xlim(-1.5, 2.75) + ax.set_ylim(-.5, 5.5) + ax.set_axis_off() + fig.show() + + +JoinStyle.input_description = "{" \ + + ", ".join([f"'{js.name}'" for js in JoinStyle]) \ + + "}" + + +class CapStyle(str, _AutoStringNameEnum): + r""" + Define how the two endpoints (caps) of an unclosed line are drawn. + + How to draw the start and end points of lines that represent a closed curve + (i.e. that end in a `~.path.Path.CLOSEPOLY`) is controlled by the line's + `JoinStyle`. For all other lines, how the start and end points are drawn is + controlled by the *CapStyle*. + + For a visual impression of each *CapStyle*, `view these docs online + ` or run `CapStyle.demo`. + + **Supported values:** + + .. rst-class:: value-list + + 'butt' + the line is squared off at its endpoint. + 'projecting' + the line is squared off as in *butt*, but the filled in area + extends beyond the endpoint a distance of ``linewidth/2``. + 'round' + like *butt*, but a semicircular cap is added to the end of the + line, of radius ``linewidth/2``. + + .. plot:: + :alt: Demo of possible CapStyle's + + from matplotlib._types import CapStyle + CapStyle.demo() + + """ + butt = 'butt' + projecting = 'projecting' + round = 'round' + + def __init__(self, s): + s = _deprecate_case_insensitive_join_cap(s) + Enum.__init__(self) + + @staticmethod + def demo(): + """Demonstrate how each CapStyle looks for a thick line segment.""" + import matplotlib.pyplot as plt + + fig = plt.figure(figsize=(4, 1.2)) + ax = fig.add_axes([0, 0, 1, 0.8]) + ax.set_title('Cap style') + + for x, style in enumerate(['butt', 'round', 'projecting']): + ax.text(x+0.25, 0.85, style, ha='center') + xx = [x, x+0.5] + yy = [0, 0] + ax.plot(xx, yy, lw=12, color='tab:blue', solid_capstyle=style) + ax.plot(xx, yy, lw=1, color='black') + ax.plot(xx, yy, 'o', color='tab:red', markersize=3) + ax.text(2.25, 0.55, '(default)', ha='center') + + ax.set_ylim(-.5, 1.5) + ax.set_axis_off() + fig.show() + + +CapStyle.input_description = "{" \ + + ", ".join([f"'{cs.name}'" for cs in CapStyle]) \ + + "}" + +docstring.interpd.update({'JoinStyle': JoinStyle.input_description, + 'CapStyle': CapStyle.input_description}) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 2b89fac08b53..925abd0741d2 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -44,14 +44,14 @@ import matplotlib as mpl from matplotlib import ( - _api, backend_tools as tools, cbook, colors, textpath, tight_bbox, - transforms, widgets, get_backend, is_interactive, rcParams) + _api, backend_tools as tools, cbook, colors, docstring, textpath, + tight_bbox, transforms, widgets, get_backend, is_interactive, rcParams) from matplotlib._pylab_helpers import Gcf from matplotlib.backend_managers import ToolManager from matplotlib.cbook import _setattr_cm from matplotlib.path import Path -from matplotlib.rcsetup import validate_joinstyle, validate_capstyle from matplotlib.transforms import Affine2D +from matplotlib._types import JoinStyle, CapStyle _log = logging.getLogger(__name__) @@ -765,11 +765,11 @@ def __init__(self): self._alpha = 1.0 self._forced_alpha = False # if True, _alpha overrides A from RGBA self._antialiased = 1 # use 0, 1 not True, False for extension code - self._capstyle = 'butt' + self._capstyle = CapStyle('butt') self._cliprect = None self._clippath = None self._dashes = 0, None - self._joinstyle = 'round' + self._joinstyle = JoinStyle('round') self._linestyle = 'solid' self._linewidth = 1 self._rgb = (0.0, 0.0, 0.0, 1.0) @@ -820,10 +820,8 @@ def get_antialiased(self): return self._antialiased def get_capstyle(self): - """ - Return the capstyle as a string in ('butt', 'round', 'projecting'). - """ - return self._capstyle + """Return the `.CapStyle`.""" + return self._capstyle.name def get_clip_rectangle(self): """ @@ -867,8 +865,8 @@ def get_forced_alpha(self): return self._forced_alpha def get_joinstyle(self): - """Return the line join style as one of ('miter', 'round', 'bevel').""" - return self._joinstyle + """Return the `.JoinStyle`.""" + return self._joinstyle.name def get_linewidth(self): """Return the line width in points.""" @@ -919,10 +917,16 @@ def set_antialiased(self, b): # Use ints to make life easier on extension code trying to read the gc. self._antialiased = int(bool(b)) + @docstring.interpd def set_capstyle(self, cs): - """Set the capstyle to be one of ('butt', 'round', 'projecting').""" - validate_capstyle(cs) - self._capstyle = cs + """ + Set how to draw endpoints of lines. + + Parameters + ---------- + cs : `.CapStyle` or %(CapStyle)s + """ + self._capstyle = CapStyle(cs) def set_clip_rectangle(self, rectangle): """Set the clip rectangle to a `.Bbox` or None.""" @@ -979,10 +983,16 @@ def set_foreground(self, fg, isRGBA=False): else: self._rgb = colors.to_rgba(fg) + @docstring.interpd def set_joinstyle(self, js): - """Set the join style to be one of ('miter', 'round', 'bevel').""" - validate_joinstyle(js) - self._joinstyle = js + """ + Set how to draw connections between line segments. + + Parameters + ---------- + js : `.JoinStyle` or %(JoinStyle)s + """ + self._joinstyle = JoinStyle(js) def set_linewidth(self, w): """Set the linewidth in points.""" @@ -1015,7 +1025,7 @@ def get_hatch(self): """Get the current hatch style.""" return self._hatch - def get_hatch_path(self, density=6.0): + def get_hatch_path(self, density=None): """Return a `.Path` for the current hatch.""" hatch = self.get_hatch() if hatch is None: diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index 8aed4182b00f..87e4ba7e2c05 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -15,7 +15,9 @@ import matplotlib as mpl from . import (_api, _path, artist, cbook, cm, colors as mcolors, docstring, - hatch as mhatch, lines as mlines, path as mpath, transforms) + lines as mlines, path as mpath, transforms) +from .hatch import Hatch +from ._types import JoinStyle, CapStyle import warnings @@ -71,6 +73,7 @@ class Collection(artist.Artist, cm.ScalarMappable): _edge_default = False @cbook._delete_parameter("3.3", "offset_position") + @docstring.interpd def __init__(self, edgecolors=None, facecolors=None, @@ -110,14 +113,12 @@ def __init__(self, where *onoffseq* is an even length tuple of on and off ink lengths in points. For examples, see :doc:`/gallery/lines_bars_and_markers/linestyles`. - capstyle : str, default: :rc:`patch.capstyle` + capstyle : `.CapStyle`-like, default: :rc:`patch.capstyle` Style to use for capping lines for all paths in the collection. - See :doc:`/gallery/lines_bars_and_markers/joinstyle` for - a demonstration of each of the allowed values. - joinstyle : str, default: :rc:`patch.joinstyle` + Allowed values are %(CapStyle)s. + joinstyle : `.JoinStyle`-like, default: :rc:`patch.joinstyle` Style to use for joining lines for all paths in the collection. - See :doc:`/gallery/lines_bars_and_markers/joinstyle` for - a demonstration of each of the allowed values. + Allowed values are %(JoinStyle)s. antialiaseds : bool or list of bool, default: :rc:`patch.antialiased` Whether each patch in the collection should be drawn with antialiasing. @@ -128,7 +129,7 @@ def __init__(self, transOffset : `~.transforms.Transform`, default: `.IdentityTransform` A single transform which will be applied to each *offsets* vector before it is used. - offset_position : {'screen' (default), 'data' (deprecated)} + offset_position : {{'screen' (default), 'data' (deprecated)}} If set to 'data' (deprecated), *offsets* will be treated as if it is in data coordinates instead of in screen coordinates. norm : `~.colors.Normalize`, optional @@ -139,10 +140,9 @@ def __init__(self, Forwarded to `.ScalarMappable`. The default of ``None`` will result in :rc:`image.cmap` being used. hatch : str, optional - Hatching pattern to use in filled paths, if any. Valid strings are - ['/', '\\', '|', '-', '+', 'x', 'o', 'O', '.', '*']. See - :doc:`/gallery/shapes_and_collections/hatch_style_reference` for - the meaning of each hatch type. + Hatching pattern to use in filled paths, if any. Primitives + available are %(Hatch)s. See `~.hatch.Hatch` for how to construct + more complex hatch patterns. pickradius : float, default: 5.0 If ``pickradius <= 0``, then `.Collection.contains` will return ``True`` whenever the test point is inside of one of the polygons @@ -492,44 +492,22 @@ def get_urls(self): def set_hatch(self, hatch): r""" - Set the hatching pattern - - *hatch* can be one of:: - - / - diagonal hatching - \ - back diagonal - | - vertical - - - horizontal - + - crossed - x - crossed diagonal - o - small circle - O - large circle - . - dots - * - stars - - Letters can be combined, in which case all the specified - hatchings are done. If same letter repeats, it increases the - density of hatching of that pattern. - - Hatching is supported in the PostScript, PDF, SVG and Agg - backends only. - - Unlike other properties such as linewidth and colors, hatching - can only be specified for the collection as a whole, not separately - for each member. + Set the hatching pattern. Parameters ---------- - hatch : {'/', '\\', '|', '-', '+', 'x', 'o', 'O', '.', '*'} + hatch : `.Hatch`-like or str + The built-in hatching patterns are %(Hatch)s. Multiple hatch types + can be combined (e.g. ``'|-'`` is equivalent to ``'+'``) and + repeating a character increases the density of that pattern. For + more advanced usage, see the `.Hatch` docs. """ - # Use validate_hatch(list) after deprecation. - mhatch._validate_hatch_pattern(hatch) - self._hatch = hatch + self._hatch = Hatch(hatch) self.stale = True def get_hatch(self): """Return the current hatching pattern.""" - return self._hatch + return self._hatch._pattern_spec def set_offsets(self, offsets): """ @@ -655,35 +633,33 @@ def set_linestyle(self, ls): self._linewidths, self._linestyles = self._bcast_lwls( self._us_lw, self._us_linestyles) + @docstring.interpd def set_capstyle(self, cs): """ - Set the capstyle for the collection (for all its elements). + Set the `.CapStyle` for the collection (for all its elements). Parameters ---------- - cs : {'butt', 'round', 'projecting'} - The capstyle. + cs : `.CapStyle` or %(CapStyle)s """ - mpl.rcsetup.validate_capstyle(cs) - self._capstyle = cs + self._capstyle = CapStyle(cs) def get_capstyle(self): - return self._capstyle + return self._capstyle.name + @docstring.interpd def set_joinstyle(self, js): """ - Set the joinstyle for the collection (for all its elements). + Set the `.JoinStyle` for the collection (for all its elements). Parameters ---------- - js : {'miter', 'round', 'bevel'} - The joinstyle. + js : `.JoinStyle` or %(JoinStyle)s """ - mpl.rcsetup.validate_joinstyle(js) - self._joinstyle = js + self._joinstyle = JoinStyle(js) def get_joinstyle(self): - return self._joinstyle + return self._joinstyle.name @staticmethod def _bcast_lwls(linewidths, dashes): diff --git a/lib/matplotlib/hatch.py b/lib/matplotlib/hatch.py index 9e2e7ee5eca0..02ec846f7167 100644 --- a/lib/matplotlib/hatch.py +++ b/lib/matplotlib/hatch.py @@ -1,8 +1,9 @@ """Contains classes for generating hatch patterns.""" +from collections.abc import Iterable import numpy as np -from matplotlib import cbook +from matplotlib import _api, docstring from matplotlib.path import Path @@ -186,46 +187,207 @@ def __init__(self, hatch, density): ] -def _validate_hatch_pattern(hatch): - valid_hatch_patterns = set(r'-+|/\xXoO.*') - if hatch is not None: - invalids = set(hatch).difference(valid_hatch_patterns) - if invalids: - valid = ''.join(sorted(valid_hatch_patterns)) - invalids = ''.join(sorted(invalids)) - cbook.warn_deprecated( - '3.4', - message=f'hatch must consist of a string of "{valid}" or ' - 'None, but found the following invalid values ' - f'"{invalids}". Passing invalid values is deprecated ' - 'since %(since)s and will become an error %(removal)s.' - ) +class Hatch: + r""" + Pattern to be tiled within a filled area. + + For a visual example of the available *Hatch* patterns, `view these docs + online ` or run `Hatch.demo`. + + When making plots that contain multiple filled shapes, like :doc:`bar + charts ` or filled + :doc:`countour plots `, it + is common to use :doc:`color ` to distinguish + between areas. However, if color is not available, such as when printing in + black and white, Matplotlib also supports hatching (i.e. filling each + area with a unique repeating pattern or lines or shapes) in order to make + it easy to refer to a specific filled bar, shape, or similar. + + .. warning:: + Hatching is currently only supported in the Agg, PostScript, PDF, and + SVG backends. + + **Hatching patterns** + + There hatching primitives built into Matplotlib are: + + .. rst-class:: value-list + + '-' + Horizontal lines. + '|' + Vertical lines. + '+' + Crossed lines. ``'+'`` is equivalent to ``'-|'``. + '\' + Diagonal lines running northwest to southeast. + '/' + Diagonal lines running southwest to northeast. + 'x' + Crossed diagonal lines. Equivalent to ``r'\/'``. + 'X' + Synonym for ``'x'``. + '.' + Dots (i.e. very small, filled circles). + 'o' + Small, unfilled circles. + 'O' + Large, unfilled circles. + '*' + Filled star shape. + + Hatching primitives can be combined to make more complicated patterns. For + example, a hatch pattern of ``'*/|'`` would fill the area with vertical and + diagonal lines as well as stars. + + **Hatching Density** + + By default, the hatching pattern is tiled so that there are **6** lines per + inch (in display space), but this can be tuned (in integer increments) + using the *density* kwarg to *Hatch*. + + For convenience, the same symbol can also be repeated to request a higher + hatching density. For example, ``'||-'`` will have twice as many vertical + lines as ``'|-'``. Notice that since ``'|-'`` can also be written as + ``'+'``, we can also write ``'||-'`` as ``'|+'``. + + Examples + -------- + For more examples of how to use hatching, see `the hatching demo + ` and `the contourf hatching + demo `. + + .. plot:: + :alt: Demo showing each hatching primitive at its default density. + + from matplotlib.hatch import Hatch + Hatch.demo() + """ + + _default_density = 6 + _valid_hatch_patterns = set(r'-|+/\xX.oO*') + + def __init__(self, pattern_spec, density=None): + self.density = Hatch._default_density if density is None else density + self._pattern_spec = pattern_spec + self.patterns = self._validate_hatch_pattern(pattern_spec) + self._build_path() + + @classmethod + def from_path(cls, path): + hatch = cls(None, 0) + hatch.path = path + + def _build_path(self): + # the API of HatchPatternBase was architected before Hatch, so instead + # of simply returning Path's that we can concatenate using + # Path.make_compound_path, we must pre-allocate the vertices array for + # the final path up front. (The performance gain from this + # preallocation is untested). + num_vertices = sum([pattern.num_vertices for pattern in self.patterns]) + + if num_vertices == 0: + self.path = Path(np.empty((0, 2))) + vertices = np.empty((num_vertices, 2)) + codes = np.empty(num_vertices, Path.code_type) + cursor = 0 + for pattern in self.patterns: + if pattern.num_vertices != 0: + vertices_chunk = vertices[cursor:cursor + pattern.num_vertices] + codes_chunk = codes[cursor:cursor + pattern.num_vertices] + pattern.set_vertices_and_codes(vertices_chunk, codes_chunk) + cursor += pattern.num_vertices + + self.path = Path(vertices, codes) + + def _validate_hatch_pattern(self, patterns): + if isinstance(patterns, Hatch): + patterns = patterns._pattern_spec + if patterns is None or patterns is []: + return [] + elif isinstance(patterns, str): + invalids = set(patterns).difference(Hatch._valid_hatch_patterns) + if invalids: + Hatch._warn_invalid_hatch(invalids) + return [hatch_type(patterns, self.density) + for hatch_type in _hatch_types] + elif isinstance(patterns, Iterable) and np.all([ + isinstance(p, HatchPatternBase) for p in patterns]): + return patterns + else: + raise ValueError(f"Cannot construct hatch pattern from {patterns}") + + def _warn_invalid_hatch(invalids): + valid = ''.join(sorted(Hatch._valid_hatch_patterns)) + invalids = ''.join(sorted(invalids)) + _api.warn_deprecated( + '3.4', + message=f'hatch must consist of a string of "{valid}" or None, ' + f'but found the following invalid values "{invalids}". ' + 'Passing invalid values is deprecated since %(since)s and ' + 'will become an error %(removal)s.' + ) + + @staticmethod + def demo(density=6): + import matplotlib.pyplot as plt + from matplotlib.patches import Rectangle + fig = plt.figure() + ax = fig.add_axes([0, 0, 1, 1]) + ax.set_axis_off() + num_patches = len(Hatch._valid_hatch_patterns) + + spacing = 0.1 # percent of width + boxes_per_row = 4 + num_rows = np.ceil(num_patches / boxes_per_row) + inter_box_dist_y = 1/num_rows + posts = np.linspace(0, 1, boxes_per_row + 1) + inter_box_dist_x = posts[1] - posts[0] + font_size = 12 + fig_size = (4, 4) + text_pad = 0.2 # fraction of text height + text_height = (1 + text_pad)*( + fig.dpi_scale_trans + ax.transAxes.inverted() + ).transform([1, 1])[1] + # half of text pad + bottom_padding = text_height*(1 - (1/(1+text_pad)))/2 + + for i, hatch in enumerate(Hatch._valid_hatch_patterns): + row = int(i/boxes_per_row) + col = i - row*boxes_per_row + ax.add_patch(Rectangle( + xy=[(col + spacing/2) * inter_box_dist_x, + bottom_padding + row*inter_box_dist_y], + width=inter_box_dist_x*(1 - spacing), + height=inter_box_dist_y*(1 - text_height), + transform=ax.transAxes, + hatch=hatch, + label="'" + hatch + "'" + )) + ax.text((col + 1/2) * inter_box_dist_x, + bottom_padding + (-text_height*(1/(1+text_pad)) + row + + 1)*inter_box_dist_y, + "'" + hatch + "'", horizontalalignment='center', + fontsize=font_size) + + +Hatch.input_description = "{" \ + + ", ".join([f"'{p}'" for p in Hatch._valid_hatch_patterns]) \ + + "}" + + +docstring.interpd.update({'Hatch': Hatch.input_description}) + + +@_api.deprecated("3.4") def get_path(hatchpattern, density=6): """ Given a hatch specifier, *hatchpattern*, generates Path to render the hatch in a unit square. *density* is the number of lines per unit square. """ - density = int(density) - - patterns = [hatch_type(hatchpattern, density) - for hatch_type in _hatch_types] - num_vertices = sum([pattern.num_vertices for pattern in patterns]) - - if num_vertices == 0: - return Path(np.empty((0, 2))) - - vertices = np.empty((num_vertices, 2)) - codes = np.empty(num_vertices, Path.code_type) - - cursor = 0 - for pattern in patterns: - if pattern.num_vertices != 0: - vertices_chunk = vertices[cursor:cursor + pattern.num_vertices] - codes_chunk = codes[cursor:cursor + pattern.num_vertices] - pattern.set_vertices_and_codes(vertices_chunk, codes_chunk) - cursor += pattern.num_vertices + return Hatch(hatchpattern).path - return Path(vertices, codes) +docstring diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index aac40606e015..1a3f55d07598 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -18,6 +18,7 @@ from .path import Path from .transforms import ( Affine2D, Bbox, BboxTransformFrom, BboxTransformTo, TransformedPath) +from ._types import JoinStyle, CapStyle # Imported here for backward compatibility, even though they don't # really belong. @@ -254,12 +255,12 @@ class Line2D(Artist): @_api.deprecated("3.4") @_api.classproperty def validCap(cls): - return ('butt', 'round', 'projecting') + return tuple(cs.value for cs in CapStyle) @_api.deprecated("3.4") @_api.classproperty def validJoin(cls): - return ('miter', 'round', 'bevel') + return tuple(js.value for js in JoinStyle) def __str__(self): if self._label != "": @@ -1154,7 +1155,7 @@ def set_linestyle(self, ls): self._dashOffset, self._dashSeq = _scale_dashes( self._us_dashOffset, self._us_dashSeq, self._linewidth) - @docstring.dedent_interpd + @docstring.interpd def set_marker(self, marker): """ Set the line marker. @@ -1309,98 +1310,101 @@ def update_from(self, other): self._marker = MarkerStyle(marker=other._marker) self._drawstyle = other._drawstyle + @docstring.interpd def set_dash_joinstyle(self, s): """ - Set the join style for dashed lines. + How to join segments of the line if it `~Line2D.is_dashed`. Parameters ---------- - s : {'miter', 'round', 'bevel'} - For examples see :doc:`/gallery/lines_bars_and_markers/joinstyle`. + s : `.JoinStyle` or %(JoinStyle)s """ - mpl.rcsetup.validate_joinstyle(s) - if self._dashjoinstyle != s: + js = JoinStyle(s) + if self._dashjoinstyle != js: self.stale = True - self._dashjoinstyle = s + self._dashjoinstyle = js + @docstring.interpd def set_solid_joinstyle(self, s): """ - Set the join style for solid lines. + How to join segments if the line is solid (not `~Line2D.is_dashed`). Parameters ---------- - s : {'miter', 'round', 'bevel'} - For examples see :doc:`/gallery/lines_bars_and_markers/joinstyle`. + s : `.JoinStyle` or %(JoinStyle)s """ - mpl.rcsetup.validate_joinstyle(s) - if self._solidjoinstyle != s: + js = JoinStyle(s) + if self._solidjoinstyle != js: self.stale = True - self._solidjoinstyle = s + self._solidjoinstyle = js def get_dash_joinstyle(self): """ - Return the join style for dashed lines. + Return the `.JoinStyle` for dashed lines. See also `~.Line2D.set_dash_joinstyle`. """ - return self._dashjoinstyle + return self._dashjoinstyle.name def get_solid_joinstyle(self): """ - Return the join style for solid lines. + Return the `.JoinStyle` for solid lines. See also `~.Line2D.set_solid_joinstyle`. """ - return self._solidjoinstyle + return self._solidjoinstyle.name + @docstring.interpd def set_dash_capstyle(self, s): """ - Set the cap style for dashed lines. + How to draw the end caps if the line is `~Line2D.is_dashed`. Parameters ---------- - s : {'butt', 'round', 'projecting'} - For examples see :doc:`/gallery/lines_bars_and_markers/joinstyle`. + s : `.CapStyle` or %(CapStyle)s """ - mpl.rcsetup.validate_capstyle(s) - if self._dashcapstyle != s: + cs = CapStyle(s) + if self._dashcapstyle != cs: self.stale = True - self._dashcapstyle = s + self._dashcapstyle = cs + @docstring.interpd def set_solid_capstyle(self, s): """ - Set the cap style for solid lines. + How to draw the end caps if the line is solid (not `~Line2D.is_dashed`) Parameters ---------- - s : {'butt', 'round', 'projecting'} - For examples see :doc:`/gallery/lines_bars_and_markers/joinstyle`. + s : `.CapStyle` or %(CapStyle)s """ - mpl.rcsetup.validate_capstyle(s) - if self._solidcapstyle != s: + cs = CapStyle(s) + if self._solidcapstyle != cs: self.stale = True - self._solidcapstyle = s + self._solidcapstyle = cs def get_dash_capstyle(self): """ - Return the cap style for dashed lines. + Return the `.CapStyle` for dashed lines. See also `~.Line2D.set_dash_capstyle`. """ - return self._dashcapstyle + return self._dashcapstyle.name def get_solid_capstyle(self): """ - Return the cap style for solid lines. + Return the `.CapStyle` for solid lines. See also `~.Line2D.set_solid_capstyle`. """ - return self._solidcapstyle + return self._solidcapstyle.name def is_dashed(self): """ Return whether line has a dashed linestyle. + A custom linestyle is assumed to be dashed, we do not inspect the + ``onoffseq`` directly. + See also `~.Line2D.set_linestyle`. """ return self._linestyle in ('--', '-.', ':') @@ -1531,4 +1535,4 @@ def onpick(self, event): # You can not set the docstring of an instancemethod, # but you can on the underlying function. Go figure. -docstring.dedent_interpd(Line2D.__init__) +docstring.interpd(Line2D.__init__) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 403321b38c70..821b8b46430f 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -134,6 +134,7 @@ from . import _api, cbook, rcParams from .path import Path from .transforms import IdentityTransform, Affine2D +from ._types import JoinStyle, CapStyle # special-purpose marker identifiers: (TICKLEFT, TICKRIGHT, TICKUP, TICKDOWN, @@ -239,8 +240,8 @@ def _recache(self): self._alt_path = None self._alt_transform = None self._snap_threshold = None - self._joinstyle = 'round' - self._capstyle = 'butt' + self._joinstyle = JoinStyle.round + self._capstyle = CapStyle.butt # Initial guess: Assume the marker is filled unless the fillstyle is # set to 'none'. The marker function will override this for unfilled # markers. @@ -380,14 +381,14 @@ def _set_tuple_marker(self): symstyle = marker[1] if symstyle == 0: self._path = Path.unit_regular_polygon(numsides) - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter elif symstyle == 1: self._path = Path.unit_regular_star(numsides) - self._joinstyle = 'bevel' + self._joinstyle = JoinStyle.bevel elif symstyle == 2: self._path = Path.unit_regular_asterisk(numsides) self._filled = False - self._joinstyle = 'bevel' + self._joinstyle = JoinStyle.bevel else: raise ValueError(f"Unexpected tuple marker: {marker}") self._transform = Affine2D().scale(0.5).rotate_deg(rotation) @@ -497,7 +498,7 @@ def _set_triangle(self, rot, skip): self._alt_transform = self._transform - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter def _set_triangle_up(self): return self._set_triangle(0.0, 0) @@ -537,7 +538,7 @@ def _set_square(self): self._transform.rotate_deg(rotate) self._alt_transform = self._transform - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter def _set_diamond(self): self._transform = Affine2D().translate(-0.5, -0.5).rotate_deg(45) @@ -558,7 +559,7 @@ def _set_diamond(self): rotate = 0. self._transform.rotate_deg(rotate) self._alt_transform = self._transform - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter def _set_thin_diamond(self): self._set_diamond() @@ -594,7 +595,7 @@ def _set_pentagon(self): self._alt_path = mpath_alt self._alt_transform = self._transform - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter def _set_star(self): self._transform = Affine2D().scale(0.5) @@ -625,7 +626,7 @@ def _set_star(self): self._alt_path = mpath_alt self._alt_transform = self._transform - self._joinstyle = 'bevel' + self._joinstyle = JoinStyle.bevel def _set_hexagon1(self): self._transform = Affine2D().scale(0.5) @@ -659,7 +660,7 @@ def _set_hexagon1(self): self._alt_path = mpath_alt self._alt_transform = self._transform - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter def _set_hexagon2(self): self._transform = Affine2D().scale(0.5).rotate_deg(30) @@ -694,7 +695,7 @@ def _set_hexagon2(self): self._alt_path = mpath_alt self._alt_transform = self._transform - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter def _set_octagon(self): self._transform = Affine2D().scale(0.5) @@ -724,7 +725,7 @@ def _set_octagon(self): self._path = self._alt_path = half self._alt_transform = self._transform.frozen().rotate_deg(180.0) - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter _line_marker_path = Path([[0.0, -1.0], [0.0, 1.0]]) @@ -798,7 +799,7 @@ def _set_caretdown(self): self._snap_threshold = 3.0 self._filled = False self._path = self._caret_path - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter def _set_caretup(self): self._set_caretdown() @@ -863,7 +864,7 @@ def _set_x(self): def _set_plus_filled(self): self._transform = Affine2D().translate(-0.5, -0.5) self._snap_threshold = 5.0 - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter fs = self.get_fillstyle() if not self._half_fill(): self._path = self._plus_filled_path @@ -895,7 +896,7 @@ def _set_plus_filled(self): def _set_x_filled(self): self._transform = Affine2D().translate(-0.5, -0.5) self._snap_threshold = 5.0 - self._joinstyle = 'miter' + self._joinstyle = JoinStyle.miter fs = self.get_fillstyle() if not self._half_fill(): self._path = self._x_filled_path diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 103adee18366..8c53ba8dedfa 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -9,13 +9,14 @@ import numpy as np import matplotlib as mpl -from . import (_api, artist, cbook, colors, docstring, hatch as mhatch, - lines as mlines, transforms) +from . import (_api, artist, cbook, colors, docstring, + hatch as mhatch, lines as mlines, transforms) from .bezier import ( NonIntersectingPathException, get_cos_sin, get_intersection, get_parallels, inside_circle, make_wedged_bezier2, split_bezier_intersecting_with_closedpath, split_path_inout) from .path import Path +from ._types import JoinStyle, CapStyle @cbook._define_aliases({ @@ -74,9 +75,9 @@ def __init__(self, if linestyle is None: linestyle = "solid" if capstyle is None: - capstyle = 'butt' + capstyle = CapStyle.butt if joinstyle is None: - joinstyle = 'miter' + joinstyle = JoinStyle.miter if antialiased is None: antialiased = mpl.rcParams['patch.antialiased'] @@ -471,32 +472,34 @@ def get_fill(self): # attribute. fill = property(get_fill, set_fill) + @docstring.interpd def set_capstyle(self, s): """ - Set the capstyle. + Set the `.CapStyle`. Parameters ---------- - s : {'butt', 'round', 'projecting'} + s : `.CapStyle` or %(CapStyle)s """ - mpl.rcsetup.validate_capstyle(s) - self._capstyle = s + cs = CapStyle(s) + self._capstyle = cs self.stale = True def get_capstyle(self): """Return the capstyle.""" return self._capstyle + @docstring.interpd def set_joinstyle(self, s): """ - Set the joinstyle. + Set the `.JoinStyle`. Parameters ---------- - s : {'miter', 'round', 'bevel'} + s : `.JoinStyle` or %(JoinStyle)s """ - mpl.rcsetup.validate_joinstyle(s) - self._joinstyle = s + js = JoinStyle(s) + self._joinstyle = js self.stale = True def get_joinstyle(self): @@ -507,38 +510,20 @@ def set_hatch(self, hatch): r""" Set the hatching pattern. - *hatch* can be one of:: - - / - diagonal hatching - \ - back diagonal - | - vertical - - - horizontal - + - crossed - x - crossed diagonal - o - small circle - O - large circle - . - dots - * - stars - - Letters can be combined, in which case all the specified - hatchings are done. If same letter repeats, it increases the - density of hatching of that pattern. - - Hatching is supported in the PostScript, PDF, SVG and Agg - backends only. - Parameters ---------- - hatch : {'/', '\\', '|', '-', '+', 'x', 'o', 'O', '.', '*'} + hatch : `.Hatch`-like or str + The built-in hatching patterns are %(Hatch)s. Multiple hatch types + can be combined (e.g. ``'|-'`` is equivalent to ``'+'``) and + repeating a character increases the density of that pattern. For + more advanced usage, see the `.Hatch` docs. """ - # Use validate_hatch(list) after deprecation. - mhatch._validate_hatch_pattern(hatch) - self._hatch = hatch + self._hatch = mhatch.Hatch(hatch) self.stale = True def get_hatch(self): """Return the hatching pattern.""" - return self._hatch + return self._hatch._pattern_spec @contextlib.contextmanager def _bind_draw_path_function(self, renderer): @@ -3970,8 +3955,8 @@ def __init__(self, posA=None, posB=None, path=None, ``joinstyle`` for `FancyArrowPatch` are set to ``"round"``. """ # Traditionally, the cap- and joinstyle for FancyArrowPatch are round - kwargs.setdefault("joinstyle", "round") - kwargs.setdefault("capstyle", "round") + kwargs.setdefault("joinstyle", JoinStyle.round) + kwargs.setdefault("capstyle", CapStyle.round) super().__init__(**kwargs) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 972b6d2ec0d1..49270daa8b5d 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -1014,14 +1014,14 @@ def wedge(cls, theta1, theta2, n=None): @staticmethod @lru_cache(8) - def hatch(hatchpattern, density=6): + def hatch(hatchpattern, density=None): """ Given a hatch specifier, *hatchpattern*, generates a Path that can be used in a repeated hatching pattern. *density* is the number of lines per unit square. """ - from matplotlib.hatch import get_path - return (get_path(hatchpattern, density) + from matplotlib.hatch import Hatch + return (Hatch(hatchpattern, density).path if hatchpattern is not None else None) def clip_to_bbox(self, bbox, inside=True): diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index e6c943fdac59..4b4f0f0b8d6d 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -19,13 +19,16 @@ from numbers import Number import operator import re +import warnings import numpy as np from matplotlib import _api, animation, cbook from matplotlib.cbook import ls_mapper -from matplotlib.fontconfig_pattern import parse_fontconfig_pattern from matplotlib.colors import is_color_like +from matplotlib.fontconfig_pattern import parse_fontconfig_pattern +from matplotlib.hatch import Hatch +from matplotlib._types import JoinStyle, CapStyle # Don't let the original cycler collide with our validating cycler from cycler import Cycler, cycler as ccycler @@ -571,41 +574,10 @@ def _is_iterable_not_string_like(x): raise ValueError(f"linestyle {ls!r} is not a valid on-off ink sequence.") -def _deprecate_case_insensitive_join_cap(s): - s_low = s.lower() - if s != s_low: - if s_low in ['miter', 'round', 'bevel']: - cbook.warn_deprecated( - "3.3", message="Case-insensitive capstyles are deprecated " - "since %(since)s and support for them will be removed " - "%(removal)s; please pass them in lowercase.") - elif s_low in ['butt', 'round', 'projecting']: - cbook.warn_deprecated( - "3.3", message="Case-insensitive joinstyles are deprecated " - "since %(since)s and support for them will be removed " - "%(removal)s; please pass them in lowercase.") - # Else, error out at the check_in_list stage. - return s_low - - -def validate_joinstyle(s): - s = _deprecate_case_insensitive_join_cap(s) - _api.check_in_list(['miter', 'round', 'bevel'], joinstyle=s) - return s - - -def validate_capstyle(s): - s = _deprecate_case_insensitive_join_cap(s) - _api.check_in_list(['butt', 'round', 'projecting'], capstyle=s) - return s - - validate_fillstyle = ValidateInStrings( 'markers.fillstyle', ['full', 'left', 'right', 'bottom', 'top', 'none']) -validate_joinstylelist = _listify_validator(validate_joinstyle) -validate_capstylelist = _listify_validator(validate_capstyle) validate_fillstylelist = _listify_validator(validate_fillstyle) @@ -756,25 +728,6 @@ def _validate_greaterequal0_lessequal1(s): 'axes.grid.axis', ['x', 'y', 'both'], _deprecated_since="3.3") -def validate_hatch(s): - r""" - Validate a hatch pattern. - A hatch pattern string can have any sequence of the following - characters: ``\ / | - + * . x o O``. - """ - if not isinstance(s, str): - raise ValueError("Hatch pattern must be a string") - cbook._check_isinstance(str, hatch_pattern=s) - unknown = set(s) - {'\\', '/', '|', '-', '+', '*', '.', 'x', 'o', 'O'} - if unknown: - raise ValueError("Unknown hatch symbol(s): %s" % list(unknown)) - return s - - -validate_hatchlist = _listify_validator(validate_hatch) -validate_dashlist = _listify_validator(validate_floatlist) - - _prop_validators = { 'color': _listify_validator(validate_color_for_prop_cycle, allow_stringlist=True), @@ -782,8 +735,8 @@ def validate_hatch(s): 'linestyle': _listify_validator(_validate_linestyle), 'facecolor': validate_colorlist, 'edgecolor': validate_colorlist, - 'joinstyle': validate_joinstylelist, - 'capstyle': validate_capstylelist, + 'joinstyle': _listify_validator(JoinStyle), + 'capstyle': _listify_validator(CapStyle), 'fillstyle': validate_fillstylelist, 'markerfacecolor': validate_colorlist, 'markersize': validate_floatlist, @@ -792,9 +745,11 @@ def validate_hatch(s): 'markevery': validate_markeverylist, 'alpha': validate_floatlist, 'marker': validate_stringlist, - 'hatch': validate_hatchlist, - 'dashes': validate_dashlist, + 'hatch': _listify_validator(Hatch), + 'dashes': _listify_validator(validate_floatlist), } + + _prop_aliases = { 'c': 'color', 'lw': 'linewidth', @@ -1000,6 +955,21 @@ def _convert_validator_spec(key, conv): return conv +@_api.deprecated('3.4') +def validate_hatch(s): + r""" + Validate a hatch pattern. + A hatch pattern string can have any sequence of the following + characters: ``\ / | - + * . x o O``. + """ + with warnings.catch_warnings(record=True) as w: + # MatplotlibDeprecationWarning if the hatch pattern is invalid. + Hatch(s) + if len(w) > 0: + raise w[0] + return s + + # Mapping of rcParams to validators. # Converters given as lists or _ignorecase are converted to ValidateInStrings # immediately below. @@ -1028,10 +998,10 @@ def _convert_validator_spec(key, conv): "lines.markeredgewidth": validate_float, "lines.markersize": validate_float, # markersize, in points "lines.antialiased": validate_bool, # antialiased (no jaggies) - "lines.dash_joinstyle": validate_joinstyle, - "lines.solid_joinstyle": validate_joinstyle, - "lines.dash_capstyle": validate_capstyle, - "lines.solid_capstyle": validate_capstyle, + "lines.dash_joinstyle": JoinStyle, + "lines.solid_joinstyle": JoinStyle, + "lines.dash_capstyle": CapStyle, + "lines.solid_capstyle": CapStyle, "lines.dashed_pattern": validate_floatlist, "lines.dashdot_pattern": validate_floatlist, "lines.dotted_pattern": validate_floatlist, diff --git a/lib/matplotlib/sphinxext/__init__.py b/lib/matplotlib/sphinxext/__init__.py index e69de29bb2d1..e994f9258771 100644 --- a/lib/matplotlib/sphinxext/__init__.py +++ b/lib/matplotlib/sphinxext/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +_static_path = Path(__file__).resolve().parent / Path('static') diff --git a/lib/matplotlib/sphinxext/plot_directive.py b/lib/matplotlib/sphinxext/plot_directive.py index 2f29dc00b3c3..857915a43bc6 100644 --- a/lib/matplotlib/sphinxext/plot_directive.py +++ b/lib/matplotlib/sphinxext/plot_directive.py @@ -156,7 +156,7 @@ import matplotlib from matplotlib.backend_bases import FigureManagerBase import matplotlib.pyplot as plt -from matplotlib import _pylab_helpers, cbook +from matplotlib import _pylab_helpers, cbook, sphinxext matplotlib.use("agg") align = cbook.deprecated( @@ -254,6 +254,13 @@ def run(self): raise self.error(str(e)) +def _copy_css_file(app, exc): + if exc is None and app.builder.format == 'html': + src = sphinxext._static_path / Path('plot_directive.css') + dst = app.outdir / Path('_static') + shutil.copy(src, dst) + + def setup(app): setup.app = app setup.config = app.config @@ -269,9 +276,9 @@ def setup(app): app.add_config_value('plot_apply_rcparams', False, True) app.add_config_value('plot_working_directory', None, True) app.add_config_value('plot_template', None, True) - app.connect('doctree-read', mark_plot_labels) - + app.add_css_file('plot_directive.css') + app.connect('build-finished', _copy_css_file) metadata = {'parallel_read_safe': True, 'parallel_write_safe': True, 'version': matplotlib.__version__} return metadata @@ -337,7 +344,6 @@ def split_code_at_show(text): # Template # ----------------------------------------------------------------------------- - TEMPLATE = """ {{ source_code }} @@ -372,9 +378,8 @@ def split_code_at_show(text): `{{ fmt }} <{{ dest_dir }}/{{ img.basename }}.{{ fmt }}>`__ {%- endfor -%} ) - {%- endif -%} - - {{ caption }} + {%- endif %} +{{ caption }} {# appropriate leading whitespace added beforehand #} {% endfor %} .. only:: not html @@ -385,7 +390,7 @@ def split_code_at_show(text): {{ option }} {% endfor %} - {{ caption }} +{{ caption }} {# appropriate leading whitespace added beforehand #} {% endfor %} """ @@ -521,7 +526,7 @@ def render_figures(code, code_path, output_dir, output_base, context, """ formats = get_plot_formats(config) - # -- Try to determine if all images already exist + # Try to determine if all images already exist code_pieces = split_code_at_show(code) @@ -624,6 +629,13 @@ def run(arguments, content, options, state_machine, state, lineno): default_fmt = formats[0][0] options.setdefault('include-source', config.plot_include_source) + if 'class' in options: + # classes are parsed into a list of string, and output by simply + # printing the list, abusing the fact that RST guarantees to strip + # non-conforming characters + options['class'] = ['plot-directive'] + options['class'] + else: + options.setdefault('class', ['plot-directive']) keep_context = 'context' in options context_opt = None if not keep_context else options['context'] diff --git a/lib/matplotlib/sphinxext/static/plot_directive.css b/lib/matplotlib/sphinxext/static/plot_directive.css new file mode 100644 index 000000000000..dcd95d62713c --- /dev/null +++ b/lib/matplotlib/sphinxext/static/plot_directive.css @@ -0,0 +1,13 @@ +/* + * plot_directive.css + * ~~~~~~~~~~~~ + * + * Stylesheet controlling images created using the `plot` directive within + * Sphinx. + * + */ + +img.plot-directive { + border: 0; + max-width: 100%; +} diff --git a/lib/matplotlib/tests/test_sphinxext.py b/lib/matplotlib/tests/test_sphinxext.py index 3ce8d2951d07..ce5ac319975d 100644 --- a/lib/matplotlib/tests/test_sphinxext.py +++ b/lib/matplotlib/tests/test_sphinxext.py @@ -57,3 +57,7 @@ def plot_file(num): assert b'Plot 17 uses the caption option.' in html_contents # check if figure caption made it into html file assert b'This is the caption for plot 18.' in html_contents + # check if the custom classes made it into the html file + assert b'plot-directive my-class my-other-class' in html_contents + # check that the multi-image caption is applied twice + assert html_contents.count(b'This caption applies to both plots.') == 2 diff --git a/lib/matplotlib/tests/tinypages/some_plots.rst b/lib/matplotlib/tests/tinypages/some_plots.rst index 5a71abc9e761..514552decfee 100644 --- a/lib/matplotlib/tests/tinypages/some_plots.rst +++ b/lib/matplotlib/tests/tinypages/some_plots.rst @@ -141,3 +141,23 @@ using the :caption: option: .. plot:: range4.py :caption: This is the caption for plot 18. + +Plot 19 uses shows that the "plot-directive" class is still appended, even if +we request other custom classes: + +.. plot:: range4.py + :class: my-class my-other-class + + Should also have a caption. + +Plot 20 shows that the default template correctly prints the multi-image +scenario: + +.. plot:: + :caption: This caption applies to both plots. + + plt.figure() + plt.plot(range(6)) + + plt.figure() + plt.plot(range(4))