diff options
-rw-r--r-- | scss/functions/core.py | 130 | ||||
-rw-r--r-- | scss/tests/functions/test_core.py | 2 | ||||
-rw-r--r-- | scss/types.py | 59 |
3 files changed, 172 insertions, 19 deletions
diff --git a/scss/functions/core.py b/scss/functions/core.py index 488c528..44ba7ab 100644 --- a/scss/functions/core.py +++ b/scss/functions/core.py @@ -3,6 +3,7 @@ Ruby implementation. """ from __future__ import absolute_import +from __future__ import division import colorsys import logging @@ -12,7 +13,7 @@ import operator from six.moves import xrange from scss.functions.library import FunctionLibrary -from scss.types import Boolean, Color, List, Number, String, Map +from scss.types import Boolean, Color, List, Number, String, Map, expect_type log = logging.getLogger(__name__) @@ -50,6 +51,40 @@ def _apply_percentage(n, relto=1): raise TypeError("Expected unitless number or percentage, got %r" % (n,)) +def _interpret_percentage(n, relto=1.): + expect_type(n, Number, unit='%') + + if n.is_unitless: + ret = n.value / relto + else: + ret = n.value / 100. + + if ret < 0: + return 0. + elif ret > 1: + return 1. + else: + return ret + + +def _interpret_rgb_args(r, g, b): + """Given arguments from Sass-land representing red, green, and blue + channels, return plain Python numbers appropriate for passing to `Color` + constructors. + """ + ret = [] + for channel in (r, g, b): + expect_type(channel, Number, unit='%') + if channel.is_simple_unit('%'): + value = channel.value / 100. + else: + value = channel.value / 255. + + ret.append(_constrain(value, 0, 1)) + + return ret + + def _color_type(color, a, type): color = Color(color).value a = Number(a).value if a is not None else color[3] @@ -61,13 +96,12 @@ def _color_type(color, a, type): @register('rgba', 4) def rgba(r, g, b, a, type='rgba'): - channels = [] - for ch in (r, g, b): - channels.append(_constrain(_apply_percentage(ch, relto=255), 0, 255)) + r = _interpret_percentage(r, relto=255.) + g = _interpret_percentage(g, relto=255.) + b = _interpret_percentage(b, relto=255.) + a = _interpret_percentage(a, relto=1.) - channels.append(_constrain(_apply_percentage(a), 0, 1)) - channels.append(type) - return Color(channels) + return Color.from_rgb(r, g, b, a) @register('rgb', 3) @@ -389,18 +423,90 @@ def _asc_color(op, color, saturation=None, lightness=None, red=None, green=None, @register('adjust-color') -def adjust_color(color, saturation=None, lightness=None, red=None, green=None, blue=None, alpha=None): +def adjust_color(color, red=None, green=None, blue=None, hue=None, saturation=None, lightness=None, alpha=None): return _asc_color(operator.__add__, color, saturation, lightness, red, green, blue, alpha) +def _scale_channel(channel, scaleby): + if scaleby is None: + return channel + + expect_type(scaleby, Number) + if not scaleby.is_simple_unit('%'): + raise ValueError("Expected percentage, got %r" % (scaleby,)) + + factor = scaleby.value / 100 + if factor > 0: + # Add x% of the remaining range, up to 1 + return channel + (1 - channel) * factor + else: + # Subtract x% of the existing channel. We add here because the factor + # is already negative + return channel * (1 + factor) + + @register('scale-color') -def scale_color(color, saturation=None, lightness=None, red=None, green=None, blue=None, alpha=None): - return _asc_color(operator.__mul__, color, saturation, lightness, red, green, blue, alpha) +def scale_color(color, red=None, green=None, blue=None, saturation=None, lightness=None, alpha=None): + do_rgb = red or green or blue + do_hsl = saturation or lightness + if do_rgb and do_hsl: + raise ValueError("Can't scale both RGB and HSL channels at the same time") + + scaled_alpha = _scale_channel(color.alpha, alpha) + + if do_rgb: + channels = [ + _scale_channel(channel, scaleby) + for channel, scaleby in zip(color.rgba, (red, green, blue)) + ] + + return Color.from_rgb(*channels, alpha=scaled_alpha) + + else: + channels = [ + _scale_channel(channel, scaleby) + for channel, scaleby in zip(color.hsl, (None, saturation, lightness)) + ] + + return Color.from_hsl(*channels, alpha=scaled_alpha) @register('change-color') -def change_color(color, saturation=None, lightness=None, red=None, green=None, blue=None, alpha=None): - return _asc_color(None, color, saturation, lightness, red, green, blue, alpha) +def change_color(color, red=None, green=None, blue=None, hue=None, saturation=None, lightness=None, alpha=None): + do_rgb = red or green or blue + do_hsl = hue or saturation or lightness + if do_rgb and do_hsl: + raise ValueError("Can't change both RGB and HSL channels at the same time") + + if alpha is None: + alpha = color.alpha + else: + alpha = alpha.value + + if do_rgb: + channels = list(color.rgba[:3]) + if red is not None: + channels[0] = _interpret_percentage(red, relto=255.) + if green is not None: + channels[1] = _interpret_percentage(green, relto=255.) + if blue is not None: + channels[2] = _interpret_percentage(blue, relto=255.) + + return Color.from_rgb(*channels, alpha=alpha) + + else: + channels = list(color.hsl) + if hue is not None: + expect_type(hue, Number, unit=None) + channels[0] = _constrain(hue / 360., 0, 1) + # Ruby sass treats plain numbers for saturation and lightness as though + # they were percentages, just without the % + if saturation is not None: + channels[1] = _interpret_percentage(saturation, relto=100.) + if lightness is not None: + channels[2] = _interpret_percentage(lightness, relto=100.) + + return Color.from_hsl(*channels, alpha=alpha) # ------------------------------------------------------------------------------ diff --git a/scss/tests/functions/test_core.py b/scss/tests/functions/test_core.py index 0988bdf..d6207f1 100644 --- a/scss/tests/functions/test_core.py +++ b/scss/tests/functions/test_core.py @@ -198,7 +198,6 @@ def test_adjust_color(calc): assert calc('adjust-color(hsl(25, 100%, 80%), $lightness: -30%, $alpha: -0.4)') == calc('hsla(25, 100%, 50%, 0.6)') -@xfail(reason="outright broken") def test_scale_color(calc): # Examples from the Ruby docs assert calc('scale-color(hsl(120, 70, 80), $lightness: 50%)') == calc('hsl(120, 70, 90)') @@ -206,7 +205,6 @@ def test_scale_color(calc): assert calc('scale-color(hsl(200, 70, 80), $saturation: -90%, $alpha: -30%)') == calc('hsla(200, 7, 80, 0.7)') -@xfail(reason="outright broken") def test_change_color(calc): # Examples from the Ruby docs assert calc('change-color(#102030, $blue: 5)') == calc('#102005') diff --git a/scss/types.py b/scss/types.py index 1a8c654..308a007 100644 --- a/scss/types.py +++ b/scss/types.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import division from __future__ import print_function +import colorsys import operator import six @@ -666,6 +667,11 @@ class Color(Value): return self @classmethod + def from_hsl(cls, hue, saturation, lightness, alpha=1.0): + r, g, b = colorsys.hls_to_rgb(hue, lightness, saturation) + return cls.from_rgb(r, g, b, alpha) + + @classmethod def from_hex(cls, hex_string): if not hex_string.startswith('#'): raise ValueError("Expected #abcdef, got %r" % (hex_string,)) @@ -705,9 +711,25 @@ class Color(Value): @property def rgb(self): + # TODO: deprecate, relies on internals return tuple(self.value[:3]) @property + def rgba(self): + return ( + self.value[0] / 255, + self.value[1] / 255, + self.value[2] / 255, + self.value[3], + ) + + @property + def hsl(self): + rgba = self.rgba + h, l, s = colorsys.rgb_to_hls(*rgba[:3]) + return h, s, l + + @property def alpha(self): return self.value[3] @@ -730,11 +752,11 @@ class Color(Value): if not isinstance(other, Color): return Boolean(False) - # Round to the nearest 5 digits for comparisons; corresponds roughly to - # 16 bits per channel, the most that generally matters. Otherwise - # float errors make equality fail for HSL colors. - left = tuple(round(n, 5) for n in self.value) - right = tuple(round(n, 5) for n in other.value) + # Scale channels to 255 and round to integers; this allows only 8-bit + # color, but Ruby sass makes the same assumption, and otherwise it's + # easy to get lots of float errors for HSL colors. + left = tuple(round(n) for n in self.value) + right = tuple(round(n) for n in other.value) return Boolean(left == right) def __add__(self, other): @@ -954,3 +976,30 @@ class Map(Value): def render(self, compress=False): raise TypeError("maps cannot be rendered as CSS") + + +def expect_type(value, types, unit=any): + if not isinstance(value, types): + if isinstance(types, type): + types = (type,) + sass_type_names = list(set(t.sass_type_name for t in types)) + sass_type_names.sort() + + # Join with commas in English fashion + if len(sass_type_names) == 1: + sass_type = sass_type_names[0] + elif len(sass_type_names) == 2: + sass_type = u' or '.join(sass_type_names) + else: + sass_type = u', '.join(sass_type_names[:-1]) + sass_type += u', or ' + sass_type_names[-1] + + raise TypeError("Expected %s, got %r" % (sass_type, value)) + + if unit is not any and isinstance(value, Number): + if unit is None and not value.is_unitless: + raise ValueError("Expected unitless number, got %r" % (value,)) + + elif unit == '%' and not ( + value.is_unitless or value.is_simple_unit('%')): + raise ValueError("Expected unitless number or percentage, got %r" % (value,)) |