Coverage for pyquickhelper/sphinxext/sphinximages/sphinxtrib/images.py: 79%

209 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-03 02:21 +0200

1# -*- coding: utf-8 -*- 

2""" 

3@file 

4@brief :epkg:`Sphinx` extension for images. 

5""" 

6__author__ = 'Tomasz Czyż <tomaszczyz@gmail.com>' 

7__license__ = "Apache 2" 

8 

9import os 

10import sys 

11import copy 

12import uuid 

13import hashlib 

14import functools 

15import logging 

16import sphinx 

17from sphinx.util.osutil import copyfile 

18try: 

19 from sphinx.util.display import status_iterator 

20except ImportError: 

21 from sphinx.util import status_iterator 

22from sphinx.util.console import brown 

23try: 

24 from docutils.parsers.rst import Directive 

25except ImportError: 

26 from sphinx.util.compat import Directive 

27from sphinx.util.osutil import ensuredir 

28from docutils import nodes 

29from docutils.parsers.rst import directives 

30import requests 

31from .. import LightBox2 

32 

33 

34STATICS_DIR_NAME = '_static' 

35 

36 

37DEFAULT_CONFIG = dict( 

38 backend='LightBox2', 

39 default_image_width='100%', 

40 default_image_height='auto', 

41 default_group=None, 

42 default_show_title=False, 

43 download=False, 

44 requests_kwargs={}, 

45 cache_path='_images', 

46 override_image_directive=False, 

47 show_caption=False, 

48) 

49 

50 

51class image_node(nodes.image, nodes.General, nodes.Element): 

52 ":epkg:`sphinx` node" 

53 pass 

54 

55 

56class gallery_node(nodes.image, nodes.General, nodes.Element): 

57 ":epkg:`sphinx` node" 

58 pass 

59 

60 

61def directive_boolean(value): 

62 "local function" 

63 if not value.strip(): 

64 raise ValueError("No argument provided but required") 

65 if value.lower().strip() in ["yes", "1", 1, "true", "ok"]: 

66 return True 

67 if value.lower().strip() in ['no', '0', 0, 'false', 'none']: 

68 return False 

69 raise ValueError("Please use on of: yes, true, no, false. " 

70 "Do not use `{}` as boolean.".format(value)) 

71 

72 

73def get_image_extension(uri): 

74 """ 

75 Guesses an extension for an image. 

76 """ 

77 exts = {'.jpg', '.png', '.svg', '.bmp'} 

78 for ext in exts: 

79 if uri.endswith(ext): 

80 return ext 

81 for ext in exts: 

82 if ext in uri: 

83 return ext 

84 for ext in exts: 

85 if (ext[1:] + "=true") in uri: 

86 return ext 

87 if ('?' + ext[1:]) in uri: 

88 return ext 

89 logger = logging.getLogger('image') 

90 logger.warning("[image] unable to guess extension for %r.", uri) 

91 return '' 

92 

93 

94class ImageDirective(Directive): 

95 ''' 

96 Directive which overrides default sphinx directive. 

97 It's backward compatibile and it's adding more cool stuff. 

98 ''' 

99 

100 align_values = ('left', 'center', 'right') 

101 

102 def align(self): 

103 # This is not callable as self.align. It cannot make it a 

104 # staticmethod because we're saving an unbound method in 

105 # option_spec below. 

106 return directives.choice(self, ImageDirective.align_values) 

107 

108 has_content = True 

109 required_arguments = True 

110 

111 option_spec = { 

112 'width': directives.length_or_percentage_or_unitless, 

113 'height': directives.length_or_unitless, 

114 'strech': directives.choice, 

115 'group': directives.unchanged, 

116 'class': directives.class_option, # or str? 

117 'alt': directives.unchanged, 

118 'target': directives.unchanged, 

119 'download': directive_boolean, 

120 'title': directives.unchanged, 

121 'align': align, 

122 'show_caption': directive_boolean, 

123 'legacy_class': directives.class_option, 

124 } 

125 

126 def run(self): 

127 env = self.state.document.settings.env 

128 conf = env.app.config.images_config 

129 

130 group = self.options.get( 

131 'group', 

132 conf['default_group'] if conf['default_group'] else uuid.uuid4()) 

133 classes = self.options.get('class', '') 

134 width = self.options.get('width', conf['default_image_width']) 

