1# This file is part of MyPaint. 2# Copyright (C) 2007-2018 by the MyPaint Development Team 3# Copyright (C) 2007 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 11import logging 12import copy 13import math 14import json 15 16from lib import mypaintlib 17from lib import helpers 18from lib import brushsettings 19from lib.eotf import eotf 20from lib.pycompat import unicode 21from lib.pycompat import PY3 22 23if PY3: 24 from urllib.parse import quote_from_bytes as url_quote 25 from urllib.parse import unquote_to_bytes as url_unquote 26else: 27 from urllib import quote as url_quote 28 from urllib import unquote as url_unquote 29 30logger = logging.getLogger(__name__) 31 32 33# Module constants: 34 35STRING_VALUE_SETTINGS = set(( 36 "parent_brush_name", 37 "group", # Possibly obsolete group field (replaced by order.conf?) 38 "comment", # MyPaint uses this to explanation what the file is 39 "notes", # Brush developer's notes field, multiline 40 "description", # Short, user-facing description field, single line 41)) 42OLDFORMAT_BRUSHFILE_VERSION = 2 43 44BRUSH_SETTINGS = set([s.cname for s in brushsettings.settings]) 45ALL_SETTINGS = BRUSH_SETTINGS.union(STRING_VALUE_SETTINGS) 46 47_BRUSHINFO_MATCH_IGNORES = [ 48 "color_h", "color_s", "color_v", 49 "parent_brush_name", 50] 51 52 53# Helper funcs for quoting and unquoting: 54 55def brushinfo_quote(string): 56 """Quote a string for serialisation of brushes. 57 58 >>> brushinfo_quote(u'foo') == b'foo' 59 True 60 >>> brushinfo_quote(u'foo/bar blah') == b'foo%2Fbar%20blah' 61 True 62 >>> expected = b'Have%20a%20nice%20day%20%E2%98%BA' 63 >>> brushinfo_quote(u'Have a nice day \u263A') == expected 64 True 65 66 """ 67 string = unicode(string) 68 u8bytes = string.encode("utf-8") 69 return url_quote(u8bytes, safe='').encode("ascii") 70 71 72def brushinfo_unquote(quoted): 73 """Unquote a serialised string value from a brush field. 74 75 >>> brushinfo_unquote(b"foo") == u'foo' 76 True 77 >>> brushinfo_unquote(b"foo%2fbar%20blah") == u'foo/bar blah' 78 True 79 >>> expected = u'Have a nice day \u263A' 80 >>> brushinfo_unquote(b'Have%20a%20nice%20day%20%E2%98%BA') == expected 81 True 82 83 """ 84 if not isinstance(quoted, bytes): 85 raise ValueError("Cann") 86 u8bytes = url_unquote(quoted) 87 return unicode(u8bytes.decode("utf-8")) 88 89 90# Exceptions raised during brush parsing: 91 92class ParseError (Exception): 93 pass 94 95 96class Obsolete (ParseError): 97 pass 98 99 100# Helper functions for parsing the old brush format: 101 102def _oldfmt_parse_value(rawvalue, cname, version): 103 """Parses a raw setting value. 104 105 This code handles a format that changed over time, so the 106 parse is for a given setting name and brushfile version. 107 108 """ 109 if cname in STRING_VALUE_SETTINGS: 110 string = brushinfo_unquote(rawvalue) 111 return [(cname, string)] 112 elif version <= 1 and cname == 'color': 113 rgb = [int(c) / 255.0 for c in rawvalue.split(" ")] 114 h, s, v = helpers.rgb_to_hsv(*rgb) 115 return [ 116 ('color_h', [h, {}]), 117 ('color_s', [s, {}]), 118 ('color_v', [v, {}]), 119 ] 120 elif version <= 1 and cname == 'change_radius': 121 if rawvalue == '0.0': 122 return [] 123 raise Obsolete('change_radius is not supported any more') 124 elif version <= 2 and cname == 'adapt_color_from_image': 125 if rawvalue == '0.0': 126 return [] 127 raise Obsolete( 128 'adapt_color_from_image is obsolete, ignored;' 129 ' use smudge and smudge_length instead' 130 ) 131 elif version <= 1 and cname == 'painting_time': 132 return [] 133 134 if version <= 1 and cname == 'speed': 135 cname = 'speed1' 136 parts = rawvalue.split('|') 137 basevalue = float(parts[0]) 138 input_points = {} 139 for part in parts[1:]: 140 inputname, rawpoints = part.strip().split(' ', 1) 141 if version <= 1: 142 points = _oldfmt_parse_points_v1(rawpoints) 143 else: 144 points = _oldfmt_parse_points_v2(rawpoints) 145 assert len(points) >= 2 146 input_points[inputname] = points 147 return [(cname, [float(basevalue), input_points])] 148 149 150def _oldfmt_parse_points_v1(rawpoints): 151 """Parses the points list format from v1""" 152 points_seq = [float(f) for f in rawpoints.split()] 153 points = [(0, 0)] 154 while points_seq: 155 x = points_seq.pop(0) 156 y = points_seq.pop(0) 157 if x == 0: 158 break 159 assert x > points[-1][0] 160 points.append((x, y)) 161 return points 162 163 164def _oldfmt_parse_points_v2(rawpoints): 165 """Parses the newer points list format of v2 and beyond.""" 166 points = [] 167 for s in rawpoints.split(', '): 168 s = s.strip() 169 if not (s.startswith('(') and s.endswith(')') and ' ' in s): 170 return '(x y) expected, got "%s"' % s 171 s = s[1:-1] 172 x, y = [float(ss) for ss in s.split(' ')] 173 points.append((x, y)) 174 return points 175 176 177def _oldfmt_transform_y(valuepair, func): 178 """Used during migration from earlier versions.""" 179 basevalue, input_points = valuepair 180 basevalue = func(basevalue) 181 input_points_new = {} 182 for inputname, points in input_points.items(): 183 points_new = [(x, func(y)) for x, y in points] 184 input_points_new[inputname] = points_new 185 return [basevalue, input_points_new] 186 187 188# Class defs: 189 190class BrushInfo (object): 191 """Fully parsed description of a brush. 192 """ 193 194 def __init__(self, string=None, default_overrides=None): 195 """Construct a BrushInfo object, optionally parsing it. 196 197 :param string: optional json string to load info from 198 :param default_overrides: optional dict of 199 "canonical setting name -> (BrushSettingInfo -> value)" mappings, 200 each used to change the default values of a settings. 201 """ 202 super(BrushInfo, self).__init__() 203 self.settings = {} 204 self.undefined_settings = set() 205 self.cache_str = None 206 self.observers = [] 207 self.default_overrides = default_overrides 208 for s in brushsettings.settings: 209 self.reset_setting(s.cname) 210 self.observers.append(self.settings_changed_cb) 211 self.observers_hidden = [] 212 self.pending_updates = set() 213 if string: 214 self.load_from_string(string) 215 216 def settings_changed_cb(self, settings): 217 self.cache_str = None 218 219 def clone(self): 220 """Returns a deep-copied duplicate.""" 221 res = BrushInfo() 222 res.load_from_brushinfo(self) 223 return res 224 225 def load_from_brushinfo(self, other): 226 """Updates the brush's Settings from (a clone of) ``brushinfo``.""" 227 self.settings = copy.deepcopy(other.settings) 228 self.default_overrides = other.default_overrides 229 self.undefined_settings = set(other.undefined_settings) 230 for f in self.observers: 231 f(ALL_SETTINGS) 232 self.cache_str = other.cache_str 233 234 def load_defaults(self): 235 """Load default brush settings, dropping all current settings.""" 236 self.begin_atomic() 237 self.settings = {} 238 for s in brushsettings.settings: 239 self.reset_setting(s.cname) 240 self.end_atomic() 241 242 def reset_setting(self, cname): 243 s = brushsettings.settings_dict[cname] 244 if self.default_overrides and cname in self.default_overrides: 245 override = self.default_overrides[cname] 246 basevalue = override(s) 247 else: 248 basevalue = s.default 249 250 if cname == 'opaque_multiply': 251 # make opaque depend on pressure by default 252 input_points = {'pressure': [(0.0, 0.0), (1.0, 1.0)]} 253 else: 254 input_points = {} 255 self.settings[cname] = [basevalue, input_points] 256 for f in self.observers: 257 f(set([cname])) 258 259 def reset_if_undefined(self, cname): 260 if cname in self.undefined_settings: 261 self.reset_setting(cname) 262 263 def to_json(self): 264 settings = dict(self.settings) 265 266 # Fields we save that aren't really brush engine settings 267 parent_brush_name = settings.pop('parent_brush_name', '') 268 brush_group = settings.pop('group', '') 269 description = settings.pop('description', '') 270 notes = settings.pop('notes', '') 271 272 # The comment we save is always the same 273 settings.pop('comment', '') 274 275 # Make the contents of each setting a bit more explicit 276 for k, v in list(settings.items()): 277 base_value, inputs = v 278 settings[k] = {'base_value': base_value, 'inputs': inputs} 279 280 document = { 281 'version': 3, 282 'comment': """MyPaint brush file""", 283 'parent_brush_name': parent_brush_name, 284 'settings': settings, 285 'group': brush_group, 286 'notes': notes, 287 'description': description, 288 } 289 return json.dumps(document, sort_keys=True, indent=4) 290 291 def from_json(self, json_string): 292 """Loads settings from a JSON string. 293 294 >>> from glob import glob 295 >>> for p in glob("tests/brushes/v3/*.myb"): 296 ... with open(p, "rb") as fp: 297 ... bstr = fp.read() 298 ... ustr = bstr.decode("utf-8") 299 ... b1 = BrushInfo() 300 ... b1.from_json(bstr) 301 ... b1 = BrushInfo() 302 ... b1.from_json(ustr) 303 304 See also load_from_string(), which can handle the old v2 format. 305 306 Accepts both unicode and byte strings. Byte strings are assumed 307 to be encoded as UTF-8 when any decoding's needed. 308 309 """ 310 311 # Py3: Ubuntu Trusty's 3.4.3 json.loads() requires unicode strs. 312 # Layer Py3, and Py2 is OK with either. 313 if not isinstance(json_string, unicode): 314 if not isinstance(json_string, bytes): 315 raise ValueError("Need either a str or a bytes object") 316 json_string = json_string.decode("utf-8") 317 318 brush_def = json.loads(json_string) 319 if brush_def.get('version', 0) < 3: 320 raise BrushInfo.ParseError( 321 'brush is not compatible with this version of mypaint ' 322 '(json file version=%r)' % (brush_def.get('version'),) 323 ) 324 325 # settings not in json_string must still be present in self.settings 326 self.load_defaults() 327 328 # settings not defined in the json 329 self.undefined_settings = BRUSH_SETTINGS.difference( 330 set(brush_def['settings'].keys()) 331 ) 332 # MyPaint expects that each setting has an array, where 333 # index 0 is base value, and index 1 is inputs 334 for k, v in brush_def['settings'].items(): 335 base_value, inputs = v['base_value'], v['inputs'] 336 if k not in self.settings: 337 logger.warning('ignoring unknown brush setting %r', k) 338 continue 339 self.settings[k] = [base_value, inputs] 340 341 # Non-libmypaint string fields 342 for cname in STRING_VALUE_SETTINGS: 343 self.settings[cname] = brush_def.get(cname, '') 344 # FIXME: Who uses "group"? 345 # FIXME: Brush groups are stored externally in order.conf, 346 # FIXME: is that one redundant? 347 348 @staticmethod 349 def brush_string_inverted_eotf(brush_string): 350 if isinstance(brush_string, bytes): 351 brush_string = brush_string.decode("utf-8") 352 try: 353 brush = json.loads(brush_string) 354 bsett = brush['settings'] 355 k = 'base_value' 356 hsv = bsett['color_h'][k], bsett['color_s'][k], bsett['color_v'][k] 357 h, s, v = helpers.transform_hsv(hsv, 1.0 / 2.2) 358 bsett['color_h'][k] = h 359 bsett['color_s'][k] = s 360 bsett['color_v'][k] = v 361 return json.dumps(brush) 362 except Exception: 363 logger.exception("Failed to invert color in brush string") 364 return brush_string 365 366 def load_from_string(self, settings_str): 367 """Load a setting string, overwriting all current settings.""" 368 369 settings_unicode = settings_str 370 if not isinstance(settings_unicode, unicode): 371 if not isinstance(settings_unicode, bytes): 372 raise ValueError("Need either a str or a bytes object") 373 settings_unicode = settings_unicode.decode("utf-8") 374 375 if settings_unicode.startswith(u'{'): 376 # new json-based brush format 377 self.from_json(settings_str) 378 elif settings_unicode.startswith(u'#'): 379 # old brush format 380 self._load_old_format(settings_str) 381 else: 382 raise BrushInfo.ParseError('brush format not recognized') 383 384 for f in self.observers: 385 f(ALL_SETTINGS) 386 self.cache_str = settings_str 387 388 def _load_old_format(self, settings_str): 389 """Loads brush settings in the old (v2) format. 390 391 >>> from glob import glob 392 >>> for p in glob("tests/brushes/v2/*.myb"): 393 ... with open(p, "rb") as fp: 394 ... bstr = fp.read() 395 ... ustr = bstr.decode("utf-8") 396 ... b1 = BrushInfo() 397 ... b1._load_old_format(bstr) 398 ... b2 = BrushInfo() 399 ... b2._load_old_format(ustr) 400 401 Accepts both unicode and byte strings. Byte strings are assumed 402 to be encoded as UTF-8 when any decoding's needed. 403 404 """ 405 406 # Py2 is happy natively comparing unicode with str, no encode 407 # needed. For Py3, need to parse as str so that updated dict 408 # keys can be compared sensibly with stuff written by other 409 # code. 410 411 if not isinstance(settings_str, unicode): 412 if not isinstance(settings_str, bytes): 413 raise ValueError("Need either a str or a bytes object") 414 if PY3: 415 settings_str = settings_str.decode("utf-8") 416 417 # Split out the raw settings and grab the version we're dealing with 418 rawsettings = [] 419 errors = [] 420 version = 1 # for files without a 'version' field 421 for line in settings_str.split('\n'): 422 try: 423 line = line.strip() 424 if not line or line.startswith('#'): 425 continue 426 cname, rawvalue = line.split(' ', 1) 427 if cname == 'version': 428 version = int(rawvalue) 429 if version > OLDFORMAT_BRUSHFILE_VERSION: 430 raise BrushInfo.ParseError( 431 "This brush is not in the old format " 432 "supported (version > {})".format( 433 OLDFORMAT_BRUSHFILE_VERSION, 434 ) 435 ) 436 else: 437 rawsettings.append((cname, rawvalue)) 438 except Exception as e: 439 errors.append((line, str(e))) 440 441 # Parse each pair 442 self.load_defaults() 443 # compatibility hack: keep disabled for old brushes, 444 # but still use non-zero default 445 self.settings['anti_aliasing'][0] = 0.0 446 num_parsed = 0 447 settings_loaded = set() 448 for rawcname, rawvalue in rawsettings: 449 try: 450 cnamevaluepairs = _oldfmt_parse_value( 451 rawvalue, 452 rawcname, 453 version, 454 ) 455 num_parsed += 1 456 for cname, value in cnamevaluepairs: 457 if cname in brushsettings.settings_migrate: 458 cname, func = brushsettings.settings_migrate[cname] 459 if func: 460 value = _oldfmt_transform_y(value, func) 461 self.settings[cname] = value 462 settings_loaded.add(cname) 463 except Exception as e: 464 line = "%s %s" % (rawcname, rawvalue) 465 errors.append((line, str(e))) 466 if errors: 467 for error in errors: 468 logger.warning(error) 469 if num_parsed == 0: 470 raise BrushInfo.ParseError( 471 "old brush file format parser did not find " 472 "any brush settings in this file", 473 ) 474 self.undefined_settings = BRUSH_SETTINGS.difference(settings_loaded) 475 476 def save_to_string(self): 477 """Serialise brush information to a string. Result is cached.""" 478 if self.cache_str: 479 return self.cache_str 480 481 res = self.to_json() 482 483 self.cache_str = res 484 return res 485 486 def get_base_value(self, cname): 487 return self.settings[cname][0] 488 489 def get_points(self, cname, input, readonly=False): 490 res = self.settings[cname][1].get(input, ()) 491 if not readonly: # slow 492 res = copy.deepcopy(res) 493 return res 494 495 def set_base_value(self, cname, value): 496 assert cname in BRUSH_SETTINGS 497 assert not math.isnan(value) 498 assert not math.isinf(value) 499 if self.settings[cname][0] != value: 500 if cname in self.undefined_settings: 501 self.undefined_settings.remove(cname) 502 self.settings[cname][0] = value 503 for f in self.observers: 504 f(set([cname])) 505 506 def set_points(self, cname, input, points): 507 assert cname in BRUSH_SETTINGS 508 if cname in self.undefined_settings: 509 self.undefined_settings.remove(cname) 510 points = tuple(points) 511 d = self.settings[cname][1] 512 if points: 513 d[input] = copy.deepcopy(points) 514 elif input in d: 515 d.pop(input) 516 517 for f in self.observers: 518 f(set([cname])) 519 520 def set_setting(self, cname, value): 521 self.settings[cname] = copy.deepcopy(value) 522 if cname in self.undefined_settings: 523 self.undefined_settings.remove(cname) 524 for f in self.observers: 525 f(set([cname])) 526 527 def get_setting(self, cname): 528 return copy.deepcopy(self.settings[cname]) 529 530 def get_string_property(self, name): 531 value = self.settings.get(name, None) 532 if value is None: 533 return None 534 return unicode(value) 535 536 def set_string_property(self, name, value): 537 assert name in STRING_VALUE_SETTINGS 538 if value is None: 539 self.settings.pop(name, None) 540 else: 541 assert isinstance(value, str) or isinstance(value, unicode) 542 self.settings[name] = unicode(value) 543 for f in self.observers: 544 f(set([name])) 545 546 def has_only_base_value(self, cname): 547 """Return whether a setting is constant for this brush.""" 548 for i in brushsettings.inputs: 549 if self.has_input(cname, i.name): 550 return False 551 return True 552 553 def has_large_base_value(self, cname, threshold=0.9): 554 return self.get_base_value(cname) > threshold 555 556 def has_small_base_value(self, cname, threshold=0.1): 557 return self.get_base_value(cname) < threshold 558 559 def has_input(self, cname, input): 560 """Return whether a given input is used by some setting.""" 561 points = self.get_points(cname, input, readonly=True) 562 return bool(points) 563 564 def begin_atomic(self): 565 self.observers_hidden.append(self.observers[:]) 566 del self.observers[:] 567 self.observers.append(self.add_pending_update) 568 569 def add_pending_update(self, settings): 570 self.pending_updates.update(settings) 571 572 def end_atomic(self): 573 self.observers[:] = self.observers_hidden.pop() 574 pending = self.pending_updates.copy() 575 if pending: 576 self.pending_updates.clear() 577 for f in self.observers: 578 f(pending) 579 580 def get_color_hsv(self): 581 h = self.get_base_value('color_h') 582 s = self.get_base_value('color_s') 583 v = self.get_base_value('color_v') 584 assert not math.isnan(h) 585 return h, s, v 586 587 def set_color_hsv(self, hsv): 588 if not hsv: 589 return 590 self.begin_atomic() 591 try: 592 h, s, v = hsv 593 self.set_base_value('color_h', h) 594 self.set_base_value('color_s', s) 595 self.set_base_value('color_v', v) 596 finally: 597 self.end_atomic() 598 599 def set_color_rgb(self, rgb): 600 self.set_color_hsv(helpers.rgb_to_hsv(*rgb)) 601 602 def get_color_rgb(self): 603 hsv = self.get_color_hsv() 604 return helpers.hsv_to_rgb(*hsv) 605 606 def is_eraser(self): 607 return self.has_large_base_value("eraser") 608 609 def is_alpha_locked(self): 610 return self.has_large_base_value("lock_alpha") 611 612 def is_colorize(self): 613 return self.has_large_base_value("colorize") 614 615 def matches(self, other, ignore=_BRUSHINFO_MATCH_IGNORES): 616 s1 = self.settings.copy() 617 s2 = other.settings.copy() 618 for k in ignore: 619 s1.pop(k, None) 620 s2.pop(k, None) 621 return s1 == s2 622 623 624class Brush (mypaintlib.PythonBrush): 625 """A brush, capable of painting to a surface 626 627 Low-level extension of the C++ brush class, propagating all changes 628 made to a BrushInfo instance down into the C brush struct. 629 630 """ 631 632 HSV_CNAMES = ('color_h', 'color_s', 'color_v') 633 HSV_SET = set(HSV_CNAMES) 634 635 def __init__(self, brushinfo): 636 super(Brush, self).__init__() 637 self.brushinfo = brushinfo 638 brushinfo.observers.append(self._update_from_brushinfo) 639 self._update_from_brushinfo(ALL_SETTINGS) 640 641 def stroke_to(self, *args): 642 """ Delegates to mypaintlib with information about color space 643 644 Checks whether color transforms should be done in linear sRGB 645 so that HSV/HSL adjustments can be handled correctly. 646 """ 647 if eotf() == 1.0: 648 return super(Brush, self).stroke_to(*args) 649 else: 650 return super(Brush, self).stroke_to_linear(*args) 651 652 def _update_from_brushinfo(self, settings): 653 """Updates changed low-level settings from the BrushInfo""" 654 655 # When eotf != 1.0, store transformed hsv values in the backend. 656 transform = eotf() != 1.0 657 if transform and any(hsv in settings for hsv in self.HSV_CNAMES): 658 self._transform_brush_color() 659 # Clear affected settings so the transformation 660 # is not undone in the next step. 661 # Note: x = x - y is not equivalent to x -= y here. 662 settings = settings - self.HSV_SET 663 664 for cname in settings: 665 self._update_setting_from_brushinfo(cname) 666 667 def _transform_brush_color(self): 668 """ Apply eotf transform to the backend color. 669 670 By only applying the transform here, the issue of 671 strokemap and brush color consistency between new 672 and old color rendering modes does not arise. 673 """ 674 hsv_orig = (self.brushinfo.get_base_value(k) for k in self.HSV_CNAMES) 675 h, s, v = helpers.transform_hsv(hsv_orig, eotf()) 676 settings_dict = brushsettings.settings_dict 677 self.set_base_value(settings_dict['color_h'].index, h) 678 self.set_base_value(settings_dict['color_s'].index, s) 679 self.set_base_value(settings_dict['color_v'].index, v) 680 681 def _update_setting_from_brushinfo(self, cname): 682 setting = brushsettings.settings_dict.get(cname) 683 if not setting: 684 return 685 base = self.brushinfo.get_base_value(cname) 686 self.set_base_value(setting.index, base) 687 for input in brushsettings.inputs: 688 points = self.brushinfo.get_points(cname, input.name, 689 readonly=True) 690 assert len(points) != 1 691 self.set_mapping_n(setting.index, input.index, len(points)) 692 for i, (x, y) in enumerate(points): 693 self.set_mapping_point(setting.index, input.index, i, x, y) 694 695 696if __name__ == "__main__": 697 import doctest 698 doctest.testmod() 699