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)
215 kernellog.verbose(app,
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:
300 "no SVG to PDF conversion available / include SVG raw."
301 "\nIncluding large raw SVGs can cause xelatex error."
302 "\nInstall Inkscape (preferred) or ImageMagick.")
303 img_node.replace_self(file2literal(src_fname))
305 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
306 img_node['uri'] = fname + '.pdf'
307 img_node['candidates'] = {'*': fname + '.pdf'}
310 # the builder needs not to copy one more time, so pop it if exists.
311 translator.builder.images.pop(img_node['uri'], None)
312 _name = dst_fname[len(translator.builder.outdir) + 1:]
314 if isNewer(dst_fname, src_fname):
315 kernellog.verbose(app,
316 "convert: {out}/%s already exists and is newer" % _name)
320 mkdir(path.dirname(dst_fname))
323 kernellog.verbose(app, 'convert DOT to: {out}/' + _name)
324 if translator.builder.format == 'latex' and not dot_Tpdf:
325 svg_fname = path.join(translator.builder.outdir, fname + '.svg')
326 ok1 = dot2format(app, src_fname, svg_fname)
327 ok2 = svg2pdf_by_rsvg(app, svg_fname, dst_fname)
331 ok = dot2format(app, src_fname, dst_fname)
333 elif in_ext == '.svg':
334 kernellog.verbose(app, 'convert SVG to: {out}/' + _name)
335 ok = svg2pdf(app, src_fname, dst_fname)
338 img_node.replace_self(file2literal(src_fname))
341 def dot2format(app, dot_fname, out_fname):
342 """Converts DOT file to ``out_fname`` using ``dot(1)``.
344 * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
345 * ``out_fname`` pathname of the output file, including format extension
347 The *format extension* depends on the ``dot`` command (see ``man dot``
348 option ``-Txxx``). Normally you will use one of the following extensions:
350 - ``.ps`` for PostScript,
351 - ``.svg`` or ``svgz`` for Structured Vector Graphics,
352 - ``.fig`` for XFIG graphics and
353 - ``.png`` or ``gif`` for common bitmap graphics.
356 out_format = path.splitext(out_fname)[1][1:]
357 cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
360 with open(out_fname, "w") as out:
361 exit_code = subprocess.call(cmd, stdout = out)
364 "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
365 return bool(exit_code == 0)
367 def svg2pdf(app, svg_fname, pdf_fname):
368 """Converts SVG to PDF with ``inkscape(1)`` or ``convert(1)`` command.
370 Uses ``inkscape(1)`` from Inkscape (https://inkscape.org/) or ``convert(1)``
371 from ImageMagick (https://www.imagemagick.org) for conversion.
372 Returns ``True`` on success and ``False`` if an error occurred.
374 * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
375 * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``)
378 cmd = [convert_cmd, svg_fname, pdf_fname]
379 cmd_name = 'convert(1)'
382 cmd_name = 'inkscape(1)'
384 cmd = [inkscape_cmd, '-o', pdf_fname, svg_fname]
386 cmd = [inkscape_cmd, '-z', '--export-pdf=%s' % pdf_fname, svg_fname]
389 warning_msg = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
391 except subprocess.CalledProcessError as err:
392 warning_msg = err.output
393 exit_code = err.returncode
397 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
399 kernellog.warn(app, "Warning msg from %s: %s"
400 % (cmd_name, str(warning_msg, 'utf-8')))
402 kernellog.verbose(app, "Warning msg from %s (likely harmless):\n%s"
403 % (cmd_name, str(warning_msg, 'utf-8')))
405 return bool(exit_code == 0)
407 def svg2pdf_by_rsvg(app, svg_fname, pdf_fname):
408 """Convert SVG to PDF with ``rsvg-convert(1)`` command.
410 * ``svg_fname`` pathname of input SVG file, including extension ``.svg``
411 * ``pdf_fname`` pathname of output PDF file, including extension ``.pdf``
413 Input SVG file should be the one generated by ``dot2format()``.
414 SVG -> PDF conversion is done by ``rsvg-convert(1)``.
416 If ``rsvg-convert(1)`` is unavailable, fall back to ``svg2pdf()``.
420 if rsvg_convert_cmd is None:
421 ok = svg2pdf(app, svg_fname, pdf_fname)
423 cmd = [rsvg_convert_cmd, '--format=pdf', '-o', pdf_fname, svg_fname]
424 # use stdout and stderr from parent
425 exit_code = subprocess.call(cmd)
427 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
428 ok = bool(exit_code == 0)
434 # ---------------------
436 def visit_kernel_image(self, node): # pylint: disable=W0613
437 """Visitor of the ``kernel_image`` Node.
439 Handles the ``image`` child-node with the ``convert_image(...)``.
442 convert_image(img_node, self)
444 class kernel_image(nodes.image):
445 """Node for ``kernel-image`` directive."""
448 class KernelImage(images.Image):
449 u"""KernelImage directive
451 Earns everything from ``.. image::`` directive, except *remote URI* and
452 *glob* pattern. The KernelImage wraps a image node into a
453 kernel_image node. See ``visit_kernel_image``.
457 uri = self.arguments[0]
458 if uri.endswith('.*') or uri.find('://') != -1:
460 'Error in "%s: %s": glob pattern and remote images are not allowed'
462 result = images.Image.run(self)
463 if len(result) == 2 or isinstance(result[0], nodes.system_message):
465 (image_node,) = result
466 # wrap image node into a kernel_image node / see visitors
467 node = kernel_image('', image_node)
471 # ---------------------
473 def visit_kernel_figure(self, node): # pylint: disable=W0613
474 """Visitor of the ``kernel_figure`` Node.
476 Handles the ``image`` child-node with the ``convert_image(...)``.
478 img_node = node[0][0]
479 convert_image(img_node, self)
481 class kernel_figure(nodes.figure):
482 """Node for ``kernel-figure`` directive."""
484 class KernelFigure(Figure):
485 u"""KernelImage directive
487 Earns everything from ``.. figure::`` directive, except *remote URI* and
488 *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure
489 node. See ``visit_kernel_figure``.
493 uri = self.arguments[0]
494 if uri.endswith('.*') or uri.find('://') != -1:
497 ' glob pattern and remote images are not allowed'
499 result = Figure.run(self)
500 if len(result) == 2 or isinstance(result[0], nodes.system_message):
502 (figure_node,) = result
503 # wrap figure node into a kernel_figure node / see visitors
504 node = kernel_figure('', figure_node)
509 # ---------------------
511 def visit_kernel_render(self, node):
512 """Visitor of the ``kernel_render`` Node.
514 If rendering tools available, save the markup of the ``literal_block`` child
515 node into a file and replace the ``literal_block`` node with a new created
516 ``image`` node, pointing to the saved markup file. Afterwards, handle the
517 image child-node with the ``convert_image(...)``.
519 app = self.builder.app
520 srclang = node.get('srclang')
522 kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang))
524 tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
526 kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang))
529 if not dot_cmd and tmp_ext == '.dot':
530 kernellog.verbose(app, "dot from graphviz not available / include raw.")
533 literal_block = node[0]
535 code = literal_block.astext()
536 hashobj = code.encode('utf-8') # str(node.attributes)
537 fname = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
539 tmp_fname = path.join(
540 self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
542 if not path.isfile(tmp_fname):
543 mkdir(path.dirname(tmp_fname))
544 with open(tmp_fname, "w") as out:
547 img_node = nodes.image(node.rawsource, **node.attributes)
548 img_node['uri'] = path.join(self.builder.imgpath, fname + tmp_ext)
549 img_node['candidates'] = {
550 '*': path.join(self.builder.imgpath, fname + tmp_ext)}
552 literal_block.replace_self(img_node)
553 convert_image(img_node, self, tmp_fname)
556 class kernel_render(nodes.General, nodes.Inline, nodes.Element):
557 """Node for ``kernel-render`` directive."""
560 class KernelRender(Figure):
561 u"""KernelRender directive
563 Render content by external tool. Has all the options known from the
564 *figure* directive, plus option ``caption``. If ``caption`` has a
565 value, a figure node with the *caption* is inserted. If not, a image node is
568 The KernelRender directive wraps the text of the directive into a
569 literal_block node and wraps it into a kernel_render node. See
570 ``visit_kernel_render``.
573 required_arguments = 1
574 optional_arguments = 0
575 final_argument_whitespace = False
577 # earn options from 'figure'
578 option_spec = Figure.option_spec.copy()
579 option_spec['caption'] = directives.unchanged
582 return [self.build_node()]
584 def build_node(self):
586 srclang = self.arguments[0].strip()
587 if srclang not in RENDER_MARKUP_EXT.keys():
588 return [self.state_machine.reporter.warning(
589 'Unknown source language "%s", use one of: %s.' % (
590 srclang, ",".join(RENDER_MARKUP_EXT.keys())),
593 code = '\n'.join(self.content)
595 return [self.state_machine.reporter.warning(
596 'Ignoring "%s" directive without content.' % (
600 node = kernel_render()
601 node['alt'] = self.options.get('alt','')
602 node['srclang'] = srclang
603 literal_node = nodes.literal_block(code, code)
606 caption = self.options.get('caption')
608 # parse caption's content
609 parsed = nodes.Element()
610 self.state.nested_parse(
611 ViewList([caption], source=''), self.content_offset, parsed)
612 caption_node = nodes.caption(
613 parsed[0].rawsource, '', *parsed[0].children)
614 caption_node.source = parsed[0].source
615 caption_node.line = parsed[0].line
617 figure_node = nodes.figure('', node)
618 for k,v in self.options.items():
620 figure_node += caption_node
626 def add_kernel_figure_to_std_domain(app, doctree):
627 """Add kernel-figure anchors to 'std' domain.
629 The ``StandardDomain.process_doc(..)`` method does not know how to resolve
630 the caption (label) of ``kernel-figure`` directive (it only knows about
631 standard nodes, e.g. table, figure etc.). Without any additional handling
632 this will result in a 'undefined label' for kernel-figures.
634 This handle adds labels of kernel-figure to the 'std' domain labels.
637 std = app.env.domains["std"]
638 docname = app.env.docname
639 labels = std.data["labels"]
641 for name, explicit in doctree.nametypes.items():
644 labelid = doctree.nameids[name]
647 node = doctree.ids[labelid]
649 if node.tagname == 'kernel_figure':
650 for n in node.next_node():
651 if n.tagname == 'caption':
652 sectname = clean_astext(n)
653 # add label to std domain
654 labels[name] = docname, labelid, sectname