135 height = self.options.get('height', conf['default_image_height']) 

136 alt = self.options.get('alt', '') 

137 target = self.options.get('target', '') 

138 title = self.options.get( 

139 'title', '' if conf['default_show_title'] else None) 

140 align = self.options.get('align', '') 

141 show_caption = self.options.get('show_caption', False) 

142 legacy_classes = self.options.get('legacy_class', '') 

143 download = self.options.get('download', conf['download']) 

144 

145 # parse nested content 

146 description = nodes.paragraph() 

147 content = nodes.paragraph() 

148 content += [nodes.Text(f"{x}") for x in self.content] 

149 self.state.nested_parse(content, 0, description) 

150 

151 img = image_node() 

152 

153 try: 

154 is_remote = self.is_remote(self.arguments[0]) 

155 except ValueError as e: 

156 this = os.path.abspath(os.path.dirname(__file__)) 

157 repl = os.path.join(this, "missing.png") 

158 self.arguments[0] = repl 

159 is_remote = self.is_remote(self.arguments[0]) 

160 logger = logging.getLogger('image') 

161 logger.warning("[image] %r, replaced by %r", e, repl) 

162 

163 if is_remote: 

164 img['remote'] = True 

165 if download: 

166 img['uri'] = os.path.join('_images', hashlib.sha1( 

167 self.arguments[0].encode()).hexdigest()) 

168 img['uri'] += get_image_extension(self.arguments[0]) 

169 img['remote_uri'] = self.arguments[0] 

170 env.remote_images[img['remote_uri']] = img['uri'] 

171 env.images.add_file('', img['uri']) 

172 else: 

173 img['uri'] = self.arguments[0] 

174 img['remote_uri'] = self.arguments[0] 

175 else: 

176 img['uri'] = self.arguments[0] 

177 img['remote'] = False 

178 env.images.add_file('', img['uri']) 

179 

180 img['content'] = description.astext() 

181 img['target'] = target 

182 img['source'] = "unknown-source" 

183 

184 if title is None: 

185 img['title'] = '' 

186 elif title: 

187 img['title'] = title 

188 else: 

189 img['title'] = img['content'] 

190 img['content'] = '' 

191 

192 img['show_caption'] = show_caption 

193 img['legacy_classes'] = legacy_classes 

194 img['group'] = group 

195 img['size'] = (width, height) 

196 img['width'] = width 

197 img['height'] = height 

198 img['alt'] = alt 

199 img['align'] = align 

200 img['download'] = download 

201 img['classes'] += classes 

202 return [img] 

203 

204 def is_remote(self, uri): 

205 "local function" 

206 uri = uri.strip() 

207 env = self.state.document.settings.env 

208 if self.state.document.settings._source is not None: 

209 app_directory = os.path.dirname( 

210 os.path.abspath(self.state.document.settings._source)) 

211 else: 

212 app_directory = None 

213 

214 if uri[0] == '/': 

215 return False 

216 if uri[0:7] == 'file://': 

217 return False 

218 if os.path.isfile(os.path.join(env.srcdir, uri)): 

219 return False 

220 if app_directory and os.path.isfile(os.path.join(app_directory, uri)): 

221 return False 

222 if '://' in uri: 

223 return True 

224 raise ValueError(f'Image URI {uri!r} has to be local relative or ' 

225 f'absolute path to image, or remote address.') 

226 

227 

228def install_backend_static_files(app, env): 

229 "local function" 

230 STATICS_DIR_PATH = os.path.join(app.builder.outdir, STATICS_DIR_NAME) 

231 dest_path = os.path.join(STATICS_DIR_PATH, 'sphinxtrib-images', 

232 app.sphinxtrib_images_backend.__class__.__name__) 

233 files_to_copy = app.sphinxtrib_images_backend.STATIC_FILES 

234 

235 for source_file_path in status_iterator(files_to_copy, 

236 'Copying static files for images...', brown, len(files_to_copy)): 

237 

238 dest_file_path = os.path.join(dest_path, source_file_path) 

239 

240 if not os.path.exists(os.path.dirname(dest_file_path)): 

241 ensuredir(os.path.dirname(dest_file_path)) 

242 

243 source_file_path = os.path.join(os.path.dirname( 

244 sys.modules[app.sphinxtrib_images_backend.__class__.__module__].__file__), 

245 source_file_path) 

246 

