Skip to content

Commit 4a0ffb8

Browse files
authored
Merge pull request matplotlib#25515 from jklymak/doc-bld-plot-directive-srcset
DOC/BLD: plot directive srcset
2 parents dcb8180 + a4498c0 commit 4a0ffb8

File tree

12 files changed

+525
-12
lines changed

12 files changed

+525
-12
lines changed

doc/api/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ Alphabetical list of modules:
141141
scale_api.rst
142142
sphinxext_mathmpl_api.rst
143143
sphinxext_plot_directive_api.rst
144+
sphinxext_figmpl_directive_api.rst
144145
spines_api.rst
145146
style_api.rst
146147
table_api.rst
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
=========================================
2+
``matplotlib.sphinxext.figmpl_directive``
3+
=========================================
4+
5+
.. automodule:: matplotlib.sphinxext.figmpl_directive
6+
:no-undoc-members:

doc/conf.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def _parse_skip_subdirs_file():
105105
'sphinx_gallery.gen_gallery',
106106
'matplotlib.sphinxext.mathmpl',
107107
'matplotlib.sphinxext.plot_directive',
108+
'matplotlib.sphinxext.figmpl_directive',
108109
'sphinxcontrib.inkscapeconverter',
109110
'sphinxext.custom_roles',
110111
'sphinxext.github',
@@ -379,7 +380,8 @@ def gallery_image_warning_filter(record):
379380
formats = {'html': ('png', 100), 'latex': ('pdf', 100)}
380381
plot_formats = [formats[target] for target in ['html', 'latex']
381382
if target in sys.argv] or list(formats.values())
382-
383+
# make 2x images for srcset argument to <img>
384+
plot_srcset = ['2x']
383385

384386
# GitHub extension
385387

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Plot Directive now can make responsive images with "srcset"
2+
-----------------------------------------------------------
3+
4+
The plot sphinx directive (``matplotlib.sphinxext.plot_directive``, invoked in
5+
rst as ``.. plot::``) can be configured to automatically make higher res
6+
figures and add these to the the built html docs. In ``conf.py``::
7+
8+
extensions = [
9+
...
10+
'matplotlib.sphinxext.plot_directive',
11+
'matplotlib.sphinxext.figmpl_directive',
12+
...]
13+
14+
plot_srcset = ['2x']
15+
16+
will make png files with double the resolution for hiDPI displays. Resulting
17+
html files will have image entries like::
18+
19+
<img src="../_images/nestedpage-index-2.png" style="" srcset="../_images/nestedpage-index-2.png, ../_images/nestedpage-index-2.2x.png 2.00x" alt="" class="plot-directive "/>

doc/users/prev_whats_new/whats_new_3.3.0.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ The `.Figure` class has a provisional method to generate complex grids of named
2424
`.axes.Axes` based on nested list input or ASCII art:
2525

2626
.. plot::
27-
:include-source: True
27+
:include-source:
2828

