diff options
Diffstat (limited to 'scss/core.py')
-rw-r--r-- | scss/core.py | 808 |
1 files changed, 808 insertions, 0 deletions
diff --git a/scss/core.py b/scss/core.py new file mode 100644 index 0000000..1f33514 --- /dev/null +++ b/scss/core.py @@ -0,0 +1,808 @@ +"""Extension for built-in Sass functionality.""" +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import math + +from six.moves import xrange + +from scss.extension import Extension +from scss.namespace import Namespace +from scss.types import ( + Arglist, Boolean, Color, List, Null, Number, String, Map, expect_type) + + +class CoreExtension(Extension): + name = 'core' + namespace = Namespace() + + +# Alias to make the below declarations less noisy +ns = CoreExtension.namespace + + +# ------------------------------------------------------------------------------ +# Color creation + +def _interpret_percentage(n, relto=1., clamp=True): + expect_type(n, Number, unit='%') + + if n.is_unitless: + ret = n.value / relto + else: + ret = n.value / 100 + + if clamp: + if ret < 0: + return 0 + elif ret > 1: + return 1 + + return ret + + +@ns.declare +def rgba(r, g, b, a): + r = _interpret_percentage(r, relto=255) + g = _interpret_percentage(g, relto=255) + b = _interpret_percentage(b, relto=255) + a = _interpret_percentage(a, relto=1) + + return Color.from_rgb(r, g, b, a) + + +@ns.declare +def rgb(r, g, b, type='rgb'): + return rgba(r, g, b, Number(1.0)) + + +@ns.declare +def rgba_(color, a=None): + if a is None: + alpha = 1 + else: + alpha = _interpret_percentage(a) + + return Color.from_rgb(*color.rgba[:3], alpha=alpha) + + +@ns.declare +def rgb_(color): + return rgba2(color, a=Number(1)) + + +@ns.declare +def hsla(h, s, l, a): + return Color.from_hsl( + h.value / 360 % 1, + # Ruby sass treats plain numbers for saturation and lightness as though + # they were percentages, just without the % + _interpret_percentage(s, relto=100), + _interpret_percentage(l, relto=100), + alpha=a.value, + ) + + +@ns.declare +def hsl(h, s, l): + return hsla(h, s, l, Number(1)) + + +@ns.declare +def hsla_(color, a=None): + return rgba2(color, a) + + +@ns.declare +def hsl_(color): + return rgba2(color, a=Number(1)) + + +@ns.declare +def mix(color1, color2, weight=Number(50, "%")): + """ + Mixes together two colors. Specifically, takes the average of each of the + RGB components, optionally weighted by the given percentage. + The opacity of the colors is also considered when weighting the components. + + Specifically, takes the average of each of the RGB components, + optionally weighted by the given percentage. + The opacity of the colors is also considered when weighting the components. + + The weight specifies the amount of the first color that should be included + in the returned color. + 50%, means that half the first color + and half the second color should be used. + 25% means that a quarter of the first color + and three quarters of the second color should be used. + + For example: + + mix(#f00, #00f) => #7f007f + mix(#f00, #00f, 25%) => #3f00bf + mix(rgba(255, 0, 0, 0.5), #00f) => rgba(63, 0, 191, 0.75) + """ + # This algorithm factors in both the user-provided weight + # and the difference between the alpha values of the two colors + # to decide how to perform the weighted average of the two RGB values. + # + # It works by first normalizing both parameters to be within [-1, 1], + # where 1 indicates "only use color1", -1 indicates "only use color 0", + # and all values in between indicated a proportionately weighted average. + # + # Once we have the normalized variables w and a, + # we apply the formula (w + a)/(1 + w*a) + # to get the combined weight (in [-1, 1]) of color1. + # This formula has two especially nice properties: + # + # * When either w or a are -1 or 1, the combined weight is also that + # number (cases where w * a == -1 are undefined, and handled as a + # special case). + # + # * When a is 0, the combined weight is w, and vice versa + # + # Finally, the weight of color1 is renormalized to be within [0, 1] + # and the weight of color2 is given by 1 minus the weight of color1. + # + # Algorithm from the Sass project: http://sass-lang.com/ + + p = _interpret_percentage(weight) + + # Scale weight to [-1, 1] + w = p * 2 - 1 + # Compute difference in alpha channels + a = color1.alpha - color2.alpha + + # Weight of first color + if w * a == -1: + # Avoid zero-div case + scaled_weight1 = w + else: + scaled_weight1 = (w + a) / (1 + w * a) + + # Unscale back to [0, 1] and get the weight of the other color + w1 = (scaled_weight1 + 1) / 2 + w2 = 1 - w1 + + # Do the scaling. Note that alpha isn't scaled by alpha, as that wouldn't + # make much sense; it uses the original untwiddled weight, p. + channels = [ + ch1 * w1 + ch2 * w2 + for (ch1, ch2) in zip(color1.rgba[:3], color2.rgba[:3])] + alpha = color1.alpha * p + color2.alpha * (1 - p) + return Color.from_rgb(*channels, alpha=alpha) + + +# ------------------------------------------------------------------------------ +# Color inspection + +@ns.declare +def red(color): + r, g, b, a = color.rgba + return Number(r * 255) + + +@ns.declare +def green(color): + r, g, b, a = color.rgba + return Number(g * 255) + + +@ns.declare +def blue(color): + r, g, b, a = color.rgba + return Number(b * 255) + + +@ns.declare_alias('opacity') +@ns.declare +def alpha(color): + return Number(color.alpha) + + +@ns.declare +def hue(color): + h, s, l = color.hsl + return Number(h * 360, "deg") + + +@ns.declare +def saturation(color): + h, s, l = color.hsl + return Number(s * 100, "%") + + +@ns.declare +def lightness(color): + h, s, l = color.hsl + return Number(l * 100, "%") + + +@ns.declare +def ie_hex_str(color): + c = Color(color).value + return String("#{3:02X}{0:02X}{1:02X}{2:02X}".format( + int(round(c[0])), + int(round(c[1])), + int(round(c[2])), + int(round(c[3] * 255)), + )) + + +# ------------------------------------------------------------------------------ +# Color modification + +@ns.declare_alias('fade-in') +@ns.declare_alias('fadein') +@ns.declare +def opacify(color, amount): + r, g, b, a = color.rgba + if amount.is_simple_unit('%'): + amt = amount.value / 100 + else: + amt = amount.value + return Color.from_rgb( + r, g, b, + alpha=a + amt) + + +@ns.declare_alias('fade-out') +@ns.declare_alias('fadeout') +@ns.declare +def transparentize(color, amount): + r, g, b, a = color.rgba + if amount.is_simple_unit('%'): + amt = amount.value / 100 + else: + amt = amount.value + return Color.from_rgb( + r, g, b, + alpha=a - amt) + + +@ns.declare +def lighten(color, amount): + return adjust_color(color, lightness=amount) + + +@ns.declare +def darken(color, amount): + return adjust_color(color, lightness=-amount) + + +@ns.declare +def saturate(color, amount): + return adjust_color(color, saturation=amount) + + +@ns.declare +def desaturate(color, amount): + return adjust_color(color, saturation=-amount) + + +@ns.declare +def greyscale(color): + h, s, l = color.hsl + return Color.from_hsl(h, 0, l, alpha=color.alpha) + + +@ns.declare +def grayscale(color): + if isinstance(color, Number): + # grayscale(n) and grayscale(n%) are CSS3 filters and should be left + # intact, but only when using the "a" spelling + return String.unquoted("grayscale(%s)" % (color.render(),)) + else: + return greyscale(color) + + +@ns.declare_alias('spin') +@ns.declare +def adjust_hue(color, degrees): + h, s, l = color.hsl + delta = degrees.value / 360 + return Color.from_hsl((h + delta) % 1, s, l, alpha=color.alpha) + + +@ns.declare +def complement(color): + h, s, l = color.hsl + return Color.from_hsl((h + 0.5) % 1, s, l, alpha=color.alpha) + + +@ns.declare +def invert(color): + """Returns the inverse (negative) of a color. The red, green, and blue + values are inverted, while the opacity is left alone. + """ + r, g, b, a = color.rgba + return Color.from_rgb(1 - r, 1 - g, 1 - b, alpha=a) + + +@ns.declare +def adjust_lightness(color, amount): + return adjust_color(color, lightness=amount) + + +@ns.declare +def adjust_saturation(color, amount): + return adjust_color(color, saturation=amount) + + +@ns.declare +def scale_lightness(color, amount): + return scale_color(color, lightness=amount) + + +@ns.declare +def scale_saturation(color, amount): + return scale_color(color, saturation=amount) + + +@ns.declare +def adjust_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 adjust both RGB and HSL channels at the same time") + + zero = Number(0) + a = color.alpha + (alpha or zero).value + + if do_rgb: + r, g, b = color.rgba[:3] + channels = [ + current + (adjustment or zero).value / 255 + for (current, adjustment) in zip(color.rgba, (red, green, blue))] + return Color.from_rgb(*channels, alpha=a) + + else: + h, s, l = color.hsl + h = (h + (hue or zero).value / 360) % 1 + s += _interpret_percentage(saturation or zero, relto=100, clamp=False) + l += _interpret_percentage(lightness or zero, relto=100, clamp=False) + return Color.from_hsl(h, s, l, a) + + +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) + + +@ns.declare +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) + + +@ns.declare +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: + channels[0] = _interpret_percentage(red, relto=255) + if green: + channels[1] = _interpret_percentage(green, relto=255) + if blue: + channels[2] = _interpret_percentage(blue, relto=255) + + return Color.from_rgb(*channels, alpha=alpha) + + else: + channels = list(color.hsl) + if hue: + expect_type(hue, Number, unit=None) + channels[0] = (hue.value / 360) % 1 + # Ruby sass treats plain numbers for saturation and lightness as though + # they were percentages, just without the % + if saturation: + channels[1] = _interpret_percentage(saturation, relto=100) + if lightness: + channels[2] = _interpret_percentage(lightness, relto=100) + + return Color.from_hsl(*channels, alpha=alpha) + + +# ------------------------------------------------------------------------------ +# String functions + +@ns.declare_alias('e') +@ns.declare_alias('escape') +@ns.declare +def unquote(*args): + arg = List.from_maybe_starargs(args).maybe() + + if isinstance(arg, String): + return String(arg.value, quotes=None) + else: + return String(arg.render(), quotes=None) + + +@ns.declare +def quote(*args): + arg = List.from_maybe_starargs(args).maybe() + + if isinstance(arg, String): + return String(arg.value, quotes='"') + else: + return String(arg.render(), quotes='"') + + +@ns.declare +def str_length(string): + expect_type(string, String) + + # nb: can't use `len(string)`, because that gives the Sass list length, + # which is 1 + return Number(len(string.value)) + + +# TODO this and several others should probably also require integers +# TODO and assert that the indexes are valid +@ns.declare +def str_insert(string, insert, index): + expect_type(string, String) + expect_type(insert, String) + expect_type(index, Number, unit=None) + + py_index = index.to_python_index(len(string.value), check_bounds=False) + return String( + string.value[:py_index] + insert.value + string.value[py_index:], + quotes=string.quotes) + + +@ns.declare +def str_index(string, substring): + expect_type(string, String) + expect_type(substring, String) + + # 1-based indexing, with 0 for failure + return Number(string.value.find(substring.value) + 1) + + +@ns.declare +def str_slice(string, start_at, end_at=None): + expect_type(string, String) + expect_type(start_at, Number, unit=None) + py_start_at = start_at.to_python_index(len(string.value)) + + if end_at is None: + py_end_at = None + else: + expect_type(end_at, Number, unit=None) + # Endpoint is inclusive, unlike Python + py_end_at = end_at.to_python_index(len(string.value)) + 1 + + return String( + string.value[py_start_at:py_end_at], + quotes=string.quotes) + + +@ns.declare +def to_upper_case(string): + expect_type(string, String) + + return String(string.value.upper(), quotes=string.quotes) + + +@ns.declare +def to_lower_case(string): + expect_type(string, String) + + return String(string.value.lower(), quotes=string.quotes) + + +# ------------------------------------------------------------------------------ +# Number functions + +@ns.declare +def percentage(value): + expect_type(value, Number, unit=None) + return value * Number(100, unit='%') + + +ns.set_function('abs', 1, Number.wrap_python_function(abs)) +ns.set_function('round', 1, Number.wrap_python_function(round)) +ns.set_function('ceil', 1, Number.wrap_python_function(math.ceil)) +ns.set_function('floor', 1, Number.wrap_python_function(math.floor)) + + +# ------------------------------------------------------------------------------ +# List functions + +def __parse_separator(separator, default_from=None): + if separator is None: + separator = 'auto' + separator = String.unquoted(separator).value + + if separator == 'comma': + return True + elif separator == 'space': + return False + elif separator == 'auto': + if not default_from: + return True + elif len(default_from) < 2: + return True + else: + return default_from.use_comma + else: + raise ValueError('Separator must be auto, comma, or space') + + +# TODO get the compass bit outta here +@ns.declare_alias('-compass-list-size') +@ns.declare +def length(*lst): + if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)): + lst = lst[0] + return Number(len(lst)) + + +@ns.declare +def set_nth(list, n, value): + expect_type(n, Number, unit=None) + + py_n = n.to_python_index(len(list)) + return List( + tuple(list[:py_n]) + (value,) + tuple(list[py_n + 1:]), + use_comma=list.use_comma) + + +# TODO get the compass bit outta here +@ns.declare_alias('-compass-nth') +@ns.declare +def nth(lst, n): + """Return the nth item in the list.""" + expect_type(n, (String, Number), unit=None) + + if isinstance(n, String): + if n.value.lower() == 'first': + i = 0 + elif n.value.lower() == 'last': + i = -1 + else: + raise ValueError("Invalid index %r" % (n,)) + else: + # DEVIATION: nth treats lists as circular lists + i = n.to_python_index(len(lst), circular=True) + + return lst[i] + + +@ns.declare +def join(lst1, lst2, separator=None): + ret = [] + ret.extend(List.from_maybe(lst1)) + ret.extend(List.from_maybe(lst2)) + + use_comma = __parse_separator(separator, default_from=lst1) + return List(ret, use_comma=use_comma) + + +@ns.declare +def min_(*lst): + if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)): + lst = lst[0] + return min(lst) + + +@ns.declare +def max_(*lst): + if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)): + lst = lst[0] + return max(lst) + + +@ns.declare +def append(lst, val, separator=None): + ret = [] + ret.extend(List.from_maybe(lst)) + ret.append(val) + + use_comma = __parse_separator(separator, default_from=lst) + return List(ret, use_comma=use_comma) + + +@ns.declare +def index(lst, val): + for i in xrange(len(lst)): + if lst.value[i] == val: + return Number(i + 1) + return Boolean(False) + + +@ns.declare +def zip_(*lists): + return List( + [List(zipped) for zipped in zip(*lists)], + use_comma=True) + + +# TODO need a way to use "list" as the arg name without shadowing the builtin +@ns.declare +def list_separator(list): + if list.use_comma: + return String.unquoted('comma') + else: + return String.unquoted('space') + + +# ------------------------------------------------------------------------------ +# Map functions + +@ns.declare +def map_get(map, key): + return map.to_dict().get(key, Null()) + + +@ns.declare +def map_merge(*maps): + key_order = [] + index = {} + for map in maps: + for key, value in map.to_pairs(): + if key not in index: + key_order.append(key) + + index[key] = value + + pairs = [(key, index[key]) for key in key_order] + return Map(pairs, index=index) + + +@ns.declare +def map_keys(map): + return List( + [k for (k, v) in map.to_pairs()], + use_comma=True) + + +@ns.declare +def map_values(map): + return List( + [v for (k, v) in map.to_pairs()], + use_comma=True) + + +@ns.declare +def map_has_key(map, key): + return Boolean(key in map.to_dict()) + + +# DEVIATIONS: these do not exist in ruby sass + +@ns.declare +def map_get3(map, key, default): + return map.to_dict().get(key, default) + + +@ns.declare +def map_get_nested3(map, keys, default=Null()): + for key in keys: + map = map.to_dict().get(key, None) + if map is None: + return default + + return map + + +@ns.declare +def map_merge_deep(*maps): + pairs = [] + keys = set() + for map in maps: + for key, value in map.to_pairs(): + keys.add(key) + + for key in keys: + values = [map.to_dict().get(key, None) for map in maps] + values = [v for v in values if v is not None] + if all(isinstance(v, Map) for v in values): + pairs.append((key, map_merge_deep(*values))) + else: + pairs.append((key, values[-1])) + + return Map(pairs) + + +# ------------------------------------------------------------------------------ +# Meta functions + +@ns.declare +def type_of(obj): # -> bool, number, string, color, list + return String(obj.sass_type_name) + + +@ns.declare +def unit(number): # -> px, em, cm, etc. + numer = '*'.join(sorted(number.unit_numer)) + denom = '*'.join(sorted(number.unit_denom)) + + if denom: + ret = numer + '/' + denom + else: + ret = numer + return String.unquoted(ret) + + +@ns.declare +def unitless(value): + if not isinstance(value, Number): + raise TypeError("Expected number, got %r" % (value,)) + + return Boolean(value.is_unitless) + + +@ns.declare +def comparable(number1, number2): + left = number1.to_base_units() + right = number2.to_base_units() + return Boolean( + left.unit_numer == right.unit_numer + and left.unit_denom == right.unit_denom) + + +@ns.declare +def keywords(value): + """Extract named arguments, as a map, from an argument list.""" + expect_type(value, Arglist) + return value.extract_keywords() + + +# ------------------------------------------------------------------------------ +# Miscellaneous + +@ns.declare +def if_(condition, if_true, if_false=Null()): + return if_true if condition else if_false |