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
« 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"
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
34STATICS_DIR_NAME = '_static'
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)
51class image_node(nodes.image, nodes.General, nodes.Element):
52 ":epkg:`sphinx` node"
53 pass
56class gallery_node(nodes.image, nodes.General, nodes.Element):
57 ":epkg:`sphinx` node"
58 pass
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))
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 ''
94class ImageDirective(Directive):
95 '''
96 Directive which overrides default sphinx directive.
97 It's backward compatibile and it's adding more cool stuff.
98 '''
100 align_values = ('left', 'center', 'right')
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)
108 has_content = True
109 required_arguments = True
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 }
126 def run(self):
127 env = self.state.document.settings.env
128 conf = env.app.config.images_config
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'])
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)
151 img = image_node()
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)
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'])
180 img['content'] = description.astext()
181 img['target'] = target
182 img['source'] = "unknown-source"
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'] = ''
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]
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
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.')
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
235 for source_file_path in status_iterator(files_to_copy,
236 'Copying static files for images...', brown, len(files_to_copy)):
238 dest_file_path = os.path.join(dest_path, source_file_path)
240 if not os.path.exists(os.path.dirname(dest_file_path)):
241 ensuredir(os.path.dirname(dest_file_path))
243 source_file_path = os.path.join(os.path.dirname(
244 sys.modules[app.sphinxtrib_images_backend.__class__.__module__].__file__),
245 source_file_path)
247 copyfile(source_file_path, dest_file_path)
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)
267def download_images(app, env):
268 """
269 Downloads images before running the documentation.
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)
295def configure_backend(app):
296 "local function"
297 global DEFAULT_CONFIG
299 config = copy.deepcopy(DEFAULT_CONFIG)
300 config.update(app.config.images_config)
301 app.config.images_config = config
303 # ensuredir(os.path.join(app.env.srcdir, config['cache_path']))
305 # html builder
306 # self.relfn2path(imguri, docname)
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)))
317 backend = backend(app)
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
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'))))
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')})
345 app.add_directive('thumbnail', ImageDirective)
346 if config['override_image_directive']:
347 app.add_directive('image', ImageDirective)
348 app.env.remote_images = {}
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}