xref: /linux/Documentation/sphinx/kfigure.py (revision b8321ed4a40c02054f930ca59d3570caa27bc86c)
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      For conversion to PDF, ``rsvg-convert(1)`` of librsvg
35      (https://gitlab.gnome.org/GNOME/librsvg) is used when available.
36
37    * SVG to PDF: To generate PDF, you need at least one of this tools:
38
39      - ``convert(1)``: ImageMagick (https://www.imagemagick.org)
40      - ``inkscape(1)``: Inkscape (https://inkscape.org/)
41
42    List of customizations:
43
44    * generate PDF from SVG / used by PDF (LaTeX) builder
45
46    * generate SVG (html-builder) and PDF (latex-builder) from DOT files.
47      DOT: see https://www.graphviz.org/content/dot-language
48
49    """
50
51import os
52from os import path
53import subprocess
54from hashlib import sha1
55import re
56from docutils import nodes
57from docutils.statemachine import ViewList
58from docutils.parsers.rst import directives
59from docutils.parsers.rst.directives import images
60import sphinx
61from sphinx.util.nodes import clean_astext
62import kernellog
63
64# Get Sphinx version
65major, minor, patch = sphinx.version_info[:3]
66if major == 1 and minor > 3:
67    # patches.Figure only landed in Sphinx 1.4
68    from sphinx.directives.patches import Figure  # pylint: disable=C0413
69else:
70    Figure = images.Figure
71
72__version__  = '1.0.0'
73
74# simple helper
75# -------------
76
77def which(cmd):
78    """Searches the ``cmd`` in the ``PATH`` environment.
79
80    This *which* searches the PATH for executable ``cmd`` . First match is
81    returned, if nothing is found, ``None` is returned.
82    """
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):
87            return fname
88
89def mkdir(folder, mode=0o775):
90    if not path.isdir(folder):
91        os.makedirs(folder, mode)
92
93def file2literal(fname):
94    with open(fname, "r") as src:
95        data = src.read()
96        node = nodes.literal_block(data, data)
97    return node
98
99def isNewer(path1, path2):
100    """Returns True if ``path1`` is newer than ``path2``
101
102    If ``path1`` exists and is newer than ``path2`` the function returns
103    ``True`` is returned otherwise ``False``
104    """
105    return (path.exists(path1)
106            and os.stat(path1).st_ctime > os.stat(path2).st_ctime)
107
108def pass_handle(self, node):           # pylint: disable=W0613
109    pass
110
111# setup conversion tools and sphinx extension
112# -------------------------------------------
113
114# Graphviz's dot(1) support
115dot_cmd = None
116# dot(1) -Tpdf should be used
117dot_Tpdf = False
118
119# ImageMagick' convert(1) support
120convert_cmd = None
121
122# librsvg's rsvg-convert(1) support
123rsvg_convert_cmd = None
124
125# Inkscape's inkscape(1) support
126inkscape_cmd = None
127# Inkscape prior to 1.0 uses different command options
128inkscape_ver_one = False
129
130
131def setup(app):
132    # check toolchain first
133    app.connect('builder-inited', setupTools)
134
135    # image handling
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), )
143
144    # figure handling
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), )
152
153    # render handling
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), )
161
162    app.connect('doctree-read', add_kernel_figure_to_std_domain)
163
164    return dict(
165        version = __version__,
166        parallel_read_safe = True,
167        parallel_write_safe = True
168    )
169
170
171def setupTools(app):
172    u"""
173    Check available build tools and log some *verbose* messages.
174
175    This function is called once, when the builder is initiated.
176    """
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 ...")
180
181    dot_cmd = which('dot')
182    convert_cmd = which('convert')
183    rsvg_convert_cmd = which('rsvg-convert')
184    inkscape_cmd = which('inkscape')
185
186    if dot_cmd:
187        kernellog.verbose(app, "use dot(1) from: " + dot_cmd)
188
189        try:
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
194            pass
195
196        dot_Tpdf_ptn = b'pdf'
197        dot_Tpdf = re.search(dot_Tpdf_ptn, dot_Thelp_list)
198    else:
199        kernellog.warn(app, "dot(1) not found, for better output quality install "
200                       "graphviz from https://www.graphviz.org")
201    if inkscape_cmd:
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)
207        convert_cmd = None
208        rsvg_convert_cmd = None
209        dot_Tpdf = False
210
211    else:
212        if convert_cmd:
213            kernellog.verbose(app, "use convert(1) from: " + convert_cmd)
214        else:
215            kernellog.warn(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)")
220
221        if rsvg_convert_cmd:
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")
224            dot_Tpdf = False
225        else:
226            kernellog.verbose(app,
227                "rsvg-convert(1) not found.\n"
228                "  SVG rendering of convert(1) is done by ImageMagick-native renderer.")
229            if dot_Tpdf:
230                kernellog.verbose(app, "use 'dot -Tpdf' for DOT -> PDF conversion")
231            else:
232                kernellog.verbose(app, "use 'dot -Tsvg' and convert(1) for DOT -> PDF conversion")
233
234
235# integrate conversion tools
236# --------------------------
237
238RENDER_MARKUP_EXT = {
239    # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
240    # <name> : <.ext>
241    'DOT' : '.dot',
242    'SVG' : '.svg'
243}
244
245def convert_image(img_node, translator, src_fname=None):
246    """Convert a image node for the builder.
247
248    Different builder prefer different image formats, e.g. *latex* builder
249    prefer PDF while *html* builder prefer SVG format for images.
250
251    This function handles output image formats in dependence of source the
252    format (of the image) and the translator's output format.
253    """
254    app = translator.builder.app
255
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'])
261
262    dst_fname = None
263
264    # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
265
266    kernellog.verbose(app, 'assert best format for: ' + img_node['uri'])
267
268    if in_ext == '.dot':
269
270        if not dot_cmd:
271            kernellog.verbose(app,
272                              "dot from graphviz not available / include DOT raw.")
273            img_node.replace_self(file2literal(src_fname))
274
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'}
279
280
281        elif translator.builder.format == 'html':
282            dst_fname = path.join(
283                translator.builder.outdir,
284                translator.builder.imagedir,
285                fname + '.svg')
286            img_node['uri'] = path.join(
287                translator.builder.imgpath, fname + '.svg')
288            img_node['candidates'] = {
289                '*': path.join(translator.builder.imgpath, fname + '.svg')}
290
291        else:
292            # all other builder formats will include DOT as raw
293            img_node.replace_self(file2literal(src_fname))
294
295    elif in_ext == '.svg':
296
297        if translator.builder.format == 'latex':
298            if not inkscape_cmd and convert_cmd is None:
299                kernellog.verbose(app,
300                                  "no SVG to PDF conversion available / include SVG raw.")
301                img_node.replace_self(file2literal(src_fname))
302            else:
303                dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
304                img_node['uri'] = fname + '.pdf'
305                img_node['candidates'] = {'*': fname + '.pdf'}
306
307    if dst_fname:
308        # the builder needs not to copy one more time, so pop it if exists.
309        translator.builder.images.pop(img_node['uri'], None)
310        _name = dst_fname[len(translator.builder.outdir) + 1:]
311
312        if isNewer(dst_fname, src_fname):
313            kernellog.verbose(app,
314                              "convert: {out}/%s already exists and is newer" % _name)
315
316        else:
317            ok = False
318            mkdir(path.dirname(dst_fname))
319
320            if in_ext == '.dot':
321                kernellog.verbose(app, 'convert DOT to: {out}/' + _name)
322                if translator.builder.format == 'latex' and not dot_Tpdf:
323                    svg_fname = path.join(translator.builder.outdir, fname + '.svg')
324                    ok1 = dot2format(app, src_fname, svg_fname)
325                    ok2 = svg2pdf_by_rsvg(app, svg_fname, dst_fname)
326                    ok = ok1 and ok2
327
328                else:
329                    ok = dot2format(app, src_fname, dst_fname)
330
331            elif in_ext == '.svg':
332                kernellog.verbose(app, 'convert SVG to: {out}/' + _name)
333                ok = svg2pdf(app, src_fname, dst_fname)
334
335            if not ok:
336                img_node.replace_self(file2literal(src_fname))
337
338
339def dot2format(app, dot_fname, out_fname):
340    """Converts DOT file to ``out_fname`` using ``dot(1)``.
341
342    * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
343    * ``out_fname`` pathname of the output file, including format extension
344
345    The *format extension* depends on the ``dot`` command (see ``man dot``
346    option ``-Txxx``). Normally you will use one of the following extensions:
347
348    - ``.ps`` for PostScript,
349    - ``.svg`` or ``svgz`` for Structured Vector Graphics,
350    - ``.fig`` for XFIG graphics and
351    - ``.png`` or ``gif`` for common bitmap graphics.
352
353    """
354    out_format = path.splitext(out_fname)[1][1:]
355    cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
356    exit_code = 42
357
358    with open(out_fname, "w") as out:
359        exit_code = subprocess.call(cmd, stdout = out)
360        if exit_code != 0:
361            kernellog.warn(app,
362                          "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
363    return bool(exit_code == 0)
364
365def svg2pdf(app, svg_fname, pdf_fname):
366    """Converts SVG to PDF with ``inkscape(1)`` or ``convert(1)`` command.
367
368    Uses ``inkscape(1)`` from Inkscape (https://inkscape.org/) or ``convert(1)``
369    from ImageMagick (https://www.imagemagick.org) for conversion.
370    Returns ``True`` on success and ``False`` if an error occurred.
371
372    * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
373    * ``pdf_name``  pathname of the output PDF file with extension (``.pdf``)
374
375    """
376    cmd = [convert_cmd, svg_fname, pdf_fname]
377    cmd_name = 'convert(1)'
378
379    if inkscape_cmd:
380        cmd_name = 'inkscape(1)'
381        if inkscape_ver_one:
382            cmd = [inkscape_cmd, '-o', pdf_fname, svg_fname]
383        else:
384            cmd = [inkscape_cmd, '-z', '--export-pdf=%s' % pdf_fname, svg_fname]
385
386    try:
387        warning_msg = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
388        exit_code = 0
389    except subprocess.CalledProcessError as err:
390        warning_msg = err.output
391        exit_code = err.returncode
392        pass
393
394    if exit_code != 0:
395        kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
396        if warning_msg:
397            kernellog.warn(app, "Warning msg from %s: %s"
398                           % (cmd_name, str(warning_msg, 'utf-8')))
399    elif warning_msg:
400        kernellog.verbose(app, "Warning msg from %s (likely harmless):\n%s"
401                          % (cmd_name, str(warning_msg, 'utf-8')))
402
403    return bool(exit_code == 0)
404
405def svg2pdf_by_rsvg(app, svg_fname, pdf_fname):
406    """Convert SVG to PDF with ``rsvg-convert(1)`` command.
407
408    * ``svg_fname`` pathname of input SVG file, including extension ``.svg``
409    * ``pdf_fname`` pathname of output PDF file, including extension ``.pdf``
410
411    Input SVG file should be the one generated by ``dot2format()``.
412    SVG -> PDF conversion is done by ``rsvg-convert(1)``.
413
414    If ``rsvg-convert(1)`` is unavailable, fall back to ``svg2pdf()``.
415
416    """
417
418    if rsvg_convert_cmd is None:
419        ok = svg2pdf(app, svg_fname, pdf_fname)
420    else:
421        cmd = [rsvg_convert_cmd, '--format=pdf', '-o', pdf_fname, svg_fname]
422        # use stdout and stderr from parent
423        exit_code = subprocess.call(cmd)
424        if exit_code != 0:
425            kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
426        ok = bool(exit_code == 0)
427
428    return ok
429
430
431# image handling
432# ---------------------
433
434def visit_kernel_image(self, node):    # pylint: disable=W0613
435    """Visitor of the ``kernel_image`` Node.
436
437    Handles the ``image`` child-node with the ``convert_image(...)``.
438    """
439    img_node = node[0]
440    convert_image(img_node, self)
441
442class kernel_image(nodes.image):
443    """Node for ``kernel-image`` directive."""
444    pass
445
446class KernelImage(images.Image):
447    u"""KernelImage directive
448
449    Earns everything from ``.. image::`` directive, except *remote URI* and
450    *glob* pattern. The KernelImage wraps a image node into a
451    kernel_image node. See ``visit_kernel_image``.
452    """
453
454    def run(self):
455        uri = self.arguments[0]
456        if uri.endswith('.*') or uri.find('://') != -1:
457            raise self.severe(
458                'Error in "%s: %s": glob pattern and remote images are not allowed'
459                % (self.name, uri))
460        result = images.Image.run(self)
461        if len(result) == 2 or isinstance(result[0], nodes.system_message):
462            return result
463        (image_node,) = result
464        # wrap image node into a kernel_image node / see visitors
465        node = kernel_image('', image_node)
466        return [node]
467
468# figure handling
469# ---------------------
470
471def visit_kernel_figure(self, node):   # pylint: disable=W0613
472    """Visitor of the ``kernel_figure`` Node.
473
474    Handles the ``image`` child-node with the ``convert_image(...)``.
475    """
476    img_node = node[0][0]
477    convert_image(img_node, self)
478
479class kernel_figure(nodes.figure):
480    """Node for ``kernel-figure`` directive."""
481
482class KernelFigure(Figure):
483    u"""KernelImage directive
484
485    Earns everything from ``.. figure::`` directive, except *remote URI* and
486    *glob* pattern.  The KernelFigure wraps a figure node into a kernel_figure
487    node. See ``visit_kernel_figure``.
488    """
489
490    def run(self):
491        uri = self.arguments[0]
492        if uri.endswith('.*') or uri.find('://') != -1:
493            raise self.severe(
494                'Error in "%s: %s":'
495                ' glob pattern and remote images are not allowed'
496                % (self.name, uri))
497        result = Figure.run(self)
498        if len(result) == 2 or isinstance(result[0], nodes.system_message):
499            return result
500        (figure_node,) = result
501        # wrap figure node into a kernel_figure node / see visitors
502        node = kernel_figure('', figure_node)
503        return [node]
504
505
506# render handling
507# ---------------------
508
509def visit_kernel_render(self, node):
510    """Visitor of the ``kernel_render`` Node.
511
512    If rendering tools available, save the markup of the ``literal_block`` child
513    node into a file and replace the ``literal_block`` node with a new created
514    ``image`` node, pointing to the saved markup file. Afterwards, handle the
515    image child-node with the ``convert_image(...)``.
516    """
517    app = self.builder.app
518    srclang = node.get('srclang')
519
520    kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang))
521
522    tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
523    if tmp_ext is None:
524        kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang))
525        return
526
527    if not dot_cmd and tmp_ext == '.dot':
528        kernellog.verbose(app, "dot from graphviz not available / include raw.")
529        return
530
531    literal_block = node[0]
532
533    code      = literal_block.astext()
534    hashobj   = code.encode('utf-8') #  str(node.attributes)
535    fname     = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
536
537    tmp_fname = path.join(
538        self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
539
540    if not path.isfile(tmp_fname):
541        mkdir(path.dirname(tmp_fname))
542        with open(tmp_fname, "w") as out:
543            out.write(code)
544
545    img_node = nodes.image(node.rawsource, **node.attributes)
546    img_node['uri'] = path.join(self.builder.imgpath, fname + tmp_ext)
547    img_node['candidates'] = {
548        '*': path.join(self.builder.imgpath, fname + tmp_ext)}
549
550    literal_block.replace_self(img_node)
551    convert_image(img_node, self, tmp_fname)
552
553
554class kernel_render(nodes.General, nodes.Inline, nodes.Element):
555    """Node for ``kernel-render`` directive."""
556    pass
557
558class KernelRender(Figure):
559    u"""KernelRender directive
560
561    Render content by external tool.  Has all the options known from the
562    *figure*  directive, plus option ``caption``.  If ``caption`` has a
563    value, a figure node with the *caption* is inserted. If not, a image node is
564    inserted.
565
566    The KernelRender directive wraps the text of the directive into a
567    literal_block node and wraps it into a kernel_render node. See
568    ``visit_kernel_render``.
569    """
570    has_content = True
571    required_arguments = 1
572    optional_arguments = 0
573    final_argument_whitespace = False
574
575    # earn options from 'figure'
576    option_spec = Figure.option_spec.copy()
577    option_spec['caption'] = directives.unchanged
578
579    def run(self):
580        return [self.build_node()]
581
582    def build_node(self):
583
584        srclang = self.arguments[0].strip()
585        if srclang not in RENDER_MARKUP_EXT.keys():
586            return [self.state_machine.reporter.warning(
587                'Unknown source language "%s", use one of: %s.' % (
588                    srclang, ",".join(RENDER_MARKUP_EXT.keys())),
589                line=self.lineno)]
590
591        code = '\n'.join(self.content)
592        if not code.strip():
593            return [self.state_machine.reporter.warning(
594                'Ignoring "%s" directive without content.' % (
595                    self.name),
596                line=self.lineno)]
597
598        node = kernel_render()
599        node['alt'] = self.options.get('alt','')
600        node['srclang'] = srclang
601        literal_node = nodes.literal_block(code, code)
602        node += literal_node
603
604        caption = self.options.get('caption')
605        if caption:
606            # parse caption's content
607            parsed = nodes.Element()
608            self.state.nested_parse(
609                ViewList([caption], source=''), self.content_offset, parsed)
610            caption_node = nodes.caption(
611                parsed[0].rawsource, '', *parsed[0].children)
612            caption_node.source = parsed[0].source
613            caption_node.line = parsed[0].line
614
615            figure_node = nodes.figure('', node)
616            for k,v in self.options.items():
617                figure_node[k] = v
618            figure_node += caption_node
619
620            node = figure_node
621
622        return node
623
624def add_kernel_figure_to_std_domain(app, doctree):
625    """Add kernel-figure anchors to 'std' domain.
626
627    The ``StandardDomain.process_doc(..)`` method does not know how to resolve
628    the caption (label) of ``kernel-figure`` directive (it only knows about
629    standard nodes, e.g. table, figure etc.). Without any additional handling
630    this will result in a 'undefined label' for kernel-figures.
631
632    This handle adds labels of kernel-figure to the 'std' domain labels.
633    """
634
635    std = app.env.domains["std"]
636    docname = app.env.docname
637    labels = std.data["labels"]
638
639    for name, explicit in doctree.nametypes.items():
640        if not explicit:
641            continue
642        labelid = doctree.nameids[name]
643        if labelid is None:
644            continue
645        node = doctree.ids[labelid]
646
647        if node.tagname == 'kernel_figure':
648            for n in node.next_node():
649                if n.tagname == 'caption':
650                    sectname = clean_astext(n)
651                    # add label to std domain
652                    labels[name] = docname, labelid, sectname
653                    break
654