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"
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
31STATICS_DIR_NAME = '_static'
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)
48class image_node(nodes.image, nodes.General, nodes.Element):
49 ":epkg:`sphinx` node"
50 pass
53class gallery_node(nodes.image, nodes.General, nodes.Element):
54 ":epkg:`sphinx` node"
55 pass
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))
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 ''
92class ImageDirective(Directive):
93 '''
94 Directive which overrides default sphinx directive.
95 It's backward compatibile and it's adding more cool stuff.
96 '''
98 align_values = ('left', 'center', 'right')
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)
106 has_content = True
107 required_arguments = True
109 option_spec = {
110 'width': directives.length_or_percentage_or_unitless,
111 'height': directives.length_or_unitless,
112 'strech': directives.choice,
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 }
125 def run(self):
126 env = self.state.document.settings.env
127 conf = env.app.config.images_config
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', '')
143 # TODO get default from config
144 download = self.options.get('download', conf['download'])
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)
153 img = image_node()
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))
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'])
182 img['content'] = description.astext()
183 img['target'] = target
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'] = ''
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]
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
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))
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
237 for source_file_path in status_iterator(files_to_copy,
238 'Copying static files for images...', brown, len(files_to_copy)):
240 dest_file_path = os.path.join(dest_path, source_file_path)
242 if not os.path.exists(os.path.dirname(dest_file_path)):
243 ensuredir(os.path.dirname(dest_file_path))
245 source_file_path = os.path.join(os.path.dirname(
246 sys.modules[app.sphinxtrib_images_backend.__class__.__module__].__file__),
247 source_file_path)
249 copyfile(source_file_path, dest_file_path)
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)
269def download_images(app, env):
270 """
271 Downloads images before running the documentation.
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):
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))
298def configure_backend(app):
299 "local function"
300 global DEFAULT_CONFIG
302 config = copy.deepcopy(DEFAULT_CONFIG)
303 config.update(app.config.images_config)
304 app.config.images_config = config
306 # ensuredir(os.path.join(app.env.srcdir, config['cache_path']))
308 # html builder
309 # self.relfn2path(imguri, docname)
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)))
320 backend = backend(app)
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
326 logger = logging.getLogger("image")
327 logger.info('Initiated images backend: ', nonl=True)
328 logger.info('`{}:{}`'.format(
329 backend.__class__.__module__, backend.__class__.__name__))
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'))))
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')})
349 app.add_directive('thumbnail', ImageDirective)
350 if config['override_image_directive']:
351 app.add_directive('image', ImageDirective)
352 app.env.remote_images = {}
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}