Skip to content

[ENH] xkcd.mplstyle w/ quasi parsing of patheffects.{functions} #26854

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions doc/users/next_whats_new/sketch_xkcd.rst
Original file line number Diff line number Diff line change
@@ -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 <customizing>`
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)
44 changes: 22 additions & 22 deletions galleries/examples/style_sheets/style_sheets_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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')

Expand All @@ -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()
9 changes: 8 additions & 1 deletion lib/matplotlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')]
Copy link
Contributor

Choose a reason for hiding this comment

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

This needs to support third party patheffects too (e.g. pkgname.modulename.patheffect instead of just withStroke)


return self._get(key)

def _get_backend_or_none(self):
Expand Down
4 changes: 3 additions & 1 deletion lib/matplotlib/mpl-data/matplotlibrc
Original file line number Diff line number Diff line change
Expand Up @@ -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')



## ***************************************************************************
Expand Down
30 changes: 30 additions & 0 deletions lib/matplotlib/mpl-data/stylelib/xkcd.mplstyle
Original file line number Diff line number Diff line change
@@ -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' })
3 changes: 3 additions & 0 deletions lib/matplotlib/patheffects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
48 changes: 23 additions & 25 deletions lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,13 +705,29 @@ def setp(obj, *args, **kwargs):
def xkcd(
scale: float = 1, length: float = 100, randomness: float = 2
) -> ExitStack:
"""
Turn on `xkcd <https://xkcd.com/>`_ sketch-style drawing mode.
r"""
[*Discouraged*] Turn on `xkcd <https://xkcd.com/>`_ 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 <https://github.com/ipython/xkcd-font/>`_
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
----------
Expand Down Expand Up @@ -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


Expand Down
37 changes: 36 additions & 1 deletion lib/matplotlib/rcsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions lib/matplotlib/rcsetup.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]]: ...
Expand Down
28 changes: 28 additions & 0 deletions lib/matplotlib/tests/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading