Fix relative references in SVGs generated by `sphinx.ext.graphviz` (#11078)

Co-authored-by: Ralf Grubenmann <ralf.grubenmann@gmail.com>
Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
This commit is contained in:
Ralf Grubenmann 2023-07-28 06:47:23 +02:00 committed by GitHub
parent 2c0b81d88b
commit 6178163cb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 75 additions and 4 deletions

View File

@ -28,6 +28,9 @@ Features added
Bugs fixed Bugs fixed
---------- ----------
* #11077: graphviz: Fix relative links from within the graph.
Patch by Ralf Grubenmann.
Testing Testing
------- -------

View File

@ -6,10 +6,13 @@ from __future__ import annotations
import posixpath import posixpath
import re import re
import subprocess import subprocess
import xml.etree.ElementTree as ET
from hashlib import sha1 from hashlib import sha1
from itertools import chain
from os import path from os import path
from subprocess import CalledProcessError from subprocess import CalledProcessError
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit, urlunsplit
from docutils import nodes from docutils import nodes
from docutils.nodes import Node from docutils.nodes import Node
@ -214,6 +217,37 @@ class GraphvizSimple(SphinxDirective):
return [figure] 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, def render_dot(self: SphinxTranslator, code: str, options: dict, format: str,
prefix: str = 'graphviz', filename: str | None = None, prefix: str = 'graphviz', filename: str | None = None,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
@ -251,10 +285,6 @@ def render_dot(self: SphinxTranslator, code: str, options: dict, format: str,
try: try:
ret = subprocess.run(dot_args, input=code.encode(), capture_output=True, ret = subprocess.run(dot_args, input=code.encode(), capture_output=True,
cwd=cwd, check=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: except OSError:
logger.warning(__('dot command %r cannot be run (needed for graphviz ' logger.warning(__('dot command %r cannot be run (needed for graphviz '
'output), check the graphviz_dot setting'), graphviz_dot) '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: except CalledProcessError as exc:
raise GraphvizError(__('dot exited with error:\n[stderr]\n%r\n' raise GraphvizError(__('dot exited with error:\n[stderr]\n%r\n'
'[stdout]\n%r') % (exc.stderr, exc.stdout)) from exc '[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, def render_dot_html(self: HTML5Translator, node: graphviz, code: str, options: dict,

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1"
height="128" width="128"
xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="red" />
</svg>

After

Width:  |  Height:  |  Size: 187 B

View File

@ -1,2 +1,3 @@
extensions = ['sphinx.ext.graphviz'] extensions = ['sphinx.ext.graphviz']
exclude_patterns = ['_build'] exclude_patterns = ['_build']
html_static_path = ["_static"]

View File

@ -31,3 +31,13 @@ Hello |graph| graphviz world
:align: center :align: center
centered 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
}

View File

@ -82,6 +82,17 @@ def test_graphviz_svg_html(app, status, warning):
r'</div>') r'</div>')
assert re.search(html, content, re.S) 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 '<ns0:image ns1:href="../_static/images/test.svg"' in image_content
assert '<ns0:a ns1:href="../_static/images/test.svg"' in image_content
assert '<ns0:a ns1:href="..#graphviz"' in image_content
@pytest.mark.sphinx('latex', testroot='ext-graphviz') @pytest.mark.sphinx('latex', testroot='ext-graphviz')
@pytest.mark.usefixtures('if_graphviz_found') @pytest.mark.usefixtures('if_graphviz_found')