From cf7da1843c45a4c2df7a749f7886a2d2ba0ee92a Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Mon, 15 Apr 2024 19:25:40 +0200 Subject: Adding upstream version 7.2.6. Signed-off-by: Daniel Baumann --- sphinx/ext/graphviz.py | 467 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 sphinx/ext/graphviz.py (limited to 'sphinx/ext/graphviz.py') 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(' 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} -- cgit v1.2.3