diff --git a/doc/users/next_whats_new/sketch_xkcd.rst b/doc/users/next_whats_new/sketch_xkcd.rst new file mode 100644 index 000000000000..c186ed3edaef --- /dev/null +++ b/doc/users/next_whats_new/sketch_xkcd.rst @@ -0,0 +1,26 @@ +path.effects rcParam can be set in stylesheet and new xkcd stylesheet +--------------------------------------------------------------------- + +Can now set the ``path.effects`` :ref:`rcParam in a style sheet ` +using a list of ``(patheffects function name, {**kwargs})``:: + + path.effects: ('Normal', ), ('Stroke', {'offset': (1, 2)}), ('withStroke', {'linewidth': 4, 'foreground': 'w'}) + + +This feature means that the xkcd style can be used like any other stylesheet: + +.. plot:: + :include-source: true + :alt: plot where graph and text appear in a hand drawn comic like style + + import numpy as np + import matplotlib.pyplot as plt + + x = np.linspace(0, 2* np.pi, 100) + y = np.sin(x) + + with plt.style.context('xkcd'): + + fig, ax = plt.subplots() + ax.set_title("sine curve") + ax.plot(x, y) diff --git a/galleries/examples/style_sheets/style_sheets_reference.py b/galleries/examples/style_sheets/style_sheets_reference.py index 43b9c4f941ee..1303ef6e6e07 100644 --- a/galleries/examples/style_sheets/style_sheets_reference.py +++ b/galleries/examples/style_sheets/style_sheets_reference.py @@ -23,6 +23,7 @@ import matplotlib.pyplot as plt import numpy as np +import matplotlib as mpl import matplotlib.colors as mcolors from matplotlib.patches import Rectangle @@ -47,7 +48,7 @@ def plot_colored_lines(ax): def sigmoid(t, t0): return 1 / (1 + np.exp(-(t - t0))) - nb_colors = len(plt.rcParams['axes.prop_cycle']) + nb_colors = len(mpl.rcParams['axes.prop_cycle']) shifts = np.linspace(-5, 5, nb_colors) amplitudes = np.linspace(1, 1.5, nb_colors) for t0, a in zip(shifts, amplitudes): @@ -75,14 +76,15 @@ def plot_colored_circles(ax, prng, nb_samples=15): the color cycle, because different styles may have different numbers of colors. """ - for sty_dict, j in zip(plt.rcParams['axes.prop_cycle'](), + for sty_dict, j in zip(mpl.rcParams['axes.prop_cycle'](), range(nb_samples)): ax.add_patch(plt.Circle(prng.normal(scale=3, size=2), radius=1.0, color=sty_dict['color'])) ax.grid(visible=True) # Add title for enabling grid - plt.title('ax.grid(True)', family='monospace', fontsize='small') + font_family = mpl.rcParams.get('font.family', 'monospace') + ax.set_title('ax.grid(True)', family=font_family, fontsize='medium') ax.set_xlim([-4, 8]) ax.set_ylim([-5, 6]) @@ -133,11 +135,12 @@ def plot_figure(style_label=""): # make a suptitle, in the same style for all subfigures, # except those with dark backgrounds, which get a lighter color: background_color = mcolors.rgb_to_hsv( - mcolors.to_rgb(plt.rcParams['figure.facecolor']))[2] + mcolors.to_rgb(mpl.rcParams['figure.facecolor']))[2] if background_color < 0.5: title_color = [0.8, 0.8, 1] else: title_color = np.array([19, 6, 84]) / 256 + fig.suptitle(style_label, x=0.01, ha='left', color=title_color, fontsize=14, fontfamily='DejaVu Sans', fontweight='normal') @@ -147,28 +150,25 @@ def plot_figure(style_label=""): plot_colored_lines(axs[3]) plot_histograms(axs[4], prng) plot_colored_circles(axs[5], prng) - # add divider rec = Rectangle((1 + 0.025, -2), 0.05, 16, clip_on=False, color='gray') axs[4].add_artist(rec) -if __name__ == "__main__": - - # Set up a list of all available styles, in alphabetical order but - # the `default` and `classic` ones, which will be forced resp. in - # first and second position. - # styles with leading underscores are for internal use such as testing - # and plot types gallery. These are excluded here. - style_list = ['default', 'classic'] + sorted( - style for style in plt.style.available - if style != 'classic' and not style.startswith('_')) - - # Plot a demonstration figure for every available style sheet. - for style_label in style_list: - with plt.rc_context({"figure.max_open_warning": len(style_list)}): - with plt.style.context(style_label): - plot_figure(style_label=style_label) - plt.show() +# Set up a list of all available styles, in alphabetical order but +# the `default` and `classic` ones, which will be forced resp. in +# first and second position. +# styles with leading underscores are for internal use such as testing +# and plot types gallery. These are excluded here. +style_list = ['default', 'classic'] + sorted( + style for style in mpl.style.available + if style != 'classic' and not style.startswith('_')) + +# Plot a demonstration figure for every available style sheet: +for style_label in style_list: + with mpl.rc_context({"figure.max_open_warning": len(style_list)}): + with mpl.style.context(style_label, after_reset=True): + plot_figure(style_label=style_label) + plt.show() diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 53f27c46314a..425b9a464916 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -163,7 +163,6 @@ from matplotlib._api import MatplotlibDeprecationWarning from matplotlib.rcsetup import validate_backend, cycler - _log = logging.getLogger(__name__) __bibtex__ = r"""@Article{Hunter:2007, @@ -764,6 +763,14 @@ def __getitem__(self, key): from matplotlib import pyplot as plt plt.switch_backend(rcsetup._auto_backend_sentinel) + elif key == "path.effects" and self is globals().get("rcParams"): + # defers loading of patheffects to avoid circular imports + import matplotlib.patheffects as path_effects + # use patheffects object or instantiate patheffects.object(**kwargs) + return [pe if isinstance(pe, path_effects.AbstractPathEffect) + else getattr(path_effects, pe[0])(**pe[1]) + for pe in self._get('path.effects')] + return self._get(key) def _get_backend_or_none(self): diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 301afc38456b..1137ab16ba1a 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -677,7 +677,9 @@ # line (in pixels). # - *randomness* is the factor by which the length is # randomly scaled. -#path.effects: +#path.effects: # list of (patheffects function name, {**kwargs} tuples + # ('withStroke', {'linewidth': 4}), ('SimpleLineShadow') + ## *************************************************************************** diff --git a/lib/matplotlib/mpl-data/stylelib/xkcd.mplstyle b/lib/matplotlib/mpl-data/stylelib/xkcd.mplstyle new file mode 100644 index 000000000000..7f548d959633 --- /dev/null +++ b/lib/matplotlib/mpl-data/stylelib/xkcd.mplstyle @@ -0,0 +1,30 @@ +## default xkcd style + +# line +lines.linewidth : 2.0 + +# font +font.family : xkcd, xkcd Script, Comic Neue, Comic Sans MS +font.size : 14.0 + +# axes +axes.linewidth : 1.5 +axes.grid : False +axes.unicode_minus: False +axes.edgecolor: black + +# ticks +xtick.major.size : 8 +xtick.major.width: 3 +ytick.major.size : 8 +ytick.major.width: 3 + +# grids +grid.linewidth: 0.0 + +# figure +figure.facecolor: white + +# path +path.sketch : 1, 100, 2 +path.effects: ('withStroke', {'linewidth': 4, 'foreground': 'w' }) diff --git a/lib/matplotlib/patheffects.py b/lib/matplotlib/patheffects.py index 5bb4c8e2a501..85da66f442d7 100644 --- a/lib/matplotlib/patheffects.py +++ b/lib/matplotlib/patheffects.py @@ -161,6 +161,9 @@ class Normal(AbstractPathEffect): no special path effect. """ + def __init__(self, offset=(0., 0.)): + super().__init__(offset) + def _subclass_with_normal(effect_class): """ diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 1255818b41b5..22a62a4ab261 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -705,13 +705,29 @@ def setp(obj, *args, **kwargs): def xkcd( scale: float = 1, length: float = 100, randomness: float = 2 ) -> ExitStack: - """ - Turn on `xkcd `_ sketch-style drawing mode. + r""" + [*Discouraged*] Turn on `xkcd `_ sketch-style drawing mode. + + .. admonition:: Discouraged + + The use of ``plt.xkcd()`` is discouraged; instead use + the ``xkcd`` style sheet:: + + plt.style.use('xkcd') + with plt.style.use('xkcd'): + + Instead of passing in arguments, modify the ``rcParam``:: - This will only have an effect on things drawn after this function is called. + import matplotlib as mpl - For best results, install the `xkcd script `_ - font; xkcd fonts are not packaged with Matplotlib. + mpl.rcParams['path.sketch'] = (scale, length, randomness) + + For more information, see :ref:`customizing` + + + This drawing mode only affects things drawn after this function is called. + For best results, the "xkcd script" font should be installed; it is + not included with Matplotlib. Parameters ---------- @@ -748,26 +764,8 @@ def xkcd( stack = ExitStack() stack.callback(dict.update, rcParams, rcParams.copy()) # type: ignore - from matplotlib import patheffects - rcParams.update({ - 'font.family': ['xkcd', 'xkcd Script', 'Comic Neue', 'Comic Sans MS'], - 'font.size': 14.0, - 'path.sketch': (scale, length, randomness), - 'path.effects': [ - patheffects.withStroke(linewidth=4, foreground="w")], - 'axes.linewidth': 1.5, - 'lines.linewidth': 2.0, - 'figure.facecolor': 'white', - 'grid.linewidth': 0.0, - 'axes.grid': False, - 'axes.unicode_minus': False, - 'axes.edgecolor': 'black', - 'xtick.major.size': 8, - 'xtick.major.width': 3, - 'ytick.major.size': 8, - 'ytick.major.width': 3, - }) - + rcParams.update({**style.library["xkcd"], + 'path.sketch': (scale, length, randomness)}) return stack diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 38d4606024d3..6246f5c861be 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -568,6 +568,41 @@ def validate_sketch(s): raise ValueError("Expected a (scale, length, randomness) tuple") from exc +def validate_path_effects(s): + if not s: + return [] + if isinstance(s, str) and s.strip().startswith("("): + s = ast.literal_eval(s) + + _validate_name = ValidateInStrings("path.effects.function", + ["Normal", + "PathPatchEffect", + "SimpleLineShadow", + "SimplePatchShadow", + "Stroke", + "TickedStroke", + "withSimplePatchShadow", + "withStroke", + "withTickedStroke"]) + + def _validate_dict(d): + if not isinstance(d, dict): + raise ValueError("Expected a dictionary of keyword arguments") + return d + + try: + # cast to list for the 1 tuple case + s = [s] if isinstance(s[0], str) else s + # patheffects.{AbstractPathEffect} object or (_valid_name, {**kwargs}) + return [pe if getattr(pe, '__module__', "") == 'matplotlib.patheffects' + else (_validate_name(pe[0].strip()), + {} if len(pe) < 2 else _validate_dict(pe[1])) + for pe in s] + except TypeError: + raise ValueError("Expected a list of patheffects functions" + " or (funcname, {**kwargs}) tuples") + + def _validate_greaterthan_minushalf(s): s = validate_float(s) if s > -0.5: @@ -1293,7 +1328,7 @@ def _convert_validator_spec(key, conv): "path.simplify_threshold": _validate_greaterequal0_lessequal1, "path.snap": validate_bool, "path.sketch": validate_sketch, - "path.effects": validate_anylist, + "path.effects": validate_path_effects, "agg.path.chunksize": validate_int, # 0 to disable chunking # key-mappings (multi-character mappings should be a list/tuple) diff --git a/lib/matplotlib/rcsetup.pyi b/lib/matplotlib/rcsetup.pyi index 70e94a7694a9..fcad07d4b3e0 100644 --- a/lib/matplotlib/rcsetup.pyi +++ b/lib/matplotlib/rcsetup.pyi @@ -2,6 +2,7 @@ from cycler import Cycler from collections.abc import Callable, Iterable from typing import Any, Literal, TypeVar +from matplotlib.patheffects import AbstractPathEffect from matplotlib.typing import ColorType, LineStyleType, MarkEveryType interactive_bk: list[str] @@ -140,6 +141,8 @@ def _validate_linestyle(s: Any) -> LineStyleType: ... def validate_markeverylist(s: Any) -> list[MarkEveryType]: ... def validate_bbox(s: Any) -> Literal["tight", "standard"] | None: ... def validate_sketch(s: Any) -> None | tuple[float, float, float]: ... +def validate_path_effects(s: Any +) -> list[None|AbstractPathEffect|tuple[str, dict[str, Any]]]: ... def validate_hatch(s: Any) -> str: ... def validate_hatchlist(s: Any) -> list[str]: ... def validate_dashlist(s: Any) -> list[list[float]]: ... diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index 8c0c32dc133b..3968bd808058 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -253,6 +253,18 @@ def test_xkcd(): ax.plot(x, y) +@image_comparison(['xkcd.png'], remove_text=True) +def test_xkcd_style(): + np.random.seed(0) + + x = np.linspace(0, 2 * np.pi, 100) + y = np.sin(x) + + with plt.style.context('xkcd'): + fig, ax = plt.subplots() + ax.plot(x, y) + + @image_comparison(['xkcd_marker.png'], remove_text=True) def test_xkcd_marker(): np.random.seed(0) @@ -269,6 +281,22 @@ def test_xkcd_marker(): ax.plot(x, y3, '^', ms=10) +@image_comparison(['xkcd_marker.png'], remove_text=True) +def test_xkcd_marker_style(): + np.random.seed(0) + + x = np.linspace(0, 5, 8) + y1 = x + y2 = 5 - x + y3 = 2.5 * np.ones(8) + + with plt.style.context('xkcd'): + fig, ax = plt.subplots() + ax.plot(x, y1, '+', ms=10) + ax.plot(x, y2, 'o', ms=10) + ax.plot(x, y3, '^', ms=10) + + @image_comparison(['marker_paths.pdf'], remove_text=True) def test_marker_paths_pdf(): N = 7 diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index e3e10145533d..25f1984422b6 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -11,6 +11,9 @@ from matplotlib import _api, _c_internal_utils import matplotlib.pyplot as plt import matplotlib.colors as mcolors +import matplotlib.patheffects as path_effects +from matplotlib.testing.decorators import check_figures_equal + import numpy as np from matplotlib.rcsetup import ( validate_bool, @@ -27,8 +30,10 @@ validate_markevery, validate_stringlist, validate_sketch, + validate_path_effects, _validate_linestyle, - _listify_validator) + _listify_validator, + ) def test_rcparams(tmp_path): @@ -629,6 +634,73 @@ def test_rcparams_legend_loc_from_file(tmp_path, value): with mpl.rc_context(fname=rc_path): assert mpl.rcParams["legend.loc"] == value +ped = [('Normal', {}), + ('Stroke', {'offset': (1, 2)}), + ('withStroke', {'linewidth': 4, 'foreground': 'w'})] + +pel = [path_effects.Normal(), + path_effects.Stroke((1, 2)), + path_effects.withStroke(linewidth=4, foreground='w')] + + +@pytest.mark.parametrize("value", [pel, ped], ids=["func", "dict"]) +def test_path_effects(value): + assert validate_path_effects(value) == value + for v in value: + assert validate_path_effects(value) == value + + +def test_path_effects_string(): + """test list of dicts properly parsed""" + pstr = "('Normal', ), " + pstr += "('Stroke', {'offset': (1, 2)})," + pstr += "('withStroke', {'linewidth': 4, 'foreground': 'w'})" + assert validate_path_effects(pstr) == ped + + +@pytest.mark.parametrize("fdict, flist", + [([ped[0]], [pel[0]]), + ([ped[1]], [pel[1]]), + ([ped[2]], [ped[2]]), + (ped, pel)], + ids=['function', 'args', 'kwargs', 'all']) +@check_figures_equal() +def test_path_effects_picture(fig_test, fig_ref, fdict, flist): + with mpl.rc_context({'path.effects': fdict}): + fig_test.subplots().plot([1, 2, 3]) + + with mpl.rc_context({'path.effects': flist}): + fig_ref.subplots().plot([1, 2, 3]) + + +@pytest.mark.parametrize("s, msg", [ + ([1, 2, 3], "Expected a list of patheffects .*"), + (("Happy", ), r".* 'Happy' is not a valid value for path\.effects\.function.*"), + (("Normal", [1, 2, 3]), "Expected a dictionary .*"),]) +def test_validate_path_effect_errors(s, msg): + with pytest.raises(ValueError, match=msg): + mpl.rcParams['path.effects'] = s + + +def test_path_effects_wrong_kwargs(): + mpl.rcParams['path.effects'] = [('Normal', {'invalid_kwarg': 1})] + + msg = ".* got an unexpected keyword argument 'invalid_kwarg'" + with pytest.raises(TypeError, match=msg): + mpl.rcParams.get('path.effects') + + +def test_path_effects_from_file(tmpdir): + # rcParams['legend.loc'] should be settable from matplotlibrc. + # if any of these are not allowed, an exception will be raised. + # test for gh issue #22338 + rc_path = tmpdir.join("matplotlibrc") + rc_path.write("path.effects: ('Normal', {}), ('withStroke', {'linewidth': 2})") + + with mpl.rc_context(fname=rc_path): + assert isinstance(mpl.rcParams["path.effects"][0], path_effects.Normal) + assert isinstance(mpl.rcParams["path.effects"][1], path_effects.withStroke) + @pytest.mark.parametrize("value", [(1, 2, 3), '1, 2, 3', '(1, 2, 3)']) def test_validate_sketch(value): diff --git a/lib/matplotlib/tests/test_style.py b/lib/matplotlib/tests/test_style.py index be038965e33d..1c9318c9a368 100644 --- a/lib/matplotlib/tests/test_style.py +++ b/lib/matplotlib/tests/test_style.py @@ -8,6 +8,7 @@ import matplotlib as mpl from matplotlib import pyplot as plt, style +from matplotlib.testing.decorators import check_figures_equal from matplotlib.style.core import USER_LIBRARY_PATHS, STYLE_EXTENSION @@ -169,6 +170,14 @@ def test_xkcd_no_cm(): assert mpl.rcParams["path.sketch"] == (1, 100, 2) +def test_xkcd_no_cm_style(): + assert mpl.rcParams["path.sketch"] is None + plt.style.use('xkcd') + assert mpl.rcParams["path.sketch"] == (1, 100, 2) + np.testing.break_cycles() + assert mpl.rcParams["path.sketch"] == (1, 100, 2) + + def test_xkcd_cm(): assert mpl.rcParams["path.sketch"] is None with plt.xkcd(): @@ -176,6 +185,25 @@ def test_xkcd_cm(): assert mpl.rcParams["path.sketch"] is None +def test_xkcd_cm_style(): + assert mpl.rcParams["path.sketch"] is None + with style.context('xkcd'): + assert mpl.rcParams["path.sketch"] == (1, 100, 2) + assert mpl.rcParams["path.sketch"] is None + + +@check_figures_equal() +def test_xkcd_style(fig_test, fig_ref): + + with style.context('xkcd'): + fig_test.subplots().plot([1, 2, 3]) + fig_test.text(.5, .5, "Hello World!") + + with plt.xkcd(): + fig_ref.subplots().plot([1, 2, 3]) + fig_ref.text(.5, .5, "Hello World!") + + def test_up_to_date_blacklist(): assert mpl.style.core.STYLE_BLACKLIST <= {*mpl.rcsetup._validators}