2929
axd = plt.figure(constrained_layout=True).subplot_mosaic(
3030
[['.', 'histx'],
@@ -38,7 +38,7 @@ The `.Figure` class has a provisional method to generate complex grids of named
3838
or as a string (with single-character Axes labels):
3939

4040
.. plot::
41-
:include-source: True
41+
:include-source:
4242

4343
axd = plt.figure(constrained_layout=True).subplot_mosaic(
4444
"""
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
"""
2+
Add a ``figure-mpl`` directive that is a responsive version of ``figure``.
3+
4+
This implementation is very similar to ``.. figure::``, except it also allows a
5+
``srcset=`` argument to be passed to the image tag, hence allowing responsive
6+
resolution images.
7+
8+
There is no particular reason this could not be used standalone, but is meant
9+
to be used with :doc:`/api/sphinxext_plot_directive_api`.
10+
11+
Note that the directory organization is a bit different than ``.. figure::``.
12+
See the *FigureMpl* documentation below.
13+
14+
"""
15+
from docutils import nodes
16+
17+
from docutils.parsers.rst import directives
18+
from docutils.parsers.rst.directives.images import Figure, Image
19+
20+
import os
21+
from os.path import relpath
22+
from pathlib import PurePath, Path
23+
import shutil
24+
25+
from sphinx.errors import ExtensionError
26+
27+
import matplotlib
28+
29+
30+
class figmplnode(nodes.General, nodes.Element):
31+
pass
32+
33+
34+
class FigureMpl(Figure):
35+
"""
36+
Implements a directive to allow an optional hidpi image.
37+
38+
Meant to be used with the *plot_srcset* configuration option in conf.py,
39+
and gets set in the TEMPLATE of plot_directive.py
40+
41+
e.g.::
42+
43+
.. figure-mpl:: plot_directive/some_plots-1.png
44+
:alt: bar
45+
:srcset: plot_directive/some_plots-1.png,
46+
plot_directive/some_plots-1.2x.png 2.00x
47+
:class: plot-directive
48+
49+
The resulting html (at ``some_plots.html``) is::
50+
51+
<img src="sphx_glr_bar_001_hidpi.png"
52+
srcset="_images/some_plot-1.png,
53+
_images/some_plots-1.2x.png 2.00x",
54+
alt="bar"
55+
class="plot_directive" />
56+
57+
Note that the handling of subdirectories is different than that used by the sphinx
58+
figure directive::
59+
60+
.. figure-mpl:: plot_directive/nestedpage/index-1.png
61+
:alt: bar
62+
:srcset: plot_directive/nestedpage/index-1.png
63+
plot_directive/nestedpage/index-1.2x.png 2.00x
64+
:class: plot_directive
65+
66+
The resulting html (at ``nestedpage/index.html``)::
67+
68+
<img src="../_images/nestedpage-index-1.png"
69+
srcset="../_images/nestedpage-index-1.png,
70+
../_images/_images/nestedpage-index-1.2x.png 2.00x",
71+
alt="bar"
72+
class="sphx-glr-single-img" />
73+
74+
where the subdirectory is included in the image name for uniqueness.
75+
"""
76+
77+
has_content = False
78+
required_arguments = 1
79+
optional_arguments = 2
80+
final_argument_whitespace = False
81+
option_spec = {
82+
'alt': directives.unchanged,
83+
'height': directives.length_or_unitless,
84+
'width': directives.length_or_percentage_or_unitless,
85+
'scale': directives.nonnegative_int,
86+
'align': Image.align,
87+
'class': directives.class_option,
88+
'caption': directives.unchanged,
89+
'srcset': directives.unchanged,
90+
}
91+
92+
def run(self):
93+
94+
image_node = figmplnode()
95+
96+
imagenm = self.arguments[0]
97+
image_node['alt'] = self.options.get('alt', '')
98+
image_node['align'] = self.options.get('align', None)
99+
image_node['class'] = self.options.get('class', None)
100+
image_node['width'] = self.options.get('width', None)
101+
image_node['height'] = self.options.get('height', None)
102+
image_node['scale'] = self.options.get('scale', None)
103+
image_node['caption'] = self.options.get('caption', None)
104+
105+
# we would like uri to be the highest dpi version so that
106+
# latex etc will use that. But for now, lets just make
107+
# imagenm... maybe pdf one day?
108+
109+
image_node['uri'] = imagenm
110+
image_node['srcset'] = self.options.get('srcset', None)
111+
112+
return [image_node]
113+
114+
115+
def _parse_srcsetNodes(st):
116+
"""
117+
parse srcset...
118+
"""
119+
entries = st.split(',')
120+
srcset = {}
121+
for entry in entries:
122+
spl = entry.strip().split(' ')
123+
if len(spl) == 1:
124+
srcset[0] = spl[0]
125+
elif len(spl) == 2:
126+
mult = spl[1][:-1]
127+
srcset[float(mult)] = spl[0]
128+
else:
129+
raise ExtensionError(f'srcset argument "{entry}" is invalid.')
130+
return srcset
131+
132+
133+
def _copy_images_figmpl(self, node):
134+
135+
# these will be the temporary place the plot-directive put the images eg:
136+
# ../../../build/html/plot_directive/users/explain/artists/index-1.png
137+
if node['srcset']:
138+
srcset = _parse_srcsetNodes(node['srcset'])
139+
else:
140+
srcset = None
141+
142+
# the rst file's location: eg /Users/username/matplotlib/doc/users/explain/artists
143+
docsource = PurePath(self.document['source']).parent
144+
145+
# get the relpath relative to root:
146+
srctop = self.builder.srcdir
147+
rel = relpath(docsource, srctop).replace('.', '').replace(os.sep, '-')
148+
if len(rel):
149+
rel += '-'
150+
# eg: users/explain/artists
151+
152+
imagedir = PurePath(self.builder.outdir, self.builder.imagedir)
153+
# eg: /Users/username/matplotlib/doc/build/html/_images/users/explain/artists
154+
155+
Path(imagedir).mkdir(parents=True, exist_ok=True)
156+
157+
# copy all the sources to the imagedir:
158+
if srcset:
159+
for src in srcset.values():
160+
# the entries in srcset are relative to docsource's directory
161+
abspath = PurePath(docsource, src)
162+
name = rel + abspath.name
163+
shutil.copyfile(abspath, imagedir / name)
164+
else:
165+
abspath = PurePath(docsource, node['uri'])
166+
name = rel + abspath.name
167+
shutil.copyfile(abspath, imagedir / name)
168+
169+
return imagedir, srcset, rel
170+
171+
172+
def visit_figmpl_html(self, node):
173+
174+
imagedir, srcset, rel = _copy_images_figmpl(self, node)
175+
176+
# /doc/examples/subd/plot_1.rst
177+
docsource = PurePath(self.document['source'])
178+
# /doc/
179+
# make sure to add the trailing slash:
180+
srctop = PurePath(self.builder.srcdir, '')
181+
# examples/subd/plot_1.rst
182+
relsource = relpath(docsource, srctop)
183+
# /doc/build/html
184+
desttop = PurePath(self.builder.outdir, '')
185+
# /doc/build/html/examples/subd
186+
dest = desttop / relsource
187+
188+
# ../../_images/ for dirhtml and ../_images/ for html
189+
imagerel = PurePath(relpath(imagedir, dest.parent)).as_posix()
190+
if self.builder.name == "dirhtml":
191+
imagerel = f'..{imagerel}'
192+
193+
# make uri also be relative...
194+
nm = PurePath(node['uri'][1:]).name
195+
uri = f'{imagerel}/{rel}{nm}'
196+
197+
# make srcset str. Need to change all the prefixes!
198+
maxsrc = uri
199+
srcsetst = ''
200+
if srcset:
201+
maxmult = -1
202+
for mult, src in srcset.items():
203+
nm = PurePath(src[1:]).name
204+
# ../../_images/plot_1_2_0x.png
205+
path = f'{imagerel}/{rel}{nm}'
206+
srcsetst += path
207+
if mult == 0:
208+
srcsetst += ', '
209+
else:
210+
srcsetst += f' {mult:1.2f}x, '
211+
212+
if mult > maxmult:
213+
maxmult = mult
214+
maxsrc = path
215+
216+
# trim trailing comma and space...
217+
srcsetst = srcsetst[:-2]
218+
219+
alt = node['alt']
220+
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:
230+
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'
249+
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)
255+
256+
257+
def visit_figmpl_latex(self, node):
258+
259+
if node['srcset'] is not None:
260+
imagedir, srcset = _copy_images_figmpl(self, node)
261+
maxmult = -1
262+
# choose the highest res version for latex:
263+
maxmult = max(srcset, default=-1)
264+
node['uri'] = PurePath(srcset[maxmult]).name
265+
266+
self.visit_figure(node)
267+
268+
269+
def depart_figmpl_html(self, node):
270+
pass
271+
272+
273+
def depart_figmpl_latex(self, node):
274+
self.depart_figure(node)
275+
276+
277+
def figurempl_addnode(app):
278+
app.add_node(figmplnode,
279+
html=(visit_figmpl_html, depart_figmpl_html),
280+
latex=(visit_figmpl_latex, depart_figmpl_latex))
281+
282+
283+
def setup(app):
284+
app.add_directive("figure-mpl", FigureMpl)
285+
figurempl_addnode(app)
286+
metadata = {'parallel_read_safe': True, 'parallel_write_safe': True,
287+
'version': matplotlib.__version__}
288+
return metadata

0 commit comments

Comments
 (0)