Coverage for src\sphinx_inline_svg\inline_svg.py: 100%
110 statements
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-15 23:46 +0900
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-15 23:46 +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.1.1'
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 attr_key = 'class' if key == 'classes' else 'key'
105 if isinstance(value, list):
106 root.set(attr_key, ' '.join(value))
107 elif isinstance(value, str):
108 root.set(attr_key, value)
110 # Resolve cross references in svg element.
111 if app.config.inline_svg_resolve_xref:
112 resolve_xref_thru_element(app, root, docname)
114 # Create svg node, and replace image node with it.
115 svg_content = html.tostring(root, encoding='unicode')
116 svg_node = svg(content=svg_content)
117 node.replace_self(svg_node)
120def remove_namespaces(element):
121 '''Remove namespaces from element and descendants
122 '''
123 # Remove relevant attrs from element.
124 for attr in ['xmlns', 'xmlns:xlink']:
125 if attr in element.attrib:
126 del element.attrib[attr]
128 # Change 'xlink:href' into 'href'.
129 if 'xlink:href' in element.attrib:
130 href = element.attrib['xlink:href']
131 del element.attrib['xlink:href']
132 element.set('href', href)
134 # Apply it recursively.
135 for child in element:
136 remove_namespaces(child)
139def resolve_xref_thru_element(app, element, docname):
140 '''Resolve cross references in element and descendants
141 '''
142 env = app.builder.env
144 # For all <a> tags with 'href' attribute
145 for a_el in element.xpath('.//a[@href]'):
146 resolved = resolve_uri(app, env, a_el, docname)
147 if resolved:
148 a_el.attrib['href'] = resolved
151def resolve_uri(app, env, a_element, docname):
152 '''Resolve href value of a_element if needed, otherwise return None
153 '''
154 href = a_element.get('href')
155 text_node = nodes.Text(a_element.text)
157 # Link to absolute URI - No need to resolve.
158 if protocol_ptn.match(href):
159 return None
161 if href.startswith('#'):
162 # Link to defined target.
163 refnode = refnode_to_target(app, env, href[1:], text_node, docname)
164 else:
165 # Link to file path.
166 refnode = refnode_to_file(app, env, href, text_node, docname)
168 return refnode.attributes['refuri'] if refnode else None
171def refnode_to_target(app, env, target, text_node, docname):
172 '''Create refnode to defined target if needed, otherwise return None
173 '''
174 # target_defs is dict {target_name: (docname, target_id), ...}
175 target_defs = env.get_domain('std').anonlabels
177 for target_name, target_def in target_defs.items():
178 if target == target_name:
179 # Target is in the same page - No need to resolve.
180 if docname == target_def[0]:
181 return None
183 ref_node = make_refnode(
184 app.builder, docname, target_def[0], target_def[1], text_node)
185 return ref_node
187 # If no target to link.
188 raise ValueError(f'Target not found: {target}')
191def refnode_to_file(app, env, href, text_node, docname):
192 '''Create refnode to file path if needed, otherwise return None
193 '''
194 if href.startswith('/'):
195 # If starts with '/', treat as relative from doc root (working dir).
196 target_path = href.lstrip('/')
197 else:
198 # Treat as relative from current file (referred via docname).
199 current_dir = os.path.dirname(docname)
200 target_path = os.path.normpath(os.path.join(current_dir, href))
202 base, ext = os.path.splitext(target_path)
203 suffix = env.config.source_suffix
204 # suffix can be either str or list.
205 suffix = [suffix] if isinstance(suffix, str) else suffix
206 if ext in suffix:
207 target_path = base + '.html'
209 target_docname = target_path.replace(os.path.sep, '/')
210 if target_docname.endswith('.html'):
211 target_docname = target_docname[:-5]
213 if target_docname in env.found_docs:
214 ref_node = make_refnode(
215 app.builder, docname, target_docname, '', text_node)
216 return ref_node
218 # If no file to link.
219 raise ValueError(f'Document not found: {target_docname}')