Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 

18from sphinx.util import status_iterator 

19from sphinx.util.console import brown 

20try: 

21 from docutils.parsers.rst import Directive 

22except ImportError: 

23 from sphinx.util.compat import Directive 

24from sphinx.util.osutil import ensuredir 

25from docutils import nodes 

26from docutils.parsers.rst import directives 

27import requests 

28from .. import LightBox2 

29 

30 

31STATICS_DIR_NAME = '_static' 

32 

33 

34DEFAULT_CONFIG = dict( 

35 backend='LightBox2', 

36 default_image_width='100%', 

37 default_image_height='auto', 

38 default_group=None, 

39 default_show_title=False, 

40 download=False, 

41 requests_kwargs={}, 

42 cache_path='_images', 

43 override_image_directive=False, 

44 show_caption=False, 

45) 

46 

47 

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

49 ":epkg:`sphinx` node" 

50 pass 

51 

52 

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

54 ":epkg:`sphinx` node" 

55 pass 

56 

57 

58def directive_boolean(value): 

59 "local function" 

60 if not value.strip(): 

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

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

63 return True 

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

65 return False 

66 else: 

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

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

69 

70 

71def get_image_extension(uri): 

72 """ 

73 Guesses an extension for an image. 

74 """ 

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

76 for ext in exts: 

77 if uri.endswith(ext): 

78 return ext 

79 for ext in exts: 

80 if ext in uri: 

81 return ext 

82 for ext in exts: 

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

84 return ext 

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

86 return ext 

87 logger = logging.getLogger('image') 

88 logger.warning("[image] unable to guess extension for '{0}'".format(uri)) 

89 return '' 

90 

91 

92class ImageDirective(Directive): 

93 ''' 

94 Directive which overrides default sphinx directive. 

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

96 ''' 

97 

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

99 

100 def align(self): 

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

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

103 # option_spec below. 

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

105 

106 has_content = True 

107 required_arguments = True 

108 

109 option_spec = { 

110 'width': directives.length_or_percentage_or_unitless, 

111 'height': directives.length_or_unitless, 

112 'strech': directives.choice, 

113 

114 'group': directives.unchanged, 

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

116 'alt': directives.unchanged, 

117 'target': directives.unchanged, 

118 'download': directive_boolean, 

119 'title': directives.unchanged, 

120 'align': align, 

121 'show_caption': directive_boolean, 

122 'legacy_class': directives.class_option, 

123 } 

124 

125 def run(self): 

126 env = self.state.document.settings.env 

127 conf = env.app.config.images_config 

128 

129 # TODO get defaults from config 

