1 # -*- coding: utf-8; mode: python -*-
2 # pylint: disable=C0103, R0903, R0912, R0915
4 scalable figure and image handling
5 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7 Sphinx extension which implements scalable image handling.
9 :copyright: Copyright (C) 2016 Markus Heiser
10 :license: GPL Version 2, June 1991 see Linux/COPYING for details.
12 The build for image formats depend on image's source format and output's
13 destination format. This extension implement methods to simplify image
14 handling from the author's POV. Directives like ``kernel-figure`` implement
15 methods *to* always get the best output-format even if some tools are not
16 installed. For more details take a look at ``convert_image(...)`` which is
17 the core of all conversions.
19 * ``.. kernel-image``: for image handling / a ``.. image::`` replacement
21 * ``.. kernel-figure``: for figure handling / a ``.. figure::`` replacement
23 * ``.. kernel-render``: for render markup / a concept to embed *render*
24 markups (or languages). Supported markups (see ``RENDER_MARKUP_EXT``)
26 - ``DOT``: render embedded Graphviz's **DOC**
27 - ``SVG``: render embedded Scalable Vector Graphics (**SVG**)
32 * ``dot(1)``: Graphviz (http://www.graphviz.org). If Graphviz is not
33 available, the DOT language is inserted as literal-block.
35 * SVG to PDF: To generate PDF, you need at least one of this tools:
37 - ``convert(1)``: ImageMagick (https://www.imagemagick.org)
39 List of customizations:
41 * generate PDF from SVG / used by PDF (LaTeX) builder
43 * generate SVG (html-builder) and PDF (latex-builder) from DOT files.
44 DOT: see http://www.graphviz.org/content/dot-language
51 from hashlib
import sha1
54 from docutils
import nodes
55 from docutils
.statemachine
import ViewList
56 from docutils
.parsers
.rst
import directives
57 from docutils
.parsers
.rst
.directives
import images
60 from sphinx
.util
.nodes
import clean_astext
61 from six
import iteritems
63 PY3
= sys
.version_info
[0] == 3
71 major
, minor
, patch
= sphinx
.version_info
[:3]
72 if major
== 1 and minor
> 3:
73 # patches.Figure only landed in Sphinx 1.4
74 from sphinx
.directives
.patches
import Figure
# pylint: disable=C0413
76 Figure
= images
.Figure
84 """Searches the ``cmd`` in the ``PATH`` enviroment.
86 This *which* searches the PATH for executable ``cmd`` . First match is
87 returned, if nothing is found, ``None` is returned.
89 envpath
= os
.environ
.get('PATH', None) or os
.defpath
90 for folder
in envpath
.split(os
.pathsep
):
91 fname
= folder
+ os
.sep
+ cmd
92 if path
.isfile(fname
):
95 def mkdir(folder
, mode
=0o775):
96 if not path
.isdir(folder
):
97 os
.makedirs(folder
, mode
)
99 def file2literal(fname
):
100 with
open(fname
, "r") as src
:
102 node
= nodes
.literal_block(data
, data
)
105 def isNewer(path1
, path2
):
106 """Returns True if ``path1`` is newer than ``path2``
108 If ``path1`` exists and is newer than ``path2`` the function returns
109 ``True`` is returned otherwise ``False``
111 return (path
.exists(path1
)
112 and os
.stat(path1
).st_ctime
> os
.stat(path2
).st_ctime
)
114 def pass_handle(self
, node
): # pylint: disable=W0613
117 # setup conversion tools and sphinx extension
118 # -------------------------------------------
120 # Graphviz's dot(1) support
123 # ImageMagick' convert(1) support
128 # check toolchain first
129 app
.connect('builder-inited', setupTools
)
132 app
.add_directive("kernel-image", KernelImage
)
133 app
.add_node(kernel_image
,
134 html
= (visit_kernel_image
, pass_handle
),
135 latex
= (visit_kernel_image
, pass_handle
),
136 texinfo
= (visit_kernel_image
, pass_handle
),
137 text
= (visit_kernel_image
, pass_handle
),
138 man
= (visit_kernel_image
, pass_handle
), )
141 app
.add_directive("kernel-figure", KernelFigure
)
142 app
.add_node(kernel_figure
,
143 html
= (visit_kernel_figure
, pass_handle
),
144 latex
= (visit_kernel_figure
, pass_handle
),
145 texinfo
= (visit_kernel_figure
, pass_handle
),
146 text
= (visit_kernel_figure
, pass_handle
),
147 man
= (visit_kernel_figure
, pass_handle
), )
150 app
.add_directive('kernel-render', KernelRender
)
151 app
.add_node(kernel_render
,
152 html
= (visit_kernel_render
, pass_handle
),
153 latex
= (visit_kernel_render
, pass_handle
),
154 texinfo
= (visit_kernel_render
, pass_handle
),
155 text
= (visit_kernel_render
, pass_handle
),
156 man
= (visit_kernel_render
, pass_handle
), )
158 app
.connect('doctree-read', add_kernel_figure_to_std_domain
)
161 version
= __version__
,
162 parallel_read_safe
= True,
163 parallel_write_safe
= True
169 Check available build tools and log some *verbose* messages.
171 This function is called once, when the builder is initiated.
173 global dot_cmd
, convert_cmd
# pylint: disable=W0603
174 app
.verbose("kfigure: check installed tools ...")
176 dot_cmd
= which('dot')
177 convert_cmd
= which('convert')
180 app
.verbose("use dot(1) from: " + dot_cmd
)
182 app
.warn("dot(1) not found, for better output quality install "
183 "graphviz from http://www.graphviz.org")
185 app
.verbose("use convert(1) from: " + convert_cmd
)
188 "convert(1) not found, for SVG to PDF conversion install "
189 "ImageMagick (https://www.imagemagick.org)")
192 # integrate conversion tools
193 # --------------------------
195 RENDER_MARKUP_EXT
= {
196 # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
202 def convert_image(img_node
, translator
, src_fname
=None):
203 """Convert a image node for the builder.
205 Different builder prefer different image formats, e.g. *latex* builder
206 prefer PDF while *html* builder prefer SVG format for images.
208 This function handles output image formats in dependence of source the
209 format (of the image) and the translator's output format.
211 app
= translator
.builder
.app
213 fname
, in_ext
= path
.splitext(path
.basename(img_node
['uri']))
214 if src_fname
is None:
215 src_fname
= path
.join(translator
.builder
.srcdir
, img_node
['uri'])
216 if not path
.exists(src_fname
):
217 src_fname
= path
.join(translator
.builder
.outdir
, img_node
['uri'])
221 # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
223 app
.verbose('assert best format for: ' + img_node
['uri'])
228 app
.verbose("dot from graphviz not available / include DOT raw.")
229 img_node
.replace_self(file2literal(src_fname
))
231 elif translator
.builder
.format
== 'latex':
232 dst_fname
= path
.join(translator
.builder
.outdir
, fname
+ '.pdf')
233 img_node
['uri'] = fname
+ '.pdf'
234 img_node
['candidates'] = {'*': fname
+ '.pdf'}
237 elif translator
.builder
.format
== 'html':
238 dst_fname
= path
.join(
239 translator
.builder
.outdir
,
240 translator
.builder
.imagedir
,
242 img_node
['uri'] = path
.join(
243 translator
.builder
.imgpath
, fname
+ '.svg')
244 img_node
['candidates'] = {
245 '*': path
.join(translator
.builder
.imgpath
, fname
+ '.svg')}
248 # all other builder formats will include DOT as raw
249 img_node
.replace_self(file2literal(src_fname
))
251 elif in_ext
== '.svg':
253 if translator
.builder
.format
== 'latex':
254 if convert_cmd
is None:
255 app
.verbose("no SVG to PDF conversion available / include SVG raw.")
256 img_node
.replace_self(file2literal(src_fname
))
258 dst_fname
= path
.join(translator
.builder
.outdir
, fname
+ '.pdf')
259 img_node
['uri'] = fname
+ '.pdf'
260 img_node
['candidates'] = {'*': fname
+ '.pdf'}
263 # the builder needs not to copy one more time, so pop it if exists.
264 translator
.builder
.images
.pop(img_node
['uri'], None)
265 _name
= dst_fname
[len(translator
.builder
.outdir
) + 1:]
267 if isNewer(dst_fname
, src_fname
):
268 app
.verbose("convert: {out}/%s already exists and is newer" % _name
)
272 mkdir(path
.dirname(dst_fname
))
275 app
.verbose('convert DOT to: {out}/' + _name
)
276 ok
= dot2format(app
, src_fname
, dst_fname
)
278 elif in_ext
== '.svg':
279 app
.verbose('convert SVG to: {out}/' + _name
)
280 ok
= svg2pdf(app
, src_fname
, dst_fname
)
283 img_node
.replace_self(file2literal(src_fname
))
286 def dot2format(app
, dot_fname
, out_fname
):
287 """Converts DOT file to ``out_fname`` using ``dot(1)``.
289 * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
290 * ``out_fname`` pathname of the output file, including format extension
292 The *format extension* depends on the ``dot`` command (see ``man dot``
293 option ``-Txxx``). Normally you will use one of the following extensions:
295 - ``.ps`` for PostScript,
296 - ``.svg`` or ``svgz`` for Structured Vector Graphics,
297 - ``.fig`` for XFIG graphics and
298 - ``.png`` or ``gif`` for common bitmap graphics.
301 out_format
= path
.splitext(out_fname
)[1][1:]
302 cmd
= [dot_cmd
, '-T%s' % out_format
, dot_fname
]
305 with
open(out_fname
, "w") as out
:
306 exit_code
= subprocess
.call(cmd
, stdout
= out
)
308 app
.warn("Error #%d when calling: %s" % (exit_code
, " ".join(cmd
)))
309 return bool(exit_code
== 0)
311 def svg2pdf(app
, svg_fname
, pdf_fname
):
312 """Converts SVG to PDF with ``convert(1)`` command.
314 Uses ``convert(1)`` from ImageMagick (https://www.imagemagick.org) for
315 conversion. Returns ``True`` on success and ``False`` if an error occurred.
317 * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
318 * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``)
321 cmd
= [convert_cmd
, svg_fname
, pdf_fname
]
322 # use stdout and stderr from parent
323 exit_code
= subprocess
.call(cmd
)
325 app
.warn("Error #%d when calling: %s" % (exit_code
, " ".join(cmd
)))
326 return bool(exit_code
== 0)
330 # ---------------------
332 def visit_kernel_image(self
, node
): # pylint: disable=W0613
333 """Visitor of the ``kernel_image`` Node.
335 Handles the ``image`` child-node with the ``convert_image(...)``.
338 convert_image(img_node
, self
)
340 class kernel_image(nodes
.image
):
341 """Node for ``kernel-image`` directive."""
344 class KernelImage(images
.Image
):
345 u
"""KernelImage directive
347 Earns everything from ``.. image::`` directive, except *remote URI* and
348 *glob* pattern. The KernelImage wraps a image node into a
349 kernel_image node. See ``visit_kernel_image``.
353 uri
= self
.arguments
[0]
354 if uri
.endswith('.*') or uri
.find('://') != -1:
356 'Error in "%s: %s": glob pattern and remote images are not allowed'
358 result
= images
.Image
.run(self
)
359 if len(result
) == 2 or isinstance(result
[0], nodes
.system_message
):
361 (image_node
,) = result
362 # wrap image node into a kernel_image node / see visitors
363 node
= kernel_image('', image_node
)
367 # ---------------------
369 def visit_kernel_figure(self
, node
): # pylint: disable=W0613
370 """Visitor of the ``kernel_figure`` Node.
372 Handles the ``image`` child-node with the ``convert_image(...)``.
374 img_node
= node
[0][0]
375 convert_image(img_node
, self
)
377 class kernel_figure(nodes
.figure
):
378 """Node for ``kernel-figure`` directive."""
380 class KernelFigure(Figure
):
381 u
"""KernelImage directive
383 Earns everything from ``.. figure::`` directive, except *remote URI* and
384 *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure
385 node. See ``visit_kernel_figure``.
389 uri
= self
.arguments
[0]
390 if uri
.endswith('.*') or uri
.find('://') != -1:
393 ' glob pattern and remote images are not allowed'
395 result
= Figure
.run(self
)
396 if len(result
) == 2 or isinstance(result
[0], nodes
.system_message
):
398 (figure_node
,) = result
399 # wrap figure node into a kernel_figure node / see visitors
400 node
= kernel_figure('', figure_node
)
405 # ---------------------
407 def visit_kernel_render(self
, node
):
408 """Visitor of the ``kernel_render`` Node.
410 If rendering tools available, save the markup of the ``literal_block`` child
411 node into a file and replace the ``literal_block`` node with a new created
412 ``image`` node, pointing to the saved markup file. Afterwards, handle the
413 image child-node with the ``convert_image(...)``.
415 app
= self
.builder
.app
416 srclang
= node
.get('srclang')
418 app
.verbose('visit kernel-render node lang: "%s"' % (srclang
))
420 tmp_ext
= RENDER_MARKUP_EXT
.get(srclang
, None)
422 app
.warn('kernel-render: "%s" unknow / include raw.' % (srclang
))
425 if not dot_cmd
and tmp_ext
== '.dot':
426 app
.verbose("dot from graphviz not available / include raw.")
429 literal_block
= node
[0]
431 code
= literal_block
.astext()
432 hashobj
= code
.encode('utf-8') # str(node.attributes)
433 fname
= path
.join('%s-%s' % (srclang
, sha1(hashobj
).hexdigest()))
435 tmp_fname
= path
.join(
436 self
.builder
.outdir
, self
.builder
.imagedir
, fname
+ tmp_ext
)
438 if not path
.isfile(tmp_fname
):
439 mkdir(path
.dirname(tmp_fname
))
440 with
open(tmp_fname
, "w") as out
:
443 img_node
= nodes
.image(node
.rawsource
, **node
.attributes
)
444 img_node
['uri'] = path
.join(self
.builder
.imgpath
, fname
+ tmp_ext
)
445 img_node
['candidates'] = {
446 '*': path
.join(self
.builder
.imgpath
, fname
+ tmp_ext
)}
448 literal_block
.replace_self(img_node
)
449 convert_image(img_node
, self
, tmp_fname
)
452 class kernel_render(nodes
.General
, nodes
.Inline
, nodes
.Element
):
453 """Node for ``kernel-render`` directive."""
456 class KernelRender(Figure
):
457 u
"""KernelRender directive
459 Render content by external tool. Has all the options known from the
460 *figure* directive, plus option ``caption``. If ``caption`` has a
461 value, a figure node with the *caption* is inserted. If not, a image node is
464 The KernelRender directive wraps the text of the directive into a
465 literal_block node and wraps it into a kernel_render node. See
466 ``visit_kernel_render``.
469 required_arguments
= 1
470 optional_arguments
= 0
471 final_argument_whitespace
= False
473 # earn options from 'figure'
474 option_spec
= Figure
.option_spec
.copy()
475 option_spec
['caption'] = directives
.unchanged
478 return [self
.build_node()]
480 def build_node(self
):
482 srclang
= self
.arguments
[0].strip()
483 if srclang
not in RENDER_MARKUP_EXT
.keys():
484 return [self
.state_machine
.reporter
.warning(
485 'Unknow source language "%s", use one of: %s.' % (
486 srclang
, ",".join(RENDER_MARKUP_EXT
.keys())),
489 code
= '\n'.join(self
.content
)
491 return [self
.state_machine
.reporter
.warning(
492 'Ignoring "%s" directive without content.' % (
496 node
= kernel_render()
497 node
['alt'] = self
.options
.get('alt','')
498 node
['srclang'] = srclang
499 literal_node
= nodes
.literal_block(code
, code
)
502 caption
= self
.options
.get('caption')
504 # parse caption's content
505 parsed
= nodes
.Element()
506 self
.state
.nested_parse(
507 ViewList([caption
], source
=''), self
.content_offset
, parsed
)
508 caption_node
= nodes
.caption(
509 parsed
[0].rawsource
, '', *parsed
[0].children
)
510 caption_node
.source
= parsed
[0].source
511 caption_node
.line
= parsed
[0].line
513 figure_node
= nodes
.figure('', node
)
514 for k
,v
in self
.options
.items():
516 figure_node
+= caption_node
522 def add_kernel_figure_to_std_domain(app
, doctree
):
523 """Add kernel-figure anchors to 'std' domain.
525 The ``StandardDomain.process_doc(..)`` method does not know how to resolve
526 the caption (label) of ``kernel-figure`` directive (it only knows about
527 standard nodes, e.g. table, figure etc.). Without any additional handling
528 this will result in a 'undefined label' for kernel-figures.
530 This handle adds labels of kernel-figure to the 'std' domain labels.
533 std
= app
.env
.domains
["std"]
534 docname
= app
.env
.docname
535 labels
= std
.data
["labels"]
537 for name
, explicit
in iteritems(doctree
.nametypes
):
540 labelid
= doctree
.nameids
[name
]
543 node
= doctree
.ids
[labelid
]
545 if node
.tagname
== 'kernel_figure':
546 for n
in node
.next_node():
547 if n
.tagname
== 'caption':
548 sectname
= clean_astext(n
)
549 # add label to std domain
550 labels
[name
] = docname
, labelid
, sectname