Skip to content

Commit db0b215

Browse files
committed
Fix alt and caption handling in Sphinx directives
We currently template new reST to be re-parsed after the plot is created, but incorrectly copied the `alt` and `caption` values when they were wrapped. Additionally, change `figmpl` to use Sphinx/docutils' tag creation functions. These functions correctly escape attributes and so fixes invalid HTML when alt text contains quotes.
1 parent 0b7a88a commit db0b215

File tree

4 files changed

+53
-44
lines changed

4 files changed

+53
-44
lines changed

lib/matplotlib/sphinxext/figmpl_directive.py

+38-40
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,14 @@
1212
See the *FigureMpl* documentation below.
1313
1414
"""
15-
from docutils import nodes
16-
17-
from docutils.parsers.rst import directives
18-
from docutils.parsers.rst.directives.images import Figure, Image
19-
2015
import os
2116
from os.path import relpath
2217
from pathlib import PurePath, Path
2318
import shutil
2419

20+
from docutils import nodes
21+
from docutils.parsers.rst import directives
22+
from docutils.parsers.rst.directives.images import Figure, Image
2523
from sphinx.errors import ExtensionError
2624

2725
import matplotlib
@@ -193,12 +191,13 @@ def visit_figmpl_html(self, node):
193191
# make uri also be relative...
194192
nm = PurePath(node['uri'][1:]).name
195193
uri = f'{imagerel}/{rel}{nm}'
194+
img_attrs = {'src': uri, 'alt': node['alt']}
196195

197196
# make srcset str. Need to change all the prefixes!
198197
maxsrc = uri
199-
srcsetst = ''
200198
if srcset:
201199
maxmult = -1
200+
srcsetst = ''
202201
for mult, src in srcset.items():
203202
nm = PurePath(src[1:]).name
204203
# ../../_images/plot_1_2_0x.png
@@ -214,44 +213,43 @@ def visit_figmpl_html(self, node):
214213
maxsrc = path
215214

216215
# trim trailing comma and space...
217-
srcsetst = srcsetst[:-2]
216+
img_attrs['srcset'] = srcsetst[:-2]
218217

219-
alt = node['alt']
220218
if node['class'] is not None:
221-
classst = ' '.join(node['class'])
222-
classst = f'class="{classst}"'
223-
224-
else:
225-
classst = ''
226-
227-
stylers = ['width', 'height', 'scale']
228-
stylest = ''
229-
for style in stylers:
219+
img_attrs['class'] = ' '.join(node['class'])
220+
for style in ['width', 'height', 'scale']:
230221
if node[style]:
231-
stylest += f'{style}: {node[style]};'
232-
233-
figalign = node['align'] if node['align'] else 'center'
234-
235-
# <figure class="align-default" id="id1">
236-
# <a class="reference internal image-reference" href="_images/index-1.2x.png">
237-
# <img alt="_images/index-1.2x.png" src="_images/index-1.2x.png" style="width: 53%;" />
238-
# </a>
239-
# <figcaption>
240-
# <p><span class="caption-text">Figure caption is here....</span>
241-
# <a class="headerlink" href="#id1" title="Permalink to this image">#</a></p>
242-
# </figcaption>
243-
# </figure>
244-
img_block = (f'<img src="{uri}" style="{stylest}" srcset="{srcsetst}" '
245-
f'alt="{alt}" {classst}/>')
246-
html_block = f'<figure class="align-{figalign}">\n'
247-
html_block += f' <a class="reference internal image-reference" href="{maxsrc}">\n'
248-
html_block += f' {img_block}\n </a>\n'
222+
if 'style' not in img_attrs:
223+
img_attrs['style'] = f'{style}: {node[style]};'
224+
else:
225+
img_attrs['style'] += f'{style}: {node[style]};'
226+
227+
# <figure class="align-default" id="id1">
228+
# <a class="reference internal image-reference" href="_images/index-1.2x.png">
229+
# <img alt="_images/index-1.2x.png"
230+
# src="_images/index-1.2x.png" style="width: 53%;" />
231+
# </a>
232+
# <figcaption>
233+
# <p><span class="caption-text">Figure caption is here....</span>
234+
# <a class="headerlink" href="#id1" title="Permalink to this image">#</a></p>
235+
# </figcaption>
236+
# </figure>
237+
self.body.append(
238+
self.starttag(
239+
node, 'figure',
240+
CLASS=f'align-{node["align"]}' if node['align'] else 'align-center'))
241+
self.body.append(
242+
self.starttag(node, 'a', CLASS='reference internal image-reference',
243+
href=maxsrc) +
244+
self.emptytag(node, 'img', **img_attrs) +
245+
'</a>\n')
249246
if node['caption']:
250-
html_block += ' <figcaption>\n'
251-
html_block += f' <p><span class="caption-text">{node["caption"]}</span></p>\n'
252-
html_block += ' </figcaption>\n'
253-
html_block += '</figure>\n'
254-
self.body.append(html_block)
247+
self.body.append(self.starttag(node, 'figcaption'))
248+
self.body.append(self.starttag(node, 'p'))
249+
self.body.append(self.starttag(node, 'span', CLASS='caption-text'))
250+
self.body.append(node['caption'])
251+
self.body.append('</span></p></figcaption>\n')
252+
self.body.append('</figure>\n')
255253

256254

257255
def visit_figmpl_latex(self, node):

lib/matplotlib/sphinxext/plot_directive.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,7 @@ def run(arguments, content, options, state_machine, state, lineno):
876876

877877
# Properly indent the caption
878878
if caption and config.plot_srcset:
879-
caption = f':caption: {caption}'
879+
caption = f':caption: {caption.replace("\n", " ")}'
880880
elif caption:
881881
caption = '\n' + '\n'.join(' ' + line.strip()
882882
for line in caption.split('\n'))
@@ -896,6 +896,9 @@ def run(arguments, content, options, state_machine, state, lineno):
896896
if nofigs:
897897
images = []
898898

899+
if 'alt' in options:
900+
options['alt'] = options['alt'].replace('\n', ' ')
901+
899902
opts = [
900903
f':{key}: {val}' for key, val in options.items()
901904
if key in ('alt', 'height', 'width', 'scale', 'align', 'class')]

lib/matplotlib/tests/test_sphinxext.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,11 @@ def plot_directive_file(num):
8383
assert filecmp.cmp(range_6, img_dir / 'range6_range6.png')
8484
# check if figure caption made it into html file
8585
assert b'This is the caption for plot 15.' in html_contents
86-
# check if figure caption using :caption: made it into html file
87-
assert b'Plot 17 uses the caption option.' in html_contents
86+
# check if figure caption using :caption: made it into html file (because this plot
87+
# doesn't use srcset, the caption preserves newlines in the output.)
88+
assert b'Plot 17 uses the caption option,\nwith multi-line input.' in html_contents
89+
# check if figure alt text using :alt: made it into html file
90+
assert b'Plot 17 uses the alt option, with multi-line input.' in html_contents
8891
# check if figure caption made it into html file
8992
assert b'This is the caption for plot 18.' in html_contents
9093
# check if the custom classes made it into the html file

lib/matplotlib/tests/tinypages/some_plots.rst

+6-1
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,12 @@ Plot 16 uses a specific function in a file with plot commands:
135135
Plot 17 gets a caption specified by the :caption: option:
136136

137137
.. plot::
138-
:caption: Plot 17 uses the caption option.
138+
:caption:
139+
Plot 17 uses the caption option,
140+
with multi-line input.
141+
:alt:
142+
Plot 17 uses the alt option,
143+
with multi-line input.
139144

140145
plt.figure()
141146
plt.plot(range(6))

0 commit comments

Comments
 (0)