1# -*- coding: utf-8; mode: python -*-
2# pylint: disable=C0103, R0903, R0912, R0915
3u"""
4    scalable figure and image handling
5    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6
7    Sphinx extension which implements scalable image handling.
8
9    :copyright:  Copyright (C) 2016  Markus Heiser
10    :license:    GPL Version 2, June 1991 see Linux/COPYING for details.
11
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.
18
19    * ``.. kernel-image``: for image handling / a ``.. image::`` replacement
20
21    * ``.. kernel-figure``: for figure handling / a ``.. figure::`` replacement
22
23    * ``.. kernel-render``: for render markup / a concept to embed *render*
24      markups (or languages). Supported markups (see ``RENDER_MARKUP_EXT``)
25
26      - ``DOT``: render embedded Graphviz's **DOC**
27      - ``SVG``: render embedded Scalable Vector Graphics (**SVG**)
28      - ... *developable*
29
30    Used tools:
31
32    * ``dot(1)``: Graphviz (https://www.graphviz.org). If Graphviz is not
33      available, the DOT language is inserted as literal-block.
34
35    * SVG to PDF: To generate PDF, you need at least one of this tools:
36
37      - ``convert(1)``: ImageMagick (https://www.imagemagick.org)
38
39    List of customizations:
40
41    * generate PDF from SVG / used by PDF (LaTeX) builder
42
43    * generate SVG (html-builder) and PDF (latex-builder) from DOT files.
44      DOT: see https://www.graphviz.org/content/dot-language
45
46    """
47
48import os
49from os import path
50import subprocess
51from hashlib import sha1
52from docutils import nodes
53from docutils.statemachine import ViewList
54from docutils.parsers.rst import directives
55from docutils.parsers.rst.directives import images
56import sphinx
57from sphinx.util.nodes import clean_astext
58import kernellog
59
60# Get Sphinx version
61major, minor, patch = sphinx.version_info[:3]
62if major == 1 and minor > 3:
63    # patches.Figure only landed in Sphinx 1.4
64    from sphinx.directives.patches import Figure  # pylint: disable=C0413
65else:
66    Figure = images.Figure
67
68__version__  = '1.0.0'
69
70# simple helper
71# -------------
72
73def which(cmd):
74    """Searches the ``cmd`` in the ``PATH`` environment.
75
76    This *which* searches the PATH for executable ``cmd`` . First match is
77    returned, if nothing is found, ``None` is returned.
78    """
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):
83            return fname
84
85def mkdir(folder, mode=0o775):
86    if not path.isdir(folder):
87        os.makedirs(folder, mode)
88
89def file2literal(fname):
90    with open(fname, "r") as src:
91        data = src.read()
92        node = nodes.literal_block(data, data)
93    return node
94
95def isNewer(path1, path2):
96    """Returns True if ``path1`` is newer than ``path2``
97
98    If ``path1`` exists and is newer than ``path2`` the function returns
99    ``True`` is returned otherwise ``False``
100    """
101    return (path.exists(path1)
102            and os.stat(path1).st_ctime > os.stat(path2).st_ctime)
103
104def pass_handle(self, node):           # pylint: disable=W0613
105    pass
106
107# setup conversion tools and sphinx extension
108# -------------------------------------------
109
110# Graphviz's dot(1) support
111dot_cmd = None
112
113# ImageMagick' convert(1) support
114convert_cmd = None
115
116
117def setup(app):
118    # check toolchain first
119    app.connect('builder-inited', setupTools)
120
121    # image handling
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), )
129
130    # figure handling
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), )
138
139    # render handling
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), )
147
148    app.connect('doctree-read', add_kernel_figure_to_std_domain)
149
150    return dict(
151        version = __version__,
152        parallel_read_safe = True,
153        parallel_write_safe = True
154    )
155
156
157def setupTools(app):
158    u"""
159    Check available build tools and log some *verbose* messages.
160
161    This function is called once, when the builder is initiated.
162    """
163    global dot_cmd, convert_cmd   # pylint: disable=W0603
164    kernellog.verbose(app, "kfigure: check installed tools ...")
165
166    dot_cmd = which('dot')
167    convert_cmd = which('convert')
168
169    if dot_cmd:
170        kernellog.verbose(app, "use dot(1) from: " + dot_cmd)
171    else:
172        kernellog.warn(app, "dot(1) not found, for better output quality install "
173                       "graphviz from https://www.graphviz.org")
174    if convert_cmd:
175        kernellog.verbose(app, "use convert(1) from: " + convert_cmd)
176    else:
177        kernellog.warn(app,
178            "convert(1) not found, for SVG to PDF conversion install "
179            "ImageMagick (https://www.imagemagick.org)")
180
181
182# integrate conversion tools
183# --------------------------
184
185RENDER_MARKUP_EXT = {
186    # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
187    # <name> : <.ext>
188    'DOT' : '.dot',
189    'SVG' : '.svg'
190}
191
192def convert_image(img_node, translator, src_fname=None):
193    """Convert a image node for the builder.
194
195    Different builder prefer different image formats, e.g. *latex* builder
196    prefer PDF while *html* builder prefer SVG format for images.
197
198    This function handles output image formats in dependence of source the
199    format (of the image) and the translator's output format.
200    """
201    app = translator.builder.app
202
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'])
208
209    dst_fname = None
210
211    # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
212
213    kernellog.verbose(app, 'assert best format for: ' + img_node['uri'])
214
215    if in_ext == '.dot':
216
217        if not dot_cmd:
218            kernellog.verbose(app,
219                              "dot from graphviz not available / include DOT raw.")
220            img_node.replace_self(file2literal(src_fname))
221
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'}
226
227
228        elif translator.builder.format == 'html':
229            dst_fname = path.join(
230                translator.builder.outdir,
231                translator.builder.imagedir,
232                fname + '.svg')
233            img_node['uri'] = path.join(
234                translator.builder.imgpath, fname + '.svg')
235            img_node['candidates'] = {
236                '*': path.join(translator.builder.imgpath, fname + '.svg')}
237
238        else:
239            # all other builder formats will include DOT as raw
240            img_node.replace_self(file2literal(src_fname))
241
242    elif in_ext == '.svg':
243
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))
249            else:
250                dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
251                img_node['uri'] = fname + '.pdf'
252                img_node['candidates'] = {'*': fname + '.pdf'}
253
254    if dst_fname:
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:]
258
259        if isNewer(dst_fname, src_fname):
260            kernellog.verbose(app,
261                              "convert: {out}/%s already exists and is newer" % _name)
262
263        else:
264            ok = False
265            mkdir(path.dirname(dst_fname))
266
267            if in_ext == '.dot':
268                kernellog.verbose(app, 'convert DOT to: {out}/' + _name)
269                ok = dot2format(app, src_fname, dst_fname)
270
271            elif in_ext == '.svg':
272                kernellog.verbose(app, 'convert SVG to: {out}/' + _name)
273                ok = svg2pdf(app, src_fname, dst_fname)
274
275            if not ok:
276                img_node.replace_self(file2literal(src_fname))
277
278
279def dot2format(app, dot_fname, out_fname):
280    """Converts DOT file to ``out_fname`` using ``dot(1)``.
281
282    * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
283    * ``out_fname`` pathname of the output file, including format extension
284
285    The *format extension* depends on the ``dot`` command (see ``man dot``
286    option ``-Txxx``). Normally you will use one of the following extensions:
287
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.
292
293    """
294    out_format = path.splitext(out_fname)[1][1:]
295    cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
296    exit_code = 42
297
298    with open(out_fname, "w") as out:
299        exit_code = subprocess.call(cmd, stdout = out)
300        if exit_code != 0:
301            kernellog.warn(app,
302                          "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
303    return bool(exit_code == 0)
304
305def svg2pdf(app, svg_fname, pdf_fname):
306    """Converts SVG to PDF with ``convert(1)`` command.
307
308    Uses ``convert(1)`` from ImageMagick (https://www.imagemagick.org) for
309    conversion.  Returns ``True`` on success and ``False`` if an error occurred.
310
311    * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
312    * ``pdf_name``  pathname of the output PDF file with extension (``.pdf``)
313
314    """
315    cmd = [convert_cmd, svg_fname, pdf_fname]
316    # use stdout and stderr from parent
317    exit_code = subprocess.call(cmd)
318    if exit_code != 0:
319        kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
320    return bool(exit_code == 0)
321
322
323# image handling
324# ---------------------
325
326def visit_kernel_image(self, node):    # pylint: disable=W0613
327    """Visitor of the ``kernel_image`` Node.
328
329    Handles the ``image`` child-node with the ``convert_image(...)``.
330    """
331    img_node = node[0]
332    convert_image(img_node, self)
333
334class kernel_image(nodes.image):
335    """Node for ``kernel-image`` directive."""
336    pass
337
338class KernelImage(images.Image):
339    u"""KernelImage directive
340
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``.
344    """
345
346    def run(self):
347        uri = self.arguments[0]
348        if uri.endswith('.*') or uri.find('://') != -1:
349            raise self.severe(
350                'Error in "%s: %s": glob pattern and remote images are not allowed'
351                % (self.name, uri))
352        result = images.Image.run(self)
353        if len(result) == 2 or isinstance(result[0], nodes.system_message):
354            return result
355        (image_node,) = result
356        # wrap image node into a kernel_image node / see visitors
357        node = kernel_image('', image_node)
358        return [node]
359
360# figure handling
361# ---------------------
362
363def visit_kernel_figure(self, node):   # pylint: disable=W0613
364    """Visitor of the ``kernel_figure`` Node.
365
366    Handles the ``image`` child-node with the ``convert_image(...)``.
367    """
368    img_node = node[0][0]
369    convert_image(img_node, self)
370
371class kernel_figure(nodes.figure):
372    """Node for ``kernel-figure`` directive."""
373
374class KernelFigure(Figure):
375    u"""KernelImage directive
376
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``.
380    """
381
382    def run(self):
383        uri = self.arguments[0]
384        if uri.endswith('.*') or uri.find('://') != -1:
385            raise self.severe(
386                'Error in "%s: %s":'
387                ' glob pattern and remote images are not allowed'
388                % (self.name, uri))
389        result = Figure.run(self)
390        if len(result) == 2 or isinstance(result[0], nodes.system_message):
391            return result
392        (figure_node,) = result
393        # wrap figure node into a kernel_figure node / see visitors
394        node = kernel_figure('', figure_node)
395        return [node]
396
397
398# render handling
399# ---------------------
400
401def visit_kernel_render(self, node):
402    """Visitor of the ``kernel_render`` Node.
403
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(...)``.
408    """
409    app = self.builder.app
410    srclang = node.get('srclang')
411
412    kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang))
413
414    tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
415    if tmp_ext is None:
416        kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang))
417        return
418
419    if not dot_cmd and tmp_ext == '.dot':
420        kernellog.verbose(app, "dot from graphviz not available / include raw.")
421        return
422
423    literal_block = node[0]
424
425    code      = literal_block.astext()
426    hashobj   = code.encode('utf-8') #  str(node.attributes)
427    fname     = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
428
429    tmp_fname = path.join(
430        self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
431
432    if not path.isfile(tmp_fname):
433        mkdir(path.dirname(tmp_fname))
434        with open(tmp_fname, "w") as out:
435            out.write(code)
436
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)}
441
442    literal_block.replace_self(img_node)
443    convert_image(img_node, self, tmp_fname)
444
445
446class kernel_render(nodes.General, nodes.Inline, nodes.Element):
447    """Node for ``kernel-render`` directive."""
448    pass
449
450class KernelRender(Figure):
451    u"""KernelRender directive
452
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
456    inserted.
457
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``.
461    """
462    has_content = True
463    required_arguments = 1
464    optional_arguments = 0
465    final_argument_whitespace = False
466
467    # earn options from 'figure'
468    option_spec = Figure.option_spec.copy()
469    option_spec['caption'] = directives.unchanged
470
471    def run(self):
472        return [self.build_node()]
473
474    def build_node(self):
475
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())),
481                line=self.lineno)]
482
483        code = '\n'.join(self.content)
484        if not code.strip():
485            return [self.state_machine.reporter.warning(
486                'Ignoring "%s" directive without content.' % (
487                    self.name),
488                line=self.lineno)]
489
490        node = kernel_render()
491        node['alt'] = self.options.get('alt','')
492        node['srclang'] = srclang
493        literal_node = nodes.literal_block(code, code)
494        node += literal_node
495
496        caption = self.options.get('caption')
497        if 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
506
507            figure_node = nodes.figure('', node)
508            for k,v in self.options.items():
509                figure_node[k] = v
510            figure_node += caption_node
511
512            node = figure_node
513
514        return node
515
516def add_kernel_figure_to_std_domain(app, doctree):
517    """Add kernel-figure anchors to 'std' domain.
518
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.
523
524    This handle adds labels of kernel-figure to the 'std' domain labels.
525    """
526
527    std = app.env.domains["std"]
528    docname = app.env.docname
529    labels = std.data["labels"]
530
531    for name, explicit in doctree.nametypes.items():
532        if not explicit:
533            continue
534        labelid = doctree.nameids[name]
535        if labelid is None:
536            continue
537        node = doctree.ids[labelid]
538
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
545                    break
546