Coverage for src\sphinx_inline_svg\inline_svg.py: 100%
114 statements
« prev ^ index » next coverage.py v7.6.2, created at 2025-04-12 13:34 +0900
« prev ^ index » next coverage.py v7.6.2, created at 2025-04-12 13:34 +0900
1import os
2import re
4from docutils import nodes
5from lxml import html
6from sphinx.application import Sphinx
7from sphinx.util import logging
8from sphinx.util.nodes import make_refnode
9from sphinx.writers.html import HTMLTranslator
11__version__ = '0.2.0'
13logger = logging.getLogger(__name__)
15protocol_ptn = re.compile(r'[a-zA-Z]+://')
18class svg(nodes.General, nodes.Element):
19 '''Special svg node to replace .svg image node
20 '''
21 pass
24def visit_svg_html(self: HTMLTranslator, node: nodes.Element):
25 self.body.append(node['content'])
28def depart_svg_html(self: HTMLTranslator, node: nodes.Element):
29 # Closing tag already added to 'content' in visit_svg_html().
30 pass
33def setup(app: Sphinx):
34 '''Set up extension
35 '''
36 # Add config parameters.
37 app.add_config_value(
38 'inline_svg_classes', ['inline-svg'], 'html', list[str])
39 app.add_config_value(
40 'inline_svg_del_attrs', ['content'], 'html', list[str])
41 app.add_config_value(
42 'inline_svg_resolve_xref', True, 'html', bool)
44 # Add special 'svg' node definition.
45 app.add_node(svg, html=(visit_svg_html, depart_svg_html))
47 # Add process after doctree resolved.
48 app.connect('doctree-resolved', process_svg_nodes)
50 return {
51 'version': __version__,
52 'parallel_read_safe': True,
53 'parallel_write_safe': True,
54 }
57def process_svg_nodes(app: Sphinx, doctree: nodes.document, docname: str):
58 '''Process after doctree resolved
59 '''
60 # This process is done only when target is html.
61 if app.builder.name != 'html':
62 return
64 for node in doctree.findall(nodes.image):
65 if node['uri'].endswith('.svg'):
66 svg_path = app.srcdir / node['uri']
67 try:
68 inline_svg(app, node, svg_path, docname)
69 except Exception as e:
70 # Just warn, continue building the document.
71 logger.warning(f"Error inlining SVG {svg_path}: {e}")
74def inline_svg(app, node, svg_path, docname):
75 '''Replace img tag with inline svg read from svg_path
76 '''
77 # Check if node has any of 'inline_svg_classes'.
78 # Inlining only nodes with any of the classes (extension's rule).
79 for klass in node.attributes['classes']:
80 if klass in app.config.inline_svg_classes:
81 break
82 else:
83 # Skip if none of 'inline_svg_classes' found.
84 return
86 # Parse SVG file to get svg element.
87 tree = html.parse(svg_path)
88 svgs = tree.xpath('//svg')
89 if not svgs:
90 raise ValueError('No svg element.')
91 root = svgs[0]
93 # Remove namespaces in svg element.
94 remove_namespaces(root)
96 # Delete attributes from svg element.
97 # Attributes are set as extension config param 'inline_svg_del_attrs'.
98 for attr in app.config.inline_svg_del_attrs:
99 if attr in root.attrib:
100 del root.attrib[attr]
102 # Add attributes from original node to svg element.
103 for key, value in node.non_default_attributes().items():
104 if key == 'classes':
105 attr_key = 'class'
106 elif key == 'ids':
107 attr_key = 'id'
108 else:
109 attr_key = key
111 if isinstance(value, list):
112 root.set(attr_key, ' '.join(value))
113 elif isinstance(value, str):
114 root.set(attr_key, value)
116 # Resolve cross references in svg element.
117 if app.config.inline_svg_resolve_xref:
118 resolve_xref_thru_element(app, root, docname)
120 # Create svg node, and replace image node with it.
121 svg_content = html.tostring(root, encoding='unicode')
122 svg_node = svg(content=svg_content)
123 node.replace_self(svg_node)
126def remove_namespaces(element):
127 '''Remove namespaces from element and descendants
128 '''
129 # Remove relevant attrs from element.
130 for attr in ['xmlns', 'xmlns:xlink']:
131 if attr in element.attrib:
132 del element.attrib[attr]
134 # Change 'xlink:href' into 'href'.
135 if 'xlink:href' in element.attrib:
136 href = element.attrib['xlink:href']
137 del element.attrib['xlink:href']
138 element.set('href', href)
140 # Apply it recursively.
141 for child in element:
142 remove_namespaces(child)
145def resolve_xref_thru_element(app, element, docname):
146 '''Resolve cross references in element and descendants
147 '''
148 env = app.builder.env
150 # For all <a> tags with 'href' attribute
151 for a_el in element.xpath('.//a[@href]'):
152 resolved = resolve_uri(app, env, a_el, docname)
153 if resolved:
154 a_el.attrib['href'] = resolved
157def resolve_uri(app, env, a_element, docname):
158 '''Resolve href value of a_element if needed, otherwise return None
159 '''
160 href = a_element.get('href')
161 text_node = nodes.Text(a_element.text)
163 # Link to absolute URI - No need to resolve.
164 if protocol_ptn.match(href):
165 return None
167 if href.startswith('#'):
168 # Link to defined target.
169 refnode = refnode_to_target(app, env, href[1:], text_node, docname)
170 else:
171 # Link to file path.
172 refnode = refnode_to_file(app, env, href, text_node, docname)
174 return refnode.attributes['refuri'] if refnode else None
177def refnode_to_target(app, env, target, text_node, docname):
178 '''Create refnode to defined target if needed, otherwise return None
179 '''
180 # target_defs is dict {target_name: (docname, target_id), ...}
181 target_defs = env.get_domain('std').anonlabels
183 for target_name, target_def in target_defs.items():
184 if target == target_name:
185 # Target is in the same page - No need to resolve.
186 if docname == target_def[0]:
187 return None
189 ref_node = make_refnode(
190 app.builder, docname, target_def[0], target_def[1], text_node)
191 return ref_node
193 # If no target to link.
194 raise ValueError(f'Target not found: {target}')
197def refnode_to_file(app, env, href, text_node, docname):
198 '''Create refnode to file path if needed, otherwise return None
199 '''
200 if href.startswith('/'):
201 # If starts with '/', treat as relative from doc root (working dir).
202 target_path = href.lstrip('/')
203 else:
204 # Treat as relative from current file (referred via docname).
205 current_dir = os.path.dirname(docname)
206 target_path = os.path.normpath(os.path.join(current_dir, href))
208 base, ext = os.path.splitext(target_path)
209 suffix = env.config.source_suffix
210 # suffix can be either str or list.
211 suffix = [suffix] if isinstance(suffix, str) else suffix
212 if ext in suffix:
213 target_path = base + '.html'
215 target_docname = target_path.replace(os.path.sep, '/')
216 if target_docname.endswith('.html'):
217 target_docname = target_docname[:-5]
219 if target_docname in env.found_docs:
220 ref_node = make_refnode(
221 app.builder, docname, target_docname, '', text_node)
222 return ref_node
224 # If no file to link.
225 raise ValueError(f'Document not found: {target_docname}')