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