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 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 63import kernellog 64 65PY3 = 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 176 kernellog.verbose(app, "kfigure: check installed tools ...") 177 178 dot_cmd = which('dot') 179 convert_cmd = which('convert') 180 181 if dot_cmd: 182 kernellog.verbose(app, "use dot(1) from: " + dot_cmd) 183 else: 184 kernellog.warn(app, "dot(1) not found, for better output quality install " 185 "graphviz from https://www.graphviz.org") 186 if convert_cmd: 187 kernellog.verbose(app, "use convert(1) from: " + convert_cmd) 188 else: 189 kernellog.warn(app, 190 "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 225 kernellog.verbose(app, 'assert best format for: ' + img_node['uri']) 226 227 if in_ext == '.dot': 228 229 if not dot_cmd: 230 kernellog.verbose(app, 231 "dot from graphviz not available / include DOT raw.") 232 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: 258 kernellog.verbose(app, 259 "no SVG to PDF conversion available / include SVG raw.") 260 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) 269 _name = dst_fname[len(translator.builder.outdir) + 1:] 270 271 if isNewer(dst_fname, src_fname): 272 kernellog.verbose(app, 273 "convert: {out}/%s already exists and is newer" % _name) 274 275 else: 276 ok = False 277 mkdir(path.dirname(dst_fname)) 278 279 if in_ext == '.dot': 280 kernellog.verbose(app, 'convert DOT to: {out}/' + _name) 281 ok = dot2format(app, src_fname, dst_fname) 282 283 elif in_ext == '.svg': 284 kernellog.verbose(app, 'convert SVG to: {out}/' + _name) 285 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: 313 kernellog.warn(app, 314 "Error #%d when calling: %s" % (exit_code, " ".join(cmd))) 315 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: 331 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd))) 332 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 424 kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang)) 425 426 tmp_ext = RENDER_MARKUP_EXT.get(srclang, None) 427 if tmp_ext is None: 428 kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang)) 429 return 430 431 if not dot_cmd and tmp_ext == '.dot': 432 kernellog.verbose(app, "dot from graphviz not available / include raw.") 433 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 558