kfigure.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. # -*- coding: utf-8; mode: python -*-
  2. # pylint: disable=C0103, R0903, R0912, R0915
  3. u"""
  4. scalable figure and image handling
  5. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  6. Sphinx extension which implements scalable image handling.
  7. :copyright: Copyright (C) 2016 Markus Heiser
  8. :license: GPL Version 2, June 1991 see Linux/COPYING for details.
  9. The build for image formats depend on image's source format and output's
  10. destination format. This extension implement methods to simplify image
  11. handling from the author's POV. Directives like ``kernel-figure`` implement
  12. methods *to* always get the best output-format even if some tools are not
  13. installed. For more details take a look at ``convert_image(...)`` which is
  14. the core of all conversions.
  15. * ``.. kernel-image``: for image handling / a ``.. image::`` replacement
  16. * ``.. kernel-figure``: for figure handling / a ``.. figure::`` replacement
  17. * ``.. kernel-render``: for render markup / a concept to embed *render*
  18. markups (or languages). Supported markups (see ``RENDER_MARKUP_EXT``)
  19. - ``DOT``: render embedded Graphviz's **DOC**
  20. - ``SVG``: render embedded Scalable Vector Graphics (**SVG**)
  21. - ... *developable*
  22. Used tools:
  23. * ``dot(1)``: Graphviz (http://www.graphviz.org). If Graphviz is not
  24. available, the DOT language is inserted as literal-block.
  25. * SVG to PDF: To generate PDF, you need at least one of this tools:
  26. - ``convert(1)``: ImageMagick (https://www.imagemagick.org)
  27. List of customizations:
  28. * generate PDF from SVG / used by PDF (LaTeX) builder
  29. * generate SVG (html-builder) and PDF (latex-builder) from DOT files.
  30. DOT: see http://www.graphviz.org/content/dot-language
  31. """
  32. import os
  33. from os import path
  34. import subprocess
  35. from hashlib import sha1
  36. import sys
  37. from docutils import nodes
  38. from docutils.statemachine import ViewList
  39. from docutils.parsers.rst import directives
  40. from docutils.parsers.rst.directives import images
  41. import sphinx
  42. from sphinx.util.nodes import clean_astext
  43. from six import iteritems
  44. PY3 = sys.version_info[0] == 3
  45. if PY3:
  46. _unicode = str
  47. else:
  48. _unicode = unicode
  49. # Get Sphinx version
  50. major, minor, patch = sphinx.version_info[:3]
  51. if major == 1 and minor > 3:
  52. # patches.Figure only landed in Sphinx 1.4
  53. from sphinx.directives.patches import Figure # pylint: disable=C0413
  54. else:
  55. Figure = images.Figure
  56. __version__ = '1.0.0'
  57. # simple helper
  58. # -------------
  59. def which(cmd):
  60. """Searches the ``cmd`` in the ``PATH`` environment.
  61. This *which* searches the PATH for executable ``cmd`` . First match is
  62. returned, if nothing is found, ``None` is returned.
  63. """
  64. envpath = os.environ.get('PATH', None) or os.defpath
  65. for folder in envpath.split(os.pathsep):
  66. fname = folder + os.sep + cmd
  67. if path.isfile(fname):
  68. return fname
  69. def mkdir(folder, mode=0o775):
  70. if not path.isdir(folder):
  71. os.makedirs(folder, mode)
  72. def file2literal(fname):
  73. with open(fname, "r") as src:
  74. data = src.read()
  75. node = nodes.literal_block(data, data)
  76. return node
  77. def isNewer(path1, path2):
  78. """Returns True if ``path1`` is newer than ``path2``
  79. If ``path1`` exists and is newer than ``path2`` the function returns
  80. ``True`` is returned otherwise ``False``
  81. """
  82. return (path.exists(path1)
  83. and os.stat(path1).st_ctime > os.stat(path2).st_ctime)
  84. def pass_handle(self, node): # pylint: disable=W0613
  85. pass
  86. # setup conversion tools and sphinx extension
  87. # -------------------------------------------
  88. # Graphviz's dot(1) support
  89. dot_cmd = None
  90. # ImageMagick' convert(1) support
  91. convert_cmd = None
  92. def setup(app):
  93. # check toolchain first
  94. app.connect('builder-inited', setupTools)
  95. # image handling
  96. app.add_directive("kernel-image", KernelImage)
  97. app.add_node(kernel_image,
  98. html = (visit_kernel_image, pass_handle),
  99. latex = (visit_kernel_image, pass_handle),
  100. texinfo = (visit_kernel_image, pass_handle),
  101. text = (visit_kernel_image, pass_handle),
  102. man = (visit_kernel_image, pass_handle), )
  103. # figure handling
  104. app.add_directive("kernel-figure", KernelFigure)
  105. app.add_node(kernel_figure,
  106. html = (visit_kernel_figure, pass_handle),
  107. latex = (visit_kernel_figure, pass_handle),
  108. texinfo = (visit_kernel_figure, pass_handle),
  109. text = (visit_kernel_figure, pass_handle),
  110. man = (visit_kernel_figure, pass_handle), )
  111. # render handling
  112. app.add_directive('kernel-render', KernelRender)
  113. app.add_node(kernel_render,
  114. html = (visit_kernel_render, pass_handle),
  115. latex = (visit_kernel_render, pass_handle),
  116. texinfo = (visit_kernel_render, pass_handle),
  117. text = (visit_kernel_render, pass_handle),
  118. man = (visit_kernel_render, pass_handle), )
  119. app.connect('doctree-read', add_kernel_figure_to_std_domain)
  120. return dict(
  121. version = __version__,
  122. parallel_read_safe = True,
  123. parallel_write_safe = True
  124. )
  125. def setupTools(app):
  126. u"""
  127. Check available build tools and log some *verbose* messages.
  128. This function is called once, when the builder is initiated.
  129. """
  130. global dot_cmd, convert_cmd # pylint: disable=W0603
  131. app.verbose("kfigure: check installed tools ...")
  132. dot_cmd = which('dot')
  133. convert_cmd = which('convert')
  134. if dot_cmd:
  135. app.verbose("use dot(1) from: " + dot_cmd)
  136. else:
  137. app.warn("dot(1) not found, for better output quality install "
  138. "graphviz from http://www.graphviz.org")
  139. if convert_cmd:
  140. app.verbose("use convert(1) from: " + convert_cmd)
  141. else:
  142. app.warn(
  143. "convert(1) not found, for SVG to PDF conversion install "
  144. "ImageMagick (https://www.imagemagick.org)")
  145. # integrate conversion tools
  146. # --------------------------
  147. RENDER_MARKUP_EXT = {
  148. # The '.ext' must be handled by convert_image(..) function's *in_ext* input.
  149. # <name> : <.ext>
  150. 'DOT' : '.dot',
  151. 'SVG' : '.svg'
  152. }
  153. def convert_image(img_node, translator, src_fname=None):
  154. """Convert a image node for the builder.
  155. Different builder prefer different image formats, e.g. *latex* builder
  156. prefer PDF while *html* builder prefer SVG format for images.
  157. This function handles output image formats in dependence of source the
  158. format (of the image) and the translator's output format.
  159. """
  160. app = translator.builder.app
  161. fname, in_ext = path.splitext(path.basename(img_node['uri']))
  162. if src_fname is None:
  163. src_fname = path.join(translator.builder.srcdir, img_node['uri'])
  164. if not path.exists(src_fname):
  165. src_fname = path.join(translator.builder.outdir, img_node['uri'])
  166. dst_fname = None
  167. # in kernel builds, use 'make SPHINXOPTS=-v' to see verbose messages
  168. app.verbose('assert best format for: ' + img_node['uri'])
  169. if in_ext == '.dot':
  170. if not dot_cmd:
  171. app.verbose("dot from graphviz not available / include DOT raw.")
  172. img_node.replace_self(file2literal(src_fname))
  173. elif translator.builder.format == 'latex':
  174. dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
  175. img_node['uri'] = fname + '.pdf'
  176. img_node['candidates'] = {'*': fname + '.pdf'}
  177. elif translator.builder.format == 'html':
  178. dst_fname = path.join(
  179. translator.builder.outdir,
  180. translator.builder.imagedir,
  181. fname + '.svg')
  182. img_node['uri'] = path.join(
  183. translator.builder.imgpath, fname + '.svg')
  184. img_node['candidates'] = {
  185. '*': path.join(translator.builder.imgpath, fname + '.svg')}
  186. else:
  187. # all other builder formats will include DOT as raw
  188. img_node.replace_self(file2literal(src_fname))
  189. elif in_ext == '.svg':
  190. if translator.builder.format == 'latex':
  191. if convert_cmd is None:
  192. app.verbose("no SVG to PDF conversion available / include SVG raw.")
  193. img_node.replace_self(file2literal(src_fname))
  194. else:
  195. dst_fname = path.join(translator.builder.outdir, fname + '.pdf')
  196. img_node['uri'] = fname + '.pdf'
  197. img_node['candidates'] = {'*': fname + '.pdf'}
  198. if dst_fname:
  199. # the builder needs not to copy one more time, so pop it if exists.
  200. translator.builder.images.pop(img_node['uri'], None)
  201. _name = dst_fname[len(translator.builder.outdir) + 1:]
  202. if isNewer(dst_fname, src_fname):
  203. app.verbose("convert: {out}/%s already exists and is newer" % _name)
  204. else:
  205. ok = False
  206. mkdir(path.dirname(dst_fname))
  207. if in_ext == '.dot':
  208. app.verbose('convert DOT to: {out}/' + _name)
  209. ok = dot2format(app, src_fname, dst_fname)
  210. elif in_ext == '.svg':
  211. app.verbose('convert SVG to: {out}/' + _name)
  212. ok = svg2pdf(app, src_fname, dst_fname)
  213. if not ok:
  214. img_node.replace_self(file2literal(src_fname))
  215. def dot2format(app, dot_fname, out_fname):
  216. """Converts DOT file to ``out_fname`` using ``dot(1)``.
  217. * ``dot_fname`` pathname of the input DOT file, including extension ``.dot``
  218. * ``out_fname`` pathname of the output file, including format extension
  219. The *format extension* depends on the ``dot`` command (see ``man dot``
  220. option ``-Txxx``). Normally you will use one of the following extensions:
  221. - ``.ps`` for PostScript,
  222. - ``.svg`` or ``svgz`` for Structured Vector Graphics,
  223. - ``.fig`` for XFIG graphics and
  224. - ``.png`` or ``gif`` for common bitmap graphics.
  225. """
  226. out_format = path.splitext(out_fname)[1][1:]
  227. cmd = [dot_cmd, '-T%s' % out_format, dot_fname]
  228. exit_code = 42
  229. with open(out_fname, "w") as out:
  230. exit_code = subprocess.call(cmd, stdout = out)
  231. if exit_code != 0:
  232. app.warn("Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
  233. return bool(exit_code == 0)
  234. def svg2pdf(app, svg_fname, pdf_fname):
  235. """Converts SVG to PDF with ``convert(1)`` command.
  236. Uses ``convert(1)`` from ImageMagick (https://www.imagemagick.org) for
  237. conversion. Returns ``True`` on success and ``False`` if an error occurred.
  238. * ``svg_fname`` pathname of the input SVG file with extension (``.svg``)
  239. * ``pdf_name`` pathname of the output PDF file with extension (``.pdf``)
  240. """
  241. cmd = [convert_cmd, svg_fname, pdf_fname]
  242. # use stdout and stderr from parent
  243. exit_code = subprocess.call(cmd)
  244. if exit_code != 0:
  245. app.warn("Error #%d when calling: %s" % (exit_code, " ".join(cmd)))
  246. return bool(exit_code == 0)
  247. # image handling
  248. # ---------------------
  249. def visit_kernel_image(self, node): # pylint: disable=W0613
  250. """Visitor of the ``kernel_image`` Node.
  251. Handles the ``image`` child-node with the ``convert_image(...)``.
  252. """
  253. img_node = node[0]
  254. convert_image(img_node, self)
  255. class kernel_image(nodes.image):
  256. """Node for ``kernel-image`` directive."""
  257. pass
  258. class KernelImage(images.Image):
  259. u"""KernelImage directive
  260. Earns everything from ``.. image::`` directive, except *remote URI* and
  261. *glob* pattern. The KernelImage wraps a image node into a
  262. kernel_image node. See ``visit_kernel_image``.
  263. """
  264. def run(self):
  265. uri = self.arguments[0]
  266. if uri.endswith('.*') or uri.find('://') != -1:
  267. raise self.severe(
  268. 'Error in "%s: %s": glob pattern and remote images are not allowed'
  269. % (self.name, uri))
  270. result = images.Image.run(self)
  271. if len(result) == 2 or isinstance(result[0], nodes.system_message):
  272. return result
  273. (image_node,) = result
  274. # wrap image node into a kernel_image node / see visitors
  275. node = kernel_image('', image_node)
  276. return [node]
  277. # figure handling
  278. # ---------------------
  279. def visit_kernel_figure(self, node): # pylint: disable=W0613
  280. """Visitor of the ``kernel_figure`` Node.
  281. Handles the ``image`` child-node with the ``convert_image(...)``.
  282. """
  283. img_node = node[0][0]
  284. convert_image(img_node, self)
  285. class kernel_figure(nodes.figure):
  286. """Node for ``kernel-figure`` directive."""
  287. class KernelFigure(Figure):
  288. u"""KernelImage directive
  289. Earns everything from ``.. figure::`` directive, except *remote URI* and
  290. *glob* pattern. The KernelFigure wraps a figure node into a kernel_figure
  291. node. See ``visit_kernel_figure``.
  292. """
  293. def run(self):
  294. uri = self.arguments[0]
  295. if uri.endswith('.*') or uri.find('://') != -1:
  296. raise self.severe(
  297. 'Error in "%s: %s":'
  298. ' glob pattern and remote images are not allowed'
  299. % (self.name, uri))
  300. result = Figure.run(self)
  301. if len(result) == 2 or isinstance(result[0], nodes.system_message):
  302. return result
  303. (figure_node,) = result
  304. # wrap figure node into a kernel_figure node / see visitors
  305. node = kernel_figure('', figure_node)
  306. return [node]
  307. # render handling
  308. # ---------------------
  309. def visit_kernel_render(self, node):
  310. """Visitor of the ``kernel_render`` Node.
  311. If rendering tools available, save the markup of the ``literal_block`` child
  312. node into a file and replace the ``literal_block`` node with a new created
  313. ``image`` node, pointing to the saved markup file. Afterwards, handle the
  314. image child-node with the ``convert_image(...)``.
  315. """
  316. app = self.builder.app
  317. srclang = node.get('srclang')
  318. app.verbose('visit kernel-render node lang: "%s"' % (srclang))
  319. tmp_ext = RENDER_MARKUP_EXT.get(srclang, None)
  320. if tmp_ext is None:
  321. app.warn('kernel-render: "%s" unknown / include raw.' % (srclang))
  322. return
  323. if not dot_cmd and tmp_ext == '.dot':
  324. app.verbose("dot from graphviz not available / include raw.")
  325. return
  326. literal_block = node[0]
  327. code = literal_block.astext()
  328. hashobj = code.encode('utf-8') # str(node.attributes)
  329. fname = path.join('%s-%s' % (srclang, sha1(hashobj).hexdigest()))
  330. tmp_fname = path.join(
  331. self.builder.outdir, self.builder.imagedir, fname + tmp_ext)
  332. if not path.isfile(tmp_fname):
  333. mkdir(path.dirname(tmp_fname))
  334. with open(tmp_fname, "w") as out:
  335. out.write(code)
  336. img_node = nodes.image(node.rawsource, **node.attributes)
  337. img_node['uri'] = path.join(self.builder.imgpath, fname + tmp_ext)
  338. img_node['candidates'] = {
  339. '*': path.join(self.builder.imgpath, fname + tmp_ext)}
  340. literal_block.replace_self(img_node)
  341. convert_image(img_node, self, tmp_fname)
  342. class kernel_render(nodes.General, nodes.Inline, nodes.Element):
  343. """Node for ``kernel-render`` directive."""
  344. pass
  345. class KernelRender(Figure):
  346. u"""KernelRender directive
  347. Render content by external tool. Has all the options known from the
  348. *figure* directive, plus option ``caption``. If ``caption`` has a
  349. value, a figure node with the *caption* is inserted. If not, a image node is
  350. inserted.
  351. The KernelRender directive wraps the text of the directive into a
  352. literal_block node and wraps it into a kernel_render node. See
  353. ``visit_kernel_render``.
  354. """
  355. has_content = True
  356. required_arguments = 1
  357. optional_arguments = 0
  358. final_argument_whitespace = False
  359. # earn options from 'figure'
  360. option_spec = Figure.option_spec.copy()
  361. option_spec['caption'] = directives.unchanged
  362. def run(self):
  363. return [self.build_node()]
  364. def build_node(self):
  365. srclang = self.arguments[0].strip()
  366. if srclang not in RENDER_MARKUP_EXT.keys():
  367. return [self.state_machine.reporter.warning(
  368. 'Unknown source language "%s", use one of: %s.' % (
  369. srclang, ",".join(RENDER_MARKUP_EXT.keys())),
  370. line=self.lineno)]
  371. code = '\n'.join(self.content)
  372. if not code.strip():
  373. return [self.state_machine.reporter.warning(
  374. 'Ignoring "%s" directive without content.' % (
  375. self.name),
  376. line=self.lineno)]
  377. node = kernel_render()
  378. node['alt'] = self.options.get('alt','')
  379. node['srclang'] = srclang
  380. literal_node = nodes.literal_block(code, code)
  381. node += literal_node
  382. caption = self.options.get('caption')
  383. if caption:
  384. # parse caption's content
  385. parsed = nodes.Element()
  386. self.state.nested_parse(
  387. ViewList([caption], source=''), self.content_offset, parsed)
  388. caption_node = nodes.caption(
  389. parsed[0].rawsource, '', *parsed[0].children)
  390. caption_node.source = parsed[0].source
  391. caption_node.line = parsed[0].line
  392. figure_node = nodes.figure('', node)
  393. for k,v in self.options.items():
  394. figure_node[k] = v
  395. figure_node += caption_node
  396. node = figure_node
  397. return node
  398. def add_kernel_figure_to_std_domain(app, doctree):
  399. """Add kernel-figure anchors to 'std' domain.
  400. The ``StandardDomain.process_doc(..)`` method does not know how to resolve
  401. the caption (label) of ``kernel-figure`` directive (it only knows about
  402. standard nodes, e.g. table, figure etc.). Without any additional handling
  403. this will result in a 'undefined label' for kernel-figures.
  404. This handle adds labels of kernel-figure to the 'std' domain labels.
  405. """
  406. std = app.env.domains["std"]
  407. docname = app.env.docname
  408. labels = std.data["labels"]
  409. for name, explicit in iteritems(doctree.nametypes):
  410. if not explicit:
  411. continue
  412. labelid = doctree.nameids[name]
  413. if labelid is None:
  414. continue
  415. node = doctree.ids[labelid]
  416. if node.tagname == 'kernel_figure':
  417. for n in node.next_node():
  418. if n.tagname == 'caption':
  419. sectname = clean_astext(n)
  420. # add label to std domain
  421. labels[name] = docname, labelid, sectname
  422. break