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 (https://www.graphviz.org). If Graphviz is not
33 available, the DOT language is inserted as literal-block.
34 For conversion to PDF, ``rsvg-convert(1)`` of librsvg
35 (https://gitlab.gnome.org/GNOME/librsvg) is used when available.
37 * SVG to PDF: To generate PDF, you need at least one of this tools:
39 - ``convert(1)``: ImageMagick (https://www.imagemagick.org)
40 - ``inkscape(1)``: Inkscape (https://inkscape.org/)
42 List of customizations:
44 * generate PDF from SVG / used by PDF (LaTeX) builder
46 * generate SVG (html-builder) and PDF (latex-builder) from DOT files.
47 DOT: see https://www.graphviz.org/content/dot-language
54 from hashlib import sha1
56 from docutils import nodes
57 from docutils.statemachine import ViewList
58 from docutils.parsers.rst import directives
59 from docutils.parsers.rst.directives import images
61 from sphinx.util.nodes import clean_astext
65 major, minor, patch = sphinx.version_info[:3]
66 if major == 1 and minor > 3:
67 # patches.Figure only landed in Sphinx 1.4
68 from sphinx.directives.patches import Figure # pylint: disable=C0413
70 Figure = images.Figure
78 """Searches the ``cmd`` in the ``PATH`` environment.
80 This *which* searches the PATH for executable ``cmd`` . First match is
81 returned, if nothing is found, ``None` is returned.
83 envpath = os.environ.get('PATH', None) or os.defpath
84 for folder in envpath.split(os.pathsep):
85 fname = folder + os.sep + cmd
86 if path.isfile(fname):
89 def mkdir(folder, mode=0o775):
90 if not path.isdir(folder):
91 os.makedirs(folder, mode)
93 def file2literal(fname):
94 with open(fname, "r") as src:
96 node = nodes.literal_block(data, data)
99 def isNewer(path1, path2):
100 """Returns True if ``path1`` is newer than ``path2``
102 If ``path1`` exists and is newer than ``path2`` the function returns
103 ``True`` is returned otherwise ``False``
105 return (path.exists(path1)
106 and os.stat(path1).st_ctime > os.stat(path2).st_ctime)
108 def pass_handle(self, node): # pylint: disable=W0613
111 # setup conversion tools and sphinx extension
112 # -------------------------------------------
114 # Graphviz's dot(1) support
116 # dot(1) -Tpdf should be used
119 # ImageMagick' convert(1) support
122 # librsvg's rsvg-convert(1) support
123 rsvg_convert_cmd = None
125 # Inkscape's inkscape(1) support
127 # Inkscape prior to 1.0 uses different command options
128 inkscape_ver_one = False
132 # check toolchain first
133 app.connect('builder-inited', setupTools)
136 app.add_directive("kernel-image", KernelImage)
137 app.add_node(kernel_image,
138 html = (visit_kernel_image, pass_handle),
139 latex = (visit_kernel_image, pass_handle),
140 texinfo = (visit_kernel_image, pass_handle),
141 text = (visit_kernel_image, pass_handle),
142 man = (visit_kernel_image, pass_handle), )
145 app.add_directive("kernel-figure", KernelFigure)
146 app.add_node(kernel_figure,
147 html = (visit_kernel_figure, pass_handle),
148 latex = (visit_kernel_figure, pass_handle),
149 texinfo = (visit_kernel_figure, pass_handle),
150 text = (visit_kernel_figure, pass_handle),
151 man = (visit_kernel_figure, pass_handle), )
154 app.add_directive('kernel-render', KernelRender)
155 app.add_node(kernel_render,
156 html = (visit_kernel_render, pass_handle),
157 latex = (visit_kernel_render, pass_handle),
158 texinfo = (visit_kernel_render, pass_handle),
159 text = (visit_kernel_render, pass_handle),
160 man = (visit_kernel_render, pass_handle), )
162 app.connect('doctree-read', add_kernel_figure_to_std_domain)
165 version = __version__,
166 parallel_read_safe = True,
167 parallel_write_safe = True
173 Check available build tools and log some *verbose* messages.
175 This function is called once, when the builder is initiated.
177 global dot_cmd, dot_Tpdf, convert_cmd, rsvg_convert_cmd # pylint: disable=W0603
178 global inkscape_cmd, inkscape_ver_one # pylint: disable=W0603
179 kernellog.verbose(app, "kfigure: check installed tools ...")
181 dot_cmd = which('dot')
182 convert_cmd = which('convert')
183 rsvg_convert_cmd = which('rsvg-convert')
184 inkscape_cmd = which('inkscape')
187 kernellog.verbose(app, "use dot(1) from: " + dot_cmd)
190 dot_Thelp_list = subprocess.check_output([dot_cmd, '-Thelp'],
191 stderr=subprocess.STDOUT)
192 except subprocess.CalledProcessError as err:
193 dot_Thelp_list = err.output
196 dot_Tpdf_ptn = b'pdf'
197 dot_Tpdf = re.search(dot_Tpdf_ptn, dot_Thelp_list)
199 kernellog.warn(app, "dot(1) not found, for better output quality install "
200 "graphviz from https://www.graphviz.org")
202 kernellog.verbose(app, "use inkscape(1) from: " + inkscape_cmd)
203 inkscape_ver = subprocess.check_output([inkscape_cmd, '--version'],
204 stderr=subprocess.DEVNULL)
205 ver_one_ptn = b'Inkscape 1'
206 inkscape_ver_one = re.search(ver_one_ptn, inkscape_ver)
208 rsvg_convert_cmd = None
213 kernellog.verbose(app, "use convert(1) from: " + convert_cmd)
216 "Neither inkscape(1) nor convert(1) found.\n"
217 "For SVG to PDF conversion, "
218 "install either Inkscape (https://inkscape.org/) (preferred) or\n"
219 "ImageMagick (https://www.imagemagick.org)")
222 kernellog.verbose(app, "use rsvg-convert(1) from: " + rsvg_convert_cmd)
223 kernellog.verbose(app, "use 'dot -Tsvg' and rsvg-convert(1) for DOT -> PDF conversion")
226 kernellog.verbose(app,
227 "rsvg-convert(1) not found.\n"
228 " SVG rendering of convert(1) is done by ImageMagick-native renderer.")
230 kernellog.verbose(app, "use 'dot -Tpdf' for DOT -> PDF conversion")
232 kernellog.verbose(app, "use 'dot -Tsvg' and convert(1) for DOT -> PDF conversion")
235 # integrate conversion tools
236 # --------------------------
238 RENDER_MARKUP_EXT = {
239 # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
245 def convert_image(img_node, translator, src_fname=None):
246 """Convert a image node for the builder.
248 Different builder prefer different image formats, e.g. *latex* builder
249 prefer PDF while *html* builder prefer SVG format for images.
251 This function handles output image formats in dependence of source the
252 format (of the image) and the translator's output format.
254 app = translator.builder.app
256 fname, in_ext = path.splitext(path.basename(img_node['uri']))
257 if src_fname is None:
258 src_fname = path.join(translator.builder.srcdir, img_node['uri'])
259 if not path.exists(src_fname):
260 src_fname = path.join(translator.builder.outdir, img_node['uri'])
264 # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
266 kernellog.verbose(app, 'assert best format for: ' + img_node['uri'])
271 kernellog.verbose(app,
272 "dot from graphviz not available / include DOT raw.")
273 img_node.replace_self(file2literal(src_fname))
275 elif translator.builder.format == 'latex':
276 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
277 img_node['uri'] = fname + '.pdf'
278 img_node['candidates'] = {'*': fname + '.pdf'}
281 elif translator.builder.format == 'html':
282 dst_fname = path.join(
283 translator.builder.outdir,
284 translator.builder.imagedir,
286 img_node['uri'] = path.join(
287 translator.builder.imgpath, fname + '.svg')
288 img_node['candidates'] = {
289 '*': path.join(translator.builder.imgpath, fname + '.svg')}
292 # all other builder formats will include DOT as raw
293 img_node.replace_self(file2literal(src_fname))
295 elif in_ext == '.svg':
297 if translator.builder.format == 'latex':
298 if not inkscape_cmd and convert_cmd is None:
299 kernellog.verbose(app,
300 "no SVG to PDF conversion available / include SVG raw.")
301 img_node.replace_self(file2literal(src_fname))
303 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
304 img_node['uri'] = fname + '.pdf'
305 img_node['candidates'] = {'*': fname + '.pdf'}
308 # the builder needs not to copy one more time, so pop it if exists.
309 translator.builder.images.pop(img_node['uri'], None)
310 _name = dst_fname[len(translator.builder.outdir) + 1:]
312 if isNewer(dst_fname, src_fname):
313 kernellog.verbose(app,
314 "convert: {out}/%s already exists and is newer" % _name)
318 mkdir(path.dirname(dst_fname))
321 kernellog.verbose(app, 'convert DOT to: {out}/' + _name)
322 if translator.builder.format == 'latex' and not dot_Tpdf:
323 svg_fname = path.join(translator.builder.outdir, fname + '.svg')
324 ok1 = dot2format(app, src_fname, svg_fname)
325 ok2 = svg2pdf_by_rsvg(app, svg_fname, dst_fname)
329 ok = dot2format(app, src_fname, dst_fname)
331 elif in_ext == '.svg':
332 kernellog.verbose(app, 'convert SVG to: {out}/' + _name)
333 ok = svg2pdf(app, src_fname, dst_fname)
336 img_node.replace_self(file2literal(src_fname))
339 def dot2format(app, dot_fname, out_fname):
340 """Converts DOT file to ``out_fname`` using ``dot(1)``.
342 * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
343 * ``out_fname`` pathname of the output file, including format extension
345 The *format extension* depends on the ``dot`` command (see ``man dot``
346 option ``-Txxx``). Normally you will use one of the following extensions:
348 - ``.ps`` for PostScript,
349 - ``.svg`` or ``svgz`` for Structured Vector Graphics,
350 - ``.fig`` for XFIG graphics and
351 - ``.png`` or ``gif`` for common bitmap graphics.
354 out_format = path.splitext(out_fname)[1][1:]
355 cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
358 with open(out_fname, "w") as out:
359 exit_code = subprocess.call(cmd, stdout = out)
362 "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
363 return bool(exit_code == 0)
365 def svg2pdf(app, svg_fname, pdf_fname):
366 """Converts SVG to PDF with ``inkscape(1)`` or ``convert(1)`` command.
368 Uses ``inkscape(1)`` from Inkscape (https://inkscape.org/) or ``convert(1)``
369 from ImageMagick (https://www.imagemagick.org) for conversion.
370 Returns ``True`` on success and ``False`` if an error occurred.
372 * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
373 * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``)
376 cmd = [convert_cmd, svg_fname, pdf_fname]
377 cmd_name = 'convert(1)'
380 cmd_name = 'inkscape(1)'
382 cmd = [inkscape_cmd, '-o', pdf_fname, svg_fname]
384 cmd = [inkscape_cmd, '-z', '--export-pdf=%s' % pdf_fname, svg_fname]
387 warning_msg = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
389 except subprocess.CalledProcessError as err:
390 warning_msg = err.output
391 exit_code = err.returncode
395 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
397 kernellog.warn(app, "Warning msg from %s: %s"
398 % (cmd_name, str(warning_msg, 'utf-8')))
400 kernellog.verbose(app, "Warning msg from %s (likely harmless):\n%s"
401 % (cmd_name, str(warning_msg, 'utf-8')))
403 return bool(exit_code == 0)
405 def svg2pdf_by_rsvg(app, svg_fname, pdf_fname):
406 """Convert SVG to PDF with ``rsvg-convert(1)`` command.
408 * ``svg_fname`` pathname of input SVG file, including extension ``.svg``
409 * ``pdf_fname`` pathname of output PDF file, including extension ``.pdf``
411 Input SVG file should be the one generated by ``dot2format()``.
412 SVG -> PDF conversion is done by ``rsvg-convert(1)``.
414 If ``rsvg-convert(1)`` is unavailable, fall back to ``svg2pdf()``.
418 if rsvg_convert_cmd is None:
419 ok = svg2pdf(app, svg_fname, pdf_fname)
421 cmd = [rsvg_convert_cmd, '--format=pdf', '-o', pdf_fname, svg_fname]
422 # use stdout and stderr from parent
423 exit_code = subprocess.call(cmd)
425 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
426 ok = bool(exit_code == 0)
432 # ---------------------
434 def visit_kernel_image(self, node): # pylint: disable=W0613
435 """Visitor of the ``kernel_image`` Node.
437 Handles the ``image`` child-node with the ``convert_image(...)``.
440 convert_image(img_node, self)
442 class kernel_image(nodes.image):
443 """Node for ``kernel-image`` directive."""
446 class KernelImage(images.Image):
447 u"""KernelImage directive
449 Earns everything from ``.. image::`` directive, except *remote URI* and
450 *glob* pattern. The KernelImage wraps a image node into a
451 kernel_image node. See ``visit_kernel_image``.
455 uri = self.arguments[0]
456 if uri.endswith('.*') or uri.find('://') != -1:
458 'Error in "%s: %s": glob pattern and remote images are not allowed'
460 result = images.Image.run(self)
461 if len(result) == 2 or isinstance(result[0], nodes.system_message):
463 (image_node,) = result
464 # wrap image node into a kernel_image node / see visitors
465 node = kernel_image('', image_node)
469 # ---------------------
471 def visit_kernel_figure(self, node): # pylint: disable=W0613
472 """Visitor of the ``kernel_figure`` Node.
474 Handles the ``image`` child-node with the ``convert_image(...)``.
476 img_node = node[0][0]
477 convert_image(img_node, self)
479 class kernel_figure(nodes.figure):
480 """Node for ``kernel-figure`` directive."""
482 class KernelFigure(Figure):
483 u"""KernelImage directive
485 Earns everything from ``.. figure::`` directive, except *remote URI* and
486 *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure
487 node. See ``visit_kernel_figure``.
491 uri = self.arguments[0]
492 if uri.endswith('.*') or uri.find('://') != -1:
495 ' glob pattern and remote images are not allowed'
497 result = Figure.run(self)
498 if len(result) == 2 or isinstance(result[0], nodes.system_message):
500 (figure_node,) = result
501 # wrap figure node into a kernel_figure node / see visitors
502 node = kernel_figure('', figure_node)
507 # ---------------------
509 def visit_kernel_render(self, node):
510 """Visitor of the ``kernel_render`` Node.
512 If rendering tools available, save the markup of the ``literal_block`` child
513 node into a file and replace the ``literal_block`` node with a new created
514 ``image`` node, pointing to the saved markup file. Afterwards, handle the
515 image child-node with the ``convert_image(...)``.
517 app = self.builder.app
518 srclang = node.get('srclang')
520 kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang))
522 tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
524 kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang))
527 if not dot_cmd and tmp_ext == '.dot':
528 kernellog.verbose(app, "dot from graphviz not available / include raw.")
531 literal_block = node[0]
533 code = literal_block.astext()
534 hashobj = code.encode('utf-8') # str(node.attributes)
535 fname = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
537 tmp_fname = path.join(
538 self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
540 if not path.isfile(tmp_fname):
541 mkdir(path.dirname(tmp_fname))
542 with open(tmp_fname, "w") as out:
545 img_node = nodes.image(node.rawsource, **node.attributes)
546 img_node['uri'] = path.join(self.builder.imgpath, fname + tmp_ext)
547 img_node['candidates'] = {
548 '*': path.join(self.builder.imgpath, fname + tmp_ext)}
550 literal_block.replace_self(img_node)
551 convert_image(img_node, self, tmp_fname)
554 class kernel_render(nodes.General, nodes.Inline, nodes.Element):
555 """Node for ``kernel-render`` directive."""
558 class KernelRender(Figure):
559 u"""KernelRender directive
561 Render content by external tool. Has all the options known from the
562 *figure* directive, plus option ``caption``. If ``caption`` has a
563 value, a figure node with the *caption* is inserted. If not, a image node is
566 The KernelRender directive wraps the text of the directive into a
567 literal_block node and wraps it into a kernel_render node. See
568 ``visit_kernel_render``.
571 required_arguments = 1
572 optional_arguments = 0
573 final_argument_whitespace = False
575 # earn options from 'figure'
576 option_spec = Figure.option_spec.copy()
577 option_spec['caption'] = directives.unchanged
580 return [self.build_node()]
582 def build_node(self):
584 srclang = self.arguments[0].strip()
585 if srclang not in RENDER_MARKUP_EXT.keys():
586 return [self.state_machine.reporter.warning(
587 'Unknown source language "%s", use one of: %s.' % (
588 srclang, ",".join(RENDER_MARKUP_EXT.keys())),
591 code = '\n'.join(self.content)
593 return [self.state_machine.reporter.warning(
594 'Ignoring "%s" directive without content.' % (
598 node = kernel_render()
599 node['alt'] = self.options.get('alt','')
600 node['srclang'] = srclang
601 literal_node = nodes.literal_block(code, code)
604 caption = self.options.get('caption')
606 # parse caption's content
607 parsed = nodes.Element()
608 self.state.nested_parse(
609 ViewList([caption], source=''), self.content_offset, parsed)
610 caption_node = nodes.caption(
611 parsed[0].rawsource, '', *parsed[0].children)
612 caption_node.source = parsed[0].source
613 caption_node.line = parsed[0].line
615 figure_node = nodes.figure('', node)
616 for k,v in self.options.items():
618 figure_node += caption_node
624 def add_kernel_figure_to_std_domain(app, doctree):
625 """Add kernel-figure anchors to 'std' domain.
627 The ``StandardDomain.process_doc(..)`` method does not know how to resolve
628 the caption (label) of ``kernel-figure`` directive (it only knows about
629 standard nodes, e.g. table, figure etc.). Without any additional handling
630 this will result in a 'undefined label' for kernel-figures.
632 This handle adds labels of kernel-figure to the 'std' domain labels.
635 std = app.env.domains["std"]
636 docname = app.env.docname
637 labels = std.data["labels"]
639 for name, explicit in doctree.nametypes.items():
642 labelid = doctree.nameids[name]
645 node = doctree.ids[labelid]
647 if node.tagname == 'kernel_figure':
648 for n in node.next_node():
649 if n.tagname == 'caption':
650 sectname = clean_astext(n)
651 # add label to std domain
652 labels[name] = docname, labelid, sectname