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