blob: 3c78828330be0ab61c7aeb304df4e75fc0dc2cfd [file] [log] [blame]
Markus Heiserdb6ccf22017-03-06 14:09:27 +01001# -*- 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
Alexander A. Klimov93431e02020-05-26 08:05:44 +020032 * ``dot(1)``: Graphviz (https://www.graphviz.org). If Graphviz is not
Markus Heiserdb6ccf22017-03-06 14:09:27 +010033 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.
Alexander A. Klimov93431e02020-05-26 08:05:44 +020044 DOT: see https://www.graphviz.org/content/dot-language
Markus Heiserdb6ccf22017-03-06 14:09:27 +010045
46 """
47
48import os
49from os import path
50import subprocess
51from hashlib import sha1
Markus Heiserdb6ccf22017-03-06 14:09:27 +010052from docutils import nodes
53from docutils.statemachine import ViewList
54from docutils.parsers.rst import directives
55from docutils.parsers.rst.directives import images
56import sphinx
Markus Heiserdb6ccf22017-03-06 14:09:27 +010057from sphinx.util.nodes import clean_astext
Jonathan Corbet096ea522019-05-21 14:23:43 -060058import kernellog
59
Markus Heiserdb6ccf22017-03-06 14:09:27 +010060# Get Sphinx version
61major, minor, patch = sphinx.version_info[:3]
62if major == 1 and minor > 3:
63 # patches.Figure only landed in Sphinx 1.4
64 from sphinx.directives.patches import Figure # pylint: disable=C0413
65else:
66 Figure = images.Figure
67
68__version__ = '1.0.0'
69
70# simple helper
71# -------------
72
73def which(cmd):
Masanari Iidaae17a872018-01-11 20:00:28 +090074 """Searches the ``cmd`` in the ``PATH`` environment.
Markus Heiserdb6ccf22017-03-06 14:09:27 +010075
76 This *which* searches the PATH for executable ``cmd`` . First match is
77 returned, if nothing is found, ``None` is returned.
78 """
79 envpath = os.environ.get('PATH', None) or os.defpath
80 for folder in envpath.split(os.pathsep):
81 fname = folder + os.sep + cmd
82 if path.isfile(fname):
83 return fname
84
85def mkdir(folder, mode=0o775):
86 if not path.isdir(folder):
87 os.makedirs(folder, mode)
88
89def file2literal(fname):
90 with open(fname, "r") as src:
91 data = src.read()
92 node = nodes.literal_block(data, data)
93 return node
94
95def isNewer(path1, path2):
96 """Returns True if ``path1`` is newer than ``path2``
97
98 If ``path1`` exists and is newer than ``path2`` the function returns
99 ``True`` is returned otherwise ``False``
100 """
101 return (path.exists(path1)
102 and os.stat(path1).st_ctime > os.stat(path2).st_ctime)
103
104def pass_handle(self, node): # pylint: disable=W0613
105 pass
106
107# setup conversion tools and sphinx extension
108# -------------------------------------------
109
110# Graphviz's dot(1) support
111dot_cmd = None
112
113# ImageMagick' convert(1) support
114convert_cmd = None
115
116
117def setup(app):
118 # check toolchain first
119 app.connect('builder-inited', setupTools)
120
121 # image handling
122 app.add_directive("kernel-image", KernelImage)
123 app.add_node(kernel_image,
124 html = (visit_kernel_image, pass_handle),
125 latex = (visit_kernel_image, pass_handle),
126 texinfo = (visit_kernel_image, pass_handle),
127 text = (visit_kernel_image, pass_handle),
128 man = (visit_kernel_image, pass_handle), )
129
130 # figure handling
131 app.add_directive("kernel-figure", KernelFigure)
132 app.add_node(kernel_figure,
133 html = (visit_kernel_figure, pass_handle),
134 latex = (visit_kernel_figure, pass_handle),
135 texinfo = (visit_kernel_figure, pass_handle),
136 text = (visit_kernel_figure, pass_handle),
137 man = (visit_kernel_figure, pass_handle), )
138
139 # render handling
140 app.add_directive('kernel-render', KernelRender)
141 app.add_node(kernel_render,
142 html = (visit_kernel_render, pass_handle),
143 latex = (visit_kernel_render, pass_handle),
144 texinfo = (visit_kernel_render, pass_handle),
145 text = (visit_kernel_render, pass_handle),
146 man = (visit_kernel_render, pass_handle), )
147
148 app.connect('doctree-read', add_kernel_figure_to_std_domain)
149
150 return dict(
151 version = __version__,
152 parallel_read_safe = True,
153 parallel_write_safe = True
154 )
155
156
157def setupTools(app):
158 u"""
159 Check available build tools and log some *verbose* messages.
160
161 This function is called once, when the builder is initiated.
162 """
163 global dot_cmd, convert_cmd # pylint: disable=W0603
Jonathan Corbet096ea522019-05-21 14:23:43 -0600164 kernellog.verbose(app, "kfigure: check installed tools ...")
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100165
166 dot_cmd = which('dot')
167 convert_cmd = which('convert')
168
169 if dot_cmd:
Jonathan Corbet096ea522019-05-21 14:23:43 -0600170 kernellog.verbose(app, "use dot(1) from: " + dot_cmd)
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100171 else:
Jonathan Corbet096ea522019-05-21 14:23:43 -0600172 kernellog.warn(app, "dot(1) not found, for better output quality install "
Alexander A. Klimov93431e02020-05-26 08:05:44 +0200173 "graphviz from https://www.graphviz.org")
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100174 if convert_cmd:
Jonathan Corbet096ea522019-05-21 14:23:43 -0600175 kernellog.verbose(app, "use convert(1) from: " + convert_cmd)
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100176 else:
Jonathan Corbet096ea522019-05-21 14:23:43 -0600177 kernellog.warn(app,
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100178 "convert(1) not found, for SVG to PDF conversion install "
179 "ImageMagick (https://www.imagemagick.org)")
180
181
182# integrate conversion tools
183# --------------------------
184
185RENDER_MARKUP_EXT = {
186 # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
187 # <name> : <.ext>
188 'DOT' : '.dot',
189 'SVG' : '.svg'
190}
191
192def convert_image(img_node, translator, src_fname=None):
193 """Convert a image node for the builder.
194
195 Different builder prefer different image formats, e.g. *latex* builder
196 prefer PDF while *html* builder prefer SVG format for images.
197
198 This function handles output image formats in dependence of source the
199 format (of the image) and the translator's output format.
200 """
201 app = translator.builder.app
202
203 fname, in_ext = path.splitext(path.basename(img_node['uri']))
204 if src_fname is None:
205 src_fname = path.join(translator.builder.srcdir, img_node['uri'])
206 if not path.exists(src_fname):
207 src_fname = path.join(translator.builder.outdir, img_node['uri'])
208
209 dst_fname = None
210
211 # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
212
Jonathan Corbet096ea522019-05-21 14:23:43 -0600213 kernellog.verbose(app, 'assert best format for: ' + img_node['uri'])
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100214
215 if in_ext == '.dot':
216
217 if not dot_cmd:
Jonathan Corbet096ea522019-05-21 14:23:43 -0600218 kernellog.verbose(app,
219 "dot from graphviz not available / include DOT raw.")
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100220 img_node.replace_self(file2literal(src_fname))
221
222 elif translator.builder.format == 'latex':
223 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
224 img_node['uri'] = fname + '.pdf'
225 img_node['candidates'] = {'*': fname + '.pdf'}
226
227
228 elif translator.builder.format == 'html':
229 dst_fname = path.join(
230 translator.builder.outdir,
231 translator.builder.imagedir,
232 fname + '.svg')
233 img_node['uri'] = path.join(
234 translator.builder.imgpath, fname + '.svg')
235 img_node['candidates'] = {
236 '*': path.join(translator.builder.imgpath, fname + '.svg')}
237
238 else:
239 # all other builder formats will include DOT as raw
240 img_node.replace_self(file2literal(src_fname))
241
242 elif in_ext == '.svg':
243
244 if translator.builder.format == 'latex':
245 if convert_cmd is None:
Jonathan Corbet096ea522019-05-21 14:23:43 -0600246 kernellog.verbose(app,
247 "no SVG to PDF conversion available / include SVG raw.")
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100248 img_node.replace_self(file2literal(src_fname))
249 else:
250 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
251 img_node['uri'] = fname + '.pdf'
252 img_node['candidates'] = {'*': fname + '.pdf'}
253
254 if dst_fname:
255 # the builder needs not to copy one more time, so pop it if exists.
256 translator.builder.images.pop(img_node['uri'], None)
257 _name = dst_fname[len(translator.builder.outdir) + 1:]
258
259 if isNewer(dst_fname, src_fname):
Jonathan Corbet096ea522019-05-21 14:23:43 -0600260 kernellog.verbose(app,
261 "convert: {out}/%s already exists and is newer" % _name)
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100262
263 else:
264 ok = False
265 mkdir(path.dirname(dst_fname))
266
267 if in_ext == '.dot':
Jonathan Corbet096ea522019-05-21 14:23:43 -0600268 kernellog.verbose(app, 'convert DOT to: {out}/' + _name)
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100269 ok = dot2format(app, src_fname, dst_fname)
270
271 elif in_ext == '.svg':
Jonathan Corbet096ea522019-05-21 14:23:43 -0600272 kernellog.verbose(app, 'convert SVG to: {out}/' + _name)
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100273 ok = svg2pdf(app, src_fname, dst_fname)
274
275 if not ok:
276 img_node.replace_self(file2literal(src_fname))
277
278
279def dot2format(app, dot_fname, out_fname):
280 """Converts DOT file to ``out_fname`` using ``dot(1)``.
281
282 * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
283 * ``out_fname`` pathname of the output file, including format extension
284
285 The *format extension* depends on the ``dot`` command (see ``man dot``
286 option ``-Txxx``). Normally you will use one of the following extensions:
287
288 - ``.ps`` for PostScript,
289 - ``.svg`` or ``svgz`` for Structured Vector Graphics,
290 - ``.fig`` for XFIG graphics and
291 - ``.png`` or ``gif`` for common bitmap graphics.
292
293 """
294 out_format = path.splitext(out_fname)[1][1:]
295 cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
296 exit_code = 42
297
298 with open(out_fname, "w") as out:
299 exit_code = subprocess.call(cmd, stdout = out)
300 if exit_code != 0:
Jonathan Corbet096ea522019-05-21 14:23:43 -0600301 kernellog.warn(app,
302 "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100303 return bool(exit_code == 0)
304
305def svg2pdf(app, svg_fname, pdf_fname):
306 """Converts SVG to PDF with ``convert(1)`` command.
307
308 Uses ``convert(1)`` from ImageMagick (https://www.imagemagick.org) for
309 conversion. Returns ``True`` on success and ``False`` if an error occurred.
310
311 * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
312 * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``)
313
314 """
315 cmd = [convert_cmd, svg_fname, pdf_fname]
316 # use stdout and stderr from parent
317 exit_code = subprocess.call(cmd)
318 if exit_code != 0:
Jonathan Corbet096ea522019-05-21 14:23:43 -0600319 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100320 return bool(exit_code == 0)
321
322
323# image handling
324# ---------------------
325
326def visit_kernel_image(self, node): # pylint: disable=W0613
327 """Visitor of the ``kernel_image`` Node.
328
329 Handles the ``image`` child-node with the ``convert_image(...)``.
330 """
331 img_node = node[0]
332 convert_image(img_node, self)
333
334class kernel_image(nodes.image):
335 """Node for ``kernel-image`` directive."""
336 pass
337
338class KernelImage(images.Image):
339 u"""KernelImage directive
340
341 Earns everything from ``.. image::`` directive, except *remote URI* and
342 *glob* pattern. The KernelImage wraps a image node into a
343 kernel_image node. See ``visit_kernel_image``.
344 """
345
346 def run(self):
347 uri = self.arguments[0]
348 if uri.endswith('.*') or uri.find('://') != -1:
349 raise self.severe(
350 'Error in "%s: %s": glob pattern and remote images are not allowed'
351 % (self.name, uri))
352 result = images.Image.run(self)
353 if len(result) == 2 or isinstance(result[0], nodes.system_message):
354 return result
355 (image_node,) = result
356 # wrap image node into a kernel_image node / see visitors
357 node = kernel_image('', image_node)
358 return [node]
359
360# figure handling
361# ---------------------
362
363def visit_kernel_figure(self, node): # pylint: disable=W0613
364 """Visitor of the ``kernel_figure`` Node.
365
366 Handles the ``image`` child-node with the ``convert_image(...)``.
367 """
368 img_node = node[0][0]
369 convert_image(img_node, self)
370
371class kernel_figure(nodes.figure):
372 """Node for ``kernel-figure`` directive."""
373
374class KernelFigure(Figure):
375 u"""KernelImage directive
376
377 Earns everything from ``.. figure::`` directive, except *remote URI* and
378 *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure
379 node. See ``visit_kernel_figure``.
380 """
381
382 def run(self):
383 uri = self.arguments[0]
384 if uri.endswith('.*') or uri.find('://') != -1:
385 raise self.severe(
386 'Error in "%s: %s":'
387 ' glob pattern and remote images are not allowed'
388 % (self.name, uri))
389 result = Figure.run(self)
390 if len(result) == 2 or isinstance(result[0], nodes.system_message):
391 return result
392 (figure_node,) = result
393 # wrap figure node into a kernel_figure node / see visitors
394 node = kernel_figure('', figure_node)
395 return [node]
396
397
398# render handling
399# ---------------------
400
401def visit_kernel_render(self, node):
402 """Visitor of the ``kernel_render`` Node.
403
404 If rendering tools available, save the markup of the ``literal_block`` child
405 node into a file and replace the ``literal_block`` node with a new created
406 ``image`` node, pointing to the saved markup file. Afterwards, handle the
407 image child-node with the ``convert_image(...)``.
408 """
409 app = self.builder.app
410 srclang = node.get('srclang')
411
Jonathan Corbet096ea522019-05-21 14:23:43 -0600412 kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang))
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100413
414 tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
415 if tmp_ext is None:
Jonathan Corbet096ea522019-05-21 14:23:43 -0600416 kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang))
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100417 return
418
419 if not dot_cmd and tmp_ext == '.dot':
Jonathan Corbet096ea522019-05-21 14:23:43 -0600420 kernellog.verbose(app, "dot from graphviz not available / include raw.")
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100421 return
422
423 literal_block = node[0]
424
425 code = literal_block.astext()
426 hashobj = code.encode('utf-8') # str(node.attributes)
427 fname = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
428
429 tmp_fname = path.join(
430 self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
431
432 if not path.isfile(tmp_fname):
433 mkdir(path.dirname(tmp_fname))
434 with open(tmp_fname, "w") as out:
435 out.write(code)
436
437 img_node = nodes.image(node.rawsource, **node.attributes)
438 img_node['uri'] = path.join(self.builder.imgpath, fname + tmp_ext)
439 img_node['candidates'] = {
440 '*': path.join(self.builder.imgpath, fname + tmp_ext)}
441
442 literal_block.replace_self(img_node)
443 convert_image(img_node, self, tmp_fname)
444
445
446class kernel_render(nodes.General, nodes.Inline, nodes.Element):
447 """Node for ``kernel-render`` directive."""
448 pass
449
450class KernelRender(Figure):
451 u"""KernelRender directive
452
453 Render content by external tool. Has all the options known from the
454 *figure* directive, plus option ``caption``. If ``caption`` has a
455 value, a figure node with the *caption* is inserted. If not, a image node is
456 inserted.
457
458 The KernelRender directive wraps the text of the directive into a
459 literal_block node and wraps it into a kernel_render node. See
460 ``visit_kernel_render``.
461 """
462 has_content = True
463 required_arguments = 1
464 optional_arguments = 0
465 final_argument_whitespace = False
466
467 # earn options from 'figure'
468 option_spec = Figure.option_spec.copy()
469 option_spec['caption'] = directives.unchanged
470
471 def run(self):
472 return [self.build_node()]
473
474 def build_node(self):
475
476 srclang = self.arguments[0].strip()
477 if srclang not in RENDER_MARKUP_EXT.keys():
478 return [self.state_machine.reporter.warning(
Masanari Iidaae17a872018-01-11 20:00:28 +0900479 'Unknown source language "%s", use one of: %s.' % (
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100480 srclang, ",".join(RENDER_MARKUP_EXT.keys())),
481 line=self.lineno)]
482
483 code = '\n'.join(self.content)
484 if not code.strip():
485 return [self.state_machine.reporter.warning(
486 'Ignoring "%s" directive without content.' % (
487 self.name),
488 line=self.lineno)]
489
490 node = kernel_render()
491 node['alt'] = self.options.get('alt','')
492 node['srclang'] = srclang
493 literal_node = nodes.literal_block(code, code)
494 node += literal_node
495
496 caption = self.options.get('caption')
497 if caption:
498 # parse caption's content
499 parsed = nodes.Element()
500 self.state.nested_parse(
501 ViewList([caption], source=''), self.content_offset, parsed)
502 caption_node = nodes.caption(
503 parsed[0].rawsource, '', *parsed[0].children)
504 caption_node.source = parsed[0].source
505 caption_node.line = parsed[0].line
506
507 figure_node = nodes.figure('', node)
508 for k,v in self.options.items():
509 figure_node[k] = v
510 figure_node += caption_node
511
512 node = figure_node
513
514 return node
515
516def add_kernel_figure_to_std_domain(app, doctree):
517 """Add kernel-figure anchors to 'std' domain.
518
519 The ``StandardDomain.process_doc(..)`` method does not know how to resolve
520 the caption (label) of ``kernel-figure`` directive (it only knows about
521 standard nodes, e.g. table, figure etc.). Without any additional handling
522 this will result in a 'undefined label' for kernel-figures.
523
524 This handle adds labels of kernel-figure to the 'std' domain labels.
525 """
526
527 std = app.env.domains["std"]
528 docname = app.env.docname
529 labels = std.data["labels"]
530
Jonathan Corbet4217e502021-02-01 17:17:14 -0700531 for name, explicit in doctree.nametypes.items():
Markus Heiserdb6ccf22017-03-06 14:09:27 +0100532 if not explicit:
533 continue
534 labelid = doctree.nameids[name]
535 if labelid is None:
536 continue
537 node = doctree.ids[labelid]
538
539 if node.tagname == 'kernel_figure':
540 for n in node.next_node():
541 if n.tagname == 'caption':
542 sectname = clean_astext(n)
543 # add label to std domain
544 labels[name] = docname, labelid, sectname
545 break