0001
0002
0003 u"""
0004 scalable figure and image handling
0005 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
0006
0007 Sphinx extension which implements scalable image handling.
0008
0009 :copyright: Copyright (C) 2016 Markus Heiser
0010 :license: GPL Version 2, June 1991 see Linux/COPYING for details.
0011
0012 The build for image formats depend on image's source format and output's
0013 destination format. This extension implement methods to simplify image
0014 handling from the author's POV. Directives like ``kernel-figure`` implement
0015 methods *to* always get the best output-format even if some tools are not
0016 installed. For more details take a look at ``convert_image(...)`` which is
0017 the core of all conversions.
0018
0019 * ``.. kernel-image``: for image handling / a ``.. image::`` replacement
0020
0021 * ``.. kernel-figure``: for figure handling / a ``.. figure::`` replacement
0022
0023 * ``.. kernel-render``: for render markup / a concept to embed *render*
0024 markups (or languages). Supported markups (see ``RENDER_MARKUP_EXT``)
0025
0026 - ``DOT``: render embedded Graphviz's **DOC**
0027 - ``SVG``: render embedded Scalable Vector Graphics (**SVG**)
0028 - ... *developable*
0029
0030 Used tools:
0031
0032 * ``dot(1)``: Graphviz (https://www.graphviz.org). If Graphviz is not
0033 available, the DOT language is inserted as literal-block.
0034 For conversion to PDF, ``rsvg-convert(1)`` of librsvg
0035 (https://gitlab.gnome.org/GNOME/librsvg) is used when available.
0036
0037 * SVG to PDF: To generate PDF, you need at least one of this tools:
0038
0039 - ``convert(1)``: ImageMagick (https://www.imagemagick.org)
0040 - ``inkscape(1)``: Inkscape (https://inkscape.org/)
0041
0042 List of customizations:
0043
0044 * generate PDF from SVG / used by PDF (LaTeX) builder
0045
0046 * generate SVG (html-builder) and PDF (latex-builder) from DOT files.
0047 DOT: see https://www.graphviz.org/content/dot-language
0048
0049 """
0050
0051 import os
0052 from os import path
0053 import subprocess
0054 from hashlib import sha1
0055 import re
0056 from docutils import nodes
0057 from docutils.statemachine import ViewList
0058 from docutils.parsers.rst import directives
0059 from docutils.parsers.rst.directives import images
0060 import sphinx
0061 from sphinx.util.nodes import clean_astext
0062 import kernellog
0063
0064
0065 major, minor, patch = sphinx.version_info[:3]
0066 if major == 1 and minor > 3:
0067
0068 from sphinx.directives.patches import Figure
0069 else:
0070 Figure = images.Figure
0071
0072 __version__ = '1.0.0'
0073
0074
0075
0076
0077 def which(cmd):
0078 """Searches the ``cmd`` in the ``PATH`` environment.
0079
0080 This *which* searches the PATH for executable ``cmd`` . First match is
0081 returned, if nothing is found, ``None` is returned.
0082 """
0083 envpath = os.environ.get('PATH', None) or os.defpath
0084 for folder in envpath.split(os.pathsep):
0085 fname = folder + os.sep + cmd
0086 if path.isfile(fname):
0087 return fname
0088
0089 def mkdir(folder, mode=0o775):
0090 if not path.isdir(folder):
0091 os.makedirs(folder, mode)
0092
0093 def file2literal(fname):
0094 with open(fname, "r") as src:
0095 data = src.read()
0096 node = nodes.literal_block(data, data)
0097 return node
0098
0099 def isNewer(path1, path2):
0100 """Returns True if ``path1`` is newer than ``path2``
0101
0102 If ``path1`` exists and is newer than ``path2`` the function returns
0103 ``True`` is returned otherwise ``False``
0104 """
0105 return (path.exists(path1)
0106 and os.stat(path1).st_ctime > os.stat(path2).st_ctime)
0107
0108 def pass_handle(self, node):
0109 pass
0110
0111
0112
0113
0114
0115 dot_cmd = None
0116
0117 dot_Tpdf = False
0118
0119
0120 convert_cmd = None
0121
0122
0123 rsvg_convert_cmd = None
0124
0125
0126 inkscape_cmd = None
0127
0128 inkscape_ver_one = False
0129
0130
0131 def setup(app):
0132
0133 app.connect('builder-inited', setupTools)
0134
0135
0136 app.add_directive("kernel-image", KernelImage)
0137 app.add_node(kernel_image,
0138 html = (visit_kernel_image, pass_handle),
0139 latex = (visit_kernel_image, pass_handle),
0140 texinfo = (visit_kernel_image, pass_handle),
0141 text = (visit_kernel_image, pass_handle),
0142 man = (visit_kernel_image, pass_handle), )
0143
0144
0145 app.add_directive("kernel-figure", KernelFigure)
0146 app.add_node(kernel_figure,
0147 html = (visit_kernel_figure, pass_handle),
0148 latex = (visit_kernel_figure, pass_handle),
0149 texinfo = (visit_kernel_figure, pass_handle),
0150 text = (visit_kernel_figure, pass_handle),
0151 man = (visit_kernel_figure, pass_handle), )
0152
0153
0154 app.add_directive('kernel-render', KernelRender)
0155 app.add_node(kernel_render,
0156 html = (visit_kernel_render, pass_handle),
0157 latex = (visit_kernel_render, pass_handle),
0158 texinfo = (visit_kernel_render, pass_handle),
0159 text = (visit_kernel_render, pass_handle),
0160 man = (visit_kernel_render, pass_handle), )
0161
0162 app.connect('doctree-read', add_kernel_figure_to_std_domain)
0163
0164 return dict(
0165 version = __version__,
0166 parallel_read_safe = True,
0167 parallel_write_safe = True
0168 )
0169
0170
0171 def setupTools(app):
0172 u"""
0173 Check available build tools and log some *verbose* messages.
0174
0175 This function is called once, when the builder is initiated.
0176 """
0177 global dot_cmd, dot_Tpdf, convert_cmd, rsvg_convert_cmd
0178 global inkscape_cmd, inkscape_ver_one
0179 kernellog.verbose(app, "kfigure: check installed tools ...")
0180
0181 dot_cmd = which('dot')
0182 convert_cmd = which('convert')
0183 rsvg_convert_cmd = which('rsvg-convert')
0184 inkscape_cmd = which('inkscape')
0185
0186 if dot_cmd:
0187 kernellog.verbose(app, "use dot(1) from: " + dot_cmd)
0188
0189 try:
0190 dot_Thelp_list = subprocess.check_output([dot_cmd, '-Thelp'],
0191 stderr=subprocess.STDOUT)
0192 except subprocess.CalledProcessError as err:
0193 dot_Thelp_list = err.output
0194 pass
0195
0196 dot_Tpdf_ptn = b'pdf'
0197 dot_Tpdf = re.search(dot_Tpdf_ptn, dot_Thelp_list)
0198 else:
0199 kernellog.warn(app, "dot(1) not found, for better output quality install "
0200 "graphviz from https://www.graphviz.org")
0201 if inkscape_cmd:
0202 kernellog.verbose(app, "use inkscape(1) from: " + inkscape_cmd)
0203 inkscape_ver = subprocess.check_output([inkscape_cmd, '--version'],
0204 stderr=subprocess.DEVNULL)
0205 ver_one_ptn = b'Inkscape 1'
0206 inkscape_ver_one = re.search(ver_one_ptn, inkscape_ver)
0207 convert_cmd = None
0208 rsvg_convert_cmd = None
0209 dot_Tpdf = False
0210
0211 else:
0212 if convert_cmd:
0213 kernellog.verbose(app, "use convert(1) from: " + convert_cmd)
0214 else:
0215 kernellog.verbose(app,
0216 "Neither inkscape(1) nor convert(1) found.\n"
0217 "For SVG to PDF conversion, "
0218 "install either Inkscape (https://inkscape.org/) (preferred) or\n"
0219 "ImageMagick (https://www.imagemagick.org)")
0220
0221 if rsvg_convert_cmd:
0222 kernellog.verbose(app, "use rsvg-convert(1) from: " + rsvg_convert_cmd)
0223 kernellog.verbose(app, "use 'dot -Tsvg' and rsvg-convert(1) for DOT -> PDF conversion")
0224 dot_Tpdf = False
0225 else:
0226 kernellog.verbose(app,
0227 "rsvg-convert(1) not found.\n"
0228 " SVG rendering of convert(1) is done by ImageMagick-native renderer.")
0229 if dot_Tpdf:
0230 kernellog.verbose(app, "use 'dot -Tpdf' for DOT -> PDF conversion")
0231 else:
0232 kernellog.verbose(app, "use 'dot -Tsvg' and convert(1) for DOT -> PDF conversion")
0233
0234
0235
0236
0237
0238 RENDER_MARKUP_EXT = {
0239
0240
0241 'DOT' : '.dot',
0242 'SVG' : '.svg'
0243 }
0244
0245 def convert_image(img_node, translator, src_fname=None):
0246 """Convert a image node for the builder.
0247
0248 Different builder prefer different image formats, e.g. *latex* builder
0249 prefer PDF while *html* builder prefer SVG format for images.
0250
0251 This function handles output image formats in dependence of source the
0252 format (of the image) and the translator's output format.
0253 """
0254 app = translator.builder.app
0255
0256 fname, in_ext = path.splitext(path.basename(img_node['uri']))
0257 if src_fname is None:
0258 src_fname = path.join(translator.builder.srcdir, img_node['uri'])
0259 if not path.exists(src_fname):
0260 src_fname = path.join(translator.builder.outdir, img_node['uri'])
0261
0262 dst_fname = None
0263
0264
0265
0266 kernellog.verbose(app, 'assert best format for: ' + img_node['uri'])
0267
0268 if in_ext == '.dot':
0269
0270 if not dot_cmd:
0271 kernellog.verbose(app,
0272 "dot from graphviz not available / include DOT raw.")
0273 img_node.replace_self(file2literal(src_fname))
0274
0275 elif translator.builder.format == 'latex':
0276 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
0277 img_node['uri'] = fname + '.pdf'
0278 img_node['candidates'] = {'*': fname + '.pdf'}
0279
0280
0281 elif translator.builder.format == 'html':
0282 dst_fname = path.join(
0283 translator.builder.outdir,
0284 translator.builder.imagedir,
0285 fname + '.svg')
0286 img_node['uri'] = path.join(
0287 translator.builder.imgpath, fname + '.svg')
0288 img_node['candidates'] = {
0289 '*': path.join(translator.builder.imgpath, fname + '.svg')}
0290
0291 else:
0292
0293 img_node.replace_self(file2literal(src_fname))
0294
0295 elif in_ext == '.svg':
0296
0297 if translator.builder.format == 'latex':
0298 if not inkscape_cmd and convert_cmd is None:
0299 kernellog.warn(app,
0300 "no SVG to PDF conversion available / include SVG raw."
0301 "\nIncluding large raw SVGs can cause xelatex error."
0302 "\nInstall Inkscape (preferred) or ImageMagick.")
0303 img_node.replace_self(file2literal(src_fname))
0304 else:
0305 dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
0306 img_node['uri'] = fname + '.pdf'
0307 img_node['candidates'] = {'*': fname + '.pdf'}
0308
0309 if dst_fname:
0310
0311 translator.builder.images.pop(img_node['uri'], None)
0312 _name = dst_fname[len(translator.builder.outdir) + 1:]
0313
0314 if isNewer(dst_fname, src_fname):
0315 kernellog.verbose(app,
0316 "convert: {out}/%s already exists and is newer" % _name)
0317
0318 else:
0319 ok = False
0320 mkdir(path.dirname(dst_fname))
0321
0322 if in_ext == '.dot':
0323 kernellog.verbose(app, 'convert DOT to: {out}/' + _name)
0324 if translator.builder.format == 'latex' and not dot_Tpdf:
0325 svg_fname = path.join(translator.builder.outdir, fname + '.svg')
0326 ok1 = dot2format(app, src_fname, svg_fname)
0327 ok2 = svg2pdf_by_rsvg(app, svg_fname, dst_fname)
0328 ok = ok1 and ok2
0329
0330 else:
0331 ok = dot2format(app, src_fname, dst_fname)
0332
0333 elif in_ext == '.svg':
0334 kernellog.verbose(app, 'convert SVG to: {out}/' + _name)
0335 ok = svg2pdf(app, src_fname, dst_fname)
0336
0337 if not ok:
0338 img_node.replace_self(file2literal(src_fname))
0339
0340
0341 def dot2format(app, dot_fname, out_fname):
0342 """Converts DOT file to ``out_fname`` using ``dot(1)``.
0343
0344 * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
0345 * ``out_fname`` pathname of the output file, including format extension
0346
0347 The *format extension* depends on the ``dot`` command (see ``man dot``
0348 option ``-Txxx``). Normally you will use one of the following extensions:
0349
0350 - ``.ps`` for PostScript,
0351 - ``.svg`` or ``svgz`` for Structured Vector Graphics,
0352 - ``.fig`` for XFIG graphics and
0353 - ``.png`` or ``gif`` for common bitmap graphics.
0354
0355 """
0356 out_format = path.splitext(out_fname)[1][1:]
0357 cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
0358 exit_code = 42
0359
0360 with open(out_fname, "w") as out:
0361 exit_code = subprocess.call(cmd, stdout = out)
0362 if exit_code != 0:
0363 kernellog.warn(app,
0364 "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
0365 return bool(exit_code == 0)
0366
0367 def svg2pdf(app, svg_fname, pdf_fname):
0368 """Converts SVG to PDF with ``inkscape(1)`` or ``convert(1)`` command.
0369
0370 Uses ``inkscape(1)`` from Inkscape (https://inkscape.org/) or ``convert(1)``
0371 from ImageMagick (https://www.imagemagick.org) for conversion.
0372 Returns ``True`` on success and ``False`` if an error occurred.
0373
0374 * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
0375 * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``)
0376
0377 """
0378 cmd = [convert_cmd, svg_fname, pdf_fname]
0379 cmd_name = 'convert(1)'
0380
0381 if inkscape_cmd:
0382 cmd_name = 'inkscape(1)'
0383 if inkscape_ver_one:
0384 cmd = [inkscape_cmd, '-o', pdf_fname, svg_fname]
0385 else:
0386 cmd = [inkscape_cmd, '-z', '--export-pdf=%s' % pdf_fname, svg_fname]
0387
0388 try:
0389 warning_msg = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
0390 exit_code = 0
0391 except subprocess.CalledProcessError as err:
0392 warning_msg = err.output
0393 exit_code = err.returncode
0394 pass
0395
0396 if exit_code != 0:
0397 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
0398 if warning_msg:
0399 kernellog.warn(app, "Warning msg from %s: %s"
0400 % (cmd_name, str(warning_msg, 'utf-8')))
0401 elif warning_msg:
0402 kernellog.verbose(app, "Warning msg from %s (likely harmless):\n%s"
0403 % (cmd_name, str(warning_msg, 'utf-8')))
0404
0405 return bool(exit_code == 0)
0406
0407 def svg2pdf_by_rsvg(app, svg_fname, pdf_fname):
0408 """Convert SVG to PDF with ``rsvg-convert(1)`` command.
0409
0410 * ``svg_fname`` pathname of input SVG file, including extension ``.svg``
0411 * ``pdf_fname`` pathname of output PDF file, including extension ``.pdf``
0412
0413 Input SVG file should be the one generated by ``dot2format()``.
0414 SVG -> PDF conversion is done by ``rsvg-convert(1)``.
0415
0416 If ``rsvg-convert(1)`` is unavailable, fall back to ``svg2pdf()``.
0417
0418 """
0419
0420 if rsvg_convert_cmd is None:
0421 ok = svg2pdf(app, svg_fname, pdf_fname)
0422 else:
0423 cmd = [rsvg_convert_cmd, '--format=pdf', '-o', pdf_fname, svg_fname]
0424
0425 exit_code = subprocess.call(cmd)
0426 if exit_code != 0:
0427 kernellog.warn(app, "Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
0428 ok = bool(exit_code == 0)
0429
0430 return ok
0431
0432
0433
0434
0435
0436 def visit_kernel_image(self, node):
0437 """Visitor of the ``kernel_image`` Node.
0438
0439 Handles the ``image`` child-node with the ``convert_image(...)``.
0440 """
0441 img_node = node[0]
0442 convert_image(img_node, self)
0443
0444 class kernel_image(nodes.image):
0445 """Node for ``kernel-image`` directive."""
0446 pass
0447
0448 class KernelImage(images.Image):
0449 u"""KernelImage directive
0450
0451 Earns everything from ``.. image::`` directive, except *remote URI* and
0452 *glob* pattern. The KernelImage wraps a image node into a
0453 kernel_image node. See ``visit_kernel_image``.
0454 """
0455
0456 def run(self):
0457 uri = self.arguments[0]
0458 if uri.endswith('.*') or uri.find('://') != -1:
0459 raise self.severe(
0460 'Error in "%s: %s": glob pattern and remote images are not allowed'
0461 % (self.name, uri))
0462 result = images.Image.run(self)
0463 if len(result) == 2 or isinstance(result[0], nodes.system_message):
0464 return result
0465 (image_node,) = result
0466
0467 node = kernel_image('', image_node)
0468 return [node]
0469
0470
0471
0472
0473 def visit_kernel_figure(self, node):
0474 """Visitor of the ``kernel_figure`` Node.
0475
0476 Handles the ``image`` child-node with the ``convert_image(...)``.
0477 """
0478 img_node = node[0][0]
0479 convert_image(img_node, self)
0480
0481 class kernel_figure(nodes.figure):
0482 """Node for ``kernel-figure`` directive."""
0483
0484 class KernelFigure(Figure):
0485 u"""KernelImage directive
0486
0487 Earns everything from ``.. figure::`` directive, except *remote URI* and
0488 *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure
0489 node. See ``visit_kernel_figure``.
0490 """
0491
0492 def run(self):
0493 uri = self.arguments[0]
0494 if uri.endswith('.*') or uri.find('://') != -1:
0495 raise self.severe(
0496 'Error in "%s: %s":'
0497 ' glob pattern and remote images are not allowed'
0498 % (self.name, uri))
0499 result = Figure.run(self)
0500 if len(result) == 2 or isinstance(result[0], nodes.system_message):
0501 return result
0502 (figure_node,) = result
0503
0504 node = kernel_figure('', figure_node)
0505 return [node]
0506
0507
0508
0509
0510
0511 def visit_kernel_render(self, node):
0512 """Visitor of the ``kernel_render`` Node.
0513
0514 If rendering tools available, save the markup of the ``literal_block`` child
0515 node into a file and replace the ``literal_block`` node with a new created
0516 ``image`` node, pointing to the saved markup file. Afterwards, handle the
0517 image child-node with the ``convert_image(...)``.
0518 """
0519 app = self.builder.app
0520 srclang = node.get('srclang')
0521
0522 kernellog.verbose(app, 'visit kernel-render node lang: "%s"' % (srclang))
0523
0524 tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
0525 if tmp_ext is None:
0526 kernellog.warn(app, 'kernel-render: "%s" unknown / include raw.' % (srclang))
0527 return
0528
0529 if not dot_cmd and tmp_ext == '.dot':
0530 kernellog.verbose(app, "dot from graphviz not available / include raw.")
0531 return
0532
0533 literal_block = node[0]
0534
0535 code = literal_block.astext()
0536 hashobj = code.encode('utf-8')
0537 fname = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
0538
0539 tmp_fname = path.join(
0540 self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
0541
0542 if not path.isfile(tmp_fname):
0543 mkdir(path.dirname(tmp_fname))
0544 with open(tmp_fname, "w") as out:
0545 out.write(code)
0546
0547 img_node = nodes.image(node.rawsource, **node.attributes)
0548 img_node['uri'] = path.join(self.builder.imgpath, fname + tmp_ext)
0549 img_node['candidates'] = {
0550 '*': path.join(self.builder.imgpath, fname + tmp_ext)}
0551
0552 literal_block.replace_self(img_node)
0553 convert_image(img_node, self, tmp_fname)
0554
0555
0556 class kernel_render(nodes.General, nodes.Inline, nodes.Element):
0557 """Node for ``kernel-render`` directive."""
0558 pass
0559
0560 class KernelRender(Figure):
0561 u"""KernelRender directive
0562
0563 Render content by external tool. Has all the options known from the
0564 *figure* directive, plus option ``caption``. If ``caption`` has a
0565 value, a figure node with the *caption* is inserted. If not, a image node is
0566 inserted.
0567
0568 The KernelRender directive wraps the text of the directive into a
0569 literal_block node and wraps it into a kernel_render node. See
0570 ``visit_kernel_render``.
0571 """
0572 has_content = True
0573 required_arguments = 1
0574 optional_arguments = 0
0575 final_argument_whitespace = False
0576
0577
0578 option_spec = Figure.option_spec.copy()
0579 option_spec['caption'] = directives.unchanged
0580
0581 def run(self):
0582 return [self.build_node()]
0583
0584 def build_node(self):
0585
0586 srclang = self.arguments[0].strip()
0587 if srclang not in RENDER_MARKUP_EXT.keys():
0588 return [self.state_machine.reporter.warning(
0589 'Unknown source language "%s", use one of: %s.' % (
0590 srclang, ",".join(RENDER_MARKUP_EXT.keys())),
0591 line=self.lineno)]
0592
0593 code = '\n'.join(self.content)
0594 if not code.strip():
0595 return [self.state_machine.reporter.warning(
0596 'Ignoring "%s" directive without content.' % (
0597 self.name),
0598 line=self.lineno)]
0599
0600 node = kernel_render()
0601 node['alt'] = self.options.get('alt','')
0602 node['srclang'] = srclang
0603 literal_node = nodes.literal_block(code, code)
0604 node += literal_node
0605
0606 caption = self.options.get('caption')
0607 if caption:
0608
0609 parsed = nodes.Element()
0610 self.state.nested_parse(
0611 ViewList([caption], source=''), self.content_offset, parsed)
0612 caption_node = nodes.caption(
0613 parsed[0].rawsource, '', *parsed[0].children)
0614 caption_node.source = parsed[0].source
0615 caption_node.line = parsed[0].line
0616
0617 figure_node = nodes.figure('', node)
0618 for k,v in self.options.items():
0619 figure_node[k] = v
0620 figure_node += caption_node
0621
0622 node = figure_node
0623
0624 return node
0625
0626 def add_kernel_figure_to_std_domain(app, doctree):
0627 """Add kernel-figure anchors to 'std' domain.
0628
0629 The ``StandardDomain.process_doc(..)`` method does not know how to resolve
0630 the caption (label) of ``kernel-figure`` directive (it only knows about
0631 standard nodes, e.g. table, figure etc.). Without any additional handling
0632 this will result in a 'undefined label' for kernel-figures.
0633
0634 This handle adds labels of kernel-figure to the 'std' domain labels.
0635 """
0636
0637 std = app.env.domains["std"]
0638 docname = app.env.docname
0639 labels = std.data["labels"]
0640
0641 for name, explicit in doctree.nametypes.items():
0642 if not explicit:
0643 continue
0644 labelid = doctree.nameids[name]
0645 if labelid is None:
0646 continue
0647 node = doctree.ids[labelid]
0648
0649 if node.tagname == 'kernel_figure':
0650 for n in node.next_node():
0651 if n.tagname == 'caption':
0652 sectname = clean_astext(n)
0653
0654 labels[name] = docname, labelid, sectname
0655 break