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