1# This file is part of MyPaint. 2# Copyright (C) 2013-2018 by the MyPaint Development Team 3# 4# This program is free software; you can redistribute it and/or modify 5# it under the terms of the GNU General Public License as published by 6# the Free Software Foundation; either version 2 of the License, or 7# (at your option) any later version. 8 9 10"""Palette: user-defined lists of color swatches""" 11 12# TODO: Make palettes part of the model, save as part of ORA documents. 13 14 15## Imports 16 17from __future__ import division, print_function 18 19import re 20from copy import copy 21import logging 22 23from lib.helpers import clamp 24from lib.observable import event 25from lib.color import RGBColor 26from lib.color import YCbCrColor 27from lib.color import UIColor # noqa 28from lib.pycompat import unicode 29from lib.pycompat import xrange 30from lib.pycompat import PY3 31from io import open 32 33logger = logging.getLogger(__name__) 34 35## Class and function defs 36 37 38class Palette (object): 39 """A flat list of color swatches, compatible with the GIMP 40 41 As a (sideways-compatible) extension to the GIMP's format, MyPaint supports 42 empty slots in the palette. These slots are represented by pure black 43 swatches with the name ``__NONE__``. 44 45 Palette objects expose the position within the palette of a current color 46 match, which can be declared to be approximate or exact. This is used for 47 highlighting the user concept of the "current color" in the GUI. 48 49 Palette objects can be serialized in the GIMP's file format (the regular 50 `unicode()` function on a Palette will do this too), or converted to and 51 from a simpler JSON-ready representation for storing in the MyPaint prefs. 52 Support for loading and saving via modal dialogs is defined here too. 53 54 """ 55 56 ## Class-level constants 57 _EMPTY_SLOT_ITEM = RGBColor(-1, -1, -1) 58 _EMPTY_SLOT_NAME = "__NONE__" 59 60 ## Construction, loading and saving 61 62 def __init__(self, filehandle=None, filename=None, colors=None): 63 """Instantiate, from a file or a sequence of colors 64 65 :param filehandle: Filehandle to load. 66 :param filename: Name of a file to load. 67 :param colors: Iterable sequence of colors (lib.color.UIColor). 68 69 The constructor arguments are mutually exclusive. With no args 70 specified, you get an empty palette. 71 72 >>> Palette() 73 <Palette colors=0, columns=0, name=None> 74 75 Palettes can be generated from interpolations, which is handy for 76 testing, at least. 77 78 >>> cols = RGBColor(1,1,0).interpolate(RGBColor(1,0,1), 10) 79 >>> Palette(colors=cols) 80 <Palette colors=10, columns=0, name=None> 81 82 """ 83 super(Palette, self).__init__() 84 85 #: Number of columns. 0 means "natural flow" 86 self._columns = 0 87 #: List of named colors 88 self._colors = [] 89 #: Name of the palette as a Unicode string, or None 90 self._name = None 91 #: Current position in the palette. None=no match; integer=index. 92 self._match_position = None 93 #: True if the current match is approximate 94 self._match_is_approx = False 95 96 # Clear and initialize 97 self.clear(silent=True) 98 if colors: 99 for col in colors: 100 col = self._copy_color_in(col) 101 self._colors.append(col) 102 elif filehandle: 103 self.load(filehandle, silent=True) 104 elif filename: 105 with open(filename, "r", encoding="utf-8", errors="replace") as fp: 106 self.load(fp, silent=True) 107 108 def clear(self, silent=False): 109 """Resets the palette to its initial state. 110 111 >>> grey16 = RGBColor(1,1,1).interpolate(RGBColor(0,0,0), 16) 112 >>> p = Palette(colors=grey16) 113 >>> p.name = "Greyscale" 114 >>> p.columns = 3 115 >>> p # doctest: +ELLIPSIS 116 <Palette colors=16, columns=3, name=...'Greyscale'> 117 >>> p.clear() 118 >>> p 119 <Palette colors=0, columns=0, name=None> 120 121 Fires the `info_changed()`, `sequence_changed()`, and `match_changed()` 122 events, unless the `silent` parameter tests true. 123 """ 124 self._colors = [] 125 self._columns = 0 126 self._name = None 127 self._match_position = None 128 self._match_is_approx = False 129 if not silent: 130 self.info_changed() 131 self.sequence_changed() 132 self.match_changed() 133 134 def load(self, filehandle, silent=False): 135 """Load contents from a file handle containing a GIMP palette. 136 137 :param filehandle: File-like object (.readline, line iteration) 138 :param bool silent: If true, don't emit any events. 139 140 >>> pal = Palette() 141 >>> with open("palettes/MyPaint_Default.gpl", "r") as fp: 142 ... pal.load(fp) 143 >>> len(pal) > 1 144 True 145 146 If the file format is incorrect, a RuntimeError will be raised. 147 148 """ 149 comment_line_re = re.compile(r'^#') 150 field_line_re = re.compile(r'^(\w+)\s*:\s*(.*)$') 151 color_line_re = re.compile(r'^(\d+)\s+(\d+)\s+(\d+)\s*(?:\b(.*))$') 152 fp = filehandle 153 self.clear(silent=True) # method fires events itself 154 line = fp.readline() 155 if line.strip() != "GIMP Palette": 156 raise RuntimeError("Not a valid GIMP Palette") 157 header_done = False 158 line_num = 0 159 for line in fp: 160 line = line.strip() 161 line_num += 1 162 if line == '': 163 continue 164 if comment_line_re.match(line): 165 continue 166 if not header_done: 167 match = field_line_re.match(line) 168 if match: 169 key, value = match.groups() 170 key = key.lower() 171 if key == 'name': 172 self._name = value.strip() 173 elif key == 'columns': 174 self._columns = int(value) 175 else: 176 logger.warning("Unknown 'key:value' pair %r", line) 177 continue 178 else: 179 header_done = True 180 match = color_line_re.match(line) 181 if not match: 182 logger.warning("Expected 'R G B [Name]', not %r", line) 183 continue 184 r, g, b, col_name = match.groups() 185 col_name = col_name.strip() 186 r = clamp(int(r), 0, 0xff) / 0xff 187 g = clamp(int(g), 0, 0xff) / 0xff 188 b = clamp(int(b), 0, 0xff) / 0xff 189 if r == g == b == 0 and col_name == self._EMPTY_SLOT_NAME: 190 self.append(None) 191 else: 192 col = RGBColor(r, g, b) 193 col.__name = col_name 194 self._colors.append(col) 195 if not silent: 196 self.info_changed() 197 self.sequence_changed() 198 self.match_changed() 199 200 def save(self, filehandle): 201 """Saves the palette to an open file handle. 202 203 :param filehandle: File-like object (.write suffices) 204 205 >>> from lib.pycompat import PY3 206 >>> if PY3: 207 ... from io import StringIO 208 ... else: 209 ... from cStringIO import StringIO 210 >>> fp = StringIO() 211 >>> cols = RGBColor(1,.7,0).interpolate(RGBColor(.1,.1,.5), 16) 212 >>> pal = Palette(colors=cols) 213 >>> pal.save(fp) 214 >>> fp.getvalue() == unicode(pal) 215 True 216 217 The file handle is not flushed, and is left open after the 218 write. 219 220 >>> fp.flush() 221 >>> fp.close() 222 223 """ 224 filehandle.write(unicode(self)) 225 226 def update(self, other): 227 """Updates all details of this palette from another palette. 228 229 Fires the `info_changed()`, `sequence_changed()`, and `match_changed()` 230 events. 231 """ 232 self.clear(silent=True) 233 for col in other._colors: 234 col = self._copy_color_in(col) 235 self._colors.append(col) 236 self._name = other._name 237 self._columns = other._columns 238 self.info_changed() 239 self.sequence_changed() 240 self.match_changed() 241 242 ## Palette size and metadata 243 244 def get_columns(self): 245 """Get the number of columns (0 means unspecified).""" 246 return self._columns 247 248 def set_columns(self, n): 249 """Set the number of columns (0 means unspecified).""" 250 self._columns = int(n) 251 self.info_changed() 252 253 def get_name(self): 254 """Gets the palette's name.""" 255 return self._name 256 257 def set_name(self, name): 258 """Sets the palette's name.""" 259 if name is not None: 260 name = unicode(name) 261 self._name = name 262 self.info_changed() 263 264 def __bool__(self): 265 """Palettes never test false, regardless of their length. 266 267 >>> p = Palette() 268 >>> bool(p) 269 True 270 271 """ 272 return True 273 274 def __len__(self): 275 """Palette length is the number of color slots within it.""" 276 return len(self._colors) 277 278 ## PY2/PY3 compat 279 280 __nonzero__ = __bool__ 281 282 ## Match position marker 283 284 def get_match_position(self): 285 """Return the position of the current match (int or None)""" 286 return self._match_position 287 288 def set_match_position(self, i): 289 """Sets the position of the current match (int or None) 290 291 Fires `match_changed()` if the value is changed.""" 292 if i is not None: 293 i = int(i) 294 if i < 0 or i >= len(self): 295 i = None 296 if i != self._match_position: 297 self._match_position = i 298 self.match_changed() 299 300 def get_match_is_approx(self): 301 """Returns whether the current match is approximate.""" 302 return self._match_is_approx 303 304 def set_match_is_approx(self, approx): 305 """Sets whether the current match is approximate 306 307 Fires match_changed() if the boolean value changes.""" 308 approx = bool(approx) 309 if approx != self._match_is_approx: 310 self._match_is_approx = approx 311 self.match_changed() 312 313 def match_color(self, col, exact=False, order=None): 314 """Moves the match position to the color closest to the argument. 315 316 :param col: The color to match. 317 :type col: lib.color.UIColor 318 :param exact: Only consider exact matches, and not near-exact or 319 approximate matches. 320 :type exact: bool 321 :param order: a search order to use. Default is outwards from the 322 match position, or in order if the match is unset. 323 :type order: sequence or iterator of integer color indices. 324 :returns: Whether the match succeeded. 325 :rtype: bool 326 327 By default, the matching algorithm favours exact or near-exact matches 328 which are close to the current position. If the current position is 329 unset, this search starts at 0. If there are no exact or near-exact 330 matches, a looser approximate match will be used, again favouring 331 matches with nearby positions. 332 333 >>> red2blue = RGBColor(1, 0, 0).interpolate(RGBColor(0, 1, 1), 5) 334 >>> p = Palette(colors=red2blue) 335 >>> p.match_color(RGBColor(0.45, 0.45, 0.45)) 336 True 337 >>> p.match_position 338 2 339 >>> p.match_is_approx 340 True 341 >>> p[p.match_position] 342 <RGBColor r=0.5000, g=0.5000, b=0.5000> 343 >>> p.match_color(RGBColor(0.5, 0.5, 0.5)) 344 True 345 >>> p.match_is_approx 346 False 347 >>> p.match_color(RGBColor(0.45, 0.45, 0.45), exact=True) 348 False 349 >>> p.match_color(RGBColor(0.5, 0.5, 0.5), exact=True) 350 True 351 352 Fires the ``match_changed()`` event when changes happen. 353 """ 354 if order is not None: 355 search_order = order 356 elif self.match_position is not None: 357 search_order = _outwards_from(len(self), self.match_position) 358 else: 359 search_order = xrange(len(self)) 360 bestmatch_i = None 361 bestmatch_d = None 362 is_approx = True 363 for i in search_order: 364 c = self._colors[i] 365 if c is self._EMPTY_SLOT_ITEM: 366 continue 367 # Closest exact or near-exact match by index distance (according to 368 # the search_order). Considering near-exact matches as equivalent 369 # to exact matches improves the feel of PaletteNext and 370 # PalettePrev. 371 if exact: 372 if c == col: 373 bestmatch_i = i 374 is_approx = False 375 break 376 else: 377 d = _color_distance(col, c) 378 if c == col or d < 0.06: 379 bestmatch_i = i 380 is_approx = False 381 break 382 if bestmatch_d is None or d < bestmatch_d: 383 bestmatch_i = i 384 bestmatch_d = d 385 # Measuring over a blend into solid equiluminant 0-chroma 386 # grey for the orange #DA5D2E with an opaque but feathered 387 # brush made huge, and picking just inside the point where the 388 # palette widget begins to call it approximate: 389 # 390 # 0.05 is a difference only discernible (to me) by tilting LCD 391 # 0.066 to 0.075 appears slightly greyer for large areas 392 # 0.1 and above is very clearly distinct 393 394 # If there are no exact or near-exact matches, choose the most similar 395 # color anywhere in the palette. 396 if bestmatch_i is not None: 397 self._match_position = bestmatch_i 398 self._match_is_approx = is_approx 399 self.match_changed() 400 return True 401 return False 402 403 def move_match_position(self, direction, refcol): 404 """Move the match position in steps, matching first if needed. 405 406 :param direction: Direction for moving, positive or negative 407 :type direction: int:, ``1`` or ``-1`` 408 :param refcol: Reference color, used for initial matching when needed. 409 :type refcol: UIColor 410 :returns: the color newly matched, if the match position has changed 411 :rtype: UIColor|NoneType 412 413 Invoking this method when there's no current match position will select 414 the color that's closest to the reference color, just like 415 `match_color()` 416 417 >>> greys = RGBColor(1,1,1).interpolate(RGBColor(0,0,0), 16) 418 >>> pal = Palette(colors=greys) 419 >>> refcol = RGBColor(0.5, 0.55, 0.45) 420 >>> pal.move_match_position(-1, refcol) 421 >>> pal.match_position 422 7 423 >>> pal.match_is_approx 424 True 425 426 When the current match is defined, but only an approximate match, this 427 method converts it to an exact match but does not change its position. 428 429 >>> pal.move_match_position(-1, refcol) is None 430 False 431 >>> pal.match_position 432 7 433 >>> pal.match_is_approx 434 False 435 436 When the match is initially exact, its position is stepped in the 437 direction indicated, either by +1 or -1. Blank palette entries are 438 skipped. 439 440 >>> pal.move_match_position(-1, refcol) is None 441 False 442 >>> pal.match_position 443 6 444 >>> pal.match_is_approx 445 False 446 447 Fires ``match_position_changed()`` and ``match_is_approx_changed()`` as 448 appropriate. The return value is the newly matched color whenever this 449 method produces a new exact match. 450 451 """ 452 # Normalize direction 453 direction = int(direction) 454 if direction < 0: 455 direction = -1 456 elif direction > 0: 457 direction = 1 458 else: 459 return None 460 # If nothing is selected, pick the closest match without changing 461 # the managed color. 462 old_pos = self._match_position 463 if old_pos is None: 464 self.match_color(refcol) 465 return None 466 # Otherwise, refine the match, or step it in the requested direction. 467 new_pos = None 468 if self._match_is_approx: 469 # Make an existing approximate match concrete. 470 new_pos = old_pos 471 else: 472 # Index reflects a close or identical match. 473 # Seek in the requested direction, skipping empty entries. 474 pos = old_pos 475 assert direction != 0 476 pos += direction 477 while pos < len(self._colors) and pos >= 0: 478 if self._colors[pos] is not self._EMPTY_SLOT_ITEM: 479 new_pos = pos 480 break 481 pos += direction 482 # Update the palette index and the managed color. 483 result = None 484 if new_pos is not None: 485 col = self._colors[new_pos] 486 if col is not self._EMPTY_SLOT_ITEM: 487 result = self._copy_color_out(col) 488 self.set_match_position(new_pos) 489 self.set_match_is_approx(False) 490 return result 491 492 ## Property-style access for setters and getters 493 494 columns = property(get_columns, set_columns) 495 name = property(get_name, set_name) 496 match_position = property(get_match_position, set_match_position) 497 match_is_approx = property(get_match_is_approx, set_match_is_approx) 498 499 ## Color access 500 501 def _copy_color_out(self, col): 502 if col is self._EMPTY_SLOT_ITEM: 503 return None 504 result = RGBColor(color=col) 505 result.__name = col.__name 506 return result 507 508 def _copy_color_in(self, col, name=None): 509 if col is self._EMPTY_SLOT_ITEM or col is None: 510 result = self._EMPTY_SLOT_ITEM 511 else: 512 if name is None: 513 try: 514 name = col.__name 515 except AttributeError: 516 pass 517 if name is not None: 518 name = unicode(name) 519 result = RGBColor(color=col) 520 result.__name = name 521 return result 522 523 def append(self, col, name=None, unique=False, match=False): 524 """Appends a color, optionally setting a name for it. 525 526 :param col: The color to append. 527 :param name: Name of the color to insert. 528 :param unique: If true, don't append if the color already exists 529 in the palette. Only exact matches count. 530 :param match: If true, set the match position to the 531 appropriate palette entry. 532 """ 533 col = self._copy_color_in(col, name) 534 if unique: 535 # Find the final exact match, if one is present 536 for i in xrange(len(self._colors)-1, -1, -1): 537 if col == self._colors[i]: 538 if match: 539 self._match_position = i 540 self._match_is_approx = False 541 self.match_changed() 542 return 543 # Append new color, and select it if requested 544 end_i = len(self._colors) 545 self._colors.append(col) 546 if match: 547 self._match_position = end_i 548 self._match_is_approx = False 549 self.match_changed() 550 self.sequence_changed() 551 552 def insert(self, i, col, name=None): 553 """Inserts a color, setting an optional name for it. 554 555 :param i: Target index. `None` indicates appending a color. 556 :param col: Color to insert. `None` indicates an empty slot. 557 :param name: Name of the color to insert. 558 559 >>> grey16 = RGBColor(1, 1, 1).interpolate(RGBColor(0, 0, 0), 16) 560 >>> p = Palette(colors=grey16) 561 >>> p.insert(5, RGBColor(1, 0, 0), name="red") 562 >>> p 563 <Palette colors=17, columns=0, name=None> 564 >>> p[5] 565 <RGBColor r=1.0000, g=0.0000, b=0.0000> 566 567 Fires the `sequence_changed()` event. If the match position changes as 568 a result, `match_changed()` is fired too. 569 570 """ 571 col = self._copy_color_in(col, name) 572 if i is None: 573 self._colors.append(col) 574 else: 575 self._colors.insert(i, col) 576 if self.match_position is not None: 577 if self.match_position >= i: 578 self.match_position += 1 579 self.sequence_changed() 580 581 def reposition(self, src_i, targ_i): 582 """Moves a color, or copies it to empty slots, or moves it the end. 583 584 :param src_i: Source color index. 585 :param targ_i: Source color index, or None to indicate the end. 586 587 This operation performs a copy if the target is an empty slot, and a 588 remove followed by an insert if the target slot contains a color. 589 590 >>> grey16 = RGBColor(1, 1, 1).interpolate(RGBColor(0, 0, 0), 16) 591 >>> p = Palette(colors=grey16) 592 >>> p[5] = None # creates an empty slot 593 >>> p.match_position = 8 594 >>> p[5] == p[0] 595 False 596 >>> p.reposition(0, 5) 597 >>> p[5] == p[0] 598 True 599 >>> p.match_position 600 8 601 >>> p[5] = RGBColor(1, 0, 0) 602 >>> p.reposition(14, 5) 603 >>> p.match_position # continues pointing to the same color 604 9 605 >>> len(p) # repositioning doesn't change the length 606 16 607 608 Fires the `color_changed()` event for copies to empty slots, or 609 `sequence_changed()` for moves. If the match position changes as a 610 result, `match_changed()` is fired too. 611 612 """ 613 assert src_i is not None 614 if src_i == targ_i: 615 return 616 try: 617 col = self._colors[src_i] 618 assert col is not None # just in case we change the internal repr 619 except IndexError: 620 return 621 622 # Special case: just copy if the target is an empty slot 623 match_pos = self.match_position 624 if targ_i is not None: 625 targ = self._colors[targ_i] 626 if targ is self._EMPTY_SLOT_ITEM: 627 self._colors[targ_i] = self._copy_color_in(col) 628 self.color_changed(targ_i) 629 # Copying from the matched color moves the match position. 630 # Copying to the match position clears the match. 631 if match_pos == src_i: 632 self.match_position = targ_i 633 elif match_pos == targ_i: 634 self.match_position = None 635 return 636 637 # Normal case. Remove... 638 self._colors.pop(src_i) 639 moving_match = False 640 updated_match = False 641 if match_pos is not None: 642 # Moving rightwards. Adjust for the pop(). 643 if targ_i is not None and targ_i > src_i: 644 targ_i -= 1 645 # Similar logic for the match position, but allow it to follow 646 # the move if it started at the src position. 647 if match_pos == src_i: 648 match_pos = None 649 moving_match = True 650 updated_match = True 651 elif match_pos > src_i: 652 match_pos -= 1 653 updated_match = True 654 # ... then append or insert. 655 if targ_i is None: 656 self._colors.append(col) 657 if moving_match: 658 match_pos = len(self._colors) - 1 659 updated_match = True 660 else: 661 self._colors.insert(targ_i, col) 662 if match_pos is not None: 663 if moving_match: 664 match_pos = targ_i 665 updated_match = True 666 elif match_pos >= targ_i: 667 match_pos += 1 668 updated_match = True 669 # Announce changes 670 self.sequence_changed() 671 if updated_match: 672 self.match_position = match_pos 673 self.match_changed() 674 675 def pop(self, i): 676 """Removes a color, returning it. 677 678 Fires the `match_changed()` event if the match index changes as a 679 result of the removal, and `sequence_changed()` if a color was removed, 680 prior to its return. 681 """ 682 i = int(i) 683 try: 684 col = self._colors.pop(i) 685 except IndexError: 686 return 687 if self.match_position == i: 688 self.match_position = None 689 elif self.match_position > i: 690 self.match_position -= 1 691 self.sequence_changed() 692 return self._copy_color_out(col) 693 694 def get_color(self, i): 695 """Looks up a color by its list index.""" 696 if i is None: 697 return None 698 try: 699 col = self._colors[i] 700 return self._copy_color_out(col) 701 except IndexError: 702 return None 703 704 def __getitem__(self, i): 705 return self.get_color(i) 706 707 def __setitem__(self, i, col): 708 self._colors[i] = self._copy_color_in(col, None) 709 self.color_changed(i) 710 711 ## Color name access 712 713 def get_color_name(self, i): 714 """Looks up a color's name by its list index.""" 715 try: 716 col = self._colors[i] 717 except IndexError: 718 return 719 if col is self._EMPTY_SLOT_ITEM: 720 return 721 return col.__name 722 723 def set_color_name(self, i, name): 724 """Sets a color's name by its list index.""" 725 try: 726 col = self._colors[i] 727 except IndexError: 728 return 729 if col is self._EMPTY_SLOT_ITEM: 730 return 731 col.__name = name 732 self.color_changed(i) 733 734 def get_color_by_name(self, name): 735 """Looks up the first color with the given name. 736 737 >>> pltt = Palette() 738 >>> pltt.append(RGBColor(1,0,1), "Magenta") 739 >>> pltt.get_color_by_name("Magenta") 740 <RGBColor r=1.0000, g=0.0000, b=1.0000> 741 742 """ 743 for col in self: 744 if col.__name == name: 745 return RGBColor(color=col) 746 747 def __iter__(self): 748 return self.iter_colors() 749 750 def iter_colors(self): 751 """Iterates across the palette's colors.""" 752 for col in self._colors: 753 if col is self._EMPTY_SLOT_ITEM: 754 yield None 755 else: 756 yield col 757 758 ## Observable events 759 760 @event 761 def info_changed(self): 762 """Event: palette name, or number of columns was changed.""" 763 764 @event 765 def match_changed(self): 766 """Event: either match position or match_is_approx was updated.""" 767 768 @event 769 def sequence_changed(self): 770 """Event: the color ordering or palette length was changed.""" 771 772 @event 773 def color_changed(self, i): 774 """Event: the color in the given slot, or its name, was modified.""" 775 776 ## Dumping and cloning 777 778 def __unicode__(self): 779 """Py2-era serialization as a Unicode string. 780 781 Used by the Py3 __str__() while we are in transition. 782 783 """ 784 result = u"GIMP Palette\n" 785 if self._name is not None: 786 result += u"Name: %s\n" % self._name 787 if self._columns > 0: 788 result += u"Columns: %d\n" % self._columns 789 result += u"#\n" 790 for col in self._colors: 791 if col is self._EMPTY_SLOT_ITEM: 792 col_name = self._EMPTY_SLOT_NAME 793 r = g = b = 0 794 else: 795 col_name = col.__name 796 r, g, b = [clamp(int(c*0xff), 0, 0xff) for c in col.get_rgb()] 797 if col_name is None: 798 result += u"%d %d %d\n" % (r, g, b) 799 else: 800 result += u"%d %d %d %s\n" % (r, g, b, col_name) 801 return result 802 803 def __str__(self): 804 """Py3: serialize as str (=Unicode). Py2: as bytes (lossy!).""" 805 s = self.__unicode__() 806 if not PY3: 807 s = s.encode("utf-8", errors="replace") 808 return s 809 810 def __copy__(self): 811 clone = Palette() 812 clone.set_name(self.get_name()) 813 clone.set_columns(self.get_columns()) 814 for col in self._colors: 815 if col is self._EMPTY_SLOT_ITEM: 816 clone.append(None) 817 else: 818 clone.append(copy(col), col.__name) 819 return clone 820 821 def __deepcopy__(self, memo): 822 return self.__copy__() 823 824 def __repr__(self): 825 return "<Palette colors=%d, columns=%d, name=%r>" % ( 826 len(self._colors), 827 self._columns, 828 self._name, 829 ) 830 831 ## Conversion to/from simple dict representation 832 833 def to_simple_dict(self): 834 """Converts the palette to a simple dict form used in the prefs.""" 835 simple = {} 836 simple["name"] = self.get_name() 837 simple["columns"] = self.get_columns() 838 entries = [] 839 for col in self.iter_colors(): 840 if col is None: 841 entries.append(None) 842 else: 843 name = col.__name 844 entries.append((col.to_hex_str(), name)) 845 simple["entries"] = entries 846 return simple 847 848 @classmethod 849 def new_from_simple_dict(cls, simple): 850 """Constructs and returns a palette from the simple dict form.""" 851 pal = cls() 852 pal.set_name(simple.get("name", None)) 853 pal.set_columns(simple.get("columns", None)) 854 for entry in simple.get("entries", []): 855 if entry is None: 856 pal.append(None) 857 else: 858 s, name = entry 859 col = RGBColor.new_from_hex_str(s) 860 pal.append(col, name) 861 return pal 862 863 864## Helper functions 865 866def _outwards_from(n, i): 867 """Search order within the palette, outwards from a given index. 868 869 Defined for a sequence of len() `n`, outwards from index `i`. 870 """ 871 assert i < n and i >= 0 872 yield i 873 for j in xrange(n): 874 exhausted = True 875 if i - j >= 0: 876 yield i - j 877 exhausted = False 878 if i + j < n: 879 yield i + j 880 exhausted = False 881 if exhausted: 882 break 883 884 885def _color_distance(c1, c2): 886 """Distance metric for color matching in the palette. 887 888 Use a geometric YCbCr distance, as recommended by Graphics Programming with 889 Perl, chapter 1, Martien Verbruggen. If we want to give the chrominance 890 dimensions a different weighting to luma, we can. 891 892 """ 893 c1 = YCbCrColor(color=c1) 894 c2 = YCbCrColor(color=c2) 895 d_cb = c1.Cb - c2.Cb 896 d_cr = c1.Cr - c2.Cr 897 d_y = c1.Y - c2.Y 898 return ((d_cb**2) + (d_cr**2) + (d_y)**2) ** (1.0/3) 899 900 901## Module testing 902 903 904if __name__ == '__main__': 905 logging.basicConfig(level=logging.DEBUG) 906 import doctest 907 doctest.testmod() 908