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 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. 44 DOT: see https://www.graphviz.org/content/dot-language 45 46 """ 47 48import os 49from os import path 50import subprocess 51from hashlib import sha1 52from docutils import nodes 53from docutils.statemachine import ViewList 54from docutils.parsers.rst import directives 55from docutils.parsers.rst.directives import images 56import sphinx 57from sphinx.util.nodes import clean_astext 58import kernellog 59 60# 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): 74 """Searches the ``cmd`` in the ``PATH`` environment. 75 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 164 kernellog.verbose(app, "kfigure: check installed tools ...") 165 166 dot_cmd = which('dot') 167 convert_cmd = which('convert') 168 169 if dot_cmd: 170 kernellog.verbose(app, "use dot(1) from: " + dot_cmd) 171 else: 172 kernellog.warn(app, "dot(1) not found, for better output quality install " 173 "graphviz from https://www.graphviz.org") 174 if convert_cmd: 175 kernellog.verbose(app, "use convert(1) from: " + convert_cmd) 176 else: 177 kernellog.warn(app, 178 "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 213 kernellog.verbose(app, 'assert best format for: ' + img_node['uri']) 214 215 if in_ext == '.dot': 216 217 if not dot_cmd: 218 kernellog.verbose(app, 219 "dot from graphviz not available / include DOT raw.") 220 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: 246 kernellog.verbose(app, 247 "no SVG to PDF conversion available / include SVG raw.") 248 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): 260 kernellog.verbose(app, 261 "convert: {out}/%s already exists and is newer" % _name) 262 263 else: 264 ok = False 265 mkdir(path.dirname(dst_fname)) 266 267 if in_ext == '.dot': 268 kernellog.verbose(app, 'convert DOT to: {out}/' + _name) 269 ok = dot2format(app, src_fname, dst_fname) 270 271 elif in_ext == '.svg': 272 kernellog.verbose(app, 'convert SVG to: {out}/' + _name) 273 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: 301 kernellog.warn(app, 302 "Error #%d when calling: %s" % (exit_code, " ".join(cmd))) 303 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: 319 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd))) 320 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 412 kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang)) 413 414 tmp_ext = RENDER_MARKUP_EXT.get(srclang, None) 415 if tmp_ext is None: 416 kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang)) 417 return 418 419 if not dot_cmd and tmp_ext == '.dot': 420 kernellog.verbose(app, "dot from graphviz not available / include raw.") 421 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( 479 'Unknown source language "%s", use one of: %s.' % ( 480 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 531 for name, explicit in doctree.nametypes.items(): 532 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 546