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.
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 https://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
65 PY3 = sys.version_info[0] == 3
73 major, minor, patch = sphinx.version_info[:3]
74 if major == 1 and minor > 3:
75 # patches.Figure only landed in Sphinx 1.4
76 from sphinx.directives.patches import Figure # pylint: disable=C0413
78 Figure = images.Figure
86 """Searches the ``cmd`` in the ``PATH`` environment.
88 This *which* searches the PATH for executable ``cmd`` . First match is
89 returned, if nothing is found, ``None` is returned.
91 envpath = os.environ.get('PATH', None) or os.defpath
92 for folder in envpath.split(os.pathsep):
93 fname = folder + os.sep + cmd
94 if path.isfile(fname):
97 def mkdir(folder, mode=0o775):
98 if not path.isdir(folder):
99 os.makedirs(folder, mode)
101 def file2literal(fname):
102 with open(fname, "r") as src:
104 node = nodes.literal_block(data, data)
107 def isNewer(path1, path2):
108 """Returns True if ``path1`` is newer than ``path2``
110 If ``path1`` exists and is newer than ``path2`` the function returns
111 ``True`` is returned otherwise ``False``
113 return (path.exists(path1)
114 and os.stat(path1).st_ctime > os.stat(path2).st_ctime)
116 def pass_handle(self, node): # pylint: disable=W0613
119 # setup conversion tools and sphinx extension
120 # -------------------------------------------
122 # Graphviz's dot(1) support
125 # ImageMagick' convert(1) support
130 # check toolchain first
131 app.connect('builder-inited', setupTools)
134 app.add_directive("kernel-image", KernelImage)
135 app.add_node(kernel_image,
136 html = (visit_kernel_image, pass_handle),
137 latex = (visit_kernel_image, pass_handle),
138 texinfo = (visit_kernel_image, pass_handle),
139 text = (visit_kernel_image, pass_handle),
140 man = (visit_kernel_image, pass_handle), )
143 app.add_directive("kernel-figure", KernelFigure)
144 app.add_node(kernel_figure,
145 html = (visit_kernel_figure, pass_handle),
146 latex = (visit_kernel_figure, pass_handle),
147 texinfo = (visit_kernel_figure, pass_handle),
148 text = (visit_kernel_figure, pass_handle),
149 man = (visit_kernel_figure, pass_handle), )
152 app.add_directive('kernel-render', KernelRender)
153 app.add_node(kernel_render,
154 html = (visit_kernel_render, pass_handle),
155 latex = (visit_kernel_render, pass_handle),
156 texinfo = (visit_kernel_render, pass_handle),
157 text = (visit_kernel_render, pass_handle),
158 man = (visit_kernel_render, pass_handle), )
160 app.connect('doctree-read', add_kernel_figure_to_std_domain)
163 version = __version__,
164 parallel_read_safe = True,
165 parallel_write_safe = True
171 Check available build tools and log some *verbose* messages.
173 This function is called once, when the builder is initiated.
175 global dot_cmd, convert_cmd # pylint: disable=W0603
176 kernellog.verbose(app, "kfigure: check installed tools ...")
178 dot_cmd = which('dot')
179 convert_cmd = which('convert')
182 kernellog.verbose(app, "use dot(1) from: " + dot_cmd)
184 kernellog.warn(app, "dot(1) not found, for better output quality install "
185 "graphviz from https://www.graphviz.org")
187 kernellog.verbose(app, "use convert(1) from: " + convert_cmd)
190 "convert(1) not found, for SVG to PDF conversion install "
191 "ImageMagick (https://www.imagemagick.org)")
194 # integrate conversion tools
195 # --------------------------
197 RENDER_MARKUP_EXT = {
198 # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
204 def convert_image(img_node, translator, src_fname=None):
205 """Convert a image node for the builder.
207 Different builder prefer different image formats, e.g. *latex* builder
208 prefer PDF while *html* builder prefer SVG format for images.
210 This function handles output image formats in dependence of source the
211 format (of the image) and the translator's output format.
213 app = translator.builder.app
215 fname, in_ext = path.splitext(path.basename(img_node['uri']))
216 if src_fname is None:
217 src_fname = path.join(translator.builder.srcdir, img_node['uri'])
218 if not path.exists(src_fname):
219 src_fname = path.join(translator.builder.outdir, img_node['uri'])
223 # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
225 kernellog.verbose(app, 'assert best format for: ' + img_node['uri'])
230 kernellog.verbose(app,
231 "dot from graphviz not available / include DOT raw.")
232 img_node.replace_self(file2literal(src_fname))
234 elif translator.builder.format == 'latex':
235 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
236 img_node['uri'] = fname + '.pdf'
237 img_node['candidates'] = {'*': fname + '.pdf'}
240 elif translator.builder.format == 'html':
241 dst_fname = path.join(
242 translator.builder.outdir,
243 translator.builder.imagedir,
245 img_node['uri'] = path.join(
246 translator.builder.imgpath, fname + '.svg')
247 img_node['candidates'] = {
248 '*': path.join(translator.builder.imgpath, fname + '.svg')}
251 # all other builder formats will include DOT as raw
252 img_node.replace_self(file2literal(src_fname))
254 elif in_ext == '.svg':
256 if translator.builder.format == 'latex':
257 if convert_cmd is None:
258 kernellog.verbose(app,
259 "no SVG to PDF conversion available / include SVG raw.")
260 img_node.replace_self(file2literal(src_fname))
262 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
263 img_node['uri'] = fname + '.pdf'
264 img_node['candidates'] = {'*': fname + '.pdf'}
267 # the builder needs not to copy one more time, so pop it if exists.
268 translator.builder.images.pop(img_node['uri'], None)
269 _name = dst_fname[len(translator.builder.outdir) + 1:]
271 if isNewer(dst_fname, src_fname):
272 kernellog.verbose(app,
273 "convert: {out}/%s already exists and is newer" % _name)
277 mkdir(path.dirname(dst_fname))
280 kernellog.verbose(app, 'convert DOT to: {out}/' + _name)
281 ok = dot2format(app, src_fname, dst_fname)
283 elif in_ext == '.svg':
284 kernellog.verbose(app, 'convert SVG to: {out}/' + _name)
285 ok = svg2pdf(app, src_fname, dst_fname)
288 img_node.replace_self(file2literal(src_fname))
291 def dot2format(app, dot_fname, out_fname):
292 """Converts DOT file to ``out_fname`` using ``dot(1)``.
294 * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
295 * ``out_fname`` pathname of the output file, including format extension
297 The *format extension* depends on the ``dot`` command (see ``man dot``
298 option ``-Txxx``). Normally you will use one of the following extensions:
300 - ``.ps`` for PostScript,
301 - ``.svg`` or ``svgz`` for Structured Vector Graphics,
302 - ``.fig`` for XFIG graphics and
303 - ``.png`` or ``gif`` for common bitmap graphics.
306 out_format = path.splitext(out_fname)[1][1:]
307 cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
310 with open(out_fname, "w") as out:
311 exit_code = subprocess.call(cmd, stdout = out)
314 "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
315 return bool(exit_code == 0)
317 def svg2pdf(app, svg_fname, pdf_fname):
318 """Converts SVG to PDF with ``convert(1)`` command.
320 Uses ``convert(1)`` from ImageMagick (https://www.imagemagick.org) for
321 conversion. Returns ``True`` on success and ``False`` if an error occurred.
323 * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
324 * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``)
327 cmd = [convert_cmd, svg_fname, pdf_fname]
328 # use stdout and stderr from parent
329 exit_code = subprocess.call(cmd)
331 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
332 return bool(exit_code == 0)
336 # ---------------------
338 def visit_kernel_image(self, node): # pylint: disable=W0613
339 """Visitor of the ``kernel_image`` Node.
341 Handles the ``image`` child-node with the ``convert_image(...)``.
344 convert_image(img_node, self)
346 class kernel_image(nodes.image):
347 """Node for ``kernel-image`` directive."""
350 class KernelImage(images.Image):
351 u"""KernelImage directive
353 Earns everything from ``.. image::`` directive, except *remote URI* and
354 *glob* pattern. The KernelImage wraps a image node into a
355 kernel_image node. See ``visit_kernel_image``.
359 uri = self.arguments[0]
360 if uri.endswith('.*') or uri.find('://') != -1:
362 'Error in "%s: %s": glob pattern and remote images are not allowed'
364 result = images.Image.run(self)
365 if len(result) == 2 or isinstance(result[0], nodes.system_message):
367 (image_node,) = result
368 # wrap image node into a kernel_image node / see visitors
369 node = kernel_image('', image_node)
373 # ---------------------
375 def visit_kernel_figure(self, node): # pylint: disable=W0613
376 """Visitor of the ``kernel_figure`` Node.
378 Handles the ``image`` child-node with the ``convert_image(...)``.
380 img_node = node[0][0]
381 convert_image(img_node, self)
383 class kernel_figure(nodes.figure):
384 """Node for ``kernel-figure`` directive."""
386 class KernelFigure(Figure):
387 u"""KernelImage directive
389 Earns everything from ``.. figure::`` directive, except *remote URI* and
390 *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure
391 node. See ``visit_kernel_figure``.
395 uri = self.arguments[0]
396 if uri.endswith('.*') or uri.find('://') != -1:
399 ' glob pattern and remote images are not allowed'
401 result = Figure.run(self)
402 if len(result) == 2 or isinstance(result[0], nodes.system_message):
404 (figure_node,) = result
405 # wrap figure node into a kernel_figure node / see visitors
406 node = kernel_figure('', figure_node)
411 # ---------------------
413 def visit_kernel_render(self, node):
414 """Visitor of the ``kernel_render`` Node.
416 If rendering tools available, save the markup of the ``literal_block`` child
417 node into a file and replace the ``literal_block`` node with a new created
418 ``image`` node, pointing to the saved markup file. Afterwards, handle the
419 image child-node with the ``convert_image(...)``.
421 app = self.builder.app
422 srclang = node.get('srclang')
424 kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang))
426 tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
428 kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang))
431 if not dot_cmd and tmp_ext == '.dot':
432 kernellog.verbose(app, "dot from graphviz not available / include raw.")
435 literal_block = node[0]
437 code = literal_block.astext()
438 hashobj = code.encode('utf-8') # str(node.attributes)
439 fname = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
441 tmp_fname = path.join(
442 self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
444 if not path.isfile(tmp_fname):
445 mkdir(path.dirname(tmp_fname))
446 with open(tmp_fname, "w") as out:
449 img_node = nodes.image(node.rawsource, **node.attributes)
450 img_node['uri'] = path.join(self.builder.imgpath, fname + tmp_ext)
451 img_node['candidates'] = {
452 '*': path.join(self.builder.imgpath, fname + tmp_ext)}
454 literal_block.replace_self(img_node)
455 convert_image(img_node, self, tmp_fname)
458 class kernel_render(nodes.General, nodes.Inline, nodes.Element):
459 """Node for ``kernel-render`` directive."""
462 class KernelRender(Figure):
463 u"""KernelRender directive
465 Render content by external tool. Has all the options known from the
466 *figure* directive, plus option ``caption``. If ``caption`` has a
467 value, a figure node with the *caption* is inserted. If not, a image node is
470 The KernelRender directive wraps the text of the directive into a
471 literal_block node and wraps it into a kernel_render node. See
472 ``visit_kernel_render``.
475 required_arguments = 1
476 optional_arguments = 0
477 final_argument_whitespace = False
479 # earn options from 'figure'
480 option_spec = Figure.option_spec.copy()
481 option_spec['caption'] = directives.unchanged
484 return [self.build_node()]
486 def build_node(self):
488 srclang = self.arguments[0].strip()
489 if srclang not in RENDER_MARKUP_EXT.keys():
490 return [self.state_machine.reporter.warning(
491 'Unknown source language "%s", use one of: %s.' % (
492 srclang, ",".join(RENDER_MARKUP_EXT.keys())),
495 code = '\n'.join(self.content)
497 return [self.state_machine.reporter.warning(
498 'Ignoring "%s" directive without content.' % (
502 node = kernel_render()
503 node['alt'] = self.options.get('alt','')
504 node['srclang'] = srclang
505 literal_node = nodes.literal_block(code, code)
508 caption = self.options.get('caption')
510 # parse caption's content
511 parsed = nodes.Element()
512 self.state.nested_parse(
513 ViewList([caption], source=''), self.content_offset, parsed)
514 caption_node = nodes.caption(
515 parsed[0].rawsource, '', *parsed[0].children)
516 caption_node.source = parsed[0].source
517 caption_node.line = parsed[0].line
519 figure_node = nodes.figure('', node)
520 for k,v in self.options.items():
522 figure_node += caption_node
528 def add_kernel_figure_to_std_domain(app, doctree):
529 """Add kernel-figure anchors to 'std' domain.
531 The ``StandardDomain.process_doc(..)`` method does not know how to resolve
532 the caption (label) of ``kernel-figure`` directive (it only knows about
533 standard nodes, e.g. table, figure etc.). Without any additional handling
534 this will result in a 'undefined label' for kernel-figures.
536 This handle adds labels of kernel-figure to the 'std' domain labels.
539 std = app.env.domains["std"]
540 docname = app.env.docname
541 labels = std.data["labels"]
543 for name, explicit in iteritems(doctree.nametypes):
546 labelid = doctree.nameids[name]
549 node = doctree.ids[labelid]
551 if node.tagname == 'kernel_figure':
552 for n in node.next_node():
553 if n.tagname == 'caption':
554 sectname = clean_astext(n)
555 # add label to std domain
556 labels[name] = docname, labelid, sectname