"""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(' 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('
' % (node['align'], node['align'])) if format == 'svg': self.body.append('
') self.body.append('\n' % (fname, imgcls)) self.body.append('

%s

' % alt) self.body.append('
\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('
') self.body.append('%s' % (fname, alt, imgmap.id, imgcls)) self.body.append('
\n') self.body.append(imgmap.generate_clickable_map()) else: # nothing in image map self.body.append('
') self.body.append('%s' % (fname, alt, imgcls)) self.body.append('
\n') if 'align' in node: self.body.append('
\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}