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.warn(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.verbose(app, 300 "no SVG to PDF conversion available / include SVG raw.") 301 img_node.replace_self(file2literal(src_fname)) 302 else: 303 dst_fname = path.join(translator.builder.outdir, fname + '.pdf') 304 img_node['uri'] = fname + '.pdf' 305 img_node['candidates'] = {'*': fname + '.pdf'} 306 307 if dst_fname: 308 # the builder needs not to copy one more time, so pop it if exists. 309 translator.builder.images.pop(img_node['uri'], None) 310 _name = dst_fname[len(translator.builder.outdir) + 1:] 311 312 if isNewer(dst_fname, src_fname): 313 kernellog.verbose(app, 314 "convert: {out}/%s already exists and is newer" % _name) 315 316 else: 317 ok = False 318 mkdir(path.dirname(dst_fname)) 319 320 if in_ext == '.dot': 321 kernellog.verbose(app, 'convert DOT to: {out}/' + _name) 322 if translator.builder.format == 'latex' and not dot_Tpdf: 323 svg_fname = path.join(translator.builder.outdir, fname + '.svg') 324 ok1 = dot2format(app, src_fname, svg_fname) 325 ok2 = svg2pdf_by_rsvg(app, svg_fname, dst_fname) 326 ok = ok1 and ok2 327 328 else: 329 ok = dot2format(app, src_fname, dst_fname) 330 331 elif in_ext == '.svg': 332 kernellog.verbose(app, 'convert SVG to: {out}/' + _name) 333 ok = svg2pdf(app, src_fname, dst_fname) 334 335 if not ok: 336 img_node.replace_self(file2literal(src_fname)) 337 338 339def dot2format(app, dot_fname, out_fname): 340 """Converts DOT file to ``out_fname`` using ``dot(1)``. 341 342 * ``dot_fname`` pathname of the input DOT file, including extension ``.dot`` 343 * ``out_fname`` pathname of the output file, including format extension 344 345 The *format extension* depends on the ``dot`` command (see ``man dot`` 346 option ``-Txxx``). Normally you will use one of the following extensions: 347 348 - ``.ps`` for PostScript, 349 - ``.svg`` or ``svgz`` for Structured Vector Graphics, 350 - ``.fig`` for XFIG graphics and 351 - ``.png`` or ``gif`` for common bitmap graphics. 352 353 """ 354 out_format = path.splitext(out_fname)[1][1:] 355 cmd = [dot_cmd, '-T%s' % out_format, dot_fname] 356 exit_code = 42 357 358 with open(out_fname, "w") as out: 359 exit_code = subprocess.call(cmd, stdout = out) 360 if exit_code != 0: 361 kernellog.warn(app, 362 "Error #%d when calling: %s" % (exit_code, " ".join(cmd))) 363 return bool(exit_code == 0) 364 365def svg2pdf(app, svg_fname, pdf_fname): 366 """Converts SVG to PDF with ``inkscape(1)`` or ``convert(1)`` command. 367 368 Uses ``inkscape(1)`` from Inkscape (https://inkscape.org/) or ``convert(1)`` 369 from ImageMagick (https://www.imagemagick.org) for conversion. 370 Returns ``True`` on success and ``False`` if an error occurred. 371 372 * ``svg_fname`` pathname of the input SVG file with extension (``.svg``) 373 * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``) 374 375 """ 376 cmd = [convert_cmd, svg_fname, pdf_fname] 377 cmd_name = 'convert(1)' 378 379 if inkscape_cmd: 380 cmd_name = 'inkscape(1)' 381 if inkscape_ver_one: 382 cmd = [inkscape_cmd, '-o', pdf_fname, svg_fname] 383 else: 384 cmd = [inkscape_cmd, '-z', '--export-pdf=%s' % pdf_fname, svg_fname] 385 386 try: 387 warning_msg = subprocess.check_output(cmd, stderr=subprocess.STDOUT) 388 exit_code = 0 389 except subprocess.CalledProcessError as err: 390 warning_msg = err.output 391 exit_code = err.returncode 392 pass 393 394 if exit_code != 0: 395 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd))) 396 if warning_msg: 397 kernellog.warn(app, "Warning msg from %s: %s" 398 % (cmd_name, str(warning_msg, 'utf-8'))) 399 elif warning_msg: 400 kernellog.verbose(app, "Warning msg from %s (likely harmless):\n%s" 401 % (cmd_name, str(warning_msg, 'utf-8'))) 402 403 return bool(exit_code == 0) 404 405def svg2pdf_by_rsvg(app, svg_fname, pdf_fname): 406 """Convert SVG to PDF with ``rsvg-convert(1)`` command. 407 408 * ``svg_fname`` pathname of input SVG file, including extension ``.svg`` 409 * ``pdf_fname`` pathname of output PDF file, including extension ``.pdf`` 410 411 Input SVG file should be the one generated by ``dot2format()``. 412 SVG -> PDF conversion is done by ``rsvg-convert(1)``. 413 414 If ``rsvg-convert(1)`` is unavailable, fall back to ``svg2pdf()``. 415 416 """ 417 418 if rsvg_convert_cmd is None: 419 ok = svg2pdf(app, svg_fname, pdf_fname) 420 else: 421 cmd = [rsvg_convert_cmd, '--format=pdf', '-o', pdf_fname, svg_fname] 422 # use stdout and stderr from parent 423 exit_code = subprocess.call(cmd) 424 if exit_code != 0: 425 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd))) 426 ok = bool(exit_code == 0) 427 428 return ok 429 430 431# image handling 432# --------------------- 433 434def visit_kernel_image(self, node): # pylint: disable=W0613 435 """Visitor of the ``kernel_image`` Node. 436 437 Handles the ``image`` child-node with the ``convert_image(...)``. 438 """ 439 img_node = node[0] 440 convert_image(img_node, self) 441 442class kernel_image(nodes.image): 443 """Node for ``kernel-image`` directive.""" 444 pass 445 446class KernelImage(images.Image): 447 u"""KernelImage directive 448 449 Earns everything from ``.. image::`` directive, except *remote URI* and 450 *glob* pattern. The KernelImage wraps a image node into a 451 kernel_image node. See ``visit_kernel_image``. 452 """ 453 454 def run(self): 455 uri = self.arguments[0] 456 if uri.endswith('.*') or uri.find('://') != -1: 457 raise self.severe( 458 'Error in "%s: %s": glob pattern and remote images are not allowed' 459 % (self.name, uri)) 460 result = images.Image.run(self) 461 if len(result) == 2 or isinstance(result[0], nodes.system_message): 462 return result 463 (image_node,) = result 464 # wrap image node into a kernel_image node / see visitors 465 node = kernel_image('', image_node) 466 return [node] 467 468# figure handling 469# --------------------- 470 471def visit_kernel_figure(self, node): # pylint: disable=W0613 472 """Visitor of the ``kernel_figure`` Node. 473 474 Handles the ``image`` child-node with the ``convert_image(...)``. 475 """ 476 img_node = node[0][0] 477 convert_image(img_node, self) 478 479class kernel_figure(nodes.figure): 480 """Node for ``kernel-figure`` directive.""" 481 482class KernelFigure(Figure): 483 u"""KernelImage directive 484 485 Earns everything from ``.. figure::`` directive, except *remote URI* and 486 *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure 487 node. See ``visit_kernel_figure``. 488 """ 489 490 def run(self): 491 uri = self.arguments[0] 492 if uri.endswith('.*') or uri.find('://') != -1: 493 raise self.severe( 494 'Error in "%s: %s":' 495 ' glob pattern and remote images are not allowed' 496 % (self.name, uri)) 497 result = Figure.run(self) 498 if len(result) == 2 or isinstance(result[0], nodes.system_message): 499 return result 500 (figure_node,) = result 501 # wrap figure node into a kernel_figure node / see visitors 502 node = kernel_figure('', figure_node) 503 return [node] 504 505 506# render handling 507# --------------------- 508 509def visit_kernel_render(self, node): 510 """Visitor of the ``kernel_render`` Node. 511 512 If rendering tools available, save the markup of the ``literal_block`` child 513 node into a file and replace the ``literal_block`` node with a new created 514 ``image`` node, pointing to the saved markup file. Afterwards, handle the 515 image child-node with the ``convert_image(...)``. 516 """ 517 app = self.builder.app 518 srclang = node.get('srclang') 519 520 kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang)) 521 522 tmp_ext = RENDER_MARKUP_EXT.get(srclang, None) 523 if tmp_ext is None: 524 kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang)) 525 return 526 527 if not dot_cmd and tmp_ext == '.dot': 528 kernellog.verbose(app, "dot from graphviz not available / include raw.") 529 return 530 531 literal_block = node[0] 532 533 code = literal_block.astext() 534 hashobj = code.encode('utf-8') # str(node.attributes) 535 fname = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest())) 536 537 tmp_fname = path.join( 538 self.builder.outdir, self.builder.imagedir, fname + tmp_ext) 539 540 if not path.isfile(tmp_fname): 541 mkdir(path.dirname(tmp_fname)) 542 with open(tmp_fname, "w") as out: 543 out.write(code) 544 545 img_node = nodes.image(node.rawsource, **node.attributes) 546 img_node['uri'] = path.join(self.builder.imgpath, fname + tmp_ext) 547 img_node['candidates'] = { 548 '*': path.join(self.builder.imgpath, fname + tmp_ext)} 549 550 literal_block.replace_self(img_node) 551 convert_image(img_node, self, tmp_fname) 552 553 554class kernel_render(nodes.General, nodes.Inline, nodes.Element): 555 """Node for ``kernel-render`` directive.""" 556 pass 557 558class KernelRender(Figure): 559 u"""KernelRender directive 560 561 Render content by external tool. Has all the options known from the 562 *figure* directive, plus option ``caption``. If ``caption`` has a 563 value, a figure node with the *caption* is inserted. If not, a image node is 564 inserted. 565 566 The KernelRender directive wraps the text of the directive into a 567 literal_block node and wraps it into a kernel_render node. See 568 ``visit_kernel_render``. 569 """ 570 has_content = True 571 required_arguments = 1 572 optional_arguments = 0 573 final_argument_whitespace = False 574 575 # earn options from 'figure' 576 option_spec = Figure.option_spec.copy() 577 option_spec['caption'] = directives.unchanged 578 579 def run(self): 580 return [self.build_node()] 581 582 def build_node(self): 583 584 srclang = self.arguments[0].strip() 585 if srclang not in RENDER_MARKUP_EXT.keys(): 586 return [self.state_machine.reporter.warning( 587 'Unknown source language "%s", use one of: %s.' % ( 588 srclang, ",".join(RENDER_MARKUP_EXT.keys())), 589 line=self.lineno)] 590 591 code = '\n'.join(self.content) 592 if not code.strip(): 593 return [self.state_machine.reporter.warning( 594 'Ignoring "%s" directive without content.' % ( 595 self.name), 596 line=self.lineno)] 597 598 node = kernel_render() 599 node['alt'] = self.options.get('alt','') 600 node['srclang'] = srclang 601 literal_node = nodes.literal_block(code, code) 602 node += literal_node 603 604 caption = self.options.get('caption') 605 if caption: 606 # parse caption's content 607 parsed = nodes.Element() 608 self.state.nested_parse( 609 ViewList([caption], source=''), self.content_offset, parsed) 610 caption_node = nodes.caption( 611 parsed[0].rawsource, '', *parsed[0].children) 612 caption_node.source = parsed[0].source 613 caption_node.line = parsed[0].line 614 615 figure_node = nodes.figure('', node) 616 for k,v in self.options.items(): 617 figure_node[k] = v 618 figure_node += caption_node 619 620 node = figure_node 621 622 return node 623 624def add_kernel_figure_to_std_domain(app, doctree): 625 """Add kernel-figure anchors to 'std' domain. 626 627 The ``StandardDomain.process_doc(..)`` method does not know how to resolve 628 the caption (label) of ``kernel-figure`` directive (it only knows about 629 standard nodes, e.g. table, figure etc.). Without any additional handling 630 this will result in a 'undefined label' for kernel-figures. 631 632 This handle adds labels of kernel-figure to the 'std' domain labels. 633 """ 634 635 std = app.env.domains["std"] 636 docname = app.env.docname 637 labels = std.data["labels"] 638 639 for name, explicit in doctree.nametypes.items(): 640 if not explicit: 641 continue 642 labelid = doctree.nameids[name] 643 if labelid is None: 644 continue 645 node = doctree.ids[labelid] 646 647 if node.tagname == 'kernel_figure': 648 for n in node.next_node(): 649 if n.tagname == 'caption': 650 sectname = clean_astext(n) 651 # add label to std domain 652 labels[name] = docname, labelid, sectname 653 break 654