From 6178163cb1613a6f00fa5a31370d6c1d33cbe28f Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Fri, 28 Jul 2023 06:47:23 +0200 Subject: [PATCH] Fix relative references in SVGs generated by ``sphinx.ext.graphviz`` (#11078) Co-authored-by: Ralf Grubenmann Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com> --- CHANGES | 3 ++ sphinx/ext/graphviz.py | 46 +++++++++++++++++-- .../test-ext-graphviz/_static/images/test.svg | 8 ++++ tests/roots/test-ext-graphviz/conf.py | 1 + tests/roots/test-ext-graphviz/index.rst | 10 ++++ tests/test_ext_graphviz.py | 11 +++++ 6 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 tests/roots/test-ext-graphviz/_static/images/test.svg diff --git a/CHANGES b/CHANGES index 8cd074da2e5..1b6d26b3369 100644 --- a/CHANGES +++ b/CHANGES @@ -28,6 +28,9 @@ Features added Bugs fixed ---------- +* #11077: graphviz: Fix relative links from within the graph. + Patch by Ralf Grubenmann. + Testing ------- diff --git a/sphinx/ext/graphviz.py b/sphinx/ext/graphviz.py index 3701a6e5922..8b21d9e3b9a 100644 --- a/sphinx/ext/graphviz.py +++ b/sphinx/ext/graphviz.py @@ -6,10 +6,13 @@ 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.nodes import Node @@ -214,6 +217,37 @@ def run(self) -> list[Node]: return [figure] +def fix_svg_relative_paths(self: SphinxTranslator, 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, url, query, fragment = urlsplit(element.attrib[href_name]) + if hostname: + # not a relative link + continue + + old_path = path.join(self.builder.outdir, url) + new_path = path.relpath( + old_path, + start=path.join(self.builder.outdir, self.builder.imgpath), + ) + 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: SphinxTranslator, code: str, options: dict, format: str, prefix: str = 'graphviz', filename: str | None = None, ) -> tuple[str | None, str | None]: @@ -251,10 +285,6 @@ def render_dot(self: SphinxTranslator, code: str, options: dict, format: str, try: ret = subprocess.run(dot_args, input=code.encode(), capture_output=True, 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) @@ -265,6 +295,14 @@ def render_dot(self: SphinxTranslator, code: str, options: dict, format: str, 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, diff --git a/tests/roots/test-ext-graphviz/_static/images/test.svg b/tests/roots/test-ext-graphviz/_static/images/test.svg new file mode 100644 index 00000000000..6134f44a5f3 --- /dev/null +++ b/tests/roots/test-ext-graphviz/_static/images/test.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/tests/roots/test-ext-graphviz/conf.py b/tests/roots/test-ext-graphviz/conf.py index cd0492924df..317457ff95b 100644 --- a/tests/roots/test-ext-graphviz/conf.py +++ b/tests/roots/test-ext-graphviz/conf.py @@ -1,2 +1,3 @@ extensions = ['sphinx.ext.graphviz'] exclude_patterns = ['_build'] +html_static_path = ["_static"] diff --git a/tests/roots/test-ext-graphviz/index.rst b/tests/roots/test-ext-graphviz/index.rst index e6db9b220ae..cb0f06936f7 100644 --- a/tests/roots/test-ext-graphviz/index.rst +++ b/tests/roots/test-ext-graphviz/index.rst @@ -31,3 +31,13 @@ Hello |graph| graphviz world :align: center centered + +.. graphviz:: + :align: center + + digraph test { + foo [label="foo", URL="#graphviz", target="_parent"] + bar [label="bar", image="./_static/images/test.svg"] + baz [label="baz", URL="./_static/images/test.svg"] + foo -> bar -> baz + } diff --git a/tests/test_ext_graphviz.py b/tests/test_ext_graphviz.py index 28591674b1e..44e5d8429e3 100644 --- a/tests/test_ext_graphviz.py +++ b/tests/test_ext_graphviz.py @@ -82,6 +82,17 @@ def test_graphviz_svg_html(app, status, warning): r'') assert re.search(html, content, re.S) + image_re = r'.*data="([^"]+)".*?digraph test' + image_path_match = re.search(image_re, content, re.S) + assert image_path_match + + image_path = image_path_match.group(1) + image_content = (app.outdir / image_path).read_text(encoding='utf8') + assert '"./_static/' not in image_content + assert '