1# This file is part of MyPaint.
2# Copyright (C) 2012-2019 by the MyPaint Development Team.
3# Copyright (C) 2007-2012 by Martin Renold <martinxyz@gmx.ch>
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9
10from __future__ import division, print_function
11
12import itertools
13from math import floor, isnan
14import os
15import hashlib
16import zipfile
17import colorsys
18import gc
19import logging
20import sys
21
22from lib.gibindings import GdkPixbuf
23from lib.gettext import C_
24
25from . import mypaintlib
26import lib.pixbuf
27import lib.glib
28from lib.pycompat import PY2
29from lib.pycompat import unicode
30
31logger = logging.getLogger(__name__)
32
33
34class Rect (object):
35    """Representation of a rectangular area.
36
37    We use our own class here because (around GTK 3.18.x, at least) it's
38    less subject to typelib omissions than Gdk.Rectangle.
39
40    Ref: https://github.com/mypaint/mypaint/issues/437
41
42    >>> big = Rect(-3, 2, 180, 222)
43    >>> a = Rect(0, 10, 5, 15)
44    >>> b = Rect(2, 10, 1, 15)
45    >>> c = Rect(-1, 10, 1, 30)
46    >>> a.contains(b)
47    True
48    >>> not b.contains(a)
49    True
50    >>> [big.contains(r) for r in [a, b, c]]
51    [True, True, True]
52    >>> [big.overlaps(r) for r in [a, b, c]]
53    [True, True, True]
54    >>> [r.overlaps(big) for r in [a, b, c]]
55    [True, True, True]
56    >>> a.overlaps(b) and b.overlaps(a)
57    True
58    >>> (not a.overlaps(c)) and (not c.overlaps(a))
59    True
60
61    >>> r1 = Rect(-40, -40, 5, 5)
62    >>> r2 = Rect(-40 - 1, - 40 + 5, 5, 500)
63    >>> assert not r1.overlaps(r2)
64    >>> assert not r2.overlaps(r1)
65    >>> r1.y += 1
66    >>> assert r1.overlaps(r2)
67    >>> assert r2.overlaps(r1)
68    >>> i = r1.intersection(r2)
69    >>> assert i.h == 1
70    >>> assert i.w == 4
71    >>> assert i.x == r1.x
72    >>> assert i.y == r2.y
73    >>> r1.x += 999
74    >>> assert not r1.overlaps(r2)
75    >>> assert not r2.overlaps(r1)
76
77    """
78
79    def __init__(self, x=0, y=0, w=0, h=0):
80        """Initializes, with optional location and dimensions."""
81        object.__init__(self)
82        self.x = x
83        self.y = y
84        self.w = w
85        self.h = h
86
87    @classmethod
88    def new_from_gdk_rectangle(cls, gdk_rect):
89        """Creates a new Rect based on a Gdk.Rectangle."""
90        return Rect(
91            x = gdk_rect.x,
92            y = gdk_rect.y,
93            w = gdk_rect.width,
94            h = gdk_rect.height,
95        )
96
97    def __iter__(self):
98        """Allows iteration, and thus casting to tuples and lists.
99
100        The sequence returned is always 4 items long, and in the order
101        x, y, w, h.
102
103        """
104        return iter((self.x, self.y, self.w, self.h))
105
106    def empty(self):
107        """Returns true if the rectangle has zero area."""
108        return self.w == 0 or self.h == 0
109
110    def copy(self):
111        """Copies and returns the Rect."""
112        return Rect(self.x, self.y, self.w, self.h)
113
114    def expand(self, border):
115        """Expand the area by a fixed border size."""
116        self.w += 2 * border
117        self.h += 2 * border
118        self.x -= border
119        self.y -= border
120
121    def contains(self, other):
122        """Returns true if this rectangle entirely contains another."""
123        return (
124            other.x >= self.x and
125            other.y >= self.y and
126            other.x + other.w <= self.x + self.w and
127            other.y + other.h <= self.y + self.h
128        )
129
130    def __eq__(self, other):
131        """Returns true if this rectangle is identical to another."""
132        try:
133            return tuple(self) == tuple(other)
134        except TypeError:  # e.g. comparison to None
135            return False
136
137    def overlaps(self, r2):
138        """Returns true if this rectangle intersects another."""
139        if max(self.x, r2.x) >= min(self.x + self.w, r2.x + r2.w):
140            return False
141        if max(self.y, r2.y) >= min(self.y + self.h, r2.y + r2.h):
142            return False
143        return True
144
145    def expand_to_include_point(self, x, y):
146        if self.w == 0 or self.h == 0:
147            self.x = x
148            self.y = y
149            self.w = 1
150            self.h = 1
151            return
152        if x < self.x:
153            self.w += self.x - x
154            self.x = x
155        if y < self.y:
156            self.h += self.y - y
157            self.y = y
158        if x > self.x + self.w - 1:
159            self.w += x - (self.x + self.w - 1)
160        if y > self.y + self.h - 1:
161            self.h += y - (self.y + self.h - 1)
162
163    def expand_to_include_rect(self, other):
164        if other.empty():
165            return
166        self.expand_to_include_point(other.x, other.y)
167        self.expand_to_include_point(
168            other.x + other.w - 1,
169            other.y + other.h - 1,
170        )
171
172    def intersection(self, other):
173        """Creates new Rect for the intersection with another
174        If the rectangles do not intersect, None is returned
175        :rtype: Rect
176        """
177        if not self.overlaps(other):
178            return None
179
180        x = max(self.x, other.x)
181        y = max(self.y, other.y)
182        rx = min(self.x + self.w, other.x + other.w)
183        ry = min(self.y + self.h, other.y + other.h)
184        return Rect(x, y, rx - x, ry - y)
185
186    def __repr__(self):
187        return 'Rect(%d, %d, %d, %d)' % (self.x, self.y, self.w, self.h)
188
189    # Deprecated method names:
190
191    expandToIncludePoint = expand_to_include_point
192    expandToIncludeRect = expand_to_include_rect
193
194
195def coordinate_bounds(tile_coords):
196    """Find min/max x, y bounds of (x, y) pairs
197
198    If the input iterable's length is 0, None is returned
199    :param iterable tile_coords: iterable of (x, y)
200    :returns: (min x, min y, max x, max y) or None
201    :rtype: (int, int, int, int) | None
202
203    >>> coordinate_bounds([])
204    >>> coordinate_bounds([(0, 0)])
205    (0, 0, 0, 0)
206    >>> coordinate_bounds([(-10, 5), (0, 0)])
207    (-10, 0, 0, 5)
208    >>> coordinate_bounds([(3, 5), (0, 0), (-3, 7), (20, -10)])
209    (-3, -10, 20, 7)
210    """
211    lim = float('inf')
212    min_x, min_y, max_x, max_y = lim, lim, -lim, -lim
213    # Determine minima and maxima in one pass
214    for x, y in tile_coords:
215        min_x = min(min_x, x)
216        min_y = min(min_y, y)
217        max_x = max(max_x, x)
218        max_y = max(max_y, y)
219    if min_x == lim:
220        return None
221    else:
222        return min_x, min_y, max_x, max_y
223
224
225def rotated_rectangle_bbox(corners):
226    list_y = [y for (x, y) in corners]
227    list_x = [x for (x, y) in corners]
228    x1 = int(floor(min(list_x)))
229    y1 = int(floor(min(list_y)))
230    x2 = int(floor(max(list_x)))
231    y2 = int(floor(max(list_y)))
232    return x1, y1, x2 - x1 + 1, y2 - y1 + 1
233
234
235def clamp(x, lo, hi):
236    if x < lo:
237        return lo
238    if x > hi:
239        return hi
240    return x
241
242
243def gdkpixbuf2numpy(pixbuf):
244    # gdk.Pixbuf.get_pixels_array() is no longer wrapped; use our own
245    # implementation.
246    return mypaintlib.gdkpixbuf_get_pixels_array(pixbuf)
247    # Can't do the following - the created generated array is immutable
248    # w, h = pixbuf.get_width(), pixbuf.get_height()
249    # assert pixbuf.get_bits_per_sample() == 8
250    # assert pixbuf.get_has_alpha()
251    # assert pixbuf.get_n_channels() == 4
252    # arr = np.frombuffer(pixbuf.get_pixels(), dtype=np.uint8)
253    # arr = arr.reshape(h, w, 4)
254    # return arr
255
256
257def freedesktop_thumbnail(filename, pixbuf=None, force=False):
258    """Fetch or (re-)generate the thumbnail in $XDG_CACHE_HOME/thumbnails.
259
260    If there is no thumbnail for the specified filename, a new
261    thumbnail will be generated and stored according to the FDO spec.
262    A thumbnail will also get regenerated if the file modification times
263    of thumbnail and original image do not match.
264
265    :param GdkPixbuf.Pixbuf pixbuf: Thumbnail to save, optional.
266    :param bool force: Force rengeneration (skip mtime checks).
267    :returns: the large (256x256) thumbnail, or None.
268    :rtype: GdkPixbuf.Pixbuf
269
270    When pixbuf is given, it will be scaled and used as thumbnail
271    instead of reading the file itself. In this case the file is still
272    accessed to get its mtime, so this method must not be called if
273    the file is still open.
274
275    >>> image = "svg/thumbnail-test-input.svg"
276    >>> p1 = freedesktop_thumbnail(image, force=True)
277    >>> isinstance(p1, GdkPixbuf.Pixbuf)
278    True
279    >>> p2 = freedesktop_thumbnail(image)
280    >>> isinstance(p2, GdkPixbuf.Pixbuf)
281    True
282    >>> p2.to_string() == p1.to_string()
283    True
284    >>> p2.get_width() == p2.get_height() == 256
285    True
286
287    """
288
289    uri = lib.glib.filename_to_uri(os.path.abspath(filename))
290    logger.debug("thumb: uri=%r", uri)
291    if not isinstance(uri, bytes):
292        uri = uri.encode("utf-8")
293    file_hash = hashlib.md5(uri).hexdigest()
294
295    cache_dir = lib.glib.get_user_cache_dir()
296    base_directory = os.path.join(cache_dir, u'thumbnails')
297
298    directory = os.path.join(base_directory, u'normal')
299    tb_filename_normal = os.path.join(directory, file_hash) + u'.png'
300
301    if not os.path.exists(directory):
302        os.makedirs(directory, 0o700)
303    directory = os.path.join(base_directory, u'large')
304    tb_filename_large = os.path.join(directory, file_hash) + u'.png'
305    if not os.path.exists(directory):
306        os.makedirs(directory, 0o700)
307
308    file_mtime = str(int(os.stat(filename).st_mtime))
309
310    save_thumbnail = True
311
312    if filename.lower().endswith(u'.ora'):
313        # don't bother with normal (128x128) thumbnails when we can
314        # get a large one (256x256) from the file in an instant
315        acceptable_tb_filenames = [tb_filename_large]
316    else:
317        # prefer the large thumbnail, but accept the normal one if
318        # available, for the sake of performance
319        acceptable_tb_filenames = [tb_filename_large, tb_filename_normal]
320
321    # Use the largest stored thumbnail that isn't obsolete,
322    # Unless one was passed in,
323    # or regeneration is being forced.
324    for fn in acceptable_tb_filenames:
325        if pixbuf or force or (not os.path.isfile(fn)):
326            continue
327        try:
328            pixbuf = lib.pixbuf.load_from_file(fn)
329        except Exception as e:
330            logger.warning(
331                u"thumb: cache file %r looks corrupt (%r). "
332                u"It will be regenerated.",
333                fn, unicode(e),
334            )
335            pixbuf = None
336        else:
337            assert pixbuf is not None
338            if file_mtime == pixbuf.get_option("tEXt::Thumb::MTime"):
339                save_thumbnail = False
340                break
341            else:
342                pixbuf = None
343
344    # Try to load a pixbuf from the file, if we still need one.
345    if not pixbuf:
346        pixbuf = get_pixbuf(filename)
347
348    # Update the fd.o thumbs cache.
349    if pixbuf:
350        pixbuf = scale_proportionally(pixbuf, 256, 256)
351        if save_thumbnail:
352            png_opts = {"tEXt::Thumb::MTime": file_mtime,
353                        "tEXt::Thumb::URI": uri}
354            logger.debug("thumb: png_opts=%r", png_opts)
355            lib.pixbuf.save(
356                pixbuf,
357                tb_filename_large,
358                type='png',
359                **png_opts
360            )
361            logger.debug("thumb: saved large (256x256) thumbnail to %r",
362                         tb_filename_large)
363            # save normal size too, in case some implementations don't
364            # bother with large thumbnails
365            pixbuf_normal = scale_proportionally(pixbuf, 128, 128)
366            lib.pixbuf.save(
367                pixbuf_normal,
368                tb_filename_normal,
369                type='png',
370                **png_opts
371            )
372            logger.debug("thumb: saved normal (128x128) thumbnail to %r",
373                         tb_filename_normal)
374
375    # Return the 256x256 scaled version.
376    return pixbuf
377
378
379def get_pixbuf(filename):
380    """Loads a thumbnail pixbuf loaded from a file.
381
382    :param filename: File to get a thumbnail image from.
383    :returns: Thumbnail puixbuf, or None.
384    :rtype: GdkPixbuf.Pixbuf
385
386    >>> p = get_pixbuf("pixmaps/mypaint_logo.png")
387    >>> isinstance(p, GdkPixbuf.Pixbuf)
388    True
389    >>> p = get_pixbuf("tests/bigimage.ora")
390    >>> isinstance(p, GdkPixbuf.Pixbuf)
391    True
392    >>> get_pixbuf("desktop/icons") is None
393    True
394    >>> get_pixbuf("pixmaps/nonexistent.foo") is None
395    True
396
397    """
398    if not os.path.isfile(filename):
399        logger.debug("No thumb pixbuf for %r: not a file", filename)
400        return None
401    ext = os.path.splitext(filename)[1].lower()
402    if ext == ".ora":
403        thumb_entry = "Thumbnails/thumbnail.png"
404        try:
405            with zipfile.ZipFile(filename) as orazip:
406                pixbuf = lib.pixbuf.load_from_zipfile(orazip, thumb_entry)
407        except Exception:
408            logger.exception(
409                "Failed to read %r entry of %r",
410                thumb_entry,
411                filename,
412            )
413            return None
414        if not pixbuf:
415            logger.error(
416                "Failed to parse %r entry of %r",
417                thumb_entry,
418                filename,
419            )
420            return None
421        logger.debug(
422            "Parsed %r entry of %r successfully",
423            thumb_entry,
424            filename,
425        )
426        return pixbuf
427    else:
428        try:
429            return lib.pixbuf.load_from_file(filename)
430        except Exception:
431            logger.exception(
432                "Failed to load thumbnail pixbuf from %r",
433                filename,
434            )
435            return None
436
437
438def scale_proportionally(pixbuf, w, h, shrink_only=True):
439    width, height = pixbuf.get_width(), pixbuf.get_height()
440    scale = min(w / width, h / height)
441    if shrink_only and scale >= 1:
442        return pixbuf
443    new_width, new_height = int(width * scale), int(height * scale)
444    new_width = max(new_width, 1)
445    new_height = max(new_height, 1)
446    return pixbuf.scale_simple(new_width, new_height,
447                               GdkPixbuf.InterpType.BILINEAR)
448
449
450def pixbuf_thumbnail(src, w, h, alpha=False):
451    """Creates a centered thumbnail of a GdkPixbuf.
452    """
453    src2 = scale_proportionally(src, w, h)
454    w2, h2 = src2.get_width(), src2.get_height()
455    dst = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, alpha, 8, w, h)
456    if alpha:
457        dst.fill(0xffffff00)  # transparent background
458    else:
459        dst.fill(0xffffffff)  # white background
460    src2.composite(
461        dst,
462        (w - w2) // 2, (h - h2) // 2,
463        w2, h2,
464        (w - w2) // 2, (h - h2) // 2,
465        1, 1,
466        GdkPixbuf.InterpType.BILINEAR,
467        255,
468    )
469    return dst
470
471
472def rgb_to_hsv(r, g, b):
473    assert not isnan(r)
474    r = clamp(r, 0.0, 1.0)
475    g = clamp(g, 0.0, 1.0)
476    b = clamp(b, 0.0, 1.0)
477    h, s, v = colorsys.rgb_to_hsv(r, g, b)
478    assert not isnan(h)
479    return h, s, v
480
481
482def hsv_to_rgb(h, s, v):
483    h = clamp(h, 0.0, 1.0)
484    s = clamp(s, 0.0, 1.0)
485    v = clamp(v, 0.0, 1.0)
486    return colorsys.hsv_to_rgb(h, s, v)
487
488
489def transform_hsv(hsv, eotf):
490    r, g, b = hsv_to_rgb(*hsv)
491    return rgb_to_hsv(r**eotf, g**eotf, b**eotf)
492
493
494def zipfile_writestr(z, arcname, data):
495    """Write a string into a zipfile entry, with standard permissions
496
497    :param zipfile.ZipFile z: A zip file open for write.
498    :param unicode arcname: Name of the file entry to add.
499    :param bytes data: Content to add.
500
501    Work around bad permissions with the standard
502    `zipfile.Zipfile.writestr`: http://bugs.python.org/issue3394. The
503    original zero-permissions defect was fixed upstream, but do we want
504    more public permissions than the fix's 0600?
505
506    """
507    zi = zipfile.ZipInfo(arcname)
508    zi.external_attr = 0o644 << 16  # wider perms, should match z.write()
509    zi.external_attr |= 0o100000 << 16  # regular file
510    z.writestr(zi, data)
511
512
513def run_garbage_collector():
514    logger.info('MEM: garbage collector run, collected %d objects',
515                gc.collect())
516    logger.info('MEM: gc.garbage contains %d items of uncollectible garbage',
517                len(gc.garbage))
518
519
520old_stats = []
521
522
523def record_memory_leak_status(print_diff=False):
524    run_garbage_collector()
525    logger.info('MEM: collecting info (can take some time)...')
526    new_stats = []
527    for obj in gc.get_objects():
528        if 'A' <= getattr(obj, '__name__', ' ')[0] <= 'Z':
529            cnt = len(gc.get_referrers(obj))
530            new_stats.append((obj.__name__ + ' ' + str(obj), cnt))
531    new_stats.sort()
532    logger.info('MEM: ...done collecting.')
533    global old_stats
534    if old_stats:
535        if print_diff:
536            d = {}
537            for obj, cnt in old_stats:
538                d[obj] = cnt
539            for obj, cnt in new_stats:
540                cnt_old = d.get(obj, 0)
541                if cnt != cnt_old:
542                    logger.info('MEM: DELTA %+d %s', cnt - cnt_old, obj)
543    else:
544        logger.info('MEM: Stored stats to compare with the next '
545                    'info collection.')
546    old_stats = new_stats
547
548
549def utf8(string):
550    """Return the input as bytes encoded by utf-8"""
551    return string.encode('utf-8')
552
553
554def fmt_time_period_abbr(t):
555    """Get a localized abbreviated minutes+seconds string
556
557    :param int t: A positive number of seconds
558    :returns: short localized string
559    :rtype: unicode
560
561    The result looks like like "<minutes>m<seconds>s",
562    or just "<seconds>s".
563
564    """
565    if t < 0:
566        raise ValueError("Parameter t cannot be negative")
567    days = int(t // (24 * 60 * 60))
568    hours = int(t - days * 24 * 60 * 60) // (60 * 60)
569    minutes = int(t - hours * 60 * 60) // 60
570    seconds = int(t - minutes * 60)
571    if t > 24 * 60 * 60:
572        # TRANSLATORS: Assumption for all "Time period abbreviations":
573        # TRANSLATORS: they don't need ngettext (to support plural/singular)
574        template = C_("Time period abbreviations", u"{days}d{hours}h")
575    elif t > 60 * 60:
576        template = C_("Time period abbreviations", u"{hours}h{minutes}m")
577    elif t > 60:
578        template = C_("Time period abbreviation", u"{minutes}m{seconds}s")
579    else:
580        template = C_("Time period abbreviation", u"{seconds}s")
581    return template.format(
582        days = days,
583        hours = hours,
584        minutes = minutes,
585        seconds = seconds,
586    )
587
588
589def grouper(iterable, n, fillvalue=None):
590    """Collect data into fixed-length chunks or blocks
591
592    :param iterable: An iterable
593    :param int n: How many items to chunk the iterator by
594    :param fillvalue: Filler value when iterable length isn't a multiple of n
595    :returns: An iterable with tuples n items from the source iterable
596    :rtype: iterable
597
598    >>> actual = grouper('ABCDEFG', 3, fillvalue='x')
599    >>> expected = [('A', 'B', 'C'), ('D', 'E', 'F'), ('G', 'x', 'x')]
600    >>> [a_val == e_val for a_val, e_val in zip(actual, expected)]
601    [True, True, True]
602    """
603    args = [iter(iterable)] * n
604    if PY2:
605        return itertools.izip_longest(*args, fillvalue=fillvalue)
606    else:
607        return itertools.zip_longest(*args, fillvalue=fillvalue)
608
609
610def casefold(s):
611    """Converts a unicode string into a case-insensitively comparable form.
612
613    Forward-compat marker for things that should be .casefold() in
614    Python 3, but which need to be .lower() in Python2.
615
616    :param str s: The string to convert.
617    :rtype: str
618    :returns: The converted string.
619
620    >>> casefold("Xyz") == u'xyz'
621    True
622
623    """
624    if sys.version_info <= (3, 0, 0):
625        s = unicode(s)
626        return s.lower()
627    else:
628        s = str(s)
629        return s.casefold()
630
631
632def _test():
633    import doctest
634    doctest.testmod()
635
636
637if __name__ == '__main__':
638    _test()
639