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