summaryrefslogtreecommitdiffstats
path: root/sphinx/ext/graphviz.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/ext/graphviz.py')
-rw-r--r--sphinx/ext/graphviz.py467
1 files changed, 467 insertions, 0 deletions
diff --git a/sphinx/ext/graphviz.py b/sphinx/ext/graphviz.py
new file mode 100644
index 0000000..528bf30
--- /dev/null
+++ b/sphinx/ext/graphviz.py
@@ -0,0 +1,467 @@
+"""Allow graphviz-formatted graphs to be included inline in generated documents.
+"""
+
+from __future__ import annotations
+
+import posixpath
+import re
+import subprocess
+import xml.etree.ElementTree as ET
+from hashlib import sha1
+from itertools import chain
+from os import path
+from subprocess import CalledProcessError
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlsplit, urlunsplit
+
+from docutils import nodes
+from docutils.parsers.rst import Directive, directives
+
+import sphinx
+from sphinx.errors import SphinxError
+from sphinx.locale import _, __
+from sphinx.util import logging
+from sphinx.util.docutils import SphinxDirective
+from sphinx.util.i18n import search_image_for_language
+from sphinx.util.nodes import set_source_info
+from sphinx.util.osutil import ensuredir
+
+if TYPE_CHECKING:
+ from docutils.nodes import Node
+
+ from sphinx.application import Sphinx
+ from sphinx.config import Config
+ from sphinx.util.typing import OptionSpec
+ from sphinx.writers.html import HTML5Translator
+ 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: str | None = None
+ self.filename = filename
+ self.content = content.splitlines()
+ self.clickable: list[str] = []
+
+ self.parse(dot=dot)
+
+ def parse(self, dot: str) -> 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(), usedforsecurity=False).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: OptionSpec = {
+ '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)
+ rel_filename = None
+ 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 rel_filename:
+ node['filename'] = rel_filename
+
+ 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: OptionSpec = {
+ '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 fix_svg_relative_paths(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
+ filepath: str) -> None:
+ """Change relative links in generated svg files to be relative to imgpath."""
+ tree = ET.parse(filepath) # NoQA: S314
+ root = tree.getroot()
+ ns = {'svg': 'http://www.w3.org/2000/svg', 'xlink': 'http://www.w3.org/1999/xlink'}
+ href_name = '{http://www.w3.org/1999/xlink}href'
+ modified = False
+
+ for element in chain(
+ root.findall('.//svg:image[@xlink:href]', ns),
+ root.findall('.//svg:a[@xlink:href]', ns),
+ ):
+ scheme, hostname, rel_uri, query, fragment = urlsplit(element.attrib[href_name])
+ if hostname:
+ # not a relative link
+ continue
+
+ docname = self.builder.env.path2doc(self.document["source"])
+ if docname is None:
+ # This shouldn't happen!
+ continue
+ doc_dir = self.builder.app.outdir.joinpath(docname).resolve().parent
+
+ old_path = doc_dir / rel_uri
+ img_path = doc_dir / self.builder.imgpath
+ new_path = path.relpath(old_path, start=img_path)
+ modified_url = urlunsplit((scheme, hostname, new_path, query, fragment))
+
+ element.set(href_name, modified_url)
+ modified = True
+
+ if modified:
+ tree.write(filepath)
+
+
+def render_dot(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
+ code: str, options: dict, format: str,
+ prefix: str = 'graphviz', filename: str | None = None,
+ ) -> tuple[str | None, str | None]:
+ """Render graphviz code into a PNG or PDF output file."""
+ graphviz_dot = options.get('graphviz_dot', self.builder.config.graphviz_dot)
+ if not graphviz_dot:
+ raise GraphvizError(
+ __('graphviz_dot executable path must be set! %r') % graphviz_dot,
+ )
+ hashkey = (code + str(options) + str(graphviz_dot) +
+ str(self.builder.config.graphviz_dot_args)).encode()
+
+ fname = f'{prefix}-{sha1(hashkey, usedforsecurity=False).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)):
+ 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')
+ if filename:
+ cwd = path.dirname(path.join(self.builder.srcdir, filename))
+ else:
+ 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(), capture_output=True,
+ cwd=cwd, check=True)
+ 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[union-attr]
+ self.builder._graphviz_warned_dot[graphviz_dot] = True
+ 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
+ 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))
+
+ if format == 'svg':
+ fix_svg_relative_paths(self, outfn)
+
+ return relfn, outfn
+
+
+def render_dot_html(self: HTML5Translator, node: graphviz, code: str, options: dict,
+ prefix: str = 'graphviz', imgcls: str | None = None,
+ alt: str | None = None, filename: str | None = 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, filename)
+ 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:
+ assert outfn is not None
+ 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: HTML5Translator, node: graphviz) -> None:
+ render_dot_html(self, node, node['code'], node['options'], filename=node.get('filename'))
+
+
+def render_dot_latex(self: LaTeXTranslator, node: graphviz, code: str,
+ options: dict, prefix: str = 'graphviz', filename: str | None = None,
+ ) -> None:
+ try:
+ fname, outfn = render_dot(self, code, options, 'pdf', prefix, filename)
+ 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'], filename=node.get('filename'))
+
+
+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_config_inited(_app: Sphinx, config: Config) -> None:
+ css_path = path.join(sphinx.package_dir, 'templates', 'graphviz', 'graphviz.css')
+ config.html_static_path.append(css_path)
+
+
+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('config-inited', on_config_inited)
+ return {'version': sphinx.__display_version__, 'parallel_read_safe': True}