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.
34 For conversion to PDF, ``rsvg-convert(1)`` of librsvg
35 (https://gitlab.gnome.org/GNOME/librsvg) is used when available.
37 * SVG to PDF: To generate PDF, you need at least one of this tools:
39 - ``convert(1)``: ImageMagick (https://www.imagemagick.org)
40 - ``inkscape(1)``: Inkscape (https://inkscape.org/)
42 List of customizations:
44 * generate PDF from SVG / used by PDF (LaTeX) builder
46 * generate SVG (html-builder) and PDF (latex-builder) from DOT files.
47 DOT: see https://www.graphviz.org/content/dot-language
54 from hashlib
import sha1
56 from docutils
import nodes
57 from docutils
.statemachine
import ViewList
58 from docutils
.parsers
.rst
import directives
59 from docutils
.parsers
.rst
.directives
import images
61 from sphinx
.util
.nodes
import clean_astext
64 Figure
= images
.Figure
72 """Searches the ``cmd`` in the ``PATH`` environment.
74 This *which* searches the PATH for executable ``cmd`` . First match is
75 returned, if nothing is found, ``None` is returned.
77 envpath
= os
.environ
.get('PATH', None) or os
.defpath
78 for folder
in envpath
.split(os
.pathsep
):
79 fname
= folder
+ os
.sep
+ cmd
80 if path
.isfile(fname
):
83 def mkdir(folder
, mode
=0o775):
84 if not path
.isdir(folder
):
85 os
.makedirs(folder
, mode
)
87 def file2literal(fname
):
88 with
open(fname
, "r") as src
:
90 node
= nodes
.literal_block(data
, data
)
93 def isNewer(path1
, path2
):
94 """Returns True if ``path1`` is newer than ``path2``
96 If ``path1`` exists and is newer than ``path2`` the function returns
97 ``True`` is returned otherwise ``False``
99 return (path
.exists(path1
)
100 and os
.stat(path1
).st_ctime
> os
.stat(path2
).st_ctime
)
102 def pass_handle(self
, node
): # pylint: disable=W0613
105 # setup conversion tools and sphinx extension
106 # -------------------------------------------
108 # Graphviz's dot(1) support
110 # dot(1) -Tpdf should be used
113 # ImageMagick' convert(1) support
116 # librsvg's rsvg-convert(1) support
117 rsvg_convert_cmd
= None
119 # Inkscape's inkscape(1) support
121 # Inkscape prior to 1.0 uses different command options
122 inkscape_ver_one
= False
126 # check toolchain first
127 app
.connect('builder-inited', setupTools
)
130 app
.add_directive("kernel-image", KernelImage
)
131 app
.add_node(kernel_image
,
132 html
= (visit_kernel_image
, pass_handle
),
133 latex
= (visit_kernel_image
, pass_handle
),
134 texinfo
= (visit_kernel_image
, pass_handle
),
135 text
= (visit_kernel_image
, pass_handle
),
136 man
= (visit_kernel_image
, pass_handle
), )
139 app
.add_directive("kernel-figure", KernelFigure
)
140 app
.add_node(kernel_figure
,
141 html
= (visit_kernel_figure
, pass_handle
),
142 latex
= (visit_kernel_figure
, pass_handle
),
143 texinfo
= (visit_kernel_figure
, pass_handle
),
144 text
= (visit_kernel_figure
, pass_handle
),
145 man
= (visit_kernel_figure
, pass_handle
), )
148 app
.add_directive('kernel-render', KernelRender
)
149 app
.add_node(kernel_render
,
150 html
= (visit_kernel_render
, pass_handle
),
151 latex
= (visit_kernel_render
, pass_handle
),
152 texinfo
= (visit_kernel_render
, pass_handle
),
153 text
= (visit_kernel_render
, pass_handle
),
154 man
= (visit_kernel_render
, pass_handle
), )
156 app
.connect('doctree-read', add_kernel_figure_to_std_domain
)
159 version
= __version__
,
160 parallel_read_safe
= True,
161 parallel_write_safe
= True
167 Check available build tools and log some *verbose* messages.
169 This function is called once, when the builder is initiated.
171 global dot_cmd
, dot_Tpdf
, convert_cmd
, rsvg_convert_cmd
# pylint: disable=W0603
172 global inkscape_cmd
, inkscape_ver_one
# pylint: disable=W0603
173 kernellog
.verbose(app
, "kfigure: check installed tools ...")
175 dot_cmd
= which('dot')
176 convert_cmd
= which('convert')
177 rsvg_convert_cmd
= which('rsvg-convert')
178 inkscape_cmd
= which('inkscape')
181 kernellog
.verbose(app
, "use dot(1) from: " + dot_cmd
)
184 dot_Thelp_list
= subprocess
.check_output([dot_cmd
, '-Thelp'],
185 stderr
=subprocess
.STDOUT
)
186 except subprocess
.CalledProcessError
as err
:
187 dot_Thelp_list
= err
.output
190 dot_Tpdf_ptn
= b
'pdf'
191 dot_Tpdf
= re
.search(dot_Tpdf_ptn
, dot_Thelp_list
)
193 kernellog
.warn(app
, "dot(1) not found, for better output quality install "
194 "graphviz from https://www.graphviz.org")
196 kernellog
.verbose(app
, "use inkscape(1) from: " + inkscape_cmd
)
197 inkscape_ver
= subprocess
.check_output([inkscape_cmd
, '--version'],
198 stderr
=subprocess
.DEVNULL
)
199 ver_one_ptn
= b
'Inkscape 1'
200 inkscape_ver_one
= re
.search(ver_one_ptn
, inkscape_ver
)
202 rsvg_convert_cmd
= None
207 kernellog
.verbose(app
, "use convert(1) from: " + convert_cmd
)
209 kernellog
.verbose(app
,
210 "Neither inkscape(1) nor convert(1) found.\n"
211 "For SVG to PDF conversion, "
212 "install either Inkscape (https://inkscape.org/) (preferred) or\n"
213 "ImageMagick (https://www.imagemagick.org)")
216 kernellog
.verbose(app
, "use rsvg-convert(1) from: " + rsvg_convert_cmd
)
217 kernellog
.verbose(app
, "use 'dot -Tsvg' and rsvg-convert(1) for DOT -> PDF conversion")
220 kernellog
.verbose(app
,
221 "rsvg-convert(1) not found.\n"
222 " SVG rendering of convert(1) is done by ImageMagick-native renderer.")
224 kernellog
.verbose(app
, "use 'dot -Tpdf' for DOT -> PDF conversion")
226 kernellog
.verbose(app
, "use 'dot -Tsvg' and convert(1) for DOT -> PDF conversion")
229 # integrate conversion tools
230 # --------------------------
232 RENDER_MARKUP_EXT
= {
233 # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
239 def convert_image(img_node
, translator
, src_fname
=None):
240 """Convert a image node for the builder.
242 Different builder prefer different image formats, e.g. *latex* builder
243 prefer PDF while *html* builder prefer SVG format for images.
245 This function handles output image formats in dependence of source the
246 format (of the image) and the translator's output format.
248 app
= translator
.builder
.app
250 fname
, in_ext
= path
.splitext(path
.basename(img_node
['uri']))
251 if src_fname
is None:
252 src_fname
= path
.join(translator
.builder
.srcdir
, img_node
['uri'])
253 if not path
.exists(src_fname
):
254 src_fname
= path
.join(translator
.builder
.outdir
, img_node
['uri'])
258 # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
260 kernellog
.verbose(app
, 'assert best format for: ' + img_node
['uri'])
265 kernellog
.verbose(app
,
266 "dot from graphviz not available / include DOT raw.")
267 img_node
.replace_self(file2literal(src_fname
))
269 elif translator
.builder
.format
== 'latex':
270 dst_fname
= path
.join(translator
.builder
.outdir
, fname
+ '.pdf')
271 img_node
['uri'] = fname
+ '.pdf'
272 img_node
['candidates'] = {'*': fname
+ '.pdf'}
275 elif translator
.builder
.format
== 'html':
276 dst_fname
= path
.join(
277 translator
.builder
.outdir
,
278 translator
.builder
.imagedir
,
280 img_node
['uri'] = path
.join(
281 translator
.builder
.imgpath
, fname
+ '.svg')
282 img_node
['candidates'] = {
283 '*': path
.join(translator
.builder
.imgpath
, fname
+ '.svg')}
286 # all other builder formats will include DOT as raw
287 img_node
.replace_self(file2literal(src_fname
))
289 elif in_ext
== '.svg':
291 if translator
.builder
.format
== 'latex':
292 if not inkscape_cmd
and convert_cmd
is None:
294 "no SVG to PDF conversion available / include SVG raw."
295 "\nIncluding large raw SVGs can cause xelatex error."
296 "\nInstall Inkscape (preferred) or ImageMagick.")
297 img_node
.replace_self(file2literal(src_fname
))
299 dst_fname
= path
.join(translator
.builder
.outdir
, fname
+ '.pdf')
300 img_node
['uri'] = fname
+ '.pdf'
301 img_node
['candidates'] = {'*': fname
+ '.pdf'}
304 # the builder needs not to copy one more time, so pop it if exists.
305 translator
.builder
.images
.pop(img_node
['uri'], None)
306 _name
= dst_fname
[len(str(translator
.builder
.outdir
)) + 1:]
308 if isNewer(dst_fname
, src_fname
):
309 kernellog
.verbose(app
,
310 "convert: {out}/%s already exists and is newer" % _name
)
314 mkdir(path
.dirname(dst_fname
))
317 kernellog
.verbose(app
, 'convert DOT to: {out}/' + _name
)
318 if translator
.builder
.format
== 'latex' and not dot_Tpdf
:
319 svg_fname
= path
.join(translator
.builder
.outdir
, fname
+ '.svg')
320 ok1
= dot2format(app
, src_fname
, svg_fname
)
321 ok2
= svg2pdf_by_rsvg(app
, svg_fname
, dst_fname
)
325 ok
= dot2format(app
, src_fname
, dst_fname
)
327 elif in_ext
== '.svg':
328 kernellog
.verbose(app
, 'convert SVG to: {out}/' + _name
)
329 ok
= svg2pdf(app
, src_fname
, dst_fname
)
332 img_node
.replace_self(file2literal(src_fname
))
335 def dot2format(app
, dot_fname
, out_fname
):
336 """Converts DOT file to ``out_fname`` using ``dot(1)``.
338 * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
339 * ``out_fname`` pathname of the output file, including format extension
341 The *format extension* depends on the ``dot`` command (see ``man dot``
342 option ``-Txxx``). Normally you will use one of the following extensions:
344 - ``.ps`` for PostScript,
345 - ``.svg`` or ``svgz`` for Structured Vector Graphics,
346 - ``.fig`` for XFIG graphics and
347 - ``.png`` or ``gif`` for common bitmap graphics.
350 out_format
= path
.splitext(out_fname
)[1][1:]
351 cmd
= [dot_cmd
, '-T%s' % out_format
, dot_fname
]
354 with
open(out_fname
, "w") as out
:
355 exit_code
= subprocess
.call(cmd
, stdout
= out
)
358 "Error #%d when calling: %s" % (exit_code
, " ".join(cmd
)))
359 return bool(exit_code
== 0)
361 def svg2pdf(app
, svg_fname
, pdf_fname
):
362 """Converts SVG to PDF with ``inkscape(1)`` or ``convert(1)`` command.
364 Uses ``inkscape(1)`` from Inkscape (https://inkscape.org/) or ``convert(1)``
365 from ImageMagick (https://www.imagemagick.org) for conversion.
366 Returns ``True`` on success and ``False`` if an error occurred.
368 * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
369 * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``)
372 cmd
= [convert_cmd
, svg_fname
, pdf_fname
]
373 cmd_name
= 'convert(1)'
376 cmd_name
= 'inkscape(1)'
378 cmd
= [inkscape_cmd
, '-o', pdf_fname
, svg_fname
]
380 cmd
= [inkscape_cmd
, '-z', '--export-pdf=%s' % pdf_fname
, svg_fname
]
383 warning_msg
= subprocess
.check_output(cmd
, stderr
=subprocess
.STDOUT
)
385 except subprocess
.CalledProcessError
as err
:
386 warning_msg
= err
.output
387 exit_code
= err
.returncode
391 kernellog
.warn(app
, "Error #%d when calling: %s" % (exit_code
, " ".join(cmd
)))
393 kernellog
.warn(app
, "Warning msg from %s: %s"
394 % (cmd_name
, str(warning_msg
, 'utf-8')))
396 kernellog
.verbose(app
, "Warning msg from %s (likely harmless):\n%s"
397 % (cmd_name
, str(warning_msg
, 'utf-8')))
399 return bool(exit_code
== 0)
401 def svg2pdf_by_rsvg(app
, svg_fname
, pdf_fname
):
402 """Convert SVG to PDF with ``rsvg-convert(1)`` command.
404 * ``svg_fname`` pathname of input SVG file, including extension ``.svg``
405 * ``pdf_fname`` pathname of output PDF file, including extension ``.pdf``
407 Input SVG file should be the one generated by ``dot2format()``.
408 SVG -> PDF conversion is done by ``rsvg-convert(1)``.
410 If ``rsvg-convert(1)`` is unavailable, fall back to ``svg2pdf()``.
414 if rsvg_convert_cmd
is None:
415 ok
= svg2pdf(app
, svg_fname
, pdf_fname
)
417 cmd
= [rsvg_convert_cmd
, '--format=pdf', '-o', pdf_fname
, svg_fname
]
418 # use stdout and stderr from parent
419 exit_code
= subprocess
.call(cmd
)
421 kernellog
.warn(app
, "Error #%d when calling: %s" % (exit_code
, " ".join(cmd
)))
422 ok
= bool(exit_code
== 0)
428 # ---------------------
430 def visit_kernel_image(self
, node
): # pylint: disable=W0613
431 """Visitor of the ``kernel_image`` Node.
433 Handles the ``image`` child-node with the ``convert_image(...)``.
436 convert_image(img_node
, self
)
438 class kernel_image(nodes
.image
):
439 """Node for ``kernel-image`` directive."""
442 class KernelImage(images
.Image
):
443 u
"""KernelImage directive
445 Earns everything from ``.. image::`` directive, except *remote URI* and
446 *glob* pattern. The KernelImage wraps a image node into a
447 kernel_image node. See ``visit_kernel_image``.
451 uri
= self
.arguments
[0]
452 if uri
.endswith('.*') or uri
.find('://') != -1:
454 'Error in "%s: %s": glob pattern and remote images are not allowed'
456 result
= images
.Image
.run(self
)
457 if len(result
) == 2 or isinstance(result
[0], nodes
.system_message
):
459 (image_node
,) = result
460 # wrap image node into a kernel_image node / see visitors
461 node
= kernel_image('', image_node
)
465 # ---------------------
467 def visit_kernel_figure(self
, node
): # pylint: disable=W0613
468 """Visitor of the ``kernel_figure`` Node.
470 Handles the ``image`` child-node with the ``convert_image(...)``.
472 img_node
= node
[0][0]
473 convert_image(img_node
, self
)
475 class kernel_figure(nodes
.figure
):
476 """Node for ``kernel-figure`` directive."""
478 class KernelFigure(Figure
):
479 u
"""KernelImage directive
481 Earns everything from ``.. figure::`` directive, except *remote URI* and
482 *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure
483 node. See ``visit_kernel_figure``.
487 uri
= self
.arguments
[0]
488 if uri
.endswith('.*') or uri
.find('://') != -1:
491 ' glob pattern and remote images are not allowed'
493 result
= Figure
.run(self
)
494 if len(result
) == 2 or isinstance(result
[0], nodes
.system_message
):
496 (figure_node
,) = result
497 # wrap figure node into a kernel_figure node / see visitors
498 node
= kernel_figure('', figure_node
)
503 # ---------------------
505 def visit_kernel_render(self
, node
):
506 """Visitor of the ``kernel_render`` Node.
508 If rendering tools available, save the markup of the ``literal_block`` child
509 node into a file and replace the ``literal_block`` node with a new created
510 ``image`` node, pointing to the saved markup file. Afterwards, handle the
511 image child-node with the ``convert_image(...)``.
513 app
= self
.builder
.app
514 srclang
= node
.get('srclang')
516 kernellog
.verbose(app
, 'visit kernel-render node lang: "%s"' % (srclang
))
518 tmp_ext
= RENDER_MARKUP_EXT
.get(srclang
, None)
520 kernellog
.warn(app
, 'kernel-render: "%s" unknown / include raw.' % (srclang
))
523 if not dot_cmd
and tmp_ext
== '.dot':
524 kernellog
.verbose(app
, "dot from graphviz not available / include raw.")
527 literal_block
= node
[0]
529 code
= literal_block
.astext()
530 hashobj
= code
.encode('utf-8') # str(node.attributes)
531 fname
= path
.join('%s-%s' % (srclang
, sha1(hashobj
).hexdigest()))
533 tmp_fname
= path
.join(
534 self
.builder
.outdir
, self
.builder
.imagedir
, fname
+ tmp_ext
)
536 if not path
.isfile(tmp_fname
):
537 mkdir(path
.dirname(tmp_fname
))
538 with
open(tmp_fname
, "w") as out
:
541 img_node
= nodes
.image(node
.rawsource
, **node
.attributes
)
542 img_node
['uri'] = path
.join(self
.builder
.imgpath
, fname
+ tmp_ext
)
543 img_node
['candidates'] = {
544 '*': path
.join(self
.builder
.imgpath
, fname
+ tmp_ext
)}
546 literal_block
.replace_self(img_node
)
547 convert_image(img_node
, self
, tmp_fname
)
550 class kernel_render(nodes
.General
, nodes
.Inline
, nodes
.Element
):
551 """Node for ``kernel-render`` directive."""
554 class KernelRender(Figure
):
555 u
"""KernelRender directive
557 Render content by external tool. Has all the options known from the
558 *figure* directive, plus option ``caption``. If ``caption`` has a
559 value, a figure node with the *caption* is inserted. If not, a image node is
562 The KernelRender directive wraps the text of the directive into a
563 literal_block node and wraps it into a kernel_render node. See
564 ``visit_kernel_render``.
567 required_arguments
= 1
568 optional_arguments
= 0
569 final_argument_whitespace
= False
571 # earn options from 'figure'
572 option_spec
= Figure
.option_spec
.copy()
573 option_spec
['caption'] = directives
.unchanged
576 return [self
.build_node()]
578 def build_node(self
):
580 srclang
= self
.arguments
[0].strip()
581 if srclang
not in RENDER_MARKUP_EXT
.keys():
582 return [self
.state_machine
.reporter
.warning(
583 'Unknown source language "%s", use one of: %s.' % (
584 srclang
, ",".join(RENDER_MARKUP_EXT
.keys())),
587 code
= '\n'.join(self
.content
)
589 return [self
.state_machine
.reporter
.warning(
590 'Ignoring "%s" directive without content.' % (
594 node
= kernel_render()
595 node
['alt'] = self
.options
.get('alt','')
596 node
['srclang'] = srclang
597 literal_node
= nodes
.literal_block(code
, code
)
600 caption
= self
.options
.get('caption')
602 # parse caption's content
603 parsed
= nodes
.Element()
604 self
.state
.nested_parse(
605 ViewList([caption
], source
=''), self
.content_offset
, parsed
)
606 caption_node
= nodes
.caption(
607 parsed
[0].rawsource
, '', *parsed
[0].children
)
608 caption_node
.source
= parsed
[0].source
609 caption_node
.line
= parsed
[0].line
611 figure_node
= nodes
.figure('', node
)
612 for k
,v
in self
.options
.items():
614 figure_node
+= caption_node
620 def add_kernel_figure_to_std_domain(app
, doctree
):
621 """Add kernel-figure anchors to 'std' domain.
623 The ``StandardDomain.process_doc(..)`` method does not know how to resolve
624 the caption (label) of ``kernel-figure`` directive (it only knows about
625 standard nodes, e.g. table, figure etc.). Without any additional handling
626 this will result in a 'undefined label' for kernel-figures.
628 This handle adds labels of kernel-figure to the 'std' domain labels.
631 std
= app
.env
.domains
["std"]
632 docname
= app
.env
.docname
633 labels
= std
.data
["labels"]
635 for name
, explicit
in doctree
.nametypes
.items():
638 labelid
= doctree
.nameids
[name
]
641 node
= doctree
.ids
[labelid
]
643 if node
.tagname
== 'kernel_figure':
644 for n
in node
.next_node():
645 if n
.tagname
== 'caption':
646 sectname
= clean_astext(n
)
647 # add label to std domain
648 labels
[name
] = docname
, labelid
, sectname