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 '