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

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.1.1' 

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

109 

110 # Resolve cross references in svg element. 

111 if app.config.inline_svg_resolve_xref: 

112 resolve_xref_thru_element(app, root, docname) 

113 

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) 

118 

119 

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] 

127 

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) 

133 

134 # Apply it recursively. 

135 for child in element: 

136 remove_namespaces(child) 

137 

138 

139def resolve_xref_thru_element(app, element, docname): 

140 '''Resolve cross references in element and descendants 

141 ''' 

142 env = app.builder.env 

143 

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 

149 

150 

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) 

156 

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

158 if protocol_ptn.match(href): 

159 return None 

160 

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) 

167 

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

169 

170 

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 

176 

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 

182 

183 ref_node = make_refnode( 

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

185 return ref_node 

186 

187 # If no target to link. 

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

189 

190 

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

201 

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' 

208 

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

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

211 target_docname = target_docname[:-5] 

212 

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 

217 

218 # If no file to link. 

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