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