You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
413 lines
15 KiB
413 lines
15 KiB
"""
|
|
sphinx.ext.graphviz
|
|
~~~~~~~~~~~~~~~~~~~
|
|
|
|
Allow graphviz-formatted graphs to be included in Sphinx-generated
|
|
documents inline.
|
|
|
|
:copyright: Copyright 2007-2020 by the Sphinx team, see AUTHORS.
|
|
:license: BSD, see LICENSE for details.
|
|
"""
|
|
|
|
import posixpath
|
|
import re
|
|
import subprocess
|
|
from os import path
|
|
from subprocess import PIPE, CalledProcessError
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
from docutils import nodes
|
|
from docutils.nodes import Node
|
|
from docutils.parsers.rst import Directive, directives
|
|
|
|
import sphinx
|
|
from sphinx.application import Sphinx
|
|
from sphinx.errors import SphinxError
|
|
from sphinx.locale import _, __
|
|
from sphinx.util import logging, sha1
|
|
from sphinx.util.docutils import SphinxDirective, SphinxTranslator
|
|
from sphinx.util.fileutil import copy_asset
|
|
from sphinx.util.i18n import search_image_for_language
|
|
from sphinx.util.nodes import set_source_info
|
|
from sphinx.util.osutil import ensuredir
|
|
from sphinx.writers.html import HTMLTranslator
|
|
from sphinx.writers.latex import LaTeXTranslator
|
|
from sphinx.writers.manpage import ManualPageTranslator
|
|
from sphinx.writers.texinfo import TexinfoTranslator
|
|
from sphinx.writers.text import TextTranslator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GraphvizError(SphinxError):
|
|
category = 'Graphviz error'
|
|
|
|
|
|
class ClickableMapDefinition:
|
|
"""A manipulator for clickable map file of graphviz."""
|
|
maptag_re = re.compile('<map id="(.*?)"')
|
|
href_re = re.compile('href=".*?"')
|
|
|
|
def __init__(self, filename: str, content: str, dot: str = '') -> None:
|
|
self.id = None # type: str
|
|
self.filename = filename
|
|
self.content = content.splitlines()
|
|
self.clickable = [] # type: List[str]
|
|
|
|
self.parse(dot=dot)
|
|
|
|
def parse(self, dot: str = None) -> None:
|
|
matched = self.maptag_re.match(self.content[0])
|
|
if not matched:
|
|
raise GraphvizError('Invalid clickable map file found: %s' % self.filename)
|
|
|
|
self.id = matched.group(1)
|
|
if self.id == '%3':
|
|
# graphviz generates wrong ID if graph name not specified
|
|
# https://gitlab.com/graphviz/graphviz/issues/1327
|
|
hashed = sha1(dot.encode()).hexdigest()
|
|
self.id = 'grapviz%s' % hashed[-10:]
|
|
self.content[0] = self.content[0].replace('%3', self.id)
|
|
|
|
for line in self.content:
|
|
if self.href_re.search(line):
|
|
self.clickable.append(line)
|
|
|
|
def generate_clickable_map(self) -> str:
|
|
"""Generate clickable map tags if clickable item exists.
|
|
|
|
If not exists, this only returns empty string.
|
|
"""
|
|
if self.clickable:
|
|
return '\n'.join([self.content[0]] + self.clickable + [self.content[-1]])
|
|
else:
|
|
return ''
|
|
|
|
|
|
class graphviz(nodes.General, nodes.Inline, nodes.Element):
|
|
pass
|
|
|
|
|
|
def figure_wrapper(directive: Directive, node: graphviz, caption: str) -> nodes.figure:
|
|
figure_node = nodes.figure('', node)
|
|
if 'align' in node:
|
|
figure_node['align'] = node.attributes.pop('align')
|
|
|
|
inodes, messages = directive.state.inline_text(caption, directive.lineno)
|
|
caption_node = nodes.caption(caption, '', *inodes)
|
|
caption_node.extend(messages)
|
|
set_source_info(directive, caption_node)
|
|
figure_node += caption_node
|
|
return figure_node
|
|
|
|
|
|
def align_spec(argument: Any) -> str:
|
|
return directives.choice(argument, ('left', 'center', 'right'))
|
|
|
|
|
|
class Graphviz(SphinxDirective):
|
|
"""
|
|
Directive to insert arbitrary dot markup.
|
|
"""
|
|
has_content = True
|
|
required_arguments = 0
|
|
optional_arguments = 1
|
|
final_argument_whitespace = False
|
|
option_spec = {
|
|
'alt': directives.unchanged,
|
|
'align': align_spec,
|
|
'caption': directives.unchanged,
|
|
'layout': directives.unchanged,
|
|
'graphviz_dot': directives.unchanged, # an old alias of `layout` option
|
|
'name': directives.unchanged,
|
|
'class': directives.class_option,
|
|
}
|
|
|
|
def run(self) -> List[Node]:
|
|
if self.arguments:
|
|
document = self.state.document
|
|
if self.content:
|
|
return [document.reporter.warning(
|
|
__('Graphviz directive cannot have both content and '
|
|
'a filename argument'), line=self.lineno)]
|
|
argument = search_image_for_language(self.arguments[0], self.env)
|
|
rel_filename, filename = self.env.relfn2path(argument)
|
|
self.env.note_dependency(rel_filename)
|
|
try:
|
|
with open(filename, encoding='utf-8') as fp:
|
|
dotcode = fp.read()
|
|
except OSError:
|
|
return [document.reporter.warning(
|
|
__('External Graphviz file %r not found or reading '
|
|
'it failed') % filename, line=self.lineno)]
|
|
else:
|
|
dotcode = '\n'.join(self.content)
|
|
if not dotcode.strip():
|
|
return [self.state_machine.reporter.warning(
|
|
__('Ignoring "graphviz" directive without content.'),
|
|
line=self.lineno)]
|
|
node = graphviz()
|
|
node['code'] = dotcode
|
|
node['options'] = {'docname': self.env.docname}
|
|
|
|
if 'graphviz_dot' in self.options:
|
|
node['options']['graphviz_dot'] = self.options['graphviz_dot']
|
|
if 'layout' in self.options:
|
|
node['options']['graphviz_dot'] = self.options['layout']
|
|
if 'alt' in self.options:
|
|
node['alt'] = self.options['alt']
|
|
if 'align' in self.options:
|
|
node['align'] = self.options['align']
|
|
if 'class' in self.options:
|
|
node['classes'] = self.options['class']
|
|
|
|
if 'caption' not in self.options:
|
|
self.add_name(node)
|
|
return [node]
|
|
else:
|
|
figure = figure_wrapper(self, node, self.options['caption'])
|
|
self.add_name(figure)
|
|
return [figure]
|
|
|
|
|
|
class GraphvizSimple(SphinxDirective):
|
|
"""
|
|
Directive to insert arbitrary dot markup.
|
|
"""
|
|
has_content = True
|
|
required_arguments = 1
|
|
optional_arguments = 0
|
|
final_argument_whitespace = False
|
|
option_spec = {
|
|
'alt': directives.unchanged,
|
|
'align': align_spec,
|
|
'caption': directives.unchanged,
|
|
'layout': directives.unchanged,
|
|
'graphviz_dot': directives.unchanged, # an old alias of `layout` option
|
|
'name': directives.unchanged,
|
|
'class': directives.class_option,
|
|
}
|
|
|
|
def run(self) -> List[Node]:
|
|
node = graphviz()
|
|
node['code'] = '%s %s {\n%s\n}\n' % \
|
|
(self.name, self.arguments[0], '\n'.join(self.content))
|
|
node['options'] = {'docname': self.env.docname}
|
|
if 'graphviz_dot' in self.options:
|
|
node['options']['graphviz_dot'] = self.options['graphviz_dot']
|
|
if 'layout' in self.options:
|
|
node['options']['graphviz_dot'] = self.options['layout']
|
|
if 'alt' in self.options:
|
|
node['alt'] = self.options['alt']
|
|
if 'align' in self.options:
|
|
node['align'] = self.options['align']
|
|
if 'class' in self.options:
|
|
node['classes'] = self.options['class']
|
|
|
|
if 'caption' not in self.options:
|
|
self.add_name(node)
|
|
return [node]
|
|
else:
|
|
figure = figure_wrapper(self, node, self.options['caption'])
|
|
self.add_name(figure)
|
|
return [figure]
|
|
|
|
|
|
def render_dot(self: SphinxTranslator, code: str, options: Dict,
|
|
format: str, prefix: str = 'graphviz') -> Tuple[str, str]:
|
|
"""Render graphviz code into a PNG or PDF output file."""
|
|
graphviz_dot = options.get('graphviz_dot', self.builder.config.graphviz_dot)
|
|
hashkey = (code + str(options) + str(graphviz_dot) +
|
|
str(self.builder.config.graphviz_dot_args)).encode()
|
|
|
|
fname = '%s-%s.%s' % (prefix, sha1(hashkey).hexdigest(), format)
|
|
relfn = posixpath.join(self.builder.imgpath, fname)
|
|
outfn = path.join(self.builder.outdir, self.builder.imagedir, fname)
|
|
|
|
if path.isfile(outfn):
|
|
return relfn, outfn
|
|
|
|
if (hasattr(self.builder, '_graphviz_warned_dot') and
|
|
self.builder._graphviz_warned_dot.get(graphviz_dot)): # type: ignore # NOQA
|
|
return None, None
|
|
|
|
ensuredir(path.dirname(outfn))
|
|
|
|
dot_args = [graphviz_dot]
|
|
dot_args.extend(self.builder.config.graphviz_dot_args)
|
|
dot_args.extend(['-T' + format, '-o' + outfn])
|
|
|
|
docname = options.get('docname', 'index')
|
|
cwd = path.dirname(path.join(self.builder.srcdir, docname))
|
|
|
|
if format == 'png':
|
|
dot_args.extend(['-Tcmapx', '-o%s.map' % outfn])
|
|
|
|
try:
|
|
ret = subprocess.run(dot_args, input=code.encode(), stdout=PIPE, stderr=PIPE,
|
|
cwd=cwd, check=True)
|
|
if not path.isfile(outfn):
|
|
raise GraphvizError(__('dot did not produce an output file:\n[stderr]\n%r\n'
|
|
'[stdout]\n%r') % (ret.stderr, ret.stdout))
|
|
return relfn, outfn
|
|
except OSError:
|
|
logger.warning(__('dot command %r cannot be run (needed for graphviz '
|
|
'output), check the graphviz_dot setting'), graphviz_dot)
|
|
if not hasattr(self.builder, '_graphviz_warned_dot'):
|
|
self.builder._graphviz_warned_dot = {} # type: ignore
|
|
self.builder._graphviz_warned_dot[graphviz_dot] = True # type: ignore
|
|
return None, None
|
|
except CalledProcessError as exc:
|
|
raise GraphvizError(__('dot exited with error:\n[stderr]\n%r\n'
|
|
'[stdout]\n%r') % (exc.stderr, exc.stdout)) from exc
|
|
|
|
|
|
def render_dot_html(self: HTMLTranslator, node: graphviz, code: str, options: Dict,
|
|
prefix: str = 'graphviz', imgcls: str = None, alt: str = None
|
|
) -> Tuple[str, str]:
|
|
format = self.builder.config.graphviz_output_format
|
|
try:
|
|
if format not in ('png', 'svg'):
|
|
raise GraphvizError(__("graphviz_output_format must be one of 'png', "
|
|
"'svg', but is %r") % format)
|
|
fname, outfn = render_dot(self, code, options, format, prefix)
|
|
except GraphvizError as exc:
|
|
logger.warning(__('dot code %r: %s'), code, exc)
|
|
raise nodes.SkipNode from exc
|
|
|
|
classes = [imgcls, 'graphviz'] + node.get('classes', [])
|
|
imgcls = ' '.join(filter(None, classes))
|
|
|
|
if fname is None:
|
|
self.body.append(self.encode(code))
|
|
else:
|
|
if alt is None:
|
|
alt = node.get('alt', self.encode(code).strip())
|
|
if 'align' in node:
|
|
self.body.append('<div align="%s" class="align-%s">' %
|
|
(node['align'], node['align']))
|
|
if format == 'svg':
|
|
self.body.append('<div class="graphviz">')
|
|
self.body.append('<object data="%s" type="image/svg+xml" class="%s">\n' %
|
|
(fname, imgcls))
|
|
self.body.append('<p class="warning">%s</p>' % alt)
|
|
self.body.append('</object></div>\n')
|
|
else:
|
|
with open(outfn + '.map', encoding='utf-8') as mapfile:
|
|
imgmap = ClickableMapDefinition(outfn + '.map', mapfile.read(), dot=code)
|
|
if imgmap.clickable:
|
|
# has a map
|
|
self.body.append('<div class="graphviz">')
|
|
self.body.append('<img src="%s" alt="%s" usemap="#%s" class="%s" />' %
|
|
(fname, alt, imgmap.id, imgcls))
|
|
self.body.append('</div>\n')
|
|
self.body.append(imgmap.generate_clickable_map())
|
|
else:
|
|
# nothing in image map
|
|
self.body.append('<div class="graphviz">')
|
|
self.body.append('<img src="%s" alt="%s" class="%s" />' %
|
|
(fname, alt, imgcls))
|
|
self.body.append('</div>\n')
|
|
if 'align' in node:
|
|
self.body.append('</div>\n')
|
|
|
|
raise nodes.SkipNode
|
|
|
|
|
|
def html_visit_graphviz(self: HTMLTranslator, node: graphviz) -> None:
|
|
render_dot_html(self, node, node['code'], node['options'])
|
|
|
|
|
|
def render_dot_latex(self: LaTeXTranslator, node: graphviz, code: str,
|
|
options: Dict, prefix: str = 'graphviz') -> None:
|
|
try:
|
|
fname, outfn = render_dot(self, code, options, 'pdf', prefix)
|
|
except GraphvizError as exc:
|
|
logger.warning(__('dot code %r: %s'), code, exc)
|
|
raise nodes.SkipNode from exc
|
|
|
|
is_inline = self.is_inline(node)
|
|
|
|
if not is_inline:
|
|
pre = ''
|
|
post = ''
|
|
if 'align' in node:
|
|
if node['align'] == 'left':
|
|
pre = '{'
|
|
post = r'\hspace*{\fill}}'
|
|
elif node['align'] == 'right':
|
|
pre = r'{\hspace*{\fill}'
|
|
post = '}'
|
|
elif node['align'] == 'center':
|
|
pre = r'{\hfill'
|
|
post = r'\hspace*{\fill}}'
|
|
self.body.append('\n%s' % pre)
|
|
|
|
self.body.append(r'\sphinxincludegraphics[]{%s}' % fname)
|
|
|
|
if not is_inline:
|
|
self.body.append('%s\n' % post)
|
|
|
|
raise nodes.SkipNode
|
|
|
|
|
|
def latex_visit_graphviz(self: LaTeXTranslator, node: graphviz) -> None:
|
|
render_dot_latex(self, node, node['code'], node['options'])
|
|
|
|
|
|
def render_dot_texinfo(self: TexinfoTranslator, node: graphviz, code: str,
|
|
options: Dict, prefix: str = 'graphviz') -> None:
|
|
try:
|
|
fname, outfn = render_dot(self, code, options, 'png', prefix)
|
|
except GraphvizError as exc:
|
|
logger.warning(__('dot code %r: %s'), code, exc)
|
|
raise nodes.SkipNode from exc
|
|
if fname is not None:
|
|
self.body.append('@image{%s,,,[graphviz],png}\n' % fname[:-4])
|
|
raise nodes.SkipNode
|
|
|
|
|
|
def texinfo_visit_graphviz(self: TexinfoTranslator, node: graphviz) -> None:
|
|
render_dot_texinfo(self, node, node['code'], node['options'])
|
|
|
|
|
|
def text_visit_graphviz(self: TextTranslator, node: graphviz) -> None:
|
|
if 'alt' in node.attributes:
|
|
self.add_text(_('[graph: %s]') % node['alt'])
|
|
else:
|
|
self.add_text(_('[graph]'))
|
|
raise nodes.SkipNode
|
|
|
|
|
|
def man_visit_graphviz(self: ManualPageTranslator, node: graphviz) -> None:
|
|
if 'alt' in node.attributes:
|
|
self.body.append(_('[graph: %s]') % node['alt'])
|
|
else:
|
|
self.body.append(_('[graph]'))
|
|
raise nodes.SkipNode
|
|
|
|
|
|
def on_build_finished(app: Sphinx, exc: Exception) -> None:
|
|
if exc is None and app.builder.format == 'html':
|
|
src = path.join(sphinx.package_dir, 'templates', 'graphviz', 'graphviz.css')
|
|
dst = path.join(app.outdir, '_static')
|
|
copy_asset(src, dst)
|
|
|
|
|
|
def setup(app: Sphinx) -> Dict[str, Any]:
|
|
app.add_node(graphviz,
|
|
html=(html_visit_graphviz, None),
|
|
latex=(latex_visit_graphviz, None),
|
|
texinfo=(texinfo_visit_graphviz, None),
|
|
text=(text_visit_graphviz, None),
|
|
man=(man_visit_graphviz, None))
|
|
app.add_directive('graphviz', Graphviz)
|
|
app.add_directive('graph', GraphvizSimple)
|
|
app.add_directive('digraph', GraphvizSimple)
|
|
app.add_config_value('graphviz_dot', 'dot', 'html')
|
|
app.add_config_value('graphviz_dot_args', [], 'html')
|
|
app.add_config_value('graphviz_output_format', 'png', 'html')
|
|
app.add_css_file('graphviz.css')
|
|
app.connect('build-finished', on_build_finished)
|
|
return {'version': sphinx.__display_version__, 'parallel_read_safe': True}
|