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
52 from docutils import nodes
53 from docutils.statemachine import ViewList
54 from docutils.parsers.rst import directives
55 from docutils.parsers.rst.directives import images
57 from sphinx.util.nodes import clean_astext
61 major, minor, patch = sphinx.version_info[:3]
62 if major == 1 and minor > 3:
63 # patches.Figure only landed in Sphinx 1.4
64 from sphinx.directives.patches import Figure # pylint: disable=C0413
66 Figure = images.Figure
74 """Searches the ``cmd`` in the ``PATH`` environment.
76 This *which* searches the PATH for executable ``cmd`` . First match is
77 returned, if nothing is found, ``None` is returned.
79 envpath = os.environ.get('PATH', None) or os.defpath
80 for folder in envpath.split(os.pathsep):
81 fname = folder + os.sep + cmd
82 if path.isfile(fname):
85 def mkdir(folder, mode=0o775):
86 if not path.isdir(folder):
87 os.makedirs(folder, mode)
89 def file2literal(fname):
90 with open(fname, "r") as src:
92 node = nodes.literal_block(data, data)
95 def isNewer(path1, path2):
96 """Returns True if ``path1`` is newer than ``path2``
98 If ``path1`` exists and is newer than ``path2`` the function returns
99 ``True`` is returned otherwise ``False``
101 return (path.exists(path1)
102 and os.stat(path1).st_ctime > os.stat(path2).st_ctime)
104 def pass_handle(self, node): # pylint: disable=W0613
107 # setup conversion tools and sphinx extension
108 # -------------------------------------------
110 # Graphviz's dot(1) support
113 # ImageMagick' convert(1) support
118 # check toolchain first
119 app.connect('builder-inited', setupTools)
122 app.add_directive("kernel-image", KernelImage)
123 app.add_node(kernel_image,
124 html = (visit_kernel_image, pass_handle),
125 latex = (visit_kernel_image, pass_handle),
126 texinfo = (visit_kernel_image, pass_handle),
127 text = (visit_kernel_image, pass_handle),
128 man = (visit_kernel_image, pass_handle), )
131 app.add_directive("kernel-figure", KernelFigure)
132 app.add_node(kernel_figure,
133 html = (visit_kernel_figure, pass_handle),
134 latex = (visit_kernel_figure, pass_handle),
135 texinfo = (visit_kernel_figure, pass_handle),
136 text = (visit_kernel_figure, pass_handle),
137 man = (visit_kernel_figure, pass_handle), )
140 app.add_directive('kernel-render', KernelRender)
141 app.add_node(kernel_render,
142 html = (visit_kernel_render, pass_handle),
143 latex = (visit_kernel_render, pass_handle),
144 texinfo = (visit_kernel_render, pass_handle),
145 text = (visit_kernel_render, pass_handle),
146 man = (visit_kernel_render, pass_handle), )
148 app.connect('doctree-read', add_kernel_figure_to_std_domain)
151 version = __version__,
152 parallel_read_safe = True,
153 parallel_write_safe = True
159 Check available build tools and log some *verbose* messages.
161 This function is called once, when the builder is initiated.
163 global dot_cmd, convert_cmd # pylint: disable=W0603
164 kernellog.verbose(app, "kfigure: check installed tools ...")
166 dot_cmd = which('dot')
167 convert_cmd = which('convert')
170 kernellog.verbose(app, "use dot(1) from: " + dot_cmd)
172 kernellog.warn(app, "dot(1) not found, for better output quality install "
173 "graphviz from https://www.graphviz.org")
175 kernellog.verbose(app, "use convert(1) from: " + convert_cmd)
178 "convert(1) not found, for SVG to PDF conversion install "
179 "ImageMagick (https://www.imagemagick.org)")
182 # integrate conversion tools
183 # --------------------------
185 RENDER_MARKUP_EXT = {
186 # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
192 def convert_image(img_node, translator, src_fname=None):
193 """Convert a image node for the builder.
195 Different builder prefer different image formats, e.g. *latex* builder
196 prefer PDF while *html* builder prefer SVG format for images.
198 This function handles output image formats in dependence of source the
199 format (of the image) and the translator's output format.
201 app = translator.builder.app
203 fname, in_ext = path.splitext(path.basename(img_node['uri']))
204 if src_fname is None:
205 src_fname = path.join(translator.builder.srcdir, img_node['uri'])
206 if not path.exists(src_fname):
207 src_fname = path.join(translator.builder.outdir, img_node['uri'])
211 # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
213 kernellog.verbose(app, 'assert best format for: ' + img_node['uri'])
218 kernellog.verbose(app,
219 "dot from graphviz not available / include DOT raw.")
220 img_node.replace_self(file2literal(src_fname))
222 elif translator.builder.format == 'latex':
223 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
224 img_node['uri'] = fname + '.pdf'
225 img_node['candidates'] = {'*': fname + '.pdf'}
228 elif translator.builder.format == 'html':
229 dst_fname = path.join(
230 translator.builder.outdir,
231 translator.builder.imagedir,
233 img_node['uri'] = path.join(
234 translator.builder.imgpath, fname + '.svg')
235 img_node['candidates'] = {
236 '*': path.join(translator.builder.imgpath, fname + '.svg')}
239 # all other builder formats will include DOT as raw
240 img_node.replace_self(file2literal(src_fname))
242 elif in_ext == '.svg':
244 if translator.builder.format == 'latex':
245 if convert_cmd is None:
246 kernellog.verbose(app,
247 "no SVG to PDF conversion available / include SVG raw.")
248 img_node.replace_self(file2literal(src_fname))
250 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
251 img_node['uri'] = fname + '.pdf'
252 img_node['candidates'] = {'*': fname + '.pdf'}
255 # the builder needs not to copy one more time, so pop it if exists.
256 translator.builder.images.pop(img_node['uri'], None)
257 _name = dst_fname[len(translator.builder.outdir) + 1:]
259 if isNewer(dst_fname, src_fname):
260 kernellog.verbose(app,
261 "convert: {out}/%s already exists and is newer" % _name)
265 mkdir(path.dirname(dst_fname))
268 kernellog.verbose(app, 'convert DOT to: {out}/' + _name)
269 ok = dot2format(app, src_fname, dst_fname)
271 elif in_ext == '.svg':
272 kernellog.verbose(app, 'convert SVG to: {out}/' + _name)
273 ok = svg2pdf(app, src_fname, dst_fname)
276 img_node.replace_self(file2literal(src_fname))
279 def dot2format(app, dot_fname, out_fname):
280 """Converts DOT file to ``out_fname`` using ``dot(1)``.
282 * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
283 * ``out_fname`` pathname of the output file, including format extension
285 The *format extension* depends on the ``dot`` command (see ``man dot``
286 option ``-Txxx``). Normally you will use one of the following extensions:
288 - ``.ps`` for PostScript,
289 - ``.svg`` or ``svgz`` for Structured Vector Graphics,
290 - ``.fig`` for XFIG graphics and
291 - ``.png`` or ``gif`` for common bitmap graphics.
294 out_format = path.splitext(out_fname)[1][1:]
295 cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
298 with open(out_fname, "w") as out:
299 exit_code = subprocess.call(cmd, stdout = out)
302 "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
303 return bool(exit_code == 0)
305 def svg2pdf(app, svg_fname, pdf_fname):
306 """Converts SVG to PDF with ``convert(1)`` command.
308 Uses ``convert(1)`` from ImageMagick (https://www.imagemagick.org) for
309 conversion. Returns ``True`` on success and ``False`` if an error occurred.
311 * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
312 * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``)
315 cmd = [convert_cmd, svg_fname, pdf_fname]
316 # use stdout and stderr from parent
317 exit_code = subprocess.call(cmd)
319 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
320 return bool(exit_code == 0)
324 # ---------------------
326 def visit_kernel_image(self, node): # pylint: disable=W0613
327 """Visitor of the ``kernel_image`` Node.
329 Handles the ``image`` child-node with the ``convert_image(...)``.
332 convert_image(img_node, self)
334 class kernel_image(nodes.image):
335 """Node for ``kernel-image`` directive."""
338 class KernelImage(images.Image):
339 u"""KernelImage directive
341 Earns everything from ``.. image::`` directive, except *remote URI* and
342 *glob* pattern. The KernelImage wraps a image node into a
343 kernel_image node. See ``visit_kernel_image``.
347 uri = self.arguments[0]
348 if uri.endswith('.*') or uri.find('://') != -1:
350 'Error in "%s: %s": glob pattern and remote images are not allowed'
352 result = images.Image.run(self)
353 if len(result) == 2 or isinstance(result[0], nodes.system_message):
355 (image_node,) = result
356 # wrap image node into a kernel_image node / see visitors
357 node = kernel_image('', image_node)
361 # ---------------------
363 def visit_kernel_figure(self, node): # pylint: disable=W0613
364 """Visitor of the ``kernel_figure`` Node.
366 Handles the ``image`` child-node with the ``convert_image(...)``.
368 img_node = node[0][0]
369 convert_image(img_node, self)
371 class kernel_figure(nodes.figure):
372 """Node for ``kernel-figure`` directive."""
374 class KernelFigure(Figure):
375 u"""KernelImage directive
377 Earns everything from ``.. figure::`` directive, except *remote URI* and
378 *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure
379 node. See ``visit_kernel_figure``.
383 uri = self.arguments[0]
384 if uri.endswith('.*') or uri.find('://') != -1:
387 ' glob pattern and remote images are not allowed'
389 result = Figure.run(self)
390 if len(result) == 2 or isinstance(result[0], nodes.system_message):
392 (figure_node,) = result
393 # wrap figure node into a kernel_figure node / see visitors
394 node = kernel_figure('', figure_node)
399 # ---------------------
401 def visit_kernel_render(self, node):
402 """Visitor of the ``kernel_render`` Node.
404 If rendering tools available, save the markup of the ``literal_block`` child
405 node into a file and replace the ``literal_block`` node with a new created
406 ``image`` node, pointing to the saved markup file. Afterwards, handle the
407 image child-node with the ``convert_image(...)``.
409 app = self.builder.app
410 srclang = node.get('srclang')
412 kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang))
414 tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
416 kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang))
419 if not dot_cmd and tmp_ext == '.dot':
420 kernellog.verbose(app, "dot from graphviz not available / include raw.")
423 literal_block = node[0]
425 code = literal_block.astext()
426 hashobj = code.encode('utf-8') # str(node.attributes)
427 fname = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
429 tmp_fname = path.join(
430 self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
432 if not path.isfile(tmp_fname):
433 mkdir(path.dirname(tmp_fname))
434 with open(tmp_fname, "w") as out:
437 img_node = nodes.image(node.rawsource, **node.attributes)
438 img_node['uri'] = path.join(self.builder.imgpath, fname + tmp_ext)
439 img_node['candidates'] = {
440 '*': path.join(self.builder.imgpath, fname + tmp_ext)}
442 literal_block.replace_self(img_node)
443 convert_image(img_node, self, tmp_fname)
446 class kernel_render(nodes.General, nodes.Inline, nodes.Element):
447 """Node for ``kernel-render`` directive."""
450 class KernelRender(Figure):
451 u"""KernelRender directive
453 Render content by external tool. Has all the options known from the
454 *figure* directive, plus option ``caption``. If ``caption`` has a
455 value, a figure node with the *caption* is inserted. If not, a image node is
458 The KernelRender directive wraps the text of the directive into a
459 literal_block node and wraps it into a kernel_render node. See
460 ``visit_kernel_render``.
463 required_arguments = 1
464 optional_arguments = 0
465 final_argument_whitespace = False
467 # earn options from 'figure'
468 option_spec = Figure.option_spec.copy()
469 option_spec['caption'] = directives.unchanged
472 return [self.build_node()]
474 def build_node(self):
476 srclang = self.arguments[0].strip()
477 if srclang not in RENDER_MARKUP_EXT.keys():
478 return [self.state_machine.reporter.warning(
479 'Unknown source language "%s", use one of: %s.' % (
480 srclang, ",".join(RENDER_MARKUP_EXT.keys())),
483 code = '\n'.join(self.content)
485 return [self.state_machine.reporter.warning(
486 'Ignoring "%s" directive without content.' % (
490 node = kernel_render()
491 node['alt'] = self.options.get('alt','')
492 node['srclang'] = srclang
493 literal_node = nodes.literal_block(code, code)
496 caption = self.options.get('caption')
498 # parse caption's content
499 parsed = nodes.Element()
500 self.state.nested_parse(
501 ViewList([caption], source=''), self.content_offset, parsed)
502 caption_node = nodes.caption(
503 parsed[0].rawsource, '', *parsed[0].children)
504 caption_node.source = parsed[0].source
505 caption_node.line = parsed[0].line
507 figure_node = nodes.figure('', node)
508 for k,v in self.options.items():
510 figure_node += caption_node
516 def add_kernel_figure_to_std_domain(app, doctree):
517 """Add kernel-figure anchors to 'std' domain.
519 The ``StandardDomain.process_doc(..)`` method does not know how to resolve
520 the caption (label) of ``kernel-figure`` directive (it only knows about
521 standard nodes, e.g. table, figure etc.). Without any additional handling
522 this will result in a 'undefined label' for kernel-figures.
524 This handle adds labels of kernel-figure to the 'std' domain labels.
527 std = app.env.domains["std"]
528 docname = app.env.docname
529 labels = std.data["labels"]
531 for name, explicit in doctree.nametypes.items():
534 labelid = doctree.nameids[name]
537 node = doctree.ids[labelid]
539 if node.tagname == 'kernel_figure':
540 for n in node.next_node():
541 if n.tagname == 'caption':
542 sectname = clean_astext(n)
543 # add label to std domain
544 labels[name] = docname, labelid, sectname