1'''image_tools.py - Various image manipulations.'''
2
3import binascii
4from functools import reduce
5from io import BytesIO
6import os
7import re
8import sys
9import operator
10
11from gi.repository import GdkPixbuf, Gio, GLib
12from PIL import Image
13from PIL import ImageEnhance
14from PIL import ImageOps
15from PIL import ImageSequence
16
17from mcomix import anime_tools
18from mcomix import constants
19from mcomix import log
20from mcomix import tools
21from mcomix.lib import reader
22from mcomix.preferences import prefs
23
24if tools.use_gui():
25    from gi.repository import Gdk, Gtk
26
27    # Fallback pixbuf for missing images.
28    MISSING_IMAGE_ICON = None
29
30    _missing_icon_dialog = Gtk.Dialog()
31    _missing_icon_pixbuf = _missing_icon_dialog.render_icon(
32        Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR
33    )
34    MISSING_IMAGE_ICON = _missing_icon_pixbuf
35    assert MISSING_IMAGE_ICON
36
37    GTK_GDK_COLOR_BLACK = Gdk.color_parse('black')
38    GTK_GDK_COLOR_WHITE = Gdk.color_parse('white')
39
40def _getexif(im):
41    exif={}
42    try:
43        exif.update(im.getexif())
44    except AttributeError:
45        pass
46    if exif:
47        return exif
48
49    # Exif of PNG is still buggy in Pillow 6.0.0
50    try:
51        l1,l2,size,*lines=im.info.get('Raw profile type exif').splitlines()
52        if l2!='exif':
53            # Not valid Exif data.
54            return {}
55        size=int(size)
56        data=binascii.unhexlify(''.join(lines))
57        if len(data)!=size:
58            # Size not match.
59            return {}
60        im.info['exif']=data
61    except:
62        # Not valid Exif data.
63        return {}
64
65    # load Exif again
66    try:
67        exif.update(im.getexif())
68    except AttributeError:
69        pass
70    return exif
71
72def rotate_pixbuf(src, rotation):
73    rotation %= 360
74    if 0 == rotation:
75        return src
76    if 90 == rotation:
77        return src.rotate_simple(GdkPixbuf.PixbufRotation.CLOCKWISE)
78    if 180 == rotation:
79        return src.rotate_simple(GdkPixbuf.PixbufRotation.UPSIDEDOWN)
80    if 270 == rotation:
81        return src.rotate_simple(GdkPixbuf.PixbufRotation.COUNTERCLOCKWISE)
82    raise ValueError('unsupported rotation: %s' % rotation)
83
84def get_fitting_size(source_size, target_size,
85                     keep_ratio=True, scale_up=False):
86    ''' Return a scaled version of <source_size>
87    small enough to fit in <target_size>.
88
89    Both <source_size> and <target_size>
90    must be (width, height) tuples.
91
92    If <keep_ratio> is True, aspect ratio is kept.
93
94    If <scale_up> is True, <source_size> is scaled up
95    when smaller than <target_size>.
96    '''
97    width, height = target_size
98    src_width, src_height = source_size
99    if not scale_up and src_width <= width and src_height <= height:
100        width, height = src_width, src_height
101    else:
102        if keep_ratio:
103            if float(src_width) / width > float(src_height) / height:
104                height = int(max(src_height * width / src_width, 1))
105            else:
106                width = int(max(src_width * height / src_height, 1))
107    return (width, height)
108
109def trans_pixbuf(src,flip=False,flop=False):
110    if is_animation(src):
111        return anime_tools.frame_executor(
112            src, trans_pixbuf,
113            kwargs=dict(flip=flip, flop=flop)
114        )
115    if flip: src = src.flip(horizontal=False)
116    if flop: src = src.flip(horizontal=True)
117    return src
118
119def fit_pixbuf_to_rectangle(src, rect, rotation):
120    if is_animation(src):
121        return anime_tools.frame_executor(
122            src, fit_pixbuf_to_rectangle,
123            args=(rect, rotation)
124        )
125    return fit_in_rectangle(src, rect[0], rect[1],
126                            rotation=rotation,
127                            keep_ratio=False,
128                            scale_up=True)
129
130def fit_in_rectangle(src, width, height, keep_ratio=True, scale_up=False,
131                     rotation=0, scaling_quality=None, pil_filter=None):
132    '''Scale (and return) a pixbuf so that it fits in a rectangle with
133    dimensions <width> x <height>. A negative <width> or <height>
134    means an unbounded dimension - both cannot be negative.
135
136    If <rotation> is 90, 180 or 270 we rotate <src> first so that the
137    rotated pixbuf is fitted in the rectangle.
138
139    Unless <scale_up> is True we don't stretch images smaller than the
140    given rectangle.
141
142    If <keep_ratio> is True, the image ratio is kept, and the result
143    dimensions may be smaller than the target dimensions.
144
145    If <src> has an alpha channel it gets a checkboard background.
146    '''
147    # "Unbounded" really means "bounded to 100000 px" - for simplicity.
148    # MComix would probably choke on larger images anyway.
149    if width < 0:
150        width = 100000
151    elif height < 0:
152        height = 100000
153    width = max(width, 1)
154    height = max(height, 1)
155
156    rotation %= 360
157    if rotation not in (0, 90, 180, 270):
158        raise ValueError('unsupported rotation: %s' % rotation)
159    if rotation in (90, 270):
160        width, height = height, width
161
162    if scaling_quality is None:
163        scaling_quality = prefs['scaling quality']
164
165    if pil_filter is None:
166        pil_filter = prefs['pil scaling filter']
167
168    src_width = src.get_width()
169    src_height = src.get_height()
170
171    width, height = get_fitting_size((src_width, src_height),
172                                     (width, height),
173                                     keep_ratio=keep_ratio,
174                                     scale_up=scale_up)
175
176    if (width, height) != (src_width, src_height) and pil_filter > -1:
177        # scale by PIL interpolation filter
178        src = pil_to_pixbuf(pixbuf_to_pil(src).resize(
179            [width,height], resample=pil_filter))
180        src_width = src.get_width()
181        src_height = src.get_height()
182        assert (width, height) == (src_width, src_height),'PIL resize bug'
183
184    if src.get_has_alpha():
185        if prefs['checkered bg for transparent images']:
186            check_size, color1, color2 = 8, 0x777777, 0x999999
187        else:
188            check_size, color1, color2 = 1024, 0xFFFFFF, 0xFFFFFF
189        if (width, height) == (src_width, src_height):
190            # Using anything other than nearest interpolation will result in a
191            # modified image if no resizing takes place (even if it's opaque).
192            scaling_quality = GdkPixbuf.InterpType.NEAREST
193        src = src.composite_color_simple(width, height, scaling_quality,
194                                         255, check_size, color1, color2)
195    elif (width, height) != (src_width, src_height):
196        src = src.scale_simple(width, height, scaling_quality)
197
198    src = rotate_pixbuf(src, rotation)
199
200    return src
201
202def add_border(pixbuf, thickness, colour=0x000000FF):
203    '''Return a pixbuf from <pixbuf> with a <thickness> px border of
204    <colour> added.
205    '''
206    canvas = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8,
207                                  pixbuf.get_width() + thickness * 2,
208                                  pixbuf.get_height() + thickness * 2)
209    canvas.fill(colour)
210    pixbuf.copy_area(0, 0, pixbuf.get_width(), pixbuf.get_height(),
211        canvas, thickness, thickness)
212    return canvas
213
214
215def get_most_common_edge_color(pixbufs, edge=2):
216    '''Return the most commonly occurring pixel value along the four edges
217    of <pixbuf>. The return value is a sequence, (r, g, b), with 16 bit
218    values. If <pixbuf> is a tuple, the edges will be computed from
219    both the left and the right image.
220
221    Note: This could be done more cleanly with subpixbuf(), but that
222    doesn't work as expected together with get_pixels().
223    '''
224
225    def group_colors(colors, steps=10):
226        ''' This rounds a list of colors in C{colors} to the next nearest value,
227        i.e. 128, 83, 10 becomes 130, 85, 10 with C{steps}=5. This compensates for
228        dirty colors where no clear dominating color can be made out.
229
230        @return: The color that appears most often in the prominent group.'''
231
232        # Start group
233        group = (0, 0, 0)
234        # List of (count, color) pairs, group contains most colors
235        colors_in_prominent_group = []
236        color_count_in_prominent_group = 0
237        # List of (count, color) pairs, current color group
238        colors_in_group = []
239        color_count_in_group = 0
240
241        for count, color in colors:
242
243            # Round color
244            rounded = [0] * len(color)
245            for i, color_value in enumerate(color):
246                if steps % 2 == 0:
247                    middle = steps // 2
248                else:
249                    middle = steps // 2 + 1
250
251                remainder = color_value % steps
252                if remainder >= middle:
253                    color_value = color_value + (steps - remainder)
254                else:
255                    color_value = color_value - remainder
256
257                rounded[i] = min(255, max(0, color_value))
258
259            # Change prominent group if necessary
260            if rounded == group:
261                # Color still fits in the previous color group
262                colors_in_group.append((count, color))
263                color_count_in_group += count
264            else:
265                # Color group changed, check if current group has more colors
266                # than last group
267                if color_count_in_group > color_count_in_prominent_group:
268                    colors_in_prominent_group = colors_in_group
269                    color_count_in_prominent_group = color_count_in_group
270
271                group = rounded
272                colors_in_group = [ (count, color) ]
273                color_count_in_group = count
274
275        # Cleanup if only one edge color group was found
276        if color_count_in_group > color_count_in_prominent_group:
277            colors_in_prominent_group = colors_in_group
278
279        colors_in_prominent_group.sort(key=operator.itemgetter(0), reverse=True)
280        # List is now sorted by color count, first color appears most often
281        return colors_in_prominent_group[0][1]
282
283    def get_edge_pixbuf(pixbuf, side, edge):
284        ''' Returns a pixbuf corresponding to the side passed in <side>.
285        Valid sides are 'left', 'right', 'top', 'bottom'. '''
286        pixbuf = static_image(pixbuf)
287        width = pixbuf.get_width()
288        height = pixbuf.get_height()
289        edge = min(edge, width, height)
290
291        subpix = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB,
292                                      pixbuf.get_has_alpha(), 8, edge, height)
293        if side == 'left':
294            pixbuf.copy_area(0, 0, edge, height, subpix, 0, 0)
295        elif side == 'right':
296            pixbuf.copy_area(width - edge, 0, edge, height, subpix, 0, 0)
297        elif side == 'top':
298            pixbuf.copy_area(0, 0, width, edge, subpix, 0, 0)
299        elif side == 'bottom':
300            pixbuf.copy_area(0, height - edge, width, edge, subpix, 0, 0)
301        else:
302            assert False, 'Invalid edge side'
303
304        return subpix
305
306    if not pixbufs:
307        return (0, 0, 0)
308
309    if not isinstance(pixbufs, (tuple, list)):
310        left_edge = get_edge_pixbuf(pixbufs, 'left', edge)
311        right_edge = get_edge_pixbuf(pixbufs, 'right', edge)
312    else:
313        assert len(pixbufs) == 2, 'Expected two pages in list'
314        left_edge = get_edge_pixbuf(pixbufs[0], 'left', edge)
315        right_edge = get_edge_pixbuf(pixbufs[1], 'right', edge)
316
317    # Find all edge colors. Color count is separate for all four edges
318    ungrouped_colors = []
319    for edge in (left_edge, right_edge):
320        im = pixbuf_to_pil(edge)
321        ungrouped_colors.extend(im.getcolors(im.size[0] * im.size[1]))
322
323    # Sum up colors from all edges
324    ungrouped_colors.sort(key=operator.itemgetter(1))
325    most_used = group_colors(ungrouped_colors)
326    return [color/255 for color in most_used]
327
328def pil_to_pixbuf(im, keep_orientation=False):
329    '''Return a pixbuf created from the PIL <im>.'''
330    if im.mode.startswith('RGB'):
331        has_alpha = im.mode == 'RGBA'
332    elif im.mode in ('LA', 'P'):
333        has_alpha = True
334    else:
335        has_alpha = False
336    target_mode = 'RGBA' if has_alpha else 'RGB'
337    if im.mode != target_mode:
338        im = im.convert(target_mode)
339    pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
340        GLib.Bytes.new(im.tobytes()), GdkPixbuf.Colorspace.RGB,
341        has_alpha, 8,
342        im.size[0], im.size[1],
343        (4 if has_alpha else 3) * im.size[0]
344    )
345    if keep_orientation:
346        # Keep orientation metadata.
347        orientation = _getexif(im).get(274, None)
348        if orientation is not None:
349            setattr(pixbuf, 'orientation', str(orientation))
350    return pixbuf
351
352def pixbuf_to_pil(pixbuf):
353    '''Return a PIL image created from <pixbuf>.'''
354    dimensions = pixbuf.get_width(), pixbuf.get_height()
355    stride = pixbuf.get_rowstride()
356    pixels = pixbuf.get_pixels()
357    mode = 'RGBA' if pixbuf.get_has_alpha() else 'RGB'
358    im = Image.frombuffer(mode, dimensions, pixels, 'raw', mode, stride, 1)
359    return im
360
361def is_animation(pixbuf):
362    return isinstance(pixbuf, GdkPixbuf.PixbufAnimation)
363
364def disable_transform(pixbuf):
365    if is_animation(pixbuf):
366        if not hasattr(pixbuf,'_framebuffer'):
367            return True
368        if not prefs['animation transform']:
369            return True
370    return False
371
372def static_image(pixbuf):
373    ''' Returns a non-animated version of the specified pixbuf. '''
374    if is_animation(pixbuf):
375        return pixbuf.get_static_image()
376    return pixbuf
377
378def unwrap_image(image):
379    ''' Returns an object that contains the image data based on
380    gtk.Image.get_storage_type or None if image is None or image.get_storage_type
381    returns Gtk.ImageType.EMPTY. '''
382    if image is None:
383        return None
384    t = image.get_storage_type()
385    if t == Gtk.ImageType.EMPTY:
386        return None
387    if t == Gtk.ImageType.PIXBUF:
388        return image.get_pixbuf()
389    if t == Gtk.ImageType.ANIMATION:
390        return image.get_animation()
391    if t == Gtk.ImageType.PIXMAP:
392        return image.get_pixmap()
393    if t == Gtk.ImageType.IMAGE:
394        return image.get_image()
395    if t == Gtk.ImageType.STOCK:
396        return image.get_stock()
397    if t == Gtk.ImageType.ICON_SET:
398        return image.get_icon_set()
399    raise ValueError()
400
401def set_from_pixbuf(image, pixbuf):
402    if is_animation(pixbuf):
403        return image.set_from_animation(pixbuf)
404    else:
405        return image.set_from_pixbuf(pixbuf)
406
407def load_animation(im):
408    if im.format=='GIF' and im.mode=='P':
409        # TODO: Pillow has bug with gif animation
410        # https://github.com/python-pillow/Pillow/labels/GIF
411        raise NotImplementedError('Pillow has bug with gif animation, '
412                                  'fallback to GdkPixbuf')
413    anime=anime_tools.AnimeFrameBuffer(im.n_frames,loop=im.info['loop'])
414    background=im.info.get('background',None)
415    if isinstance(background,tuple):
416        color=0
417        for n,c in enumerate(background):
418            color|=c<<n*8
419        background=color
420    frameiter=ImageSequence.Iterator(im)
421    for n,frame in enumerate(frameiter):
422        anime.add_frame(n,pil_to_pixbuf(frame),
423                        int(frame.info.get('duration',0)),
424                        background=background)
425    return anime.create_animation()
426
427def load_pixbuf(path):
428    ''' Loads a pixbuf from a given image file. '''
429    enable_anime = prefs['animation mode'] != constants.ANIMATION_DISABLED
430    try:
431        with reader.LockedFileIO(path) as fio:
432            with Image.open(fio) as im:
433                # make sure n_frames loaded
434                im.load()
435                if enable_anime and getattr(im,'is_animated',False):
436                    return load_animation(im)
437                return pil_to_pixbuf(im, keep_orientation=True)
438    except:
439        pass
440    if enable_anime:
441        pixbuf = GdkPixbuf.PixbufAnimation.new_from_file(path)
442        if pixbuf.is_static_image():
443            return pixbuf.get_static_image()
444        return pixbuf
445    return GdkPixbuf.Pixbuf.new_from_file(path)
446
447def load_pixbuf_size(path, width, height):
448    ''' Loads a pixbuf from a given image file and scale it to fit
449    inside (width, height). '''
450    try:
451        with reader.LockedFileIO(path) as fio:
452            with Image.open(fio) as im:
453                im.thumbnail((width, height), resample=Image.BOX)
454                return pil_to_pixbuf(im, keep_orientation=True)
455    except:
456        info, image_width, image_height = GdkPixbuf.Pixbuf.get_file_info(path)
457        # If we could not get the image info, still try to load
458        # the image to let GdkPixbuf raise the appropriate exception.
459        if not info:
460            pixbuf = GdkPixbuf.Pixbuf.new_from_file(path)
461        # Don't upscale if smaller than target dimensions!
462        if image_width <= width and image_height <= height:
463            width, height = image_width, image_height
464        return GdkPixbuf.Pixbuf.new_from_file_at_size(path, width, height)
465
466def load_pixbuf_data(imgdata):
467    ''' Loads a pixbuf from the data passed in <imgdata>. '''
468    try:
469        with Image.open(BytesIO(imgdata)) as im:
470            return pil_to_pixbuf(im, keep_orientation=True)
471    except:
472        pass
473    loader = GdkPixbuf.PixbufLoader()
474    loader.write(imgdata)
475    loader.close()
476    return loader.get_pixbuf()
477
478def enhance(pixbuf, brightness=1.0, contrast=1.0, saturation=1.0,
479            sharpness=1.0, autocontrast=False):
480    '''Return a modified pixbuf from <pixbuf> where the enhancement operations
481    corresponding to each argument has been performed. A value of 1.0 means
482    no change. If <autocontrast> is True it overrides the <contrast> value,
483    but only if the image mode is supported by ImageOps.autocontrast (i.e.
484    it is L or RGB.)
485    '''
486    if is_animation(pixbuf):
487        return anime_tools.frame_executor(
488            pixbuf, enhance,
489            kwargs=dict(
490                brightness=brightness, contrast=contrast,
491                saturation=saturation, sharpness=1.0,
492                autocontrast=False
493            )
494        )
495    im = pixbuf_to_pil(pixbuf)
496    if brightness != 1.0:
497        im = ImageEnhance.Brightness(im).enhance(brightness)
498    if autocontrast and im.mode in ('L', 'RGB'):
499        im = ImageOps.autocontrast(im, cutoff=0.1)
500    elif contrast != 1.0:
501        im = ImageEnhance.Contrast(im).enhance(contrast)
502    if saturation != 1.0:
503        im = ImageEnhance.Color(im).enhance(saturation)
504    if sharpness != 1.0:
505        im = ImageEnhance.Sharpness(im).enhance(sharpness)
506    return pil_to_pixbuf(im)
507
508def get_implied_rotation(pixbuf):
509    '''Return the implied rotation in degrees: 0, 90, 180, or 270.
510
511    The implied rotation is the angle (in degrees) that the raw pixbuf should
512    be rotated in order to be displayed "correctly". E.g. a photograph taken
513    by a camera that is held sideways might store this fact in its Exif data,
514    and the pixbuf loader will set the orientation option correspondingly.
515    '''
516    pixbuf = static_image(pixbuf)
517    orientation = getattr(pixbuf, 'orientation', None)
518    if orientation is None:
519        orientation = pixbuf.get_option('orientation')
520    if orientation == '3':
521        return 180
522    elif orientation == '6':
523        return 90
524    elif orientation == '8':
525        return 270
526    return 0
527
528def combine_pixbufs( pixbuf1, pixbuf2, are_in_manga_mode ):
529    if are_in_manga_mode:
530        r_source_pixbuf = pixbuf1
531        l_source_pixbuf = pixbuf2
532    else:
533        l_source_pixbuf = pixbuf1
534        r_source_pixbuf = pixbuf2
535
536    has_alpha = False
537
538    if l_source_pixbuf.get_property('has-alpha') or \
539       r_source_pixbuf.get_property('has-alpha'):
540        has_alpha = True
541
542    bits_per_sample = 8
543
544    l_source_pixbuf_width = l_source_pixbuf.get_property('width')
545    r_source_pixbuf_width = r_source_pixbuf.get_property('width')
546
547    l_source_pixbuf_height = l_source_pixbuf.get_property('height')
548    r_source_pixbuf_height = r_source_pixbuf.get_property('height')
549
550    new_width = l_source_pixbuf_width + r_source_pixbuf_width
551
552    new_height = max(l_source_pixbuf_height, r_source_pixbuf_height)
553
554    new_pix_buf = GdkPixbuf.Pixbuf.new(colorspace=GdkPixbuf.Colorspace.RGB,
555                                       has_alpha=has_alpha,
556                                       bits_per_sample=bits_per_sample,
557                                       width=new_width, height=new_height)
558
559    l_source_pixbuf.copy_area(0, 0, l_source_pixbuf_width,
560                               l_source_pixbuf_height,
561                               new_pix_buf, 0, 0)
562
563    r_source_pixbuf.copy_area(0, 0, r_source_pixbuf_width,
564                              r_source_pixbuf_height,
565                              new_pix_buf, l_source_pixbuf_width, 0)
566
567    return new_pix_buf
568
569def convert_rgb16list_to_rgba8int(c):
570    return 0x000000FF | (c[0] >> 8 << 24) | (c[1] >> 8 << 16) | (c[2] >> 8 << 8)
571
572def rgb_to_y_601(color):
573    return color[0] * 0.299 + color[1] * 0.587 + color[2] * 0.114
574
575def text_color_for_background_color(bgcolor):
576    return GTK_GDK_COLOR_BLACK if rgb_to_y_601(bgcolor) >= \
577        65535.0 / 2.0 else GTK_GDK_COLOR_WHITE
578
579def get_image_info(path):
580    '''Return image informations:
581        (format, width, height)
582    '''
583    info = None
584    try:
585        with reader.LockedFileIO(path) as fio:
586            with Image.open(fio) as im:
587                return (im.format,) + im.size
588    except:
589        info = GdkPixbuf.Pixbuf.get_file_info(path)
590        if info[0] is None:
591            info = None
592        else:
593            info = info[0].get_name().upper(), info[1], info[2]
594    if info is None:
595        info = (_('Unknown filetype'), 0, 0)
596    return info
597
598SUPPORTED_IMAGE_EXTS=set()
599SUPPORTED_IMAGE_MIMES=set()
600SUPPORTED_IMAGE_FORMATS={}
601
602def init_supported_formats():
603    # formats supported by PIL
604    # Make sure all supported formats are registered.
605    Image.init()
606    for ext,name in Image.EXTENSION.items():
607        fmt=SUPPORTED_IMAGE_FORMATS.setdefault(name,(set(),set()))
608        fmt[1].add(ext.lower())
609        mime=Image.MIME.get(
610            name, Gio.content_type_guess(filename='file'+ext)[0]).lower()
611        if mime and mime != 'application/octet-stream':
612            fmt[0].add(mime)
613
614    # formats supported by gdk-pixbuf
615    for gdkfmt in GdkPixbuf.Pixbuf.get_formats():
616        fmt=SUPPORTED_IMAGE_FORMATS.setdefault(
617            gdkfmt.get_name().upper(),(set(),set()))
618        for m in map(lambda s:s.lower(),gdkfmt.get_mime_types()):
619            fmt[0].add(m)
620        # get_extensions() return extensions without '.'
621        for e in map(lambda s:'.'+s.lower(),gdkfmt.get_extensions()):
622            fmt[1].add(e)
623            m = Gio.content_type_guess(filename='file'+e)[0].lower()
624            if m and m != 'application/octet-stream':
625                fmt[0].add(m)
626
627    # cache a supported extensions list
628    for mimes,exts in SUPPORTED_IMAGE_FORMATS.values():
629        SUPPORTED_IMAGE_EXTS.update(exts)
630        SUPPORTED_IMAGE_MIMES.update(mimes)
631
632def get_supported_formats():
633    if not SUPPORTED_IMAGE_FORMATS:
634        init_supported_formats()
635    return SUPPORTED_IMAGE_FORMATS
636
637def is_image_file(path, check_mimetype=False):
638    # if check_mimetype is True,
639    # read starting bytes and using Gio.content_type_guess
640    # to guess if path is supported, ignoring file extension.
641    if not SUPPORTED_IMAGE_FORMATS:
642        init_supported_formats()
643    if prefs['check image mimetype'] and check_mimetype and os.path.isfile(path):
644        with open(path, mode='rb') as fd:
645            magic = fd.read(10)
646        mime, uncertain = Gio.content_type_guess(data=magic)
647        return mime.lower() in SUPPORTED_IMAGE_MIMES
648    return path.lower().endswith(tuple(SUPPORTED_IMAGE_EXTS))
649
650# vim: expandtab:sw=4:ts=4
651