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
52import sys
53
54from docutils import nodes
55from docutils.statemachine import ViewList
56from docutils.parsers.rst import directives
57from docutils.parsers.rst.directives import images
58import sphinx
59
60from sphinx.util.nodes import clean_astext
61from six import iteritems
62
63import kernellog
64
65PY3 = sys.version_info[0] == 3
66
67if PY3:
68    _unicode = str
69else:
70    _unicode = unicode
71
72# Get Sphinx version
73major, minor, patch = sphinx.version_info[:3]
74if major == 1 and minor > 3:
75    # patches.Figure only landed in Sphinx 1.4
76    from sphinx.directives.patches import Figure  # pylint: disable=C0413
77else:
78    Figure = images.Figure
79
80__version__  = '1.0.0'
81
82# simple helper
83# -------------
84
85def which(cmd):
86    """Searches the ``cmd`` in the ``PATH`` environment.
87
88    This *which* searches the PATH for executable ``cmd`` . First match is
89    returned, if nothing is found, ``None` is returned.
90    """
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):
95            return fname
96
97def mkdir(folder, mode=0o775):
98    if not path.isdir(folder):
99        os.makedirs(folder, mode)
100
101def file2literal(fname):
102    with open(fname, "r") as src:
103        data = src.read()
104        node = nodes.literal_block(data, data)
105    return node
106
107def isNewer(path1, path2):
108    """Returns True if ``path1`` is newer than ``path2``
109
110    If ``path1`` exists and is newer than ``path2`` the function returns
111    ``True`` is returned otherwise ``False``
112    """
113    return (path.exists(path1)
114            and os.stat(path1).st_ctime > os.stat(path2).st_ctime)
115
116def pass_handle(self, node):           # pylint: disable=W0613
117    pass
118
119# setup conversion tools and sphinx extension
120# -------------------------------------------
121
122# Graphviz's dot(1) support
123dot_cmd = None
124
125# ImageMagick' convert(1) support
126convert_cmd = None
127
128
129def setup(app):
130    # check toolchain first
131    app.connect('builder-inited', setupTools)
132
133    # image handling
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), )
141
142    # figure handling
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), )
150
151    # render handling
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), )
159
160    app.connect('doctree-read', add_kernel_figure_to_std_domain)
161
162    return dict(
163        version = __version__,
164        parallel_read_safe = True,
165        parallel_write_safe = True
166    )
167
168
169def setupTools(app):
170    u"""
171    Check available build tools and log some *verbose* messages.
172
173    This function is called once, when the builder is initiated.
174    """
175    global dot_cmd, convert_cmd   # pylint: disable=W0603
176    kernellog.verbose(app, "kfigure: check installed tools ...")
177
178    dot_cmd = which('dot')
179    convert_cmd = which('convert')
180
181    if dot_cmd:
182        kernellog.verbose(app, "use dot(1) from: " + dot_cmd)
183    else:
184        kernellog.warn(app, "dot(1) not found, for better output quality install "
185                       "graphviz from https://www.graphviz.org")
186    if convert_cmd:
187        kernellog.verbose(app, "use convert(1) from: " + convert_cmd)
188    else:
189        kernellog.warn(app,
190            "convert(1) not found, for SVG to PDF conversion install "
191            "ImageMagick (https://www.imagemagick.org)")
192
193
194# integrate conversion tools
195# --------------------------
196
197RENDER_MARKUP_EXT = {
198    # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
199    # <name> : <.ext>
200    'DOT' : '.dot',
201    'SVG' : '.svg'
202}
203
204def convert_image(img_node, translator, src_fname=None):
205    """Convert a image node for the builder.
206
207    Different builder prefer different image formats, e.g. *latex* builder
208    prefer PDF while *html* builder prefer SVG format for images.
209
210    This function handles output image formats in dependence of source the
211    format (of the image) and the translator's output format.
212    """
213    app = translator.builder.app
214
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'])
220
221    dst_fname = None
222
223    # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
224
225    kernellog.verbose(app, 'assert best format for: ' + img_node['uri'])
226
227    if in_ext == '.dot':
228
229        if not dot_cmd:
230            kernellog.verbose(app,
231                              "dot from graphviz not available / include DOT raw.")
232            img_node.replace_self(file2literal(src_fname))
233
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'}
238
239
240        elif translator.builder.format == 'html':
241            dst_fname = path.join(
242                translator.builder.outdir,
243                translator.builder.imagedir,
244                fname + '.svg')
245            img_node['uri'] = path.join(
246                translator.builder.imgpath, fname + '.svg')
247            img_node['candidates'] = {
248                '*': path.join(translator.builder.imgpath, fname + '.svg')}
249
250        else:
251            # all other builder formats will include DOT as raw
252            img_node.replace_self(file2literal(src_fname))
253
254    elif in_ext == '.svg':
255
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))
261            else:
262                dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
263                img_node['uri'] = fname + '.pdf'
264                img_node['candidates'] = {'*': fname + '.pdf'}
265
266    if dst_fname:
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:]
270
271        if isNewer(dst_fname, src_fname):
272            kernellog.verbose(app,
273                              "convert: {out}/%s already exists and is newer" % _name)
274
275        else:
276            ok = False
277            mkdir(path.dirname(dst_fname))
278
279            if in_ext == '.dot':
280                kernellog.verbose(app, 'convert DOT to: {out}/' + _name)
281                ok = dot2format(app, src_fname, dst_fname)
282
283            elif in_ext == '.svg':
284                kernellog.verbose(app, 'convert SVG to: {out}/' + _name)
285                ok = svg2pdf(app, src_fname, dst_fname)
286
287            if not ok:
288                img_node.replace_self(file2literal(src_fname))
289
290
291def dot2format(app, dot_fname, out_fname):
292    """Converts DOT file to ``out_fname`` using ``dot(1)``.
293
294    * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
295    * ``out_fname`` pathname of the output file, including format extension
296
297    The *format extension* depends on the ``dot`` command (see ``man dot``
298    option ``-Txxx``). Normally you will use one of the following extensions:
299
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.
304
305    """
306    out_format = path.splitext(out_fname)[1][1:]
307    cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
308    exit_code = 42
309
310    with open(out_fname, "w") as out:
311        exit_code = subprocess.call(cmd, stdout = out)
312        if exit_code != 0:
313            kernellog.warn(app,
314                          "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
315    return bool(exit_code == 0)
316
317def svg2pdf(app, svg_fname, pdf_fname):
318    """Converts SVG to PDF with ``convert(1)`` command.
319
320    Uses ``convert(1)`` from ImageMagick (https://www.imagemagick.org) for
321    conversion.  Returns ``True`` on success and ``False`` if an error occurred.
322
323    * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
324    * ``pdf_name``  pathname of the output PDF file with extension (``.pdf``)
325
326    """
327    cmd = [convert_cmd, svg_fname, pdf_fname]
328    # use stdout and stderr from parent
329    exit_code = subprocess.call(cmd)
330    if exit_code != 0:
331        kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
332    return bool(exit_code == 0)
333
334
335# image handling
336# ---------------------
337
338def visit_kernel_image(self, node):    # pylint: disable=W0613
339    """Visitor of the ``kernel_image`` Node.
340
341    Handles the ``image`` child-node with the ``convert_image(...)``.
342    """
343    img_node = node[0]
344    convert_image(img_node, self)
345
346class kernel_image(nodes.image):
347    """Node for ``kernel-image`` directive."""
348    pass
349
350class KernelImage(images.Image):
351    u"""KernelImage directive
352
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``.
356    """
357
358    def run(self):
359        uri = self.arguments[0]
360        if uri.endswith('.*') or uri.find('://') != -1:
361            raise self.severe(
362                'Error in "%s: %s": glob pattern and remote images are not allowed'
363                % (self.name, uri))
364        result = images.Image.run(self)
365        if len(result) == 2 or isinstance(result[0], nodes.system_message):
366            return result
367        (image_node,) = result
368        # wrap image node into a kernel_image node / see visitors
369        node = kernel_image('', image_node)
370        return [node]
371
372# figure handling
373# ---------------------
374
375def visit_kernel_figure(self, node):   # pylint: disable=W0613
376    """Visitor of the ``kernel_figure`` Node.
377
378    Handles the ``image`` child-node with the ``convert_image(...)``.
379    """
380    img_node = node[0][0]
381    convert_image(img_node, self)
382
383class kernel_figure(nodes.figure):
384    """Node for ``kernel-figure`` directive."""
385
386class KernelFigure(Figure):
387    u"""KernelImage directive
388
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``.
392    """
393
394    def run(self):
395        uri = self.arguments[0]
396        if uri.endswith('.*') or uri.find('://') != -1:
397            raise self.severe(
398                'Error in "%s: %s":'
399                ' glob pattern and remote images are not allowed'
400                % (self.name, uri))
401        result = Figure.run(self)
402        if len(result) == 2 or isinstance(result[0], nodes.system_message):
403            return result
404        (figure_node,) = result
405        # wrap figure node into a kernel_figure node / see visitors
406        node = kernel_figure('', figure_node)
407        return [node]
408
409
410# render handling
411# ---------------------
412
413def visit_kernel_render(self, node):
414    """Visitor of the ``kernel_render`` Node.
415
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(...)``.
420    """
421    app = self.builder.app
422    srclang = node.get('srclang')
423
424    kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang))
425
426    tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
427    if tmp_ext is None:
428        kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang))
429        return
430
431    if not dot_cmd and tmp_ext == '.dot':
432        kernellog.verbose(app, "dot from graphviz not available / include raw.")
433        return
434
435    literal_block = node[0]
436
437    code      = literal_block.astext()
438    hashobj   = code.encode('utf-8') #  str(node.attributes)
439    fname     = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
440
441    tmp_fname = path.join(
442        self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
443
444    if not path.isfile(tmp_fname):
445        mkdir(path.dirname(tmp_fname))
446        with open(tmp_fname, "w") as out:
447            out.write(code)
448
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)}
453
454    literal_block.replace_self(img_node)
455    convert_image(img_node, self, tmp_fname)
456
457
458class kernel_render(nodes.General, nodes.Inline, nodes.Element):
459    """Node for ``kernel-render`` directive."""
460    pass
461
462class KernelRender(Figure):
463    u"""KernelRender directive
464
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
468    inserted.
469
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``.
473    """
474    has_content = True
475    required_arguments = 1
476    optional_arguments = 0
477    final_argument_whitespace = False
478
479    # earn options from 'figure'
480    option_spec = Figure.option_spec.copy()
481    option_spec['caption'] = directives.unchanged
482
483    def run(self):
484        return [self.build_node()]
485
486    def build_node(self):
487
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())),
493                line=self.lineno)]
494
495        code = '\n'.join(self.content)
496        if not code.strip():
497            return [self.state_machine.reporter.warning(
498                'Ignoring "%s" directive without content.' % (
499                    self.name),
500                line=self.lineno)]
501
502        node = kernel_render()
503        node['alt'] = self.options.get('alt','')
504        node['srclang'] = srclang
505        literal_node = nodes.literal_block(code, code)
506        node += literal_node
507
508        caption = self.options.get('caption')
509        if 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
518
519            figure_node = nodes.figure('', node)
520            for k,v in self.options.items():
521                figure_node[k] = v
522            figure_node += caption_node
523
524            node = figure_node
525
526        return node
527
528def add_kernel_figure_to_std_domain(app, doctree):
529    """Add kernel-figure anchors to 'std' domain.
530
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.
535
536    This handle adds labels of kernel-figure to the 'std' domain labels.
537    """
538
539    std = app.env.domains["std"]
540    docname = app.env.docname
541    labels = std.data["labels"]
542
543    for name, explicit in iteritems(doctree.nametypes):
544        if not explicit:
545            continue
546        labelid = doctree.nameids[name]
547        if labelid is None:
548            continue
549        node = doctree.ids[labelid]
550
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
557                    break
558