diff --git a/doc/api/artist_api.rst b/doc/api/artist_api.rst index b99842372bbe..6bba98028147 100644 --- a/doc/api/artist_api.rst +++ b/doc/api/artist_api.rst @@ -186,6 +186,21 @@ Miscellaneous Artist.get_in_layout Artist.stale + +Accessibility +------------- + + +.. autosummary:: + :template: autosummary.rst + :toctree: _as_gen + :nosignatures: + + Artist.get_aria + Artist.set_aria + Artist.update_aria + + Functions ========= diff --git a/doc/users/next_whats_new/aria.rst b/doc/users/next_whats_new/aria.rst new file mode 100644 index 000000000000..7dde632d00dd --- /dev/null +++ b/doc/users/next_whats_new/aria.rst @@ -0,0 +1,23 @@ +All ``Arist`` now carry wai-aria data +------------------------------------- + +It is now possible to attach `wai-aria +`__ role +information to any `~matplotlib.artist.Artist`. These roles are the +industry standard for providing accessibility mark up on the web. This +information can be used by downstream applications for providing accessible +descriptions of visualizations. Best practices in the space are still +developing, but by providing a mechanism to store and access this information +we will enable this development. + +There are three methods provided: + +- `~matplotlib.artist.Artist.set_aria` which will completely replace any existing roles. +- `~matplotlib.artist.Artist.update_aria` which will update the current roles in-place. +- `~matplotlib.artist.Artist.get_aria` which will return a copy of the current roles. + +We currently do no validation on either the keys or the values. + + +Matplotlib will use the ``'aria-label'`` role when saving svg output if it is +provided. diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 7967f597d483..84372547f542 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -192,6 +192,7 @@ def __init__(self): self._clippath = None self._clipon = True self._label = '' + self._aria = {} self._picker = None self._rasterized = False self._agg_filter = None @@ -1085,6 +1086,35 @@ def set_in_layout(self, in_layout): """ self._in_layout = in_layout + def get_aria(self): + """Return any ARIA properties assigned to the artist""" + return dict(self._aria) + + def set_aria(self, aria): + """ + Set ARIA properties to the artist. + + A primary use of this method is to attach aria-label to the artist to + provide alt text to figures. + + Parameters + ---------- + aria : dict + + """ + # TODO validation + if not isinstance(aria, dict): + if aria is not None: + raise TypeError( + f'aria must be dict or None, not {type(aria)}') + self._aria = aria + + def update_aria(self, **aria): + """Update WAI-ARIA properties on the artist.""" + # TODO validation + for k, v in aria.items(): + self._aria[k] = v + def get_label(self): """Return the label used for this artist in the legend.""" return self._label diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index 03b376a69894..1a5190083e2a 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -317,7 +317,7 @@ def _check_is_iterable_of_str(infos, key): class RendererSVG(RendererBase): def __init__(self, width, height, svgwriter, basename=None, image_dpi=72, - *, metadata=None): + *, metadata=None, aria=None): self.width = width self.height = height self.writer = XMLWriter(svgwriter) @@ -337,6 +337,7 @@ def __init__(self, width, height, svgwriter, basename=None, image_dpi=72, self._hatchd = {} self._has_gouraud = False self._n_gradients = 0 + self._aria = dict(aria or {}) super().__init__() self._glyph_map = dict() @@ -350,7 +351,9 @@ def __init__(self, width, height, svgwriter, basename=None, image_dpi=72, viewBox='0 0 %s %s' % (str_width, str_height), xmlns="http://www.w3.org/2000/svg", version="1.1", - attrib={'xmlns:xlink': "http://www.w3.org/1999/xlink"}) + attrib={'xmlns:xlink': "http://www.w3.org/1999/xlink"}, + **{k: self._aria[k] for k in ['aria-label'] if k in self._aria} + ) self._write_metadata(metadata) self._write_default_style() @@ -1373,9 +1376,12 @@ def print_svg(self, filename, *, bbox_inches_restore=None, metadata=None): self.figure.dpi = 72 width, height = self.figure.get_size_inches() w, h = width * 72, height * 72 + aria = self.figure.get_aria() renderer = MixedModeRenderer( self.figure, width, height, dpi, - RendererSVG(w, h, fh, image_dpi=dpi, metadata=metadata), + RendererSVG( + w, h, fh, image_dpi=dpi, metadata=metadata, aria=aria + ), bbox_inches_restore=bbox_inches_restore) self.figure.draw(renderer) renderer.finalize() diff --git a/lib/matplotlib/tests/test_artist.py b/lib/matplotlib/tests/test_artist.py index 9bfb4ebce1bd..10bd7752f68e 100644 --- a/lib/matplotlib/tests/test_artist.py +++ b/lib/matplotlib/tests/test_artist.py @@ -562,3 +562,10 @@ def draw(self, renderer, extra): assert 'aardvark' == art.draw(renderer, 'aardvark') assert 'aardvark' == art.draw(renderer, extra='aardvark') + + +def test_set_aria(): + art = martist.Artist() + with pytest.raises(TypeError, match='^aria must be dict'): + art.set_aria([]) + art.set_aria(dict(label="artist alt text")) diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index 78383904c4fd..7c9dd162518e 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -609,3 +609,20 @@ def test_svg_font_string(font_str, include_generic): assert font_info == f"{size}px {font_str}" assert text_count == len(ax.texts) + + +def test_aria(): + fig, ax = plt.subplots() + + with BytesIO() as fd: + fig.savefig(fd, format="svg") + buf = fd.getvalue() + + assert b'aria-label' not in buf + + fig.set_aria({'aria-label': 'A test of inserting the label'}) + with BytesIO() as fd: + fig.savefig(fd, format="svg") + buf = fd.getvalue() + + assert b'aria-label' in buf