247 copyfile(source_file_path, dest_file_path) 

248 

249 if dest_file_path.endswith('.js'): 

250 name = os.path.relpath(dest_file_path, STATICS_DIR_PATH) 

251 try: 

252 # Sphinx >= 1.8 

253 app.add_js_file(name) 

254 except AttributeError: 

255 # Sphinx < 1.8 

256 app.add_javascript(name) 

257 elif dest_file_path.endswith('.css'): 

258 name = os.path.relpath(dest_file_path, STATICS_DIR_PATH) 

259 try: 

260 # Sphinx >= 1.8 

261 app.add_css_file(name) 

262 except AttributeError: 

263 # Sphinx < 1.8 

264 app.add_stylesheet(name) 

265 

266 

267def download_images(app, env): 

268 """ 

269 Downloads images before running the documentation. 

270 

271 @param app :epkg:`Sphinx` application 

272 @param env environment 

273 """ 

274 logger = logging.getLogger("image") 

275 conf = app.config.images_config 

276 for src in status_iterator(env.remote_images, 

277 'Downloading remote images...', brown, 

278 len(env.remote_images)): 

279 dst = os.path.join(env.srcdir, env.remote_images[src]) 

280 dirn = os.path.dirname(dst) 

281 ensuredir(dirn) 

282 if not os.path.isfile(dst): 

283 logger.info('%r -> %r (downloading)', src, dst) 

284 with open(dst, 'wb') as f: 

285 # TODO: apply reuqests_kwargs 

286 try: 

287 f.write(requests.get(src, 

288 **conf['requests_kwargs']).content) 

289 except requests.ConnectionError: 

290 logger.info("Cannot download %r", src) 

291 else: 

292 logger.info('%r -> %r (already in cache)', src, dst) 

293 

294 

295def configure_backend(app): 

296 "local function" 

297 global DEFAULT_CONFIG 

298 

299 config = copy.deepcopy(DEFAULT_CONFIG) 

300 config.update(app.config.images_config) 

301 app.config.images_config = config 

302 

303 # ensuredir(os.path.join(app.env.srcdir, config['cache_path'])) 

304 

305 # html builder 

306 # self.relfn2path(imguri, docname) 

307 

308 backend_name_or_callable = config['backend'] 

309 if callable(backend_name_or_callable): 

310 pass 

311 elif backend_name_or_callable == "LightBox2": 

312 backend = LightBox2 

313 else: 

314 raise TypeError("images backend is configured improperly. It is `{}` (type:`{}`).".format( 

315 backend_name_or_callable, type(backend_name_or_callable))) 

316 

317 backend = backend(app) 

318 

319 # remember the chosen backend for processing. Env and config cannot be used 

320 # because sphinx try to make a pickle from it. 

321 app.sphinxtrib_images_backend = backend 

322 

323 def backend_methods(node, output_type): 

324 "local function" 

325 def backend_method(f): 

326 "local function" 

327 @functools.wraps(f) 

328 def inner_wrapper(writer, node): 

329 "local function" 

330 return f(writer, node) 

331 return inner_wrapper 

332 signature = f'_{node.__name__}_{output_type}' 

333 return (backend_method(getattr(backend, 'visit' + signature, 

334 getattr(backend, 'visit_' + node.__name__ + '_fallback'))), 

335 backend_method(getattr(backend, 'depart' + signature, 

336 getattr(backend, 'depart_' + node.__name__ + '_fallback')))) 

337 

338 # add new node to the stack 

339 # connect backend processing methods to this node 

340 app.add_node( 

341 image_node, 

342 **{output_type: backend_methods(image_node, output_type) 

343 for output_type in ('html', 'latex', 'man', 'texinfo', 'text', 'epub')}) 

344 

345 app.add_directive('thumbnail', ImageDirective) 

346 if config['override_image_directive']: 

347 app.add_directive('image', ImageDirective) 

348 app.env.remote_images = {} 

349 

350 

351def setup(app): 

352 """setup for :epkg:`sphinx` extension""" 

353 global DEFAULT_CONFIG 

354 app.add_config_value('images_config', DEFAULT_CONFIG, 'env') 

355 app.connect('builder-inited', configure_backend) 

356 app.connect('env-updated', download_images) 

357 app.connect('env-updated', install_backend_static_files) 

358 return {'version': sphinx.__version__, 'parallel_read_safe': True}