1'''thumbnail.py - Thumbnail module for MComix implementing (most of) the 2freedesktop.org "standard" at http://jens.triq.net/thumbnail-spec/ 3''' 4 5import os 6import re 7import shutil 8import tempfile 9import threading 10import traceback 11import PIL.Image as Image 12from urllib.request import pathname2url 13 14from hashlib import md5 15 16from mcomix import archive_extractor 17from mcomix import archive_tools 18from mcomix import callback 19from mcomix import constants 20from mcomix import i18n 21from mcomix import image_tools 22from mcomix import log 23from mcomix import mimetypes 24from mcomix import portability 25from mcomix import tools 26from mcomix.lib import reader 27from mcomix.preferences import prefs 28 29 30class Thumbnailer(object): 31 ''' The Thumbnailer class is responsible for managing MComix 32 internal thumbnail creation. Depending on its settings, 33 it either stores thumbnails on disk and retrieves them later, 34 or simply creates new thumbnails each time it is called. ''' 35 36 def __init__(self, dst_dir=constants.THUMBNAIL_PATH, store_on_disk=None, 37 size=None, force_recreation=False, archive_support=False): 38 ''' 39 <dst_dir> set the thumbnailer's storage directory. 40 41 If <store_on_disk> on disk is True, it changes the thumbnailer's 42 behaviour to store files on disk, or just create new thumbnails each 43 time it was called when set to False. Defaults to the 'create 44 thumbnails' preference if not set. 45 46 The dimensions for the created thumbnails is set by <size>, a (width, 47 height) tupple. Defaults to the 'thumbnail size' preference if not set. 48 49 If <force_recreation> is True, thumbnails stored on disk 50 will always be re-created instead of being re-used. 51 52 If <archive_support> is True, support for archive thumbnail creation 53 (based on cover detection) is enabled. Otherwise, only image files are 54 supported. 55 ''' 56 self.dst_dir = dst_dir 57 if store_on_disk is None: 58 self.store_on_disk = prefs['create thumbnails'] 59 else: 60 self.store_on_disk = store_on_disk 61 if size is None: 62 self.width = self.height = prefs['thumbnail size'] 63 self.default_sizes = True 64 else: 65 self.width, self.height = size 66 self.default_sizes = False 67 self.force_recreation = force_recreation 68 self.archive_support = archive_support 69 70 def thumbnail(self, filepath, mt=False): 71 ''' Returns a thumbnail pixbuf for <filepath>, transparently handling 72 both normal image files and archives. If a thumbnail file already exists, 73 it is re-used. Otherwise, a new thumbnail is created from <filepath>. 74 75 Returns None if thumbnail creation failed, or if the thumbnail creation 76 is run asynchrounosly. ''' 77 78 # Update width and height from preferences if they haven't been set explicitly 79 if self.default_sizes: 80 self.width = prefs['thumbnail size'] 81 self.height = prefs['thumbnail size'] 82 83 if self._thumbnail_exists(filepath): 84 thumbpath = self._path_to_thumbpath(filepath) 85 pixbuf = image_tools.load_pixbuf(thumbpath) 86 self.thumbnail_finished(filepath, pixbuf) 87 return pixbuf 88 89 else: 90 if mt: 91 thread = threading.Thread(target=self._create_thumbnail, args=(filepath,)) 92 thread.name += '-thumbnailer' 93 thread.daemon=True 94 thread.start() 95 return None 96 else: 97 return self._create_thumbnail(filepath) 98 99 @callback.Callback 100 def thumbnail_finished(self, filepath, pixbuf): 101 ''' Called every time a thumbnail has been completed. 102 <filepath> is the file that was used as source, <pixbuf> is the 103 resulting thumbnail. ''' 104 105 pass 106 107 def delete(self, filepath): 108 ''' Deletes the thumbnail for <filepath> (if it exists) ''' 109 thumbpath = self._path_to_thumbpath(filepath) 110 if os.path.isfile(thumbpath): 111 try: 112 os.remove(thumbpath) 113 except IOError as error: 114 log.error(_('! Could not remove file "%s"'), thumbpath) 115 log.error(error) 116 117 def _create_thumbnail_pixbuf(self, filepath): 118 ''' Creates a thumbnail pixbuf from <filepath>, and returns it as a 119 tuple along with a file metadata dictionary: (pixbuf, tEXt_data) ''' 120 121 if self.archive_support: 122 mime = archive_tools.archive_mime_type(filepath) 123 else: 124 mime = None 125 if mime is not None: 126 if not archive_tools.is_archive_file(filepath): 127 return None, None 128 with archive_tools.get_recursive_archive_handler( 129 filepath, type=mime, 130 prefix='mcomix_archive_thumb.') as archive: 131 if archive is None: 132 return None, None 133 if archive.is_encrypted: 134 image_path=tools.pkg_path('images','encrypted-book.png') 135 else: 136 files = archive.list_contents(decrypt=False) 137 wanted = self._guess_cover(files) 138 if wanted is None: 139 return None, None 140 141 image_path = archive.extract(wanted) 142 if not os.path.isfile(image_path): 143 return None, None 144 145 pixbuf = image_tools.load_pixbuf_size(image_path, self.width, self.height) 146 if self.store_on_disk: 147 tEXt_data = self._get_text_data(image_path) 148 # Use the archive's mTime instead of the extracted file's mtime 149 tEXt_data['tEXt::Thumb::MTime'] = str(os.stat(filepath).st_mtime) 150 else: 151 tEXt_data = None 152 153 return pixbuf, tEXt_data 154 155 elif image_tools.is_image_file(filepath, check_mimetype=True): 156 pixbuf = image_tools.load_pixbuf_size(filepath, self.width, self.height) 157 if self.store_on_disk: 158 tEXt_data = self._get_text_data(filepath) 159 else: 160 tEXt_data = None 161 162 return pixbuf, tEXt_data 163 else: 164 return None, None 165 166 def _create_thumbnail(self, filepath): 167 ''' Creates the thumbnail pixbuf for <filepath>, and saves the pixbuf 168 to disk if necessary. Returns the created pixbuf, or None, if creation failed. ''' 169 170 pixbuf, tEXt_data = self._create_thumbnail_pixbuf(filepath) 171 self.thumbnail_finished(filepath, pixbuf) 172 173 if pixbuf and self.store_on_disk: 174 thumbpath = self._path_to_thumbpath(filepath) 175 self._save_thumbnail(pixbuf, thumbpath, tEXt_data) 176 177 return pixbuf 178 179 def _get_text_data(self, filepath): 180 ''' Creates a tEXt dictionary for <filepath>. ''' 181 mime = mimetypes.guess_type(filepath)[0] or 'unknown/mime' 182 uri = portability.uri_prefix() + pathname2url(i18n.to_unicode(os.path.normpath(filepath))) 183 stat = os.stat(filepath) 184 # MTime could be floating point number, so convert to long first to have a fixed point number 185 mtime = str(stat.st_mtime) 186 size = str(stat.st_size) 187 format, width, height = image_tools.get_image_info(filepath) 188 return { 189 'tEXt::Thumb::URI': uri, 190 'tEXt::Thumb::MTime': mtime, 191 'tEXt::Thumb::Size': size, 192 'tEXt::Thumb::Mimetype': mime, 193 'tEXt::Thumb::Image::Width': str(width), 194 'tEXt::Thumb::Image::Height': str(height), 195 'tEXt::Software': 'MComix %s' % constants.VERSION 196 } 197 198 def _save_thumbnail(self, pixbuf, thumbpath, tEXt_data): 199 ''' Saves <pixbuf> as <thumbpath>, with additional metadata 200 from <tEXt_data>. If <thumbpath> already exists, it is overwritten. ''' 201 202 try: 203 directory = os.path.dirname(thumbpath) 204 if not os.path.isdir(directory): 205 os.makedirs(directory, 0o700) 206 if os.path.isfile(thumbpath): 207 os.remove(thumbpath) 208 209 option_keys = [] 210 option_values = [] 211 for key, value in tEXt_data.items(): 212 option_keys.append(key) 213 option_values.append(value) 214 pixbuf.savev(thumbpath, 'png', option_keys, option_values) 215 os.chmod(thumbpath, 0o600) 216 217 except Exception as ex: 218 log.warning( _('! Could not save thumbnail "%(thumbpath)s": %(error)s'), 219 { 'thumbpath' : thumbpath, 'error' : ex } ) 220 221 def _thumbnail_exists(self, filepath): 222 ''' Checks if the thumbnail for <filepath> already exists. 223 This function will return False if the thumbnail exists 224 and it's mTime doesn't match the mTime of <filepath>, 225 it's size is different from the one specified in the thumbnailer, 226 or if <force_recreation> is True. ''' 227 228 if not self.force_recreation: 229 thumbpath = self._path_to_thumbpath(filepath) 230 231 if os.path.isfile(thumbpath): 232 # Check the thumbnail's stored mTime 233 try: 234 with reader.LockedFileIO(thumbpath) as fio: 235 with Image.open(fio) as img: 236 info = img.info 237 stored_mtime = float(info['Thumb::MTime']) 238 # The source file might no longer exist 239 file_mtime = os.path.isfile(filepath) and os.stat(filepath).st_mtime or stored_mtime 240 return stored_mtime == file_mtime and \ 241 max(*img.size) == max(self.width, self.height) 242 except IOError: 243 return False 244 else: 245 return False 246 else: 247 return False 248 249 def _path_to_thumbpath(self, filepath): 250 ''' Converts <path> to an URI for the thumbnail in <dst_dir>. ''' 251 uri = portability.uri_prefix() + pathname2url(i18n.to_unicode(os.path.normpath(filepath))) 252 return self._uri_to_thumbpath(uri) 253 254 def _uri_to_thumbpath(self, uri): 255 ''' Return the full path to the thumbnail for <uri> with <dst_dir> 256 being the base thumbnail directory. ''' 257 md5hash = md5(uri.encode()).hexdigest() 258 thumbpath = os.path.join(self.dst_dir, md5hash + '.png') 259 return thumbpath 260 261 def _guess_cover(self, files): 262 '''Return the filename within <files> that is the most likely to be the 263 cover of an archive using some simple heuristics. 264 ''' 265 # Ignore MacOSX meta files. 266 files = filter(lambda filename: 267 '__MACOSX' not in os.path.normpath(filename).split(os.sep), 268 files) 269 # Ignore credit files if possible. 270 files = filter(lambda filename: 271 'credit' not in os.path.basename(filename).lower(), files) 272 273 images = [f for f in files if image_tools.is_image_file(f)] 274 275 tools.alphanumeric_sort(images) 276 277 front_re = re.compile('(cover|front)', re.I) 278 candidates = filter(front_re.search, images) 279 candidates = [c for c in candidates if 'back' not in c.lower()] 280 281 if candidates: 282 return candidates[0] 283 284 if images: 285 return images[0] 286 287 return None 288 289# vim: expandtab:sw=4:ts=4 290