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

1import os 

2import re 

3 

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 

10 

11__version__ = '0.2.0' 

12 

13logger = logging.getLogger(__name__) 

14 

15protocol_ptn = re.compile(r'[a-zA-Z]+://') 

16 

17 

18class svg(nodes.General, nodes.Element): 

19 '''Special svg node to replace .svg image node 

20 ''' 

21 pass 

22 

23 

24def visit_svg_html(self: HTMLTranslator, node: nodes.Element): 

25 self.body.append(node['content']) 

26 

27 

28def depart_svg_html(self: HTMLTranslator, node: nodes.Element): 

29 # Closing tag already added to 'content' in visit_svg_html(). 

30 pass 

31 

32 

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) 

43 

44 # Add special 'svg' node definition. 

45 app.add_node(svg, html=(visit_svg_html, depart_svg_html)) 

46 

47 # Add process after doctree resolved. 

48 app.connect('doctree-resolved', process_svg_nodes) 

49 

50 return { 

51 'version': __version__, 

52 'parallel_read_safe': True, 

53 'parallel_write_safe': True, 

54 } 

55 

56 

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 

63 

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}") 

72 

73 

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 

85 

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] 

92 

93 # Remove namespaces in svg element. 

94 remove_namespaces(root) 

95 

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] 

101 

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 

110 

111 if isinstance(value, list): 

112 root.set(attr_key, ' '.join(value)) 

113 elif isinstance(value, str): 

114 root.set(attr_key, value) 

115 

116 # Resolve cross references in svg element. 

117 if app.config.inline_svg_resolve_xref: 

118 resolve_xref_thru_element(app, root, docname) 

119 

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) 

124 

125 

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] 

133 

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) 

139 

140 # Apply it recursively. 

141 for child in element: 

142 remove_namespaces(child) 

143 

144 

145def resolve_xref_thru_element(app, element, docname): 

146 '''Resolve cross references in element and descendants 

147 ''' 

148 env = app.builder.env 

149 

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 

155 

156 

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) 

162 

163 # Link to absolute URI - No need to resolve. 

164 if protocol_ptn.match(href): 

165 return None 

166 

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) 

173 

174 return refnode.attributes['refuri'] if refnode else None 

175 

176 

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 

182 

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 

188 

189 ref_node = make_refnode( 

190 app.builder, docname, target_def[0], target_def[1], text_node) 

191 return ref_node 

192 

193 # If no target to link. 

194 raise ValueError(f'Target not found: {target}') 

195 

196 

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)) 

207 

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' 

214 

215 target_docname = target_path.replace(os.path.sep, '/') 

216 if target_docname.endswith('.html'): 

217 target_docname = target_docname[:-5] 

218 

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 

223 

224 # If no file to link. 

225 raise ValueError(f'Document not found: {target_docname}')