130 group = self.options.get('group', 

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

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

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

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

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

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

137 title = self.options.get( 

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

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

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

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

142 

143 # TODO get default from config 

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

145 

146 # parse nested content 

147 # TODO: something is broken here, not parsed as expected 

148 description = nodes.paragraph() 

149 content = nodes.paragraph() 

150 content += [nodes.Text("%s" % x) for x in self.content] 

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

152 

153 img = image_node() 

154 

155 try: 

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

157 except ValueError as e: 

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

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

160 self.arguments[0] = repl 

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

162 logger = logging.getLogger('image') 

163 logger.warning("[image] {0}, replaced by '{1}'".format(e, repl)) 

164 

165 if is_remote: 

166 img['remote'] = True 

167 if download: 

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

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

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

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

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

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

174 else: 

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

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

177 else: 

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

179 img['remote'] = False 

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

181 

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

183 img['target'] = target 

184 

185 if title is None: 

186 img['title'] = '' 

187 elif title: 

188 img['title'] = title 

189 else: 

190 img['title'] = img['content'] 

191 img['content'] = '' 

192 

193 img['show_caption'] = show_caption 

194 img['legacy_classes'] = legacy_classes 

195 img['group'] = group 

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

197 img['width'] = width 

198 img['height'] = height 

199 img['classes'] += classes 

200 img['alt'] = alt 

201 img['align'] = align 

202 img['download'] = download 

203 return [img] 

204 

205 def is_remote(self, uri): 

206 "local function" 

207 uri = uri.strip() 

208 env = self.state.document.settings.env 

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

210 app_directory = os.path.dirname( 

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

212 else: 

213 app_directory = None 

214 

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

216 return False 

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

218 return False 

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

220 return False 

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

222 return False 

223 if '://' in uri: 

224 return True 

225 raise ValueError('Image URI `{}` has to be local relative or ' 

226 'absolute path to image, or remote address.' 

227 .format(uri)) 

228 

229 

230def install_backend_static_files(app, env): 

231 "local function" 

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

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

234 app.sphinxtrib_images_backend.__class__.__name__) 

235 files_to_copy = app.sphinxtrib_images_backend.STATIC_FILES 

236 

237 for source_file_path in status_iterator(files_to_copy, 

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

239 

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

241 

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

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

244 

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

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

247 source_file_path) 

248 

249 copyfile(source_file_path, dest_file_path) 

250 

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

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

253 try: 

254 # Sphinx >= 1.8 

255 app.add_js_file(name) 

256 except AttributeError: 

257 # Sphinx < 1.8 

258 app.add_javascript(name) 

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

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

261 try: 

262 # Sphinx >= 1.8 

263 app.add_css_file(name) 

264 except AttributeError: 

265 # Sphinx < 1.8 

266 app.add_stylesheet(name) 

267 

268 

269def download_images(app, env): 

270 """ 

271 Downloads images before running the documentation. 

272 

273 @param app :epkg:`Sphinx` application 

274 @param env environment 

275 """ 

276 logger = logging.getLogger("image") 

277 conf = app.config.images_config 

278 for src in status_iterator(env.remote_images, 

279 'Downloading remote images...', brown, 

280 len(env.remote_images)): 

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

282 dirn = os.path.dirname(dst) 

283 ensuredir(dirn) 

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

285 

286 logger.info('{} -> {} (downloading)'.format(src, dst)) 

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

288 # TODO: apply reuqests_kwargs 

289 try: 

290 f.write(requests.get(src, 

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

292 except requests.ConnectionError: 

293 logger.info("Cannot download `{}`".format(src)) 

294 else: 

295 logger.info('{} -> {} (already in cache)'.format(src, dst)) 

296 

297 

298def configure_backend(app): 

299 "local function" 

300 global DEFAULT_CONFIG 

301 

302 config = copy.deepcopy(DEFAULT_CONFIG) 

303 config.update(app.config.images_config) 

304 app.config.images_config = config 

305 

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

307 

308 # html builder 

309 # self.relfn2path(imguri, docname) 

310 

311 backend_name_or_callable = config['backend'] 

312 if callable(backend_name_or_callable): 

313 pass 

314 elif backend_name_or_callable == "LightBox2": 

315 backend = LightBox2 

316 else: 

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

318 backend_name_or_callable, type(backend_name_or_callable))) 

319 

320 backend = backend(app) 

321 

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

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

324 app.sphinxtrib_images_backend = backend 

325 

326 logger = logging.getLogger("image") 

327 logger.info('Initiated images backend: ', nonl=True) 

328 logger.info('`{}:{}`'.format( 

329 backend.__class__.__module__, backend.__class__.__name__)) 

330 

331 def backend_methods(node, output_type): 

332 "local function" 

333 def backend_method(f): 

334 "local function" 

335 @functools.wraps(f) 

336 def inner_wrapper(writer, node): 

337 "local function" 

338 return f(writer, node) 

339 return inner_wrapper 

340 signature = '_{}_{}'.format(node.__name__, output_type) 

341 return (backend_method(getattr(backend, 'visit' + signature, getattr(backend, 'visit_' + node.__name__ + '_fallback'))), 

342 backend_method(getattr(backend, 'depart' + signature, getattr(backend, 'depart_' + node.__name__ + '_fallback')))) 

343 

344 # add new node to the stack 

345 # connect backend processing methods to this node 

346 app.add_node(image_node, **{output_type: backend_methods(image_node, output_type) 

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

348 

349 app.add_directive('thumbnail', ImageDirective) 

350 if config['override_image_directive']: 

351 app.add_directive('image', ImageDirective) 

352 app.env.remote_images = {} 

353 

354 

355def setup(app): 

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

357 global DEFAULT_CONFIG 

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

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

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

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

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