Livereload css
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from scss.functions.library import FunctionLibrary
|
||||
|
||||
from scss.functions.core import CORE_LIBRARY
|
||||
from scss.functions.extra import EXTRA_LIBRARY
|
||||
from scss.functions.compass.sprites import COMPASS_SPRITES_LIBRARY
|
||||
from scss.functions.compass.gradients import COMPASS_GRADIENTS_LIBRARY
|
||||
from scss.functions.compass.helpers import COMPASS_HELPERS_LIBRARY
|
||||
from scss.functions.compass.images import COMPASS_IMAGES_LIBRARY
|
||||
|
||||
|
||||
ALL_BUILTINS_LIBRARY = FunctionLibrary()
|
||||
ALL_BUILTINS_LIBRARY.inherit(
|
||||
CORE_LIBRARY,
|
||||
EXTRA_LIBRARY,
|
||||
COMPASS_GRADIENTS_LIBRARY,
|
||||
COMPASS_HELPERS_LIBRARY,
|
||||
COMPASS_IMAGES_LIBRARY,
|
||||
COMPASS_SPRITES_LIBRARY,
|
||||
)
|
||||
|
||||
# TODO back-compat for the only codebase still using the old name :)
|
||||
FunctionRegistry = FunctionLibrary
|
||||
scss_builtins = ALL_BUILTINS_LIBRARY
|
||||
@@ -0,0 +1,2 @@
|
||||
# Global cache of image sizes, shared between sprites and images libraries.
|
||||
_image_size_cache = {}
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Utilities for working with gradients. Inspired by Compass, but not quite
|
||||
the same.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import six
|
||||
|
||||
from scss.functions.library import FunctionLibrary
|
||||
from scss.functions.compass.helpers import opposite_position, position
|
||||
from scss.types import Color, List, Number, String
|
||||
from scss.util import escape, split_params, to_float, to_str
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
COMPASS_GRADIENTS_LIBRARY = FunctionLibrary()
|
||||
register = COMPASS_GRADIENTS_LIBRARY.register
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
def __color_stops(percentages, *args):
|
||||
if len(args) == 1:
|
||||
if isinstance(args[0], (list, tuple, List)):
|
||||
list(args[0])
|
||||
elif isinstance(args[0], (String, six.string_types)):
|
||||
color_stops = []
|
||||
colors = split_params(getattr(args[0], 'value', args[0]))
|
||||
for color in colors:
|
||||
color = color.strip()
|
||||
if color.startswith('color-stop('):
|
||||
s, c = split_params(color[11:].rstrip(')'))
|
||||
s = s.strip()
|
||||
c = c.strip()
|
||||
else:
|
||||
c, s = color.split()
|
||||
color_stops.append((to_float(s), c))
|
||||
return color_stops
|
||||
|
||||
colors = []
|
||||
stops = []
|
||||
prev_color = False
|
||||
for c in args:
|
||||
for c in List.from_maybe(c):
|
||||
if isinstance(c, Color):
|
||||
if prev_color:
|
||||
stops.append(None)
|
||||
colors.append(c)
|
||||
prev_color = True
|
||||
elif isinstance(c, Number):
|
||||
stops.append(c)
|
||||
prev_color = False
|
||||
|
||||
if prev_color:
|
||||
stops.append(None)
|
||||
stops = stops[:len(colors)]
|
||||
if stops[0] is None:
|
||||
stops[0] = Number(0, '%')
|
||||
if stops[-1] is None:
|
||||
stops[-1] = Number(100, '%')
|
||||
|
||||
maxable_stops = [s for s in stops if s and not s.is_simple_unit('%')]
|
||||
if maxable_stops:
|
||||
max_stops = max(maxable_stops)
|
||||
else:
|
||||
max_stops = None
|
||||
|
||||
stops = [_s / max_stops if _s and not _s.is_simple_unit('%') else _s for _s in stops]
|
||||
|
||||
init = 0
|
||||
start = None
|
||||
for i, s in enumerate(stops + [1.0]):
|
||||
if s is None:
|
||||
if start is None:
|
||||
start = i
|
||||
end = i
|
||||
else:
|
||||
final = s
|
||||
if start is not None:
|
||||
stride = (final - init) / Number(end - start + 1 + (1 if i < len(stops) else 0))
|
||||
for j in range(start, end + 1):
|
||||
stops[j] = init + stride * Number(j - start + 1)
|
||||
init = final
|
||||
start = None
|
||||
|
||||
if not max_stops or percentages:
|
||||
pass
|
||||
else:
|
||||
stops = [s if s.is_simple_unit('%') else s * max_stops for s in stops]
|
||||
|
||||
return list(zip(stops, colors))
|
||||
|
||||
|
||||
def _render_standard_color_stops(color_stops):
|
||||
pairs = []
|
||||
for i, (stop, color) in enumerate(color_stops):
|
||||
if ((i == 0 and stop == Number(0, '%')) or
|
||||
(i == len(color_stops) - 1 and stop == Number(100, '%'))):
|
||||
pairs.append(color)
|
||||
else:
|
||||
pairs.append(List([color, stop], use_comma=False))
|
||||
|
||||
return List(pairs, use_comma=True)
|
||||
|
||||
|
||||
@register('grad-color-stops')
|
||||
def grad_color_stops(*args):
|
||||
args = List.from_maybe_starargs(args)
|
||||
color_stops = __color_stops(True, *args)
|
||||
ret = ', '.join(['color-stop(%s, %s)' % (s.render(), c.render()) for s, c in color_stops])
|
||||
return String.unquoted(ret)
|
||||
|
||||
|
||||
def __grad_end_position(radial, color_stops):
|
||||
return __grad_position(-1, 100, radial, color_stops)
|
||||
|
||||
|
||||
@register('grad-point')
|
||||
def grad_point(*p):
|
||||
pos = set()
|
||||
hrz = vrt = Number(0.5, '%')
|
||||
for _p in p:
|
||||
pos.update(String.unquoted(_p).value.split())
|
||||
if 'left' in pos:
|
||||
hrz = Number(0, '%')
|
||||
elif 'right' in pos:
|
||||
hrz = Number(1, '%')
|
||||
if 'top' in pos:
|
||||
vrt = Number(0, '%')
|
||||
elif 'bottom' in pos:
|
||||
vrt = Number(1, '%')
|
||||
return List([v for v in (hrz, vrt) if v is not None])
|
||||
|
||||
|
||||
def __grad_position(index, default, radial, color_stops):
|
||||
try:
|
||||
stops = Number(color_stops[index][0])
|
||||
if radial and not stops.is_simple_unit('px') and (index == 0 or index == -1 or index == len(color_stops) - 1):
|
||||
log.warn("Webkit only supports pixels for the start and end stops for radial gradients. Got %s", stops)
|
||||
except IndexError:
|
||||
stops = Number(default)
|
||||
return stops
|
||||
|
||||
|
||||
@register('grad-end-position')
|
||||
def grad_end_position(*color_stops):
|
||||
color_stops = __color_stops(False, *color_stops)
|
||||
return Number(__grad_end_position(False, color_stops))
|
||||
|
||||
|
||||
@register('color-stops')
|
||||
def color_stops(*args):
|
||||
args = List.from_maybe_starargs(args)
|
||||
color_stops = __color_stops(False, *args)
|
||||
ret = ', '.join(['%s %s' % (c.render(), s.render()) for s, c in color_stops])
|
||||
return String.unquoted(ret)
|
||||
|
||||
|
||||
@register('color-stops-in-percentages')
|
||||
def color_stops_in_percentages(*args):
|
||||
args = List.from_maybe_starargs(args)
|
||||
color_stops = __color_stops(True, *args)
|
||||
ret = ', '.join(['%s %s' % (c.render(), s.render()) for s, c in color_stops])
|
||||
return String.unquoted(ret)
|
||||
|
||||
|
||||
def _get_gradient_position_and_angle(args):
|
||||
for arg in args:
|
||||
ret = None
|
||||
skip = False
|
||||
for a in arg:
|
||||
if isinstance(a, Color):
|
||||
skip = True
|
||||
break
|
||||
elif isinstance(a, Number):
|
||||
ret = arg
|
||||
if skip:
|
||||
continue
|
||||
if ret is not None:
|
||||
return ret
|
||||
for seek in (
|
||||
'center',
|
||||
'top', 'bottom',
|
||||
'left', 'right',
|
||||
):
|
||||
if String(seek) in arg:
|
||||
return arg
|
||||
return None
|
||||
|
||||
|
||||
def _get_gradient_shape_and_size(args):
|
||||
for arg in args:
|
||||
for seek in (
|
||||
'circle', 'ellipse',
|
||||
'closest-side', 'closest-corner',
|
||||
'farthest-side', 'farthest-corner',
|
||||
'contain', 'cover',
|
||||
):
|
||||
if String(seek) in arg:
|
||||
return arg
|
||||
return None
|
||||
|
||||
|
||||
def _get_gradient_color_stops(args):
|
||||
color_stops = []
|
||||
for arg in args:
|
||||
for a in List.from_maybe(arg):
|
||||
if isinstance(a, Color):
|
||||
color_stops.append(arg)
|
||||
break
|
||||
return color_stops or None
|
||||
|
||||
|
||||
@register('radial-gradient')
|
||||
def radial_gradient(*args):
|
||||
args = List.from_maybe_starargs(args)
|
||||
|
||||
position_and_angle = _get_gradient_position_and_angle(args)
|
||||
shape_and_size = _get_gradient_shape_and_size(args)
|
||||
color_stops = _get_gradient_color_stops(args)
|
||||
if color_stops is None:
|
||||
raise Exception('No color stops provided to radial-gradient function')
|
||||
color_stops = __color_stops(False, *color_stops)
|
||||
|
||||
args = [
|
||||
position(position_and_angle) if position_and_angle is not None else None,
|
||||
shape_and_size if shape_and_size is not None else None,
|
||||
]
|
||||
args.extend(_render_standard_color_stops(color_stops))
|
||||
|
||||
to__s = 'radial-gradient(' + ', '.join(to_str(a) for a in args or [] if a is not None) + ')'
|
||||
ret = String.unquoted(to__s)
|
||||
|
||||
def to__css2():
|
||||
return String.unquoted('')
|
||||
ret.to__css2 = to__css2
|
||||
|
||||
def to__moz():
|
||||
return String.unquoted('-moz-' + to__s)
|
||||
ret.to__moz = to__moz
|
||||
|
||||
def to__pie():
|
||||
log.warn("PIE does not support radial-gradient.")
|
||||
return String.unquoted('-pie-radial-gradient(unsupported)')
|
||||
ret.to__pie = to__pie
|
||||
|
||||
def to__webkit():
|
||||
return String.unquoted('-webkit-' + to__s)
|
||||
ret.to__webkit = to__webkit
|
||||
|
||||
def to__owg():
|
||||
args = [
|
||||
'radial',
|
||||
grad_point(position_and_angle) if position_and_angle is not None else 'center',
|
||||
'0',
|
||||
grad_point(position_and_angle) if position_and_angle is not None else 'center',
|
||||
__grad_end_position(True, color_stops),
|
||||
]
|
||||
args.extend('color-stop(%s, %s)' % (s.render(), c.render()) for s, c in color_stops)
|
||||
ret = '-webkit-gradient(' + ', '.join(to_str(a) for a in args or [] if a is not None) + ')'
|
||||
return String.unquoted(ret)
|
||||
ret.to__owg = to__owg
|
||||
|
||||
def to__svg():
|
||||
return radial_svg_gradient(color_stops, position_and_angle or 'center')
|
||||
ret.to__svg = to__svg
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@register('linear-gradient')
|
||||
def linear_gradient(*args):
|
||||
args = List.from_maybe_starargs(args)
|
||||
|
||||
position_and_angle = _get_gradient_position_and_angle(args)
|
||||
color_stops = _get_gradient_color_stops(args)
|
||||
if color_stops is None:
|
||||
raise Exception('No color stops provided to linear-gradient function')
|
||||
color_stops = __color_stops(False, *color_stops)
|
||||
|
||||
args = [
|
||||
position(position_and_angle) if position_and_angle is not None else None,
|
||||
]
|
||||
args.extend(_render_standard_color_stops(color_stops))
|
||||
|
||||
to__s = 'linear-gradient(' + ', '.join(to_str(a) for a in args or [] if a is not None) + ')'
|
||||
ret = String.unquoted(to__s)
|
||||
|
||||
def to__css2():
|
||||
return String.unquoted('')
|
||||
ret.to__css2 = to__css2
|
||||
|
||||
def to__moz():
|
||||
return String.unquoted('-moz-' + to__s)
|
||||
ret.to__moz = to__moz
|
||||
|
||||
def to__pie():
|
||||
return String.unquoted('-pie-' + to__s)
|
||||
ret.to__pie = to__pie
|
||||
|
||||
def to__ms():
|
||||
return String.unquoted('-ms-' + to__s)
|
||||
ret.to__ms = to__ms
|
||||
|
||||
def to__o():
|
||||
return String.unquoted('-o-' + to__s)
|
||||
ret.to__o = to__o
|
||||
|
||||
def to__webkit():
|
||||
return String.unquoted('-webkit-' + to__s)
|
||||
ret.to__webkit = to__webkit
|
||||
|
||||
def to__owg():
|
||||
args = [
|
||||
'linear',
|
||||
position(position_and_angle or None),
|
||||
opposite_position(position_and_angle or None),
|
||||
]
|
||||
args.extend('color-stop(%s, %s)' % (s.render(), c.render()) for s, c in color_stops)
|
||||
ret = '-webkit-gradient(' + ', '.join(to_str(a) for a in args if a is not None) + ')'
|
||||
return String.unquoted(ret)
|
||||
ret.to__owg = to__owg
|
||||
|
||||
def to__svg():
|
||||
return linear_svg_gradient(color_stops, position_and_angle or 'top')
|
||||
ret.to__svg = to__svg
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@register('radial-svg-gradient')
|
||||
def radial_svg_gradient(*args):
|
||||
args = List.from_maybe_starargs(args)
|
||||
color_stops = args
|
||||
center = None
|
||||
if isinstance(args[-1], (String, Number, six.string_types)):
|
||||
center = args[-1]
|
||||
color_stops = args[:-1]
|
||||
color_stops = __color_stops(False, *color_stops)
|
||||
cx, cy = zip(*grad_point(center).items())[1]
|
||||
r = __grad_end_position(True, color_stops)
|
||||
svg = __radial_svg(color_stops, cx, cy, r)
|
||||
url = 'data:' + 'image/svg+xml' + ';base64,' + base64.b64encode(svg)
|
||||
inline = 'url("%s")' % escape(url)
|
||||
return String.unquoted(inline)
|
||||
|
||||
|
||||
@register('linear-svg-gradient')
|
||||
def linear_svg_gradient(*args):
|
||||
args = List.from_maybe_starargs(args)
|
||||
color_stops = args
|
||||
start = None
|
||||
if isinstance(args[-1], (String, Number, six.string_types)):
|
||||
start = args[-1]
|
||||
color_stops = args[:-1]
|
||||
color_stops = __color_stops(False, *color_stops)
|
||||
x1, y1 = zip(*grad_point(start).items())[1]
|
||||
x2, y2 = zip(*grad_point(opposite_position(start)).items())[1]
|
||||
svg = _linear_svg(color_stops, x1, y1, x2, y2)
|
||||
url = 'data:' + 'image/svg+xml' + ';base64,' + base64.b64encode(svg)
|
||||
inline = 'url("%s")' % escape(url)
|
||||
return String.unquoted(inline)
|
||||
|
||||
|
||||
def __color_stops_svg(color_stops):
|
||||
ret = ''.join('<stop offset="%s" stop-color="%s"/>' % (to_str(s), c) for s, c in color_stops)
|
||||
return ret
|
||||
|
||||
|
||||
def __svg_template(gradient):
|
||||
ret = '<?xml version="1.0" encoding="utf-8"?>\
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg">\
|
||||
<defs>%s</defs>\
|
||||
<rect x="0" y="0" width="100%%" height="100%%" fill="url(#grad)" />\
|
||||
</svg>' % gradient
|
||||
return ret
|
||||
|
||||
|
||||
def _linear_svg(color_stops, x1, y1, x2, y2):
|
||||
gradient = '<linearGradient id="grad" x1="%s" y1="%s" x2="%s" y2="%s">%s</linearGradient>' % (
|
||||
to_str(Number(x1)),
|
||||
to_str(Number(y1)),
|
||||
to_str(Number(x2)),
|
||||
to_str(Number(y2)),
|
||||
__color_stops_svg(color_stops)
|
||||
)
|
||||
return __svg_template(gradient)
|
||||
|
||||
|
||||
def __radial_svg(color_stops, cx, cy, r):
|
||||
gradient = '<radialGradient id="grad" gradientUnits="userSpaceOnUse" cx="%s" cy="%s" r="%s">%s</radialGradient>' % (
|
||||
to_str(Number(cx)),
|
||||
to_str(Number(cy)),
|
||||
to_str(Number(r)),
|
||||
__color_stops_svg(color_stops)
|
||||
)
|
||||
return __svg_template(gradient)
|
||||
@@ -0,0 +1,654 @@
|
||||
"""Miscellaneous helper functions ported from Compass.
|
||||
|
||||
See: http://compass-style.org/reference/compass/helpers/
|
||||
|
||||
This collection is not necessarily complete or up-to-date.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import math
|
||||
import os.path
|
||||
import time
|
||||
|
||||
import six
|
||||
|
||||
from scss import config
|
||||
from scss.functions.library import FunctionLibrary
|
||||
from scss.types import Boolean, List, Null, Number, String
|
||||
from scss.util import escape, to_str
|
||||
import re
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
COMPASS_HELPERS_LIBRARY = FunctionLibrary()
|
||||
register = COMPASS_HELPERS_LIBRARY.register
|
||||
|
||||
FONT_TYPES = {
|
||||
'woff': 'woff',
|
||||
'otf': 'opentype',
|
||||
'opentype': 'opentype',
|
||||
'ttf': 'truetype',
|
||||
'truetype': 'truetype',
|
||||
'svg': 'svg',
|
||||
'eot': 'embedded-opentype'
|
||||
}
|
||||
|
||||
|
||||
def add_cache_buster(url, mtime):
|
||||
fragment = url.split('#')
|
||||
query = fragment[0].split('?')
|
||||
if len(query) > 1 and query[1] != '':
|
||||
cb = '&_=%s' % (mtime)
|
||||
url = '?'.join(query) + cb
|
||||
else:
|
||||
cb = '?_=%s' % (mtime)
|
||||
url = query[0] + cb
|
||||
if len(fragment) > 1:
|
||||
url += '#' + fragment[1]
|
||||
return url
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Data manipulation
|
||||
|
||||
@register('blank')
|
||||
def blank(*objs):
|
||||
"""Returns true when the object is false, an empty string, or an empty list"""
|
||||
for o in objs:
|
||||
if isinstance(o, Boolean):
|
||||
is_blank = not o
|
||||
elif isinstance(o, String):
|
||||
is_blank = not len(o.value.strip())
|
||||
elif isinstance(o, List):
|
||||
is_blank = all(blank(el) for el in o)
|
||||
else:
|
||||
is_blank = False
|
||||
|
||||
if not is_blank:
|
||||
return Boolean(False)
|
||||
|
||||
return Boolean(True)
|
||||
|
||||
|
||||
@register('compact')
|
||||
def compact(*args):
|
||||
"""Returns a new list after removing any non-true values"""
|
||||
use_comma = True
|
||||
if len(args) == 1 and isinstance(args[0], List):
|
||||
use_comma = args[0].use_comma
|
||||
args = args[0]
|
||||
|
||||
return List(
|
||||
[arg for arg in args if arg],
|
||||
use_comma=use_comma,
|
||||
)
|
||||
|
||||
|
||||
@register('reject')
|
||||
def reject(lst, *values):
|
||||
"""Removes the given values from the list"""
|
||||
lst = List.from_maybe(lst)
|
||||
values = frozenset(List.from_maybe_starargs(values))
|
||||
|
||||
ret = []
|
||||
for item in lst:
|
||||
if item not in values:
|
||||
ret.append(item)
|
||||
return List(ret, use_comma=lst.use_comma)
|
||||
|
||||
|
||||
@register('first-value-of')
|
||||
def first_value_of(*args):
|
||||
if len(args) == 1 and isinstance(args[0], String):
|
||||
first = args[0].value.split()[0]
|
||||
return type(args[0])(first)
|
||||
|
||||
args = List.from_maybe_starargs(args)
|
||||
if len(args):
|
||||
return args[0]
|
||||
else:
|
||||
return Null()
|
||||
|
||||
|
||||
@register('-compass-list')
|
||||
def dash_compass_list(*args):
|
||||
return List.from_maybe_starargs(args)
|
||||
|
||||
|
||||
@register('-compass-space-list')
|
||||
def dash_compass_space_list(*lst):
|
||||
"""
|
||||
If the argument is a list, it will return a new list that is space delimited
|
||||
Otherwise it returns a new, single element, space-delimited list.
|
||||
"""
|
||||
ret = dash_compass_list(*lst)
|
||||
ret.value.pop('_', None)
|
||||
return ret
|
||||
|
||||
|
||||
@register('-compass-slice', 3)
|
||||
def dash_compass_slice(lst, start_index, end_index=None):
|
||||
start_index = Number(start_index).value
|
||||
end_index = Number(end_index).value if end_index is not None else None
|
||||
ret = {}
|
||||
lst = List(lst)
|
||||
if end_index:
|
||||
# This function has an inclusive end, but Python slicing is exclusive
|
||||
end_index += 1
|
||||
ret = lst.value[start_index:end_index]
|
||||
return List(ret, use_comma=lst.use_comma)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Property prefixing
|
||||
|
||||
@register('prefixed')
|
||||
def prefixed(prefix, *args):
|
||||
to_fnct_str = 'to_' + to_str(prefix).replace('-', '_')
|
||||
for arg in List.from_maybe_starargs(args):
|
||||
if hasattr(arg, to_fnct_str):
|
||||
return Boolean(True)
|
||||
return Boolean(False)
|
||||
|
||||
|
||||
@register('prefix')
|
||||
def prefix(prefix, *args):
|
||||
to_fnct_str = 'to_' + to_str(prefix).replace('-', '_')
|
||||
args = list(args)
|
||||
for i, arg in enumerate(args):
|
||||
if isinstance(arg, List):
|
||||
_value = []
|
||||
for iarg in arg:
|
||||
to_fnct = getattr(iarg, to_fnct_str, None)
|
||||
if to_fnct:
|
||||
_value.append(to_fnct())
|
||||
else:
|
||||
_value.append(iarg)
|
||||
args[i] = List(_value)
|
||||
else:
|
||||
to_fnct = getattr(arg, to_fnct_str, None)
|
||||
if to_fnct:
|
||||
args[i] = to_fnct()
|
||||
|
||||
return List.maybe_new(args, use_comma=True)
|
||||
|
||||
|
||||
@register('-moz')
|
||||
def dash_moz(*args):
|
||||
return prefix('_moz', *args)
|
||||
|
||||
|
||||
@register('-svg')
|
||||
def dash_svg(*args):
|
||||
return prefix('_svg', *args)
|
||||
|
||||
|
||||
@register('-css2')
|
||||
def dash_css2(*args):
|
||||
return prefix('_css2', *args)
|
||||
|
||||
|
||||
@register('-pie')
|
||||
def dash_pie(*args):
|
||||
return prefix('_pie', *args)
|
||||
|
||||
|
||||
@register('-webkit')
|
||||
def dash_webkit(*args):
|
||||
return prefix('_webkit', *args)
|
||||
|
||||
|
||||
@register('-owg')
|
||||
def dash_owg(*args):
|
||||
return prefix('_owg', *args)
|
||||
|
||||
|
||||
@register('-khtml')
|
||||
def dash_khtml(*args):
|
||||
return prefix('_khtml', *args)
|
||||
|
||||
|
||||
@register('-ms')
|
||||
def dash_ms(*args):
|
||||
return prefix('_ms', *args)
|
||||
|
||||
|
||||
@register('-o')
|
||||
def dash_o(*args):
|
||||
return prefix('_o', *args)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Selector generation
|
||||
|
||||
@register('append-selector', 2)
|
||||
def append_selector(selector, to_append):
|
||||
if isinstance(selector, List):
|
||||
lst = selector.value
|
||||
else:
|
||||
lst = String.unquoted(selector).value.split(',')
|
||||
to_append = String.unquoted(to_append).value.strip()
|
||||
ret = sorted(set(s.strip() + to_append for s in lst if s.strip()))
|
||||
ret = dict(enumerate(ret))
|
||||
ret['_'] = ','
|
||||
return ret
|
||||
|
||||
|
||||
_elements_of_type_block = 'address, article, aside, blockquote, center, dd, details, dir, div, dl, dt, fieldset, figcaption, figure, footer, form, frameset, h1, h2, h3, h4, h5, h6, header, hgroup, hr, isindex, menu, nav, noframes, noscript, ol, p, pre, section, summary, ul'
|
||||
_elements_of_type_inline = 'a, abbr, acronym, audio, b, basefont, bdo, big, br, canvas, cite, code, command, datalist, dfn, em, embed, font, i, img, input, kbd, keygen, label, mark, meter, output, progress, q, rp, rt, ruby, s, samp, select, small, span, strike, strong, sub, sup, textarea, time, tt, u, var, video, wbr'
|
||||
_elements_of_type_table = 'table'
|
||||
_elements_of_type_list_item = 'li'
|
||||
_elements_of_type_table_row_group = 'tbody'
|
||||
_elements_of_type_table_header_group = 'thead'
|
||||
_elements_of_type_table_footer_group = 'tfoot'
|
||||
_elements_of_type_table_row = 'tr'
|
||||
_elements_of_type_table_cel = 'td, th'
|
||||
_elements_of_type_html5_block = 'article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary'
|
||||
_elements_of_type_html5_inline = 'audio, canvas, command, datalist, embed, keygen, mark, meter, output, progress, rp, rt, ruby, time, video, wbr'
|
||||
_elements_of_type_html5 = 'article, aside, audio, canvas, command, datalist, details, embed, figcaption, figure, footer, header, hgroup, keygen, mark, menu, meter, nav, output, progress, rp, rt, ruby, section, summary, time, video, wbr'
|
||||
_elements_of_type = {
|
||||
'block': sorted(_elements_of_type_block.replace(' ', '').split(',')),
|
||||
'inline': sorted(_elements_of_type_inline.replace(' ', '').split(',')),
|
||||
'table': sorted(_elements_of_type_table.replace(' ', '').split(',')),
|
||||
'list-item': sorted(_elements_of_type_list_item.replace(' ', '').split(',')),
|
||||
'table-row-group': sorted(_elements_of_type_table_row_group.replace(' ', '').split(',')),
|
||||
'table-header-group': sorted(_elements_of_type_table_header_group.replace(' ', '').split(',')),
|
||||
'table-footer-group': sorted(_elements_of_type_table_footer_group.replace(' ', '').split(',')),
|
||||
'table-row': sorted(_elements_of_type_table_footer_group.replace(' ', '').split(',')),
|
||||
'table-cell': sorted(_elements_of_type_table_footer_group.replace(' ', '').split(',')),
|
||||
'html5-block': sorted(_elements_of_type_html5_block.replace(' ', '').split(',')),
|
||||
'html5-inline': sorted(_elements_of_type_html5_inline.replace(' ', '').split(',')),
|
||||
'html5': sorted(_elements_of_type_html5.replace(' ', '').split(',')),
|
||||
}
|
||||
|
||||
|
||||
@register('elements-of-type', 1)
|
||||
def elements_of_type(display):
|
||||
d = String.unquoted(display)
|
||||
ret = _elements_of_type.get(d.value, None)
|
||||
if ret is None:
|
||||
raise Exception("Elements of type '%s' not found!" % d.value)
|
||||
return List(ret, use_comma=True)
|
||||
|
||||
|
||||
@register('enumerate', 3)
|
||||
@register('enumerate', 4)
|
||||
def enumerate_(prefix, frm, through, separator='-'):
|
||||
separator = String.unquoted(separator).value
|
||||
try:
|
||||
frm = int(getattr(frm, 'value', frm))
|
||||
except ValueError:
|
||||
frm = 1
|
||||
try:
|
||||
through = int(getattr(through, 'value', through))
|
||||
except ValueError:
|
||||
through = frm
|
||||
if frm > through:
|
||||
# DEVIATION: allow reversed enumerations (and ranges as range() uses enumerate, like '@for .. from .. through')
|
||||
frm, through = through, frm
|
||||
rev = reversed
|
||||
else:
|
||||
rev = lambda x: x
|
||||
|
||||
ret = []
|
||||
for i in rev(range(frm, through + 1)):
|
||||
if prefix and prefix.value:
|
||||
ret.append(String.unquoted(prefix.value + separator + str(i)))
|
||||
else:
|
||||
ret.append(Number(i))
|
||||
|
||||
return List(ret, use_comma=True)
|
||||
|
||||
|
||||
@register('headers', 0)
|
||||
@register('headers', 1)
|
||||
@register('headers', 2)
|
||||
@register('headings', 0)
|
||||
@register('headings', 1)
|
||||
@register('headings', 2)
|
||||
def headers(frm=None, to=None):
|
||||
if frm and to is None:
|
||||
if isinstance(frm, String) and frm.value.lower() == 'all':
|
||||
frm = 1
|
||||
to = 6
|
||||
else:
|
||||
try:
|
||||
to = int(getattr(frm, 'value', frm))
|
||||
except ValueError:
|
||||
to = 6
|
||||
frm = 1
|
||||
else:
|
||||
try:
|
||||
frm = 1 if frm is None else int(getattr(frm, 'value', frm))
|
||||
except ValueError:
|
||||
frm = 1
|
||||
try:
|
||||
to = 6 if to is None else int(getattr(to, 'value', to))
|
||||
except ValueError:
|
||||
to = 6
|
||||
ret = [String.unquoted('h' + str(i)) for i in range(frm, to + 1)]
|
||||
return List(ret, use_comma=True)
|
||||
|
||||
|
||||
@register('nest')
|
||||
def nest(*arguments):
|
||||
if isinstance(arguments[0], List):
|
||||
lst = arguments[0]
|
||||
elif isinstance(arguments[0], String):
|
||||
lst = arguments[0].value.split(',')
|
||||
else:
|
||||
raise TypeError("Expected list or string, got %r" % (arguments[0],))
|
||||
|
||||
ret = []
|
||||
for s in lst:
|
||||
if isinstance(s, String):
|
||||
s = s.value
|
||||
elif isinstance(s, six.string_types):
|
||||
s = s
|
||||
else:
|
||||
raise TypeError("Expected string, got %r" % (s,))
|
||||
|
||||
s = s.strip()
|
||||
if not s:
|
||||
continue
|
||||
|
||||
ret.append(s)
|
||||
|
||||
for arg in arguments[1:]:
|
||||
if isinstance(arg, List):
|
||||
lst = arg
|
||||
elif isinstance(arg, String):
|
||||
lst = arg.value.split(',')
|
||||
else:
|
||||
raise TypeError("Expected list or string, got %r" % (arg,))
|
||||
|
||||
new_ret = []
|
||||
for s in lst:
|
||||
if isinstance(s, String):
|
||||
s = s.value
|
||||
elif isinstance(s, six.string_types):
|
||||
s = s
|
||||
else:
|
||||
raise TypeError("Expected string, got %r" % (s,))
|
||||
|
||||
s = s.strip()
|
||||
if not s:
|
||||
continue
|
||||
|
||||
for r in ret:
|
||||
if '&' in s:
|
||||
new_ret.append(s.replace('&', r))
|
||||
else:
|
||||
if not r or r[-1] in ('.', ':', '#'):
|
||||
new_ret.append(r + s)
|
||||
else:
|
||||
new_ret.append(r + ' ' + s)
|
||||
ret = new_ret
|
||||
|
||||
ret = [String.unquoted(s) for s in sorted(set(ret))]
|
||||
return List(ret, use_comma=True)
|
||||
|
||||
|
||||
# This isn't actually from Compass, but it's just a shortcut for enumerate().
|
||||
# DEVIATION: allow reversed ranges (range() uses enumerate() which allows reversed values, like '@for .. from .. through')
|
||||
@register('range', 1)
|
||||
@register('range', 2)
|
||||
def range_(frm, through=None):
|
||||
if through is None:
|
||||
through = frm
|
||||
frm = 1
|
||||
return enumerate_(None, frm, through)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Working with CSS constants
|
||||
|
||||
OPPOSITE_POSITIONS = {
|
||||
'top': String.unquoted('bottom'),
|
||||
'bottom': String.unquoted('top'),
|
||||
'left': String.unquoted('right'),
|
||||
'right': String.unquoted('left'),
|
||||
'center': String.unquoted('center'),
|
||||
}
|
||||
DEFAULT_POSITION = [String.unquoted('center'), String.unquoted('top')]
|
||||
|
||||
|
||||
def _position(opposite, positions):
|
||||
if positions is None:
|
||||
positions = DEFAULT_POSITION
|
||||
else:
|
||||
positions = List.from_maybe(positions)
|
||||
|
||||
ret = []
|
||||
for pos in positions:
|
||||
if isinstance(pos, (String, six.string_types)):
|
||||
pos_value = getattr(pos, 'value', pos)
|
||||
if pos_value in OPPOSITE_POSITIONS:
|
||||
if opposite:
|
||||
ret.append(OPPOSITE_POSITIONS[pos_value])
|
||||
else:
|
||||
ret.append(pos)
|
||||
continue
|
||||
elif pos_value == 'to':
|
||||
# Gradient syntax keyword; leave alone
|
||||
ret.append(pos)
|
||||
continue
|
||||
|
||||
elif isinstance(pos, Number):
|
||||
if pos.is_simple_unit('%'):
|
||||
if opposite:
|
||||
ret.append(Number(100 - pos.value, '%'))
|
||||
else:
|
||||
ret.append(pos)
|
||||
continue
|
||||
elif pos.is_simple_unit('deg'):
|
||||
# TODO support other angle types?
|
||||
if opposite:
|
||||
ret.append(Number((pos.value + 180) % 360, 'deg'))
|
||||
else:
|
||||
ret.append(pos)
|
||||
continue
|
||||
|
||||
if opposite:
|
||||
log.warn("Can't find opposite for position %r" % (pos,))
|
||||
ret.append(pos)
|
||||
|
||||
return List(ret, use_comma=False).maybe()
|
||||
|
||||
|
||||
@register('position')
|
||||
def position(p):
|
||||
return _position(False, p)
|
||||
|
||||
|
||||
@register('opposite-position')
|
||||
def opposite_position(p):
|
||||
return _position(True, p)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Math
|
||||
|
||||
@register('pi', 0)
|
||||
def pi():
|
||||
return Number(math.pi)
|
||||
|
||||
|
||||
@register('e', 0)
|
||||
def e():
|
||||
return Number(math.e)
|
||||
|
||||
|
||||
@register('log', 1)
|
||||
@register('log', 2)
|
||||
def log_(number, base=None):
|
||||
if not isinstance(number, Number):
|
||||
raise TypeError("Expected number, got %r" % (number,))
|
||||
elif not number.is_unitless:
|
||||
raise ValueError("Expected unitless number, got %r" % (number,))
|
||||
|
||||
if base is None:
|
||||
pass
|
||||
elif not isinstance(base, Number):
|
||||
raise TypeError("Expected number, got %r" % (base,))
|
||||
elif not base.is_unitless:
|
||||
raise ValueError("Expected unitless number, got %r" % (base,))
|
||||
|
||||
if base is None:
|
||||
ret = math.log(number.value)
|
||||
else:
|
||||
ret = math.log(number.value, base.value)
|
||||
|
||||
return Number(ret)
|
||||
|
||||
|
||||
@register('pow', 2)
|
||||
def pow(number, exponent):
|
||||
return number ** exponent
|
||||
|
||||
|
||||
COMPASS_HELPERS_LIBRARY.add(Number.wrap_python_function(math.sqrt), 'sqrt', 1)
|
||||
COMPASS_HELPERS_LIBRARY.add(Number.wrap_python_function(math.sin), 'sin', 1)
|
||||
COMPASS_HELPERS_LIBRARY.add(Number.wrap_python_function(math.cos), 'cos', 1)
|
||||
COMPASS_HELPERS_LIBRARY.add(Number.wrap_python_function(math.tan), 'tan', 1)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Fonts
|
||||
|
||||
def _fonts_root():
|
||||
return config.STATIC_ROOT if config.FONTS_ROOT is None else config.FONTS_ROOT
|
||||
|
||||
|
||||
def _font_url(path, only_path=False, cache_buster=True, inline=False):
|
||||
filepath = String.unquoted(path).value
|
||||
file = None
|
||||
FONTS_ROOT = _fonts_root()
|
||||
if callable(FONTS_ROOT):
|
||||
try:
|
||||
_file, _storage = list(FONTS_ROOT(filepath))[0]
|
||||
d_obj = _storage.modified_time(_file)
|
||||
filetime = int(time.mktime(d_obj.timetuple()))
|
||||
if inline:
|
||||
file = _storage.open(_file)
|
||||
except:
|
||||
filetime = 'NA'
|
||||
else:
|
||||
_path = os.path.join(FONTS_ROOT, filepath.strip('/'))
|
||||
if os.path.exists(_path):
|
||||
filetime = int(os.path.getmtime(_path))
|
||||
if inline:
|
||||
file = open(_path, 'rb')
|
||||
else:
|
||||
filetime = 'NA'
|
||||
|
||||
BASE_URL = config.FONTS_URL or config.STATIC_URL
|
||||
if file and inline:
|
||||
font_type = None
|
||||
if re.match(r'^([^?]+)[.](.*)([?].*)?$', path.value):
|
||||
font_type = String.unquoted(re.match(r'^([^?]+)[.](.*)([?].*)?$', path.value).groups()[1]).value
|
||||
|
||||
if not FONT_TYPES.get(font_type):
|
||||
raise Exception('Could not determine font type for "%s"' % path.value)
|
||||
|
||||
mime = FONT_TYPES.get(font_type)
|
||||
if font_type == 'woff':
|
||||
mime = 'application/font-woff'
|
||||
elif font_type == 'eot':
|
||||
mime = 'application/vnd.ms-fontobject'
|
||||
url = 'data:' + (mime if '/' in mime else 'font/%s' % mime) + ';base64,' + base64.b64encode(file.read())
|
||||
file.close()
|
||||
else:
|
||||
url = '%s/%s' % (BASE_URL.rstrip('/'), filepath.lstrip('/'))
|
||||
if cache_buster and filetime != 'NA':
|
||||
url = add_cache_buster(url, filetime)
|
||||
|
||||
if not only_path:
|
||||
url = 'url(%s)' % escape(url)
|
||||
return String.unquoted(url)
|
||||
|
||||
|
||||
def _font_files(args, inline):
|
||||
if args == ():
|
||||
return String.unquoted("")
|
||||
|
||||
fonts = []
|
||||
args_len = len(args)
|
||||
skip_next = False
|
||||
for index in range(len(args)):
|
||||
arg = args[index]
|
||||
if not skip_next:
|
||||
font_type = args[index + 1] if args_len > (index + 1) else None
|
||||
if font_type and font_type.value in FONT_TYPES:
|
||||
skip_next = True
|
||||
else:
|
||||
if re.match(r'^([^?]+)[.](.*)([?].*)?$', arg.value):
|
||||
font_type = String.unquoted(re.match(r'^([^?]+)[.](.*)([?].*)?$', arg.value).groups()[1])
|
||||
|
||||
if font_type.value in FONT_TYPES:
|
||||
fonts.append(String.unquoted('%s format("%s")' % (_font_url(arg, inline=inline), String.unquoted(FONT_TYPES[font_type.value]).value)))
|
||||
else:
|
||||
raise Exception('Could not determine font type for "%s"' % arg.value)
|
||||
else:
|
||||
skip_next = False
|
||||
|
||||
return List(fonts, separator=',')
|
||||
|
||||
|
||||
@register('font-url', 1)
|
||||
@register('font-url', 2)
|
||||
def font_url(path, only_path=False, cache_buster=True):
|
||||
"""
|
||||
Generates a path to an asset found relative to the project's font directory.
|
||||
Passing a true value as the second argument will cause the only the path to
|
||||
be returned instead of a `url()` function
|
||||
"""
|
||||
return _font_url(path, only_path, cache_buster, False)
|
||||
|
||||
|
||||
@register('font-files')
|
||||
def font_files(*args):
|
||||
return _font_files(args, inline=False)
|
||||
|
||||
|
||||
@register('inline-font-files')
|
||||
def inline_font_files(*args):
|
||||
return _font_files(args, inline=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# External stylesheets
|
||||
|
||||
@register('stylesheet-url', 1)
|
||||
@register('stylesheet-url', 2)
|
||||
def stylesheet_url(path, only_path=False, cache_buster=True):
|
||||
"""
|
||||
Generates a path to an asset found relative to the project's css directory.
|
||||
Passing a true value as the second argument will cause the only the path to
|
||||
be returned instead of a `url()` function
|
||||
"""
|
||||
filepath = String.unquoted(path).value
|
||||
if callable(config.STATIC_ROOT):
|
||||
try:
|
||||
_file, _storage = list(config.STATIC_ROOT(filepath))[0]
|
||||
d_obj = _storage.modified_time(_file)
|
||||
filetime = int(time.mktime(d_obj.timetuple()))
|
||||
except:
|
||||
filetime = 'NA'
|
||||
else:
|
||||
_path = os.path.join(config.STATIC_ROOT, filepath.strip('/'))
|
||||
if os.path.exists(_path):
|
||||
filetime = int(os.path.getmtime(_path))
|
||||
else:
|
||||
filetime = 'NA'
|
||||
BASE_URL = config.STATIC_URL
|
||||
|
||||
url = '%s%s' % (BASE_URL, filepath)
|
||||
if cache_buster:
|
||||
url = add_cache_buster(url, filetime)
|
||||
if not only_path:
|
||||
url = 'url("%s")' % (url)
|
||||
return String.unquoted(url)
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Image utilities ported from Compass."""
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import mimetypes
|
||||
import os.path
|
||||
import time
|
||||
|
||||
import six
|
||||
from six.moves import xrange
|
||||
|
||||
from scss import config
|
||||
from scss.functions.compass import _image_size_cache
|
||||
from scss.functions.compass.helpers import add_cache_buster
|
||||
from scss.functions.library import FunctionLibrary
|
||||
from scss.types import Color, List, Number, String
|
||||
from scss.util import escape
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
try:
|
||||
import Image
|
||||
except:
|
||||
Image = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
COMPASS_IMAGES_LIBRARY = FunctionLibrary()
|
||||
register = COMPASS_IMAGES_LIBRARY.register
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
def _images_root():
|
||||
return config.STATIC_ROOT if config.IMAGES_ROOT is None else config.IMAGES_ROOT
|
||||
|
||||
|
||||
def _image_url(path, only_path=False, cache_buster=True, dst_color=None, src_color=None, inline=False, mime_type=None, spacing=None, collapse_x=None, collapse_y=None):
|
||||
"""
|
||||
src_color - a list of or a single color to be replaced by each corresponding dst_color colors
|
||||
spacing - spaces to be added to the image
|
||||
collapse_x, collapse_y - collapsable (layered) image of the given size (x, y)
|
||||
"""
|
||||
if inline or dst_color or spacing:
|
||||
if not Image:
|
||||
raise Exception("Images manipulation require PIL")
|
||||
filepath = String.unquoted(path).value
|
||||
fileext = os.path.splitext(filepath)[1].lstrip('.').lower()
|
||||
if mime_type:
|
||||
mime_type = String.unquoted(mime_type).value
|
||||
if not mime_type:
|
||||
mime_type = mimetypes.guess_type(filepath)[0]
|
||||
if not mime_type:
|
||||
mime_type = 'image/%s' % fileext
|
||||
path = None
|
||||
IMAGES_ROOT = _images_root()
|
||||
if callable(IMAGES_ROOT):
|
||||
try:
|
||||
_file, _storage = list(IMAGES_ROOT(filepath))[0]
|
||||
d_obj = _storage.modified_time(_file)
|
||||
filetime = int(time.mktime(d_obj.timetuple()))
|
||||
if inline or dst_color or spacing:
|
||||
path = _storage.open(_file)
|
||||
except:
|
||||
filetime = 'NA'
|
||||
else:
|
||||
_path = os.path.join(IMAGES_ROOT.rstrip('/'), filepath.strip('/'))
|
||||
if os.path.exists(_path):
|
||||
filetime = int(os.path.getmtime(_path))
|
||||
if inline or dst_color or spacing:
|
||||
path = open(_path, 'rb')
|
||||
else:
|
||||
filetime = 'NA'
|
||||
|
||||
BASE_URL = config.IMAGES_URL or config.STATIC_URL
|
||||
if path:
|
||||
dst_colors = [list(Color(v).value[:3]) for v in List.from_maybe(dst_color) if v]
|
||||
|
||||
src_color = Color.from_name('black') if src_color is None else src_color
|
||||
src_colors = [tuple(Color(v).value[:3]) for v in List.from_maybe(src_color)]
|
||||
|
||||
len_colors = max(len(dst_colors), len(src_colors))
|
||||
dst_colors = (dst_colors * len_colors)[:len_colors]
|
||||
src_colors = (src_colors * len_colors)[:len_colors]
|
||||
|
||||
spacing = Number(0) if spacing is None else spacing
|
||||
spacing = [int(Number(v).value) for v in List.from_maybe(spacing)]
|
||||
spacing = (spacing * 4)[:4]
|
||||
|
||||
file_name, file_ext = os.path.splitext(os.path.normpath(filepath).replace('\\', '_').replace('/', '_'))
|
||||
key = (filetime, src_color, dst_color, spacing)
|
||||
key = file_name + '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_')
|
||||
asset_file = key + file_ext
|
||||
ASSETS_ROOT = config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets')
|
||||
asset_path = os.path.join(ASSETS_ROOT, asset_file)
|
||||
|
||||
if os.path.exists(asset_path):
|
||||
filepath = asset_file
|
||||
BASE_URL = config.ASSETS_URL
|
||||
if inline:
|
||||
path = open(asset_path, 'rb')
|
||||
url = 'data:' + mime_type + ';base64,' + base64.b64encode(path.read())
|
||||
else:
|
||||
url = '%s%s' % (BASE_URL, filepath)
|
||||
if cache_buster:
|
||||
filetime = int(os.path.getmtime(asset_path))
|
||||
url = add_cache_buster(url, filetime)
|
||||
else:
|
||||
simply_process = False
|
||||
image = None
|
||||
|
||||
if fileext in ('cur',):
|
||||
simply_process = True
|
||||
else:
|
||||
try:
|
||||
image = Image.open(path)
|
||||
except IOError:
|
||||
if not collapse_x and not collapse_y and not dst_colors:
|
||||
simply_process = True
|
||||
|
||||
if simply_process:
|
||||
if inline:
|
||||
url = 'data:' + mime_type + ';base64,' + base64.b64encode(path.read())
|
||||
else:
|
||||
url = '%s%s' % (BASE_URL, filepath)
|
||||
if cache_buster:
|
||||
filetime = int(os.path.getmtime(asset_path))
|
||||
url = add_cache_buster(url, filetime)
|
||||
else:
|
||||
width, height = collapse_x or image.size[0], collapse_y or image.size[1]
|
||||
new_image = Image.new(
|
||||
mode='RGBA',
|
||||
size=(width + spacing[1] + spacing[3], height + spacing[0] + spacing[2]),
|
||||
color=(0, 0, 0, 0)
|
||||
)
|
||||
for i, dst_color in enumerate(dst_colors):
|
||||
src_color = src_colors[i]
|
||||
pixdata = image.load()
|
||||
for _y in xrange(image.size[1]):
|
||||
for _x in xrange(image.size[0]):
|
||||
pixel = pixdata[_x, _y]
|
||||
if pixel[:3] == src_color:
|
||||
pixdata[_x, _y] = tuple([int(c) for c in dst_color] + [pixel[3] if len(pixel) == 4 else 255])
|
||||
iwidth, iheight = image.size
|
||||
if iwidth != width or iheight != height:
|
||||
cy = 0
|
||||
while cy < iheight:
|
||||
cx = 0
|
||||
while cx < iwidth:
|
||||
cropped_image = image.crop((cx, cy, cx + width, cy + height))
|
||||
new_image.paste(cropped_image, (int(spacing[3]), int(spacing[0])), cropped_image)
|
||||
cx += width
|
||||
cy += height
|
||||
else:
|
||||
new_image.paste(image, (int(spacing[3]), int(spacing[0])))
|
||||
|
||||
if not inline:
|
||||
try:
|
||||
new_image.save(asset_path)
|
||||
filepath = asset_file
|
||||
BASE_URL = config.ASSETS_URL
|
||||
if cache_buster:
|
||||
filetime = int(os.path.getmtime(asset_path))
|
||||
except IOError:
|
||||
log.exception("Error while saving image")
|
||||
inline = True # Retry inline version
|
||||
url = os.path.join(config.ASSETS_URL.rstrip('/'), asset_file.lstrip('/'))
|
||||
if cache_buster:
|
||||
url = add_cache_buster(url, filetime)
|
||||
if inline:
|
||||
output = six.BytesIO()
|
||||
new_image.save(output, format='PNG')
|
||||
contents = output.getvalue()
|
||||
output.close()
|
||||
url = 'data:' + mime_type + ';base64,' + base64.b64encode(contents)
|
||||
else:
|
||||
url = os.path.join(BASE_URL.rstrip('/'), filepath.lstrip('/'))
|
||||
if cache_buster and filetime != 'NA':
|
||||
url = add_cache_buster(url, filetime)
|
||||
|
||||
if not only_path:
|
||||
url = 'url(%s)' % escape(url)
|
||||
return String.unquoted(url)
|
||||
|
||||
|
||||
@register('inline-image', 1)
|
||||
@register('inline-image', 2)
|
||||
@register('inline-image', 3)
|
||||
@register('inline-image', 4)
|
||||
@register('inline-image', 5)
|
||||
def inline_image(image, mime_type=None, dst_color=None, src_color=None, spacing=None, collapse_x=None, collapse_y=None):
|
||||
"""
|
||||
Embeds the contents of a file directly inside your stylesheet, eliminating
|
||||
the need for another HTTP request. For small files such images or fonts,
|
||||
this can be a performance benefit at the cost of a larger generated CSS
|
||||
file.
|
||||
"""
|
||||
return _image_url(image, False, False, dst_color, src_color, True, mime_type, spacing, collapse_x, collapse_y)
|
||||
|
||||
|
||||
@register('image-url', 1)
|
||||
@register('image-url', 2)
|
||||
@register('image-url', 3)
|
||||
@register('image-url', 4)
|
||||
@register('image-url', 5)
|
||||
@register('image-url', 6)
|
||||
def image_url(path, only_path=False, cache_buster=True, dst_color=None, src_color=None, spacing=None, collapse_x=None, collapse_y=None):
|
||||
"""
|
||||
Generates a path to an asset found relative to the project's images
|
||||
directory.
|
||||
Passing a true value as the second argument will cause the only the path to
|
||||
be returned instead of a `url()` function
|
||||
"""
|
||||
return _image_url(path, only_path, cache_buster, dst_color, src_color, False, None, spacing, collapse_x, collapse_y)
|
||||
|
||||
|
||||
@register('image-width', 1)
|
||||
def image_width(image):
|
||||
"""
|
||||
Returns the width of the image found at the path supplied by `image`
|
||||
relative to your project's images directory.
|
||||
"""
|
||||
if not Image:
|
||||
raise Exception("Images manipulation require PIL")
|
||||
filepath = String.unquoted(image).value
|
||||
path = None
|
||||
try:
|
||||
width = _image_size_cache[filepath][0]
|
||||
except KeyError:
|
||||
width = 0
|
||||
IMAGES_ROOT = _images_root()
|
||||
if callable(IMAGES_ROOT):
|
||||
try:
|
||||
_file, _storage = list(IMAGES_ROOT(filepath))[0]
|
||||
path = _storage.open(_file)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
_path = os.path.join(IMAGES_ROOT, filepath.strip('/'))
|
||||
if os.path.exists(_path):
|
||||
path = open(_path, 'rb')
|
||||
if path:
|
||||
image = Image.open(path)
|
||||
size = image.size
|
||||
width = size[0]
|
||||
_image_size_cache[filepath] = size
|
||||
return Number(width, 'px')
|
||||
|
||||
|
||||
@register('image-height', 1)
|
||||
def image_height(image):
|
||||
"""
|
||||
Returns the height of the image found at the path supplied by `image`
|
||||
relative to your project's images directory.
|
||||
"""
|
||||
if not Image:
|
||||
raise Exception("Images manipulation require PIL")
|
||||
filepath = String.unquoted(image).value
|
||||
path = None
|
||||
try:
|
||||
height = _image_size_cache[filepath][1]
|
||||
except KeyError:
|
||||
height = 0
|
||||
IMAGES_ROOT = _images_root()
|
||||
if callable(IMAGES_ROOT):
|
||||
try:
|
||||
_file, _storage = list(IMAGES_ROOT(filepath))[0]
|
||||
path = _storage.open(_file)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
_path = os.path.join(IMAGES_ROOT, filepath.strip('/'))
|
||||
if os.path.exists(_path):
|
||||
path = open(_path, 'rb')
|
||||
if path:
|
||||
image = Image.open(path)
|
||||
size = image.size
|
||||
height = size[1]
|
||||
_image_size_cache[filepath] = size
|
||||
return Number(height, 'px')
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Functions used for generating packed CSS sprite maps.
|
||||
|
||||
|
||||
These are ported from the Binary Tree Bin Packing Algorithm:
|
||||
http://codeincomplete.com/posts/2011/5/7/bin_packing/
|
||||
"""
|
||||
|
||||
# Copyright (c) 2011, 2012, 2013 Jake Gordon and contributors
|
||||
# Copyright (c) 2013 German M. Bravo
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
|
||||
class LayoutNode(object):
|
||||
def __init__(self, x, y, w, h, down=None, right=None, used=False):
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.w = w
|
||||
self.h = h
|
||||
self.down = down
|
||||
self.right = right
|
||||
self.used = used
|
||||
self.width = 0
|
||||
self.height = 0
|
||||
|
||||
@property
|
||||
def area(self):
|
||||
return self.width * self.height
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s (%s, %s) [%sx%s]>' % (self.__class__.__name__, self.x, self.y, self.w, self.h)
|
||||
|
||||
|
||||
class SpritesLayout(object):
|
||||
def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None):
|
||||
self.num_blocks = len(blocks)
|
||||
|
||||
if margin is None:
|
||||
margin = [[0] * 4] * self.num_blocks
|
||||
elif not isinstance(margin, (tuple, list)):
|
||||
margin = [[margin] * 4] * self.num_blocks
|
||||
elif not isinstance(margin[0], (tuple, list)):
|
||||
margin = [margin] * self.num_blocks
|
||||
|
||||
if padding is None:
|
||||
padding = [[0] * 4] * self.num_blocks
|
||||
elif not isinstance(padding, (tuple, list)):
|
||||
padding = [[padding] * 4] * self.num_blocks
|
||||
elif not isinstance(padding[0], (tuple, list)):
|
||||
padding = [padding] * self.num_blocks
|
||||
|
||||
if pmargin is None:
|
||||
pmargin = [[0.0] * 4] * self.num_blocks
|
||||
elif not isinstance(pmargin, (tuple, list)):
|
||||
pmargin = [[pmargin] * 4] * self.num_blocks
|
||||
elif not isinstance(pmargin[0], (tuple, list)):
|
||||
pmargin = [pmargin] * self.num_blocks
|
||||
|
||||
if ppadding is None:
|
||||
ppadding = [[0.0] * 4] * self.num_blocks
|
||||
elif not isinstance(ppadding, (tuple, list)):
|
||||
ppadding = [[ppadding] * 4] * self.num_blocks
|
||||
elif not isinstance(ppadding[0], (tuple, list)):
|
||||
ppadding = [ppadding] * self.num_blocks
|
||||
|
||||
self.blocks = tuple((
|
||||
b[0] + padding[i][3] + padding[i][1] + margin[i][3] + margin[i][1] + int(round(b[0] * (ppadding[i][3] + ppadding[i][1] + pmargin[i][3] + pmargin[i][1]))),
|
||||
b[1] + padding[i][0] + padding[i][2] + margin[i][0] + margin[i][2] + int(round(b[1] * (ppadding[i][0] + ppadding[i][2] + pmargin[i][0] + pmargin[i][2]))),
|
||||
b[0],
|
||||
b[1],
|
||||
i
|
||||
) for i, b in enumerate(blocks))
|
||||
|
||||
self.margin = margin
|
||||
self.padding = padding
|
||||
self.pmargin = pmargin
|
||||
self.ppadding = ppadding
|
||||
|
||||
|
||||
class PackedSpritesLayout(SpritesLayout):
|
||||
@staticmethod
|
||||
def MAXSIDE(a, b):
|
||||
"""maxside: Sort pack by maximum sides"""
|
||||
return cmp(max(b[0], b[1]), max(a[0], a[1])) or cmp(min(b[0], b[1]), min(a[0], a[1])) or cmp(b[1], a[1]) or cmp(b[0], a[0])
|
||||
|
||||
@staticmethod
|
||||
def WIDTH(a, b):
|
||||
"""width: Sort pack by width"""
|
||||
return cmp(b[0], a[0]) or cmp(b[1], a[1])
|
||||
|
||||
@staticmethod
|
||||
def HEIGHT(a, b):
|
||||
"""height: Sort pack by height"""
|
||||
return cmp(b[1], a[1]) or cmp(b[0], a[0])
|
||||
|
||||
@staticmethod
|
||||
def AREA(a, b):
|
||||
"""area: Sort pack by area"""
|
||||
return cmp(b[0] * b[1], a[0] * a[1]) or cmp(b[1], a[1]) or cmp(b[0], a[0])
|
||||
|
||||
def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None, methods=None):
|
||||
super(PackedSpritesLayout, self).__init__(blocks, padding, margin, ppadding, pmargin)
|
||||
|
||||
ratio = 0
|
||||
|
||||
if methods is None:
|
||||
methods = (self.MAXSIDE, self.WIDTH, self.HEIGHT, self.AREA)
|
||||
|
||||
for method in methods:
|
||||
sorted_blocks = sorted(
|
||||
self.blocks,
|
||||
cmp=method,
|
||||
)
|
||||
root = LayoutNode(
|
||||
x=0,
|
||||
y=0,
|
||||
w=sorted_blocks[0][0] if sorted_blocks else 0,
|
||||
h=sorted_blocks[0][1] if sorted_blocks else 0
|
||||
)
|
||||
|
||||
area = 0
|
||||
nodes = [None] * self.num_blocks
|
||||
|
||||
for block in sorted_blocks:
|
||||
w, h, width, height, i = block
|
||||
node = self._findNode(root, w, h)
|
||||
if node:
|
||||
node = self._splitNode(node, w, h)
|
||||
else:
|
||||
root = self._growNode(root, w, h)
|
||||
node = self._findNode(root, w, h)
|
||||
if node:
|
||||
node = self._splitNode(node, w, h)
|
||||
else:
|
||||
node = None
|
||||
nodes[i] = node
|
||||
node.width = width
|
||||
node.height = height
|
||||
area += node.area
|
||||
|
||||
this_ratio = area / float(root.w * root.h)
|
||||
# print method.__doc__, "%g%%" % (this_ratio * 100)
|
||||
if ratio < this_ratio:
|
||||
self.root = root
|
||||
self.nodes = nodes
|
||||
self.method = method
|
||||
ratio = this_ratio
|
||||
if ratio > 0.96:
|
||||
break
|
||||
# print self.method.__doc__, "%g%%" % (ratio * 100)
|
||||
|
||||
def __iter__(self):
|
||||
for i, node in enumerate(self.nodes):
|
||||
margin, padding = self.margin[i], self.padding[i]
|
||||
pmargin, ppadding = self.pmargin[i], self.ppadding[i]
|
||||
cssw = node.width + padding[3] + padding[1] + int(round(node.width * (ppadding[3] + ppadding[1]))) # image width plus padding
|
||||
cssh = node.height + padding[0] + padding[2] + int(round(node.height * (ppadding[0] + ppadding[2]))) # image height plus padding
|
||||
cssx = node.x + margin[3] + int(round(node.width * pmargin[3]))
|
||||
cssy = node.y + margin[0] + int(round(node.height * pmargin[0]))
|
||||
x = cssx + padding[3] + int(round(node.width * ppadding[3]))
|
||||
y = cssy + padding[0] + int(round(node.height * ppadding[0]))
|
||||
yield x, y, node.width, node.height, cssx, cssy, cssw, cssh
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self.root.w
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self.root.h
|
||||
|
||||
def _findNode(self, root, w, h):
|
||||
if root.used:
|
||||
return self._findNode(root.right, w, h) or self._findNode(root.down, w, h)
|
||||
elif w <= root.w and h <= root.h:
|
||||
return root
|
||||
else:
|
||||
return None
|
||||
|
||||
def _splitNode(self, node, w, h):
|
||||
node.used = True
|
||||
node.down = LayoutNode(
|
||||
x=node.x,
|
||||
y=node.y + h,
|
||||
w=node.w,
|
||||
h=node.h - h
|
||||
)
|
||||
node.right = LayoutNode(
|
||||
x=node.x + w,
|
||||
y=node.y,
|
||||
w=node.w - w,
|
||||
h=h
|
||||
)
|
||||
return node
|
||||
|
||||
def _growNode(self, root, w, h):
|
||||
canGrowDown = w <= root.w
|
||||
canGrowRight = h <= root.h
|
||||
|
||||
shouldGrowRight = canGrowRight and (root.h >= root.w + w) # attempt to keep square-ish by growing right when height is much greater than width
|
||||
shouldGrowDown = canGrowDown and (root.w >= root.h + h) # attempt to keep square-ish by growing down when width is much greater than height
|
||||
|
||||
if shouldGrowRight:
|
||||
return self._growRight(root, w, h)
|
||||
elif shouldGrowDown:
|
||||
return self._growDown(root, w, h)
|
||||
elif canGrowRight:
|
||||
return self._growRight(root, w, h)
|
||||
elif canGrowDown:
|
||||
return self._growDown(root, w, h)
|
||||
else:
|
||||
# need to ensure sensible root starting size to avoid this happening
|
||||
assert False, "Blocks must be properly sorted!"
|
||||
|
||||
def _growRight(self, root, w, h):
|
||||
root = LayoutNode(
|
||||
used=True,
|
||||
x=0,
|
||||
y=0,
|
||||
w=root.w + w,
|
||||
h=root.h,
|
||||
down=root,
|
||||
right=LayoutNode(
|
||||
x=root.w,
|
||||
y=0,
|
||||
w=w,
|
||||
h=root.h
|
||||
)
|
||||
)
|
||||
return root
|
||||
|
||||
def _growDown(self, root, w, h):
|
||||
root = LayoutNode(
|
||||
used=True,
|
||||
x=0,
|
||||
y=0,
|
||||
w=root.w,
|
||||
h=root.h + h,
|
||||
down=LayoutNode(
|
||||
x=0,
|
||||
y=root.h,
|
||||
w=root.w,
|
||||
h=h
|
||||
),
|
||||
right=root
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
class HorizontalSpritesLayout(SpritesLayout):
|
||||
def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None, position=None):
|
||||
super(HorizontalSpritesLayout, self).__init__(blocks, padding, margin, ppadding, pmargin)
|
||||
|
||||
self.width = sum(zip(*self.blocks)[0])
|
||||
self.height = max(zip(*self.blocks)[1])
|
||||
|
||||
if position is None:
|
||||
position = [0.0] * self.num_blocks
|
||||
elif not isinstance(position, (tuple, list)):
|
||||
position = [position] * self.num_blocks
|
||||
self.position = position
|
||||
|
||||
def __iter__(self):
|
||||
cx = 0
|
||||
for i, block in enumerate(self.blocks):
|
||||
w, h, width, height, i = block
|
||||
margin, padding = self.margin[i], self.padding[i]
|
||||
pmargin, ppadding = self.pmargin[i], self.ppadding[i]
|
||||
position = self.position[i]
|
||||
cssw = width + padding[3] + padding[1] + int(round(width * (ppadding[3] + ppadding[1]))) # image width plus padding
|
||||
cssh = height + padding[0] + padding[2] + int(round(height * (ppadding[0] + ppadding[2]))) # image height plus padding
|
||||
cssx = cx + margin[3] + int(round(width * pmargin[3])) # anchored at x
|
||||
cssy = int(round((self.height - cssh) * position)) # centered vertically
|
||||
x = cssx + padding[3] + int(round(width * ppadding[3])) # image drawn offset to account for padding
|
||||
y = cssy + padding[0] + int(round(height * ppadding[0])) # image drawn offset to account for padding
|
||||
yield x, y, width, height, cssx, cssy, cssw, cssh
|
||||
cx += cssw + margin[3] + margin[1] + int(round(width * (pmargin[3] + pmargin[1])))
|
||||
|
||||
|
||||
class VerticalSpritesLayout(SpritesLayout):
|
||||
def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None, position=None):
|
||||
super(VerticalSpritesLayout, self).__init__(blocks, padding, margin, ppadding, pmargin)
|
||||
|
||||
self.width = max(zip(*self.blocks)[0])
|
||||
self.height = sum(zip(*self.blocks)[1])
|
||||
|
||||
if position is None:
|
||||
position = [0.0] * self.num_blocks
|
||||
elif not isinstance(position, (tuple, list)):
|
||||
position = [position] * self.num_blocks
|
||||
self.position = position
|
||||
|
||||
def __iter__(self):
|
||||
cy = 0
|
||||
for i, block in enumerate(self.blocks):
|
||||
w, h, width, height, i = block
|
||||
margin, padding = self.margin[i], self.padding[i]
|
||||
pmargin, ppadding = self.pmargin[i], self.ppadding[i]
|
||||
position = self.position[i]
|
||||
cssw = width + padding[3] + padding[1] + int(round(width * (ppadding[3] + ppadding[1]))) # image width plus padding
|
||||
cssh = height + padding[0] + padding[2] + int(round(height * (ppadding[0] + ppadding[2]))) # image height plus padding
|
||||
cssx = int(round((self.width - cssw) * position)) # centered horizontally
|
||||
cssy = cy + margin[0] + int(round(height * pmargin[0])) # anchored at y
|
||||
x = cssx + padding[3] + int(round(width * ppadding[3])) # image drawn offset to account for padding
|
||||
y = cssy + padding[0] + int(round(height * ppadding[0])) # image drawn offset to account for padding
|
||||
yield x, y, width, height, cssx, cssy, cssw, cssh
|
||||
cy += cssh + margin[0] + margin[2] + int(round(height * (pmargin[0] + pmargin[2])))
|
||||
|
||||
|
||||
class DiagonalSpritesLayout(SpritesLayout):
|
||||
def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None):
|
||||
super(DiagonalSpritesLayout, self).__init__(blocks, padding, margin, ppadding, pmargin)
|
||||
self.width = sum(zip(*self.blocks)[0])
|
||||
self.height = sum(zip(*self.blocks)[1])
|
||||
|
||||
def __iter__(self):
|
||||
cx, cy = 0, 0
|
||||
for i, block in enumerate(self.blocks):
|
||||
w, h, width, height, i = block
|
||||
margin, padding = self.margin[i], self.padding[i]
|
||||
pmargin, ppadding = self.pmargin[i], self.ppadding[i]
|
||||
cssw = width + padding[3] + padding[1] + int(round(width * (ppadding[3] + ppadding[1]))) # image width plus padding
|
||||
cssh = height + padding[0] + padding[2] + int(round(height * (ppadding[0] + ppadding[2]))) # image height plus padding
|
||||
cssx = cx + margin[3] + int(round(width * pmargin[3])) # anchored at x
|
||||
cssy = cy + margin[0] + int(round(height * pmargin[0])) # anchored at y
|
||||
x = cssx + padding[3] + int(round(width * ppadding[3])) # image drawn offset to account for padding
|
||||
y = cssy + padding[0] + int(round(height * ppadding[0])) # image drawn offset to account for padding
|
||||
yield x, y, width, height, cssx, cssy, cssw, cssh
|
||||
cx += cssw + margin[3] + margin[1] + int(round(width * (pmargin[3] + pmargin[1])))
|
||||
cy += cssh + margin[0] + margin[2] + int(round(height * (pmargin[0] + pmargin[2])))
|
||||
@@ -0,0 +1,524 @@
|
||||
"""Functions used for generating CSS sprites.
|
||||
|
||||
These are ported from the Compass sprite library:
|
||||
http://compass-style.org/reference/compass/utilities/sprites/
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import six
|
||||
|
||||
import base64
|
||||
import glob
|
||||
import hashlib
|
||||
import logging
|
||||
import os.path
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
try:
|
||||
import cPickle as pickle
|
||||
except ImportError:
|
||||
import pickle
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
try:
|
||||
import Image
|
||||
except:
|
||||
Image = None
|
||||
|
||||
from six.moves import xrange
|
||||
|
||||
from scss import config
|
||||
from scss.functions.compass import _image_size_cache
|
||||
from scss.functions.compass.layouts import PackedSpritesLayout, HorizontalSpritesLayout, VerticalSpritesLayout, DiagonalSpritesLayout
|
||||
from scss.functions.library import FunctionLibrary
|
||||
from scss.types import Color, List, Number, String, Boolean
|
||||
from scss.util import escape
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_SPRITE_MAPS = 4096
|
||||
KEEP_SPRITE_MAPS = int(MAX_SPRITE_MAPS * 0.8)
|
||||
|
||||
COMPASS_SPRITES_LIBRARY = FunctionLibrary()
|
||||
register = COMPASS_SPRITES_LIBRARY.register
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Compass-like functionality for sprites and images
|
||||
|
||||
sprite_maps = {}
|
||||
|
||||
|
||||
def alpha_composite(im1, im2, offset=None, box=None, opacity=1):
|
||||
im1size = im1.size
|
||||
im2size = im2.size
|
||||
if offset is None:
|
||||
offset = (0, 0)
|
||||
if box is None:
|
||||
box = (0, 0) + im2size
|
||||
o1x, o1y = offset
|
||||
o2x, o2y, o2w, o2h = box
|
||||
width = o2w - o2x
|
||||
height = o2h - o2y
|
||||
im1_data = im1.load()
|
||||
im2_data = im2.load()
|
||||
for y in xrange(height):
|
||||
for x in xrange(width):
|
||||
pos1 = o1x + x, o1y + y
|
||||
if pos1[0] >= im1size[0] or pos1[1] >= im1size[1]:
|
||||
continue
|
||||
pos2 = o2x + x, o2y + y
|
||||
if pos2[0] >= im2size[0] or pos2[1] >= im2size[1]:
|
||||
continue
|
||||
dr, dg, db, da = im1_data[pos1]
|
||||
sr, sg, sb, sa = im2_data[pos2]
|
||||
da /= 255.0
|
||||
sa /= 255.0
|
||||
sa *= opacity
|
||||
ida = da * (1 - sa)
|
||||
oa = (sa + ida)
|
||||
if oa:
|
||||
pixel = (
|
||||
int(round((sr * sa + dr * ida)) / oa),
|
||||
int(round((sg * sa + dg * ida)) / oa),
|
||||
int(round((sb * sa + db * ida)) / oa),
|
||||
int(round(255 * oa))
|
||||
)
|
||||
else:
|
||||
pixel = (0, 0, 0, 0)
|
||||
im1_data[pos1] = pixel
|
||||
return im1
|
||||
|
||||
|
||||
@register('sprite-map')
|
||||
def sprite_map(g, **kwargs):
|
||||
"""
|
||||
Generates a sprite map from the files matching the glob pattern.
|
||||
Uses the keyword-style arguments passed in to control the placement.
|
||||
|
||||
$direction - Sprite map layout. Can be `vertical` (default), `horizontal`, `diagonal` or `smart`.
|
||||
|
||||
$position - For `horizontal` and `vertical` directions, the position of the sprite. (defaults to `0`)
|
||||
$<sprite>-position - Position of a given sprite.
|
||||
|
||||
$padding, $spacing - Adds paddings to sprites (top, right, bottom, left). (defaults to `0, 0, 0, 0`)
|
||||
$<sprite>-padding, $<sprite>-spacing - Padding for a given sprite.
|
||||
|
||||
$dst-color - Together with `$src-color`, forms a map of source colors to be converted to destiny colors (same index of `$src-color` changed to `$dst-color`).
|
||||
$<sprite>-dst-color - Destiny colors for a given sprite. (defaults to `$dst-color`)
|
||||
|
||||
$src-color - Selects source colors to be converted to the corresponding destiny colors. (defaults to `black`)
|
||||
$<sprite>-dst-color - Source colors for a given sprite. (defaults to `$src-color`)
|
||||
|
||||
$collapse - Collapses every image in the sprite map to a fixed size (`x` and `y`).
|
||||
$collapse-x - Collapses a size for `x`.
|
||||
$collapse-y - Collapses a size for `y`.
|
||||
"""
|
||||
if not Image:
|
||||
raise Exception("Images manipulation require PIL")
|
||||
|
||||
now_time = time.time()
|
||||
|
||||
g = String(g, quotes=None).value
|
||||
|
||||
if g in sprite_maps:
|
||||
sprite_maps[glob]['*'] = now_time
|
||||
elif '..' not in g: # Protect against going to prohibited places...
|
||||
if callable(config.STATIC_ROOT):
|
||||
glob_path = g
|
||||
rfiles = files = sorted(config.STATIC_ROOT(g))
|
||||
else:
|
||||
glob_path = os.path.join(config.STATIC_ROOT, g)
|
||||
files = glob.glob(glob_path)
|
||||
files = sorted((f, None) for f in files)
|
||||
rfiles = [(rf[len(config.STATIC_ROOT):], s) for rf, s in files]
|
||||
|
||||
if not files:
|
||||
log.error("Nothing found at '%s'", glob_path)
|
||||
return String.unquoted('')
|
||||
|
||||
map_name = os.path.normpath(os.path.dirname(g)).replace('\\', '_').replace('/', '_')
|
||||
key = list(zip(*files)[0]) + [repr(kwargs), config.ASSETS_URL]
|
||||
key = map_name + '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_')
|
||||
asset_file = key + '.png'
|
||||
ASSETS_ROOT = config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets')
|
||||
asset_path = os.path.join(ASSETS_ROOT, asset_file)
|
||||
cache_path = os.path.join(config.CACHE_ROOT or ASSETS_ROOT, asset_file + '.cache')
|
||||
|
||||
inline = Boolean(kwargs.get('inline', False))
|
||||
|
||||
sprite_map = None
|
||||
asset = None
|
||||
file_asset = None
|
||||
inline_asset = None
|
||||
if os.path.exists(asset_path) or inline:
|
||||
try:
|
||||
save_time, file_asset, inline_asset, sprite_map, sizes = pickle.load(open(cache_path))
|
||||
if file_asset:
|
||||
sprite_maps[file_asset.render()] = sprite_map
|
||||
if inline_asset:
|
||||
sprite_maps[inline_asset.render()] = sprite_map
|
||||
if inline:
|
||||
asset = inline_asset
|
||||
else:
|
||||
asset = file_asset
|
||||
except:
|
||||
pass
|
||||
|
||||
if sprite_map:
|
||||
for file_, storage in files:
|
||||
if storage is not None:
|
||||
d_obj = storage.modified_time(file_)
|
||||
_time = time.mktime(d_obj.timetuple())
|
||||
else:
|
||||
_time = os.path.getmtime(file_)
|
||||
if save_time < _time:
|
||||
if _time > now_time:
|
||||
log.warning("File '%s' has a date in the future (cache ignored)" % file_)
|
||||
sprite_map = None # Invalidate cached sprite map
|
||||
break
|
||||
|
||||
if sprite_map is None or asset is None:
|
||||
cache_buster = Boolean(kwargs.get('cache_buster', True))
|
||||
direction = String.unquoted(kwargs.get('direction', config.SPRTE_MAP_DIRECTION)).value
|
||||
repeat = String.unquoted(kwargs.get('repeat', 'no-repeat')).value
|
||||
collapse = kwargs.get('collapse', Number(0))
|
||||
if isinstance(collapse, List):
|
||||
collapse_x = int(Number(collapse[0]).value)
|
||||
collapse_y = int(Number(collapse[-1]).value)
|
||||
else:
|
||||
collapse_x = collapse_y = int(Number(collapse).value)
|
||||
if 'collapse_x' in kwargs:
|
||||
collapse_x = int(Number(kwargs['collapse_x']).value)
|
||||
if 'collapse_y' in kwargs:
|
||||
collapse_y = int(Number(kwargs['collapse_y']).value)
|
||||
|
||||
position = Number(kwargs.get('position', 0))
|
||||
if not position.is_simple_unit('%') and position.value > 1:
|
||||
position = position.value / 100.0
|
||||
else:
|
||||
position = position.value
|
||||
if position < 0:
|
||||
position = 0.0
|
||||
elif position > 1:
|
||||
position = 1.0
|
||||
|
||||
padding = kwargs.get('padding', kwargs.get('spacing', Number(0)))
|
||||
padding = [int(Number(v).value) for v in List.from_maybe(padding)]
|
||||
padding = (padding * 4)[:4]
|
||||
|
||||
dst_colors = kwargs.get('dst_color')
|
||||
dst_colors = [list(Color(v).value[:3]) for v in List.from_maybe(dst_colors) if v]
|
||||
src_colors = kwargs.get('src_color', Color.from_name('black'))
|
||||
src_colors = [tuple(Color(v).value[:3]) for v in List.from_maybe(src_colors)]
|
||||
len_colors = max(len(dst_colors), len(src_colors))
|
||||
dst_colors = (dst_colors * len_colors)[:len_colors]
|
||||
src_colors = (src_colors * len_colors)[:len_colors]
|
||||
|
||||
def images(f=lambda x: x):
|
||||
for file_, storage in f(files):
|
||||
if storage is not None:
|
||||
_file = storage.open(file_)
|
||||
else:
|
||||
_file = file_
|
||||
_image = Image.open(_file)
|
||||
yield _image
|
||||
|
||||
names = tuple(os.path.splitext(os.path.basename(file_))[0] for file_, storage in files)
|
||||
|
||||
has_dst_colors = False
|
||||
all_dst_colors = []
|
||||
all_src_colors = []
|
||||
all_positions = []
|
||||
all_paddings = []
|
||||
|
||||
for name in names:
|
||||
name = name.replace('-', '_')
|
||||
|
||||
_position = kwargs.get(name + '_position')
|
||||
if _position is None:
|
||||
_position = position
|
||||
else:
|
||||
_position = Number(_position)
|
||||
if not _position.is_simple_unit('%') and _position.value > 1:
|
||||
_position = _position.value / 100.0
|
||||
else:
|
||||
_position = _position.value
|
||||
if _position < 0:
|
||||
_position = 0.0
|
||||
elif _position > 1:
|
||||
_position = 1.0
|
||||
all_positions.append(_position)
|
||||
|
||||
_padding = kwargs.get(name + '_padding', kwargs.get(name + '_spacing'))
|
||||
if _padding is None:
|
||||
_padding = padding
|
||||
else:
|
||||
_padding = [int(Number(v).value) for v in List.from_maybe(_padding)]
|
||||
_padding = (_padding * 4)[:4]
|
||||
all_paddings.append(_padding)
|
||||
|
||||
_dst_colors = kwargs.get(name + '_dst_color')
|
||||
if _dst_colors is None:
|
||||
_dst_colors = dst_colors
|
||||
if dst_colors:
|
||||
has_dst_colors = True
|
||||
else:
|
||||
has_dst_colors = True
|
||||
_dst_colors = [list(Color(v).value[:3]) for v in List.from_maybe(_dst_colors) if v]
|
||||
_src_colors = kwargs.get(name + '_src_color', Color.from_name('black'))
|
||||
if _src_colors is None:
|
||||
_src_colors = src_colors
|
||||
else:
|
||||
_src_colors = [tuple(Color(v).value[:3]) for v in List.from_maybe(_src_colors)]
|
||||
_len_colors = max(len(_dst_colors), len(_src_colors))
|
||||
_dst_colors = (_dst_colors * _len_colors)[:_len_colors]
|
||||
_src_colors = (_src_colors * _len_colors)[:_len_colors]
|
||||
all_dst_colors.append(_dst_colors)
|
||||
all_src_colors.append(_src_colors)
|
||||
|
||||
sizes = tuple((collapse_x or i.size[0], collapse_y or i.size[1]) for i in images())
|
||||
|
||||
if direction == 'horizontal':
|
||||
layout = HorizontalSpritesLayout(sizes, all_paddings, position=all_positions)
|
||||
elif direction == 'vertical':
|
||||
layout = VerticalSpritesLayout(sizes, all_paddings, position=all_positions)
|
||||
elif direction == 'diagonal':
|
||||
layout = DiagonalSpritesLayout(sizes, all_paddings)
|
||||
elif direction == 'smart':
|
||||
layout = PackedSpritesLayout(sizes, all_paddings)
|
||||
else:
|
||||
raise Exception("Invalid direction %r" % (direction,))
|
||||
layout_positions = list(layout)
|
||||
|
||||
new_image = Image.new(
|
||||
mode='RGBA',
|
||||
size=(layout.width, layout.height),
|
||||
color=(0, 0, 0, 0)
|
||||
)
|
||||
|
||||
useless_dst_color = has_dst_colors
|
||||
|
||||
offsets_x = []
|
||||
offsets_y = []
|
||||
for i, image in enumerate(images()):
|
||||
x, y, width, height, cssx, cssy, cssw, cssh = layout_positions[i]
|
||||
iwidth, iheight = image.size
|
||||
|
||||
if has_dst_colors:
|
||||
pixdata = image.load()
|
||||
for _y in xrange(iheight):
|
||||
for _x in xrange(iwidth):
|
||||
pixel = pixdata[_x, _y]
|
||||
a = pixel[3] if len(pixel) == 4 else 255
|
||||
if a:
|
||||
rgb = pixel[:3]
|
||||
for j, dst_color in enumerate(all_dst_colors[i]):
|
||||
if rgb == all_src_colors[i][j]:
|
||||
new_color = tuple([int(c) for c in dst_color] + [a])
|
||||
if pixel != new_color:
|
||||
pixdata[_x, _y] = new_color
|
||||
useless_dst_color = False
|
||||
break
|
||||
|
||||
if iwidth != width or iheight != height:
|
||||
cy = 0
|
||||
while cy < iheight:
|
||||
cx = 0
|
||||
while cx < iwidth:
|
||||
new_image = alpha_composite(new_image, image, (x, y), (cx, cy, cx + width, cy + height))
|
||||
cx += width
|
||||
cy += height
|
||||
else:
|
||||
new_image.paste(image, (x, y))
|
||||
offsets_x.append(cssx)
|
||||
offsets_y.append(cssy)
|
||||
|
||||
if useless_dst_color:
|
||||
log.warning("Useless use of $dst-color in sprite map for files at '%s' (never used for)" % glob_path)
|
||||
|
||||
filetime = int(now_time)
|
||||
|
||||
if not inline:
|
||||
try:
|
||||
new_image.save(asset_path)
|
||||
url = '%s%s' % (config.ASSETS_URL, asset_file)
|
||||
if cache_buster:
|
||||
url += '?_=%s' % filetime
|
||||
except IOError:
|
||||
log.exception("Error while saving image")
|
||||
inline = True
|
||||
if inline:
|
||||
output = six.BytesIO()
|
||||
new_image.save(output, format='PNG')
|
||||
contents = output.getvalue()
|
||||
output.close()
|
||||
mime_type = 'image/png'
|
||||
url = 'data:' + mime_type + ';base64,' + base64.b64encode(contents)
|
||||
|
||||
url = 'url(%s)' % escape(url)
|
||||
if inline:
|
||||
asset = inline_asset = List([String.unquoted(url), String.unquoted(repeat)])
|
||||
else:
|
||||
asset = file_asset = List([String.unquoted(url), String.unquoted(repeat)])
|
||||
|
||||
# Add the new object:
|
||||
sprite_map = dict(zip(names, zip(sizes, rfiles, offsets_x, offsets_y)))
|
||||
sprite_map['*'] = now_time
|
||||
sprite_map['*f*'] = asset_file
|
||||
sprite_map['*k*'] = key
|
||||
sprite_map['*n*'] = map_name
|
||||
sprite_map['*t*'] = filetime
|
||||
|
||||
cache_tmp = tempfile.NamedTemporaryFile(delete=False, dir=ASSETS_ROOT)
|
||||
pickle.dump((now_time, file_asset, inline_asset, sprite_map, zip(files, sizes)), cache_tmp)
|
||||
cache_tmp.close()
|
||||
os.rename(cache_tmp.name, cache_path)
|
||||
|
||||
# Use the sorted list to remove older elements (keep only 500 objects):
|
||||
if len(sprite_maps) > MAX_SPRITE_MAPS:
|
||||
for a in sorted(sprite_maps, key=lambda a: sprite_maps[a]['*'], reverse=True)[KEEP_SPRITE_MAPS:]:
|
||||
del sprite_maps[a]
|
||||
log.warning("Exceeded maximum number of sprite maps (%s)" % MAX_SPRITE_MAPS)
|
||||
sprite_maps[asset.render()] = sprite_map
|
||||
for file_, size in sizes:
|
||||
_image_size_cache[file_] = size
|
||||
# TODO this sometimes returns an empty list, or is never assigned to
|
||||
return asset
|
||||
|
||||
|
||||
@register('sprite-map-name', 1)
|
||||
def sprite_map_name(map):
|
||||
"""
|
||||
Returns the name of a sprite map The name is derived from the folder than
|
||||
contains the sprites.
|
||||
"""
|
||||
map = map.render()
|
||||
sprite_map = sprite_maps.get(map)
|
||||
if not sprite_map:
|
||||
log.error("No sprite map found: %s", map, extra={'stack': True})
|
||||
if sprite_map:
|
||||
return String.unquoted(sprite_map['*n*'])
|
||||
return String.unquoted('')
|
||||
|
||||
|
||||
@register('sprite-file', 2)
|
||||
def sprite_file(map, sprite):
|
||||
"""
|
||||
Returns the relative path (from the images directory) to the original file
|
||||
used when construction the sprite. This is suitable for passing to the
|
||||
image_width and image_height helpers.
|
||||
"""
|
||||
map = map.render()
|
||||
sprite_map = sprite_maps.get(map)
|
||||
sprite_name = String.unquoted(sprite).value
|
||||
sprite = sprite_map and sprite_map.get(sprite_name)
|
||||
if not sprite_map:
|
||||
log.error("No sprite map found: %s", map, extra={'stack': True})
|
||||
elif not sprite:
|
||||
log.error("No sprite found: %s in %s", sprite_name, sprite_map['*n*'], extra={'stack': True})
|
||||
if sprite:
|
||||
return String(sprite[1][0])
|
||||
return String.unquoted('')
|
||||
|
||||
|
||||
@register('sprites', 1)
|
||||
@register('sprite-names', 1)
|
||||
def sprites(map):
|
||||
map = map.render()
|
||||
sprite_map = sprite_maps.get(map, {})
|
||||
return List(list(String.unquoted(s) for s in sorted(s for s in sprite_map if not s.startswith('*'))))
|
||||
|
||||
|
||||
@register('sprite', 2)
|
||||
@register('sprite', 3)
|
||||
@register('sprite', 4)
|
||||
@register('sprite', 5)
|
||||
def sprite(map, sprite, offset_x=None, offset_y=None, cache_buster=True):
|
||||
"""
|
||||
Returns the image and background position for use in a single shorthand
|
||||
property
|
||||
"""
|
||||
map = map.render()
|
||||
sprite_map = sprite_maps.get(map)
|
||||
sprite_name = String.unquoted(sprite).value
|
||||
sprite = sprite_map and sprite_map.get(sprite_name)
|
||||
if not sprite_map:
|
||||
log.error("No sprite map found: %s", map, extra={'stack': True})
|
||||
elif not sprite:
|
||||
log.error("No sprite found: %s in %s", sprite_name, sprite_map['*n*'], extra={'stack': True})
|
||||
if sprite:
|
||||
url = '%s%s' % (config.ASSETS_URL, sprite_map['*f*'])
|
||||
if cache_buster:
|
||||
url += '?_=%s' % sprite_map['*t*']
|
||||
x = Number(offset_x or 0, 'px')
|
||||
y = Number(offset_y or 0, 'px')
|
||||
if not x.value or (x.value <= -1 or x.value >= 1) and not x.is_simple_unit('%'):
|
||||
x -= Number(sprite[2], 'px')
|
||||
if not y.value or (y.value <= -1 or y.value >= 1) and not y.is_simple_unit('%'):
|
||||
y -= Number(sprite[3], 'px')
|
||||
url = "url(%s)" % escape(url)
|
||||
return List([String.unquoted(url), x, y])
|
||||
return List([Number(0), Number(0)])
|
||||
|
||||
|
||||
@register('sprite-url', 1)
|
||||
@register('sprite-url', 2)
|
||||
def sprite_url(map, cache_buster=True):
|
||||
"""
|
||||
Returns a url to the sprite image.
|
||||
"""
|
||||
map = map.render()
|
||||
sprite_map = sprite_maps.get(map)
|
||||
if not sprite_map:
|
||||
log.error("No sprite map found: %s", map, extra={'stack': True})
|
||||
if sprite_map:
|
||||
url = '%s%s' % (config.ASSETS_URL, sprite_map['*f*'])
|
||||
if cache_buster:
|
||||
url += '?_=%s' % sprite_map['*t*']
|
||||
url = "url(%s)" % escape(url)
|
||||
return String.unquoted(url)
|
||||
return String.unquoted('')
|
||||
|
||||
|
||||
@register('sprite-position', 2)
|
||||
@register('sprite-position', 3)
|
||||
@register('sprite-position', 4)
|
||||
def sprite_position(map, sprite, offset_x=None, offset_y=None):
|
||||
"""
|
||||
Returns the position for the original image in the sprite.
|
||||
This is suitable for use as a value to background-position.
|
||||
"""
|
||||
map = map.render()
|
||||
sprite_map = sprite_maps.get(map)
|
||||
sprite_name = String.unquoted(sprite).value
|
||||
sprite = sprite_map and sprite_map.get(sprite_name)
|
||||
if not sprite_map:
|
||||
log.error("No sprite map found: %s", map, extra={'stack': True})
|
||||
elif not sprite:
|
||||
log.error("No sprite found: %s in %s", sprite_name, sprite_map['*n*'], extra={'stack': True})
|
||||
if sprite:
|
||||
x = None
|
||||
if offset_x is not None and not isinstance(offset_x, Number):
|
||||
x = offset_x
|
||||
if not x or x.value not in ('left', 'right', 'center'):
|
||||
if x:
|
||||
offset_x = None
|
||||
x = Number(offset_x or 0, 'px')
|
||||
if not x.value or (x.value <= -1 or x.value >= 1) and not x.is_simple_unit('%'):
|
||||
x -= Number(sprite[2], 'px')
|
||||
y = None
|
||||
if offset_y is not None and not isinstance(offset_y, Number):
|
||||
y = offset_y
|
||||
if not y or y.value not in ('top', 'bottom', 'center'):
|
||||
if y:
|
||||
offset_y = None
|
||||
y = Number(offset_y or 0, 'px')
|
||||
if not y.value or (y.value <= -1 or y.value >= 1) and not y.is_simple_unit('%'):
|
||||
y -= Number(sprite[3], 'px')
|
||||
return List([x, y])
|
||||
return List([Number(0), Number(0)])
|
||||
@@ -0,0 +1,784 @@
|
||||
"""Functions from the Sass "standard library", i.e., built into the original
|
||||
Ruby implementation.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import division
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from six.moves import xrange
|
||||
|
||||
from scss.functions.library import FunctionLibrary
|
||||
from scss.types import Boolean, Color, List, Null, Number, String, Map, expect_type
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CORE_LIBRARY = FunctionLibrary()
|
||||
register = CORE_LIBRARY.register
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
|
||||
@register('rgba', 4)
|
||||
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)
|
||||
|
||||
|
||||
@register('rgb', 3)
|
||||
def rgb(r, g, b, type='rgb'):
|
||||
return rgba(r, g, b, Number(1.0))
|
||||
|
||||
|
||||
@register('rgba', 1)
|
||||
@register('rgba', 2)
|
||||
def rgba2(color, a=None):
|
||||
if a is None:
|
||||
alpha = 1
|
||||
else:
|
||||
alpha = _interpret_percentage(a)
|
||||
|
||||
return Color.from_rgb(*color.rgba[:3], alpha=alpha)
|
||||
|
||||
|
||||
@register('rgb', 1)
|
||||
def rgb1(color):
|
||||
return rgba2(color, a=Number(1))
|
||||
|
||||
|
||||
@register('hsla', 4)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@register('hsl', 3)
|
||||
def hsl(h, s, l):
|
||||
return hsla(h, s, l, Number(1))
|
||||
|
||||
|
||||
@register('hsla', 1)
|
||||
@register('hsla', 2)
|
||||
def hsla2(color, a=None):
|
||||
return rgba2(color, a)
|
||||
|
||||
|
||||
@register('hsl', 1)
|
||||
def hsl1(color):
|
||||
return rgba2(color, a=Number(1))
|
||||
|
||||
|
||||
@register('mix', 2)
|
||||
@register('mix', 3)
|
||||
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
|
||||
|
||||
@register('red', 1)
|
||||
def red(color):
|
||||
r, g, b, a = color.rgba
|
||||
return Number(r * 255)
|
||||
|
||||
|
||||
@register('green', 1)
|
||||
def green(color):
|
||||
r, g, b, a = color.rgba
|
||||
return Number(g * 255)
|
||||
|
||||
|
||||
@register('blue', 1)
|
||||
def blue(color):
|
||||
r, g, b, a = color.rgba
|
||||
return Number(b * 255)
|
||||
|
||||
|
||||
@register('opacity', 1)
|
||||
@register('alpha', 1)
|
||||
def alpha(color):
|
||||
return Number(color.alpha)
|
||||
|
||||
|
||||
@register('hue', 1)
|
||||
def hue(color):
|
||||
h, s, l = color.hsl
|
||||
return Number(h * 360, "deg")
|
||||
|
||||
|
||||
@register('saturation', 1)
|
||||
def saturation(color):
|
||||
h, s, l = color.hsl
|
||||
return Number(s * 100, "%")
|
||||
|
||||
|
||||
@register('lightness', 1)
|
||||
def lightness(color):
|
||||
h, s, l = color.hsl
|
||||
return Number(l * 100, "%")
|
||||
|
||||
|
||||
@register('ie-hex-str', 1)
|
||||
def ie_hex_str(color):
|
||||
c = Color(color).value
|
||||
return String(u'#%02X%02X%02X%02X' % (round(c[3] * 255), round(c[0]), round(c[1]), round(c[2])))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Color modification
|
||||
|
||||
@register('fade-in', 2)
|
||||
@register('fadein', 2)
|
||||
@register('opacify', 2)
|
||||
def opacify(color, amount):
|
||||
r, g, b, a = color.rgba
|
||||
return Color.from_rgb(
|
||||
r, g, b,
|
||||
alpha=color.alpha + amount.value)
|
||||
|
||||
|
||||
@register('fade-out', 2)
|
||||
@register('fadeout', 2)
|
||||
@register('transparentize', 2)
|
||||
def transparentize(color, amount):
|
||||
r, g, b, a = color.rgba
|
||||
return Color.from_rgb(
|
||||
r, g, b,
|
||||
alpha=color.alpha - amount.value)
|
||||
|
||||
|
||||
@register('lighten', 2)
|
||||
def lighten(color, amount):
|
||||
return adjust_color(color, lightness=amount)
|
||||
|
||||
|
||||
@register('darken', 2)
|
||||
def darken(color, amount):
|
||||
return adjust_color(color, lightness=-amount)
|
||||
|
||||
|
||||
@register('saturate', 2)
|
||||
def saturate(color, amount):
|
||||
return adjust_color(color, saturation=amount)
|
||||
|
||||
|
||||
@register('desaturate', 2)
|
||||
def desaturate(color, amount):
|
||||
return adjust_color(color, saturation=-amount)
|
||||
|
||||
|
||||
@register('greyscale', 1)
|
||||
def greyscale(color):
|
||||
h, s, l = color.hsl
|
||||
return Color.from_hsl(h, 0, l, alpha=color.alpha)
|
||||
|
||||
|
||||
@register('grayscale', 1)
|
||||
def grayscale(color):
|
||||
if isinstance(color, Number) and color.is_unitless:
|
||||
# grayscale(n) is a CSS3 filter and should be left intact, but only
|
||||
# when using the "a" spelling
|
||||
return String.unquoted("grayscale(%d)" % (color.value,))
|
||||
else:
|
||||
return greyscale(color)
|
||||
|
||||
|
||||
@register('spin', 2)
|
||||
@register('adjust-hue', 2)
|
||||
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)
|
||||
|
||||
|
||||
@register('complement', 1)
|
||||
def complement(color):
|
||||
h, s, l = color.hsl
|
||||
return Color.from_hsl((h + 0.5) % 1, s, l, alpha=color.alpha)
|
||||
|
||||
|
||||
@register('invert', 1)
|
||||
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)
|
||||
|
||||
|
||||
@register('adjust-lightness', 2)
|
||||
def adjust_lightness(color, amount):
|
||||
return adjust_color(color, lightness=amount)
|
||||
|
||||
|
||||
@register('adjust-saturation', 2)
|
||||
def adjust_saturation(color, amount):
|
||||
return adjust_color(color, saturation=amount)
|
||||
|
||||
|
||||
@register('scale-lightness', 2)
|
||||
def scale_lightness(color, amount):
|
||||
return scale_color(color, lightness=amount)
|
||||
|
||||
|
||||
@register('scale-saturation', 2)
|
||||
def scale_saturation(color, amount):
|
||||
return scale_color(color, saturation=amount)
|
||||
|
||||
|
||||
@register('adjust-color')
|
||||
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)
|
||||
|
||||
|
||||
@register('scale-color')
|
||||
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, 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
|
||||
|
||||
@register('e', 1)
|
||||
@register('escape', 1)
|
||||
@register('unquote')
|
||||
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)
|
||||
|
||||
|
||||
@register('quote')
|
||||
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='"')
|
||||
|
||||
|
||||
@register('str-length', 1)
|
||||
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
|
||||
@register('str-insert', 3)
|
||||
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)
|
||||
|
||||
|
||||
@register('str-index', 2)
|
||||
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)
|
||||
|
||||
|
||||
@register('str-slice', 2)
|
||||
@register('str-slice', 3)
|
||||
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)
|
||||
|
||||
|
||||
@register('to-upper-case', 1)
|
||||
def to_upper_case(string):
|
||||
expect_type(string, String)
|
||||
|
||||
return String(string.value.upper(), quotes=string.quotes)
|
||||
|
||||
|
||||
@register('to-lower-case', 1)
|
||||
def to_lower_case(string):
|
||||
expect_type(string, String)
|
||||
|
||||
return String(string.value.lower(), quotes=string.quotes)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Number functions
|
||||
|
||||
@register('percentage', 1)
|
||||
def percentage(value):
|
||||
expect_type(value, Number, unit=None)
|
||||
return value * Number(100, unit='%')
|
||||
|
||||
CORE_LIBRARY.add(Number.wrap_python_function(abs), 'abs', 1)
|
||||
CORE_LIBRARY.add(Number.wrap_python_function(round), 'round', 1)
|
||||
CORE_LIBRARY.add(Number.wrap_python_function(math.ceil), 'ceil', 1)
|
||||
CORE_LIBRARY.add(Number.wrap_python_function(math.floor), 'floor', 1)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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
|
||||
@register('-compass-list-size')
|
||||
@register('length')
|
||||
def _length(*lst):
|
||||
if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)):
|
||||
lst = lst[0]
|
||||
return Number(len(lst))
|
||||
|
||||
|
||||
@register('set-nth', 3)
|
||||
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
|
||||
@register('-compass-nth', 2)
|
||||
@register('nth', 2)
|
||||
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]
|
||||
|
||||
|
||||
@register('join', 2)
|
||||
@register('join', 3)
|
||||
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)
|
||||
|
||||
|
||||
@register('min')
|
||||
def min_(*lst):
|
||||
if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)):
|
||||
lst = lst[0]
|
||||
return min(lst)
|
||||
|
||||
|
||||
@register('max')
|
||||
def max_(*lst):
|
||||
if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)):
|
||||
lst = lst[0]
|
||||
return max(lst)
|
||||
|
||||
|
||||
@register('append', 2)
|
||||
@register('append', 3)
|
||||
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)
|
||||
|
||||
|
||||
@register('index', 2)
|
||||
def index(lst, val):
|
||||
for i in xrange(len(lst)):
|
||||
if lst.value[i] == val:
|
||||
return Number(i + 1)
|
||||
return Boolean(False)
|
||||
|
||||
|
||||
@register('zip')
|
||||
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
|
||||
@register('list-separator', 1)
|
||||
def list_separator(list):
|
||||
if list.use_comma:
|
||||
return String.unquoted('comma')
|
||||
else:
|
||||
return String.unquoted('space')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Map functions
|
||||
|
||||
@register('map-get', 2)
|
||||
def map_get(map, key):
|
||||
return map.to_dict().get(key, Null())
|
||||
|
||||
|
||||
@register('map-merge', 2)
|
||||
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)
|
||||
|
||||
|
||||
@register('map-keys', 1)
|
||||
def map_keys(map):
|
||||
return List(
|
||||
[k for (k, v) in map.to_pairs()],
|
||||
use_comma=True)
|
||||
|
||||
|
||||
@register('map-values', 1)
|
||||
def map_values(map):
|
||||
return List(
|
||||
[v for (k, v) in map.to_pairs()],
|
||||
use_comma=True)
|
||||
|
||||
|
||||
@register('map-has-key', 2)
|
||||
def map_has_key(map, key):
|
||||
return Boolean(key in map.to_dict())
|
||||
|
||||
|
||||
# DEVIATIONS: these do not exist in ruby sass
|
||||
|
||||
@register('map-get', 3)
|
||||
def map_get3(map, key, default):
|
||||
return map.to_dict().get(key, default)
|
||||
|
||||
|
||||
@register('map-get-nested', 2)
|
||||
@register('map-get-nested', 3)
|
||||
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
|
||||
|
||||
|
||||
@register('map-merge-deep', 2)
|
||||
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
|
||||
|
||||
@register('type-of', 1)
|
||||
def _type_of(obj): # -> bool, number, string, color, list
|
||||
return String(obj.sass_type_name)
|
||||
|
||||
|
||||
@register('unit', 1)
|
||||
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)
|
||||
|
||||
|
||||
@register('unitless', 1)
|
||||
def unitless(value):
|
||||
if not isinstance(value, Number):
|
||||
raise TypeError("Expected number, got %r" % (value,))
|
||||
|
||||
return Boolean(value.is_unitless)
|
||||
|
||||
|
||||
@register('comparable', 2)
|
||||
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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Miscellaneous
|
||||
|
||||
@register('if', 2)
|
||||
@register('if', 3)
|
||||
def if_(condition, if_true, if_false=Null()):
|
||||
return if_true if condition else if_false
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Functions new to the pyScss library."""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os.path
|
||||
import random
|
||||
|
||||
import six
|
||||
from six.moves import xrange
|
||||
|
||||
from scss import config
|
||||
from scss.functions.library import FunctionLibrary
|
||||
from scss.types import Color, Number, String, List
|
||||
from scss.util import escape
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
try:
|
||||
import Image
|
||||
import ImageDraw
|
||||
except:
|
||||
Image = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
EXTRA_LIBRARY = FunctionLibrary()
|
||||
register = EXTRA_LIBRARY.register
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Image stuff
|
||||
def _image_noise(pixdata, size, density=None, intensity=None, color=None, opacity=None, monochrome=None, background=None):
|
||||
if not density:
|
||||
density = [0.8]
|
||||
elif not isinstance(density, (tuple, list)):
|
||||
density = [density]
|
||||
|
||||
if not intensity:
|
||||
intensity = [0.5]
|
||||
elif not isinstance(intensity, (tuple, list)):
|
||||
intensity = [intensity]
|
||||
|
||||
if not color:
|
||||
color = [(0, 0, 0, 0)]
|
||||
elif not isinstance(color, (tuple, list)) or not isinstance(color[0], (tuple, list)):
|
||||
color = [color]
|
||||
|
||||
if not opacity:
|
||||
opacity = [0.2]
|
||||
elif not isinstance(opacity, (tuple, list)):
|
||||
opacity = [opacity]
|
||||
|
||||
if not monochrome:
|
||||
monochrome = [False]
|
||||
elif not isinstance(monochrome, (tuple, list)):
|
||||
monochrome = [monochrome]
|
||||
|
||||
pixels = {}
|
||||
|
||||
if background:
|
||||
for y in xrange(size):
|
||||
for x in xrange(size):
|
||||
ca = float(background[3])
|
||||
pixels[(x, y)] = (background[0] * ca, background[1] * ca, background[2] * ca, ca)
|
||||
|
||||
loops = max(map(len, (density, intensity, color, opacity, monochrome)))
|
||||
for l in range(loops):
|
||||
_density = density[l % len(density)]
|
||||
_intensity = intensity[l % len(intensity)]
|
||||
_color = color[l % len(color)]
|
||||
_opacity = opacity[l % len(opacity)]
|
||||
_monochrome = monochrome[l % len(monochrome)]
|
||||
_intensity = 1 - _intensity
|
||||
if _intensity < 0.5:
|
||||
cx = 255 * _intensity
|
||||
cm = cx
|
||||
else:
|
||||
cx = 255 * (1 - _intensity)
|
||||
cm = 255 * _intensity
|
||||
xa = int(cm - cx)
|
||||
xb = int(cm + cx)
|
||||
if xa > 0:
|
||||
xa &= 255
|
||||
else:
|
||||
xa = 0
|
||||
if xb > 0:
|
||||
xb &= 255
|
||||
else:
|
||||
xb = 0
|
||||
r, g, b, a = _color
|
||||
for i in xrange(int(round(_density * size ** 2))):
|
||||
x = random.randint(1, size)
|
||||
y = random.randint(1, size)
|
||||
cc = random.randint(xa, xb)
|
||||
cr = (cc) * (1 - a) + a * r
|
||||
cg = (cc if _monochrome else random.randint(xa, xb)) * (1 - a) + a * g
|
||||
cb = (cc if _monochrome else random.randint(xa, xb)) * (1 - a) + a * b
|
||||
ca = random.random() * _opacity
|
||||
ica = 1 - ca
|
||||
pos = (x - 1, y - 1)
|
||||
dst = pixels.get(pos, (0, 0, 0, 0))
|
||||
src = (cr * ca, cg * ca, cb * ca, ca)
|
||||
pixels[pos] = (src[0] + dst[0] * ica, src[1] + dst[1] * ica, src[2] + dst[2] * ica, src[3] + dst[3] * ica)
|
||||
|
||||
for pos, col in pixels.items():
|
||||
ca = col[3]
|
||||
if ca:
|
||||
pixdata[pos] = tuple(int(round(c)) for c in (col[0] / ca, col[1] / ca, col[2] / ca, ca * 255))
|
||||
|
||||
|
||||
def _image_brushed(pixdata, size, density=None, intensity=None, color=None, opacity=None, monochrome=None, direction=None, spread=None, background=None):
|
||||
if not density:
|
||||
density = [0.8]
|
||||
elif not isinstance(density, (tuple, list)):
|
||||
density = [density]
|
||||
|
||||
if not intensity:
|
||||
intensity = [0.5]
|
||||
elif not isinstance(intensity, (tuple, list)):
|
||||
intensity = [intensity]
|
||||
|
||||
if not color:
|
||||
color = [(0, 0, 0, 0)]
|
||||
elif not isinstance(color, (tuple, list)) or not isinstance(color[0], (tuple, list)):
|
||||
color = [color]
|
||||
|
||||
if not opacity:
|
||||
opacity = [0.2]
|
||||
elif not isinstance(opacity, (tuple, list)):
|
||||
opacity = [opacity]
|
||||
|
||||
if not monochrome:
|
||||
monochrome = [False]
|
||||
elif not isinstance(monochrome, (tuple, list)):
|
||||
monochrome = [monochrome]
|
||||
|
||||
if not direction:
|
||||
direction = [0]
|
||||
elif not isinstance(direction, (tuple, list)):
|
||||
direction = [direction]
|
||||
|
||||
if not spread:
|
||||
spread = [0]
|
||||
elif not isinstance(spread, (tuple, list)):
|
||||
spread = [spread]
|
||||
|
||||
def ppgen(d):
|
||||
if d is None:
|
||||
return
|
||||
d = d % 4
|
||||
if d == 0:
|
||||
pp = lambda x, y, o: ((x - o) % size, y)
|
||||
elif d == 1:
|
||||
pp = lambda x, y, o: ((x - o) % size, (y + x - o) % size)
|
||||
elif d == 2:
|
||||
pp = lambda x, y, o: (y, (x - o) % size)
|
||||
else:
|
||||
pp = lambda x, y, o: ((x - o) % size, (y - x - o) % size)
|
||||
return pp
|
||||
|
||||
pixels = {}
|
||||
|
||||
if background:
|
||||
for y in xrange(size):
|
||||
for x in xrange(size):
|
||||
ca = float(background[3])
|
||||
pixels[(x, y)] = (background[0] * ca, background[1] * ca, background[2] * ca, ca)
|
||||
|
||||
loops = max(map(len, (density, intensity, color, opacity, monochrome, direction, spread)))
|
||||
for l in range(loops):
|
||||
_density = density[l % len(density)]
|
||||
_intensity = intensity[l % len(intensity)]
|
||||
_color = color[l % len(color)]
|
||||
_opacity = opacity[l % len(opacity)]
|
||||
_monochrome = monochrome[l % len(monochrome)]
|
||||
_direction = direction[l % len(direction)]
|
||||
_spread = spread[l % len(spread)]
|
||||
_intensity = 1 - _intensity
|
||||
if _intensity < 0.5:
|
||||
cx = 255 * _intensity
|
||||
cm = cx
|
||||
else:
|
||||
cx = 255 * (1 - _intensity)
|
||||
cm = 255 * _intensity
|
||||
xa = int(cm - cx)
|
||||
xb = int(cm + cx)
|
||||
if xa > 0:
|
||||
xa &= 255
|
||||
else:
|
||||
xa = 0
|
||||
if xb > 0:
|
||||
xb &= 255
|
||||
else:
|
||||
xb = 0
|
||||
r, g, b, a = _color
|
||||
pp = ppgen(_direction)
|
||||
if pp:
|
||||
for y in xrange(size):
|
||||
if _spread and (y + (l % 2)) % _spread:
|
||||
continue
|
||||
o = random.randint(1, size)
|
||||
cc = random.randint(xa, xb)
|
||||
cr = (cc) * (1 - a) + a * r
|
||||
cg = (cc if _monochrome else random.randint(xa, xb)) * (1 - a) + a * g
|
||||
cb = (cc if _monochrome else random.randint(xa, xb)) * (1 - a) + a * b
|
||||
da = random.randint(0, 255) * _opacity
|
||||
ip = round((size / 2.0 * _density) / int(1 / _density))
|
||||
iq = round((size / 2.0 * (1 - _density)) / int(1 / _density))
|
||||
if ip:
|
||||
i = da / ip
|
||||
aa = 0
|
||||
else:
|
||||
i = 0
|
||||
aa = da
|
||||
d = 0
|
||||
p = ip
|
||||
for x in xrange(size):
|
||||
if d == 0:
|
||||
if p > 0:
|
||||
p -= 1
|
||||
aa += i
|
||||
else:
|
||||
d = 1
|
||||
q = iq
|
||||
elif d == 1:
|
||||
if q > 0:
|
||||
q -= 1
|
||||
else:
|
||||
d = 2
|
||||
p = ip
|
||||
elif d == 2:
|
||||
if p > 0:
|
||||
p -= 1
|
||||
aa -= i
|
||||
else:
|
||||
d = 3
|
||||
q = iq
|
||||
elif d == 3:
|
||||
if q > 0:
|
||||
q -= 1
|
||||
else:
|
||||
d = 0
|
||||
p = ip
|
||||
if aa > 0:
|
||||
ca = aa / 255.0
|
||||
else:
|
||||
ca = 0.0
|
||||
ica = 1 - ca
|
||||
pos = pp(x, y, o)
|
||||
dst = pixels.get(pos, (0, 0, 0, 0))
|
||||
src = (cr * ca, cg * ca, cb * ca, ca)
|
||||
pixels[pos] = (src[0] + dst[0] * ica, src[1] + dst[1] * ica, src[2] + dst[2] * ica, src[3] + dst[3] * ica)
|
||||
|
||||
for pos, col in pixels.items():
|
||||
ca = col[3]
|
||||
if ca:
|
||||
pixdata[pos] = tuple(int(round(c)) for c in (col[0] / ca, col[1] / ca, col[2] / ca, ca * 255))
|
||||
|
||||
|
||||
@register('background-noise', 0)
|
||||
@register('background-noise', 1)
|
||||
@register('background-noise', 2)
|
||||
@register('background-noise', 3)
|
||||
@register('background-noise', 4)
|
||||
@register('background-noise', 5)
|
||||
@register('background-noise', 6)
|
||||
@register('background-noise', 7)
|
||||
def background_noise(density=None, opacity=None, size=None, monochrome=False, intensity=(), color=None, background=None, inline=False):
|
||||
if not Image:
|
||||
raise Exception("Images manipulation require PIL")
|
||||
|
||||
density = [Number(v).value for v in List.from_maybe(density)]
|
||||
intensity = [Number(v).value for v in List.from_maybe(intensity)]
|
||||
color = [Color(v).value for v in List.from_maybe(color) if v]
|
||||
opacity = [Number(v).value for v in List.from_maybe(opacity)]
|
||||
|
||||
size = int(Number(size).value) if size else 0
|
||||
if size < 1 or size > 512:
|
||||
size = 200
|
||||
|
||||
monochrome = bool(monochrome)
|
||||
|
||||
background = Color(background).value if background else None
|
||||
|
||||
new_image = Image.new(
|
||||
mode='RGBA',
|
||||
size=(size, size)
|
||||
)
|
||||
|
||||
pixdata = new_image.load()
|
||||
_image_noise(pixdata, size, density, intensity, color, opacity, monochrome)
|
||||
|
||||
if not inline:
|
||||
key = (size, density, intensity, color, opacity, monochrome)
|
||||
asset_file = 'noise-%s%sx%s' % ('mono-' if monochrome else '', size, size)
|
||||
# asset_file += '-[%s][%s]' % ('-'.join(to_str(s).replace('.', '_') for s in density or []), '-'.join(to_str(s).replace('.', '_') for s in opacity or []))
|
||||
asset_file += '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_')
|
||||
asset_file += '.png'
|
||||
asset_path = os.path.join(config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets'), asset_file)
|
||||
try:
|
||||
new_image.save(asset_path)
|
||||
except IOError:
|
||||
log.exception("Error while saving image")
|
||||
inline = True # Retry inline version
|
||||
url = '%s%s' % (config.ASSETS_URL, asset_file)
|
||||
if inline:
|
||||
output = six.BytesIO()
|
||||
new_image.save(output, format='PNG')
|
||||
contents = output.getvalue()
|
||||
output.close()
|
||||
url = 'data:image/png;base64,' + base64.b64encode(contents)
|
||||
|
||||
inline = 'url("%s")' % escape(url)
|
||||
return String.unquoted(inline)
|
||||
|
||||
|
||||
@register('background-brushed', 0)
|
||||
@register('background-brushed', 1)
|
||||
@register('background-brushed', 2)
|
||||
@register('background-brushed', 3)
|
||||
@register('background-brushed', 4)
|
||||
@register('background-brushed', 5)
|
||||
@register('background-brushed', 6)
|
||||
@register('background-brushed', 7)
|
||||
@register('background-brushed', 8)
|
||||
@register('background-brushed', 9)
|
||||
def background_brushed(density=None, intensity=None, color=None, opacity=None, size=None, monochrome=False, direction=(), spread=(), background=None, inline=False):
|
||||
if not Image:
|
||||
raise Exception("Images manipulation require PIL")
|
||||
|
||||
density = [Number(v).value for v in List.from_maybe(density)]
|
||||
intensity = [Number(v).value for v in List.from_maybe(intensity)]
|
||||
color = [Color(v).value for v in List.from_maybe(color) if v]
|
||||
opacity = [Number(v).value for v in List.from_maybe(opacity)]
|
||||
|
||||
size = int(Number(size).value) if size else -1
|
||||
if size < 0 or size > 512:
|
||||
size = 200
|
||||
|
||||
monochrome = bool(monochrome)
|
||||
|
||||
direction = [Number(v).value for v in List.from_maybe(direction)]
|
||||
spread = [Number(v).value for v in List.from_maybe(spread)]
|
||||
|
||||
background = Color(background).value if background else None
|
||||
|
||||
new_image = Image.new(
|
||||
mode='RGBA',
|
||||
size=(size, size)
|
||||
)
|
||||
|
||||
pixdata = new_image.load()
|
||||
_image_brushed(pixdata, size, density, intensity, color, opacity, monochrome, direction, spread, background)
|
||||
|
||||
if not inline:
|
||||
key = (size, density, intensity, color, opacity, monochrome, direction, spread, background)
|
||||
asset_file = 'brushed-%s%sx%s' % ('mono-' if monochrome else '', size, size)
|
||||
# asset_file += '-[%s][%s][%s]' % ('-'.join(to_str(s).replace('.', '_') for s in density or []), '-'.join(to_str(s).replace('.', '_') for s in opacity or []), '-'.join(to_str(s).replace('.', '_') for s in direction or []))
|
||||
asset_file += '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_')
|
||||
asset_file += '.png'
|
||||
asset_path = os.path.join(config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets'), asset_file)
|
||||
try:
|
||||
new_image.save(asset_path)
|
||||
except IOError:
|
||||
log.exception("Error while saving image")
|
||||
inline = True # Retry inline version
|
||||
url = '%s%s' % (config.ASSETS_URL, asset_file)
|
||||
if inline:
|
||||
output = six.BytesIO()
|
||||
new_image.save(output, format='PNG')
|
||||
contents = output.getvalue()
|
||||
output.close()
|
||||
url = 'data:image/png;base64,' + base64.b64encode(contents)
|
||||
|
||||
inline = 'url("%s")' % escape(url)
|
||||
return String.unquoted(inline)
|
||||
|
||||
|
||||
@register('grid-image', 4)
|
||||
@register('grid-image', 5)
|
||||
def _grid_image(left_gutter, width, right_gutter, height, columns=1, grid_color=None, baseline_color=None, background_color=None, inline=False):
|
||||
if not Image:
|
||||
raise Exception("Images manipulation require PIL")
|
||||
if grid_color is None:
|
||||
grid_color = (120, 170, 250, 15)
|
||||
else:
|
||||
c = Color(grid_color).value
|
||||
grid_color = (c[0], c[1], c[2], int(c[3] * 255.0))
|
||||
if baseline_color is None:
|
||||
baseline_color = (120, 170, 250, 30)
|
||||
else:
|
||||
c = Color(baseline_color).value
|
||||
baseline_color = (c[0], c[1], c[2], int(c[3] * 255.0))
|
||||
if background_color is None:
|
||||
background_color = (0, 0, 0, 0)
|
||||
else:
|
||||
c = Color(background_color).value
|
||||
background_color = (c[0], c[1], c[2], int(c[3] * 255.0))
|
||||
_height = int(height) if height >= 1 else int(height * 1000.0)
|
||||
_width = int(width) if width >= 1 else int(width * 1000.0)
|
||||
_left_gutter = int(left_gutter) if left_gutter >= 1 else int(left_gutter * 1000.0)
|
||||
_right_gutter = int(right_gutter) if right_gutter >= 1 else int(right_gutter * 1000.0)
|
||||
if _height <= 0 or _width <= 0 or _left_gutter <= 0 or _right_gutter <= 0:
|
||||
raise ValueError
|
||||
_full_width = (_left_gutter + _width + _right_gutter)
|
||||
new_image = Image.new(
|
||||
mode='RGBA',
|
||||
size=(_full_width * int(columns), _height),
|
||||
color=background_color
|
||||
)
|
||||
draw = ImageDraw.Draw(new_image)
|
||||
for i in range(int(columns)):
|
||||
draw.rectangle((i * _full_width + _left_gutter, 0, i * _full_width + _left_gutter + _width - 1, _height - 1), fill=grid_color)
|
||||
if _height > 1:
|
||||
draw.rectangle((0, _height - 1, _full_width * int(columns) - 1, _height - 1), fill=baseline_color)
|
||||
if not inline:
|
||||
grid_name = 'grid_'
|
||||
if left_gutter:
|
||||
grid_name += str(int(left_gutter)) + '+'
|
||||
grid_name += str(int(width))
|
||||
if right_gutter:
|
||||
grid_name += '+' + str(int(right_gutter))
|
||||
if height and height > 1:
|
||||
grid_name += 'x' + str(int(height))
|
||||
key = (columns, grid_color, baseline_color, background_color)
|
||||
key = grid_name + '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_')
|
||||
asset_file = key + '.png'
|
||||
asset_path = os.path.join(config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets'), asset_file)
|
||||
try:
|
||||
new_image.save(asset_path)
|
||||
except IOError:
|
||||
log.exception("Error while saving image")
|
||||
inline = True # Retry inline version
|
||||
url = '%s%s' % (config.ASSETS_URL, asset_file)
|
||||
if inline:
|
||||
output = six.BytesIO()
|
||||
new_image.save(output, format='PNG')
|
||||
contents = output.getvalue()
|
||||
output.close()
|
||||
url = 'data:image/png;base64,' + base64.b64encode(contents)
|
||||
inline = 'url("%s")' % escape(url)
|
||||
return String.unquoted(inline)
|
||||
|
||||
|
||||
@register('image-color', 1)
|
||||
@register('image-color', 2)
|
||||
@register('image-color', 3)
|
||||
def image_color(color, width=1, height=1):
|
||||
if not Image:
|
||||
raise Exception("Images manipulation require PIL")
|
||||
w = int(Number(width).value)
|
||||
h = int(Number(height).value)
|
||||
if w <= 0 or h <= 0:
|
||||
raise ValueError
|
||||
new_image = Image.new(
|
||||
mode='RGB' if color.alpha == 1 else 'RGBA',
|
||||
size=(w, h),
|
||||
color=color.rgba255,
|
||||
)
|
||||
output = six.BytesIO()
|
||||
new_image.save(output, format='PNG')
|
||||
contents = output.getvalue()
|
||||
output.close()
|
||||
mime_type = 'image/png'
|
||||
url = 'data:' + mime_type + ';base64,' + base64.b64encode(contents)
|
||||
inline = 'url("%s")' % escape(url)
|
||||
return String.unquoted(inline)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Defines `FunctionLibrary`, a class that collects functions to be exposed to
|
||||
SCSS during compilation.
|
||||
"""
|
||||
|
||||
# TODO the constant versions of this should be frozen
|
||||
class FunctionLibrary(object):
|
||||
"""Contains a set of functions to be exposed to SCSS.
|
||||
|
||||
Functions are keyed by both their name _and_ arity; this allows for
|
||||
function overloading somewhat beyond what Python easily allows, and is
|
||||
required for several functions in the standard Sass library.
|
||||
|
||||
Functions may also have arbitrary arity, in which case they accept any
|
||||
number of arguments, though a function of the same name with a specific
|
||||
arity takes precedence.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._functions = {}
|
||||
|
||||
def derive(self):
|
||||
"""Returns a new registry object, pre-populated with all the functions
|
||||
in this registry.
|
||||
"""
|
||||
new = type(self)()
|
||||
new.inherit(self)
|
||||
return new
|
||||
|
||||
def inherit(self, *other_libraries):
|
||||
"""Import all the functions from the given other libraries into this one.
|
||||
|
||||
Note that existing functions ARE NOT replaced -- which also means that
|
||||
functions from the first library take precedence over functions from
|
||||
the second library, etc.
|
||||
"""
|
||||
new_functions = dict()
|
||||
|
||||
# dict.update replaces keys; go through the other libraries in reverse,
|
||||
# so the first one wins
|
||||
for library in reversed(other_libraries):
|
||||
new_functions.update(library._functions)
|
||||
|
||||
new_functions.update(self._functions)
|
||||
self._functions = new_functions
|
||||
|
||||
def register(self, name, argc=None):
|
||||
"""Decorator for adding a function to this library."""
|
||||
# XXX: this should allow specifying names of keyword arguments, as
|
||||
# well. currently none of these functions support kwargs, i think.
|
||||
# XXX automatically inferring the name and args would be...
|
||||
# interesting
|
||||
# XXX also it would be nice if a function which EXISTS but has the
|
||||
# wrong number of args threw a useful error; atm i think it'll become
|
||||
# literal (yikes!)
|
||||
|
||||
key = (name, argc)
|
||||
|
||||
def decorator(f):
|
||||
self._functions[key] = f
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
def add(self, function, name, argc=None):
|
||||
"""Add a function to this library imperatively."""
|
||||
|
||||
key = (name, argc)
|
||||
self._functions[key] = function
|
||||
|
||||
def lookup(self, name, argc=None):
|
||||
"""Find a function given its name and the number of arguments it takes.
|
||||
Falls back to a function with the same name that takes any number of
|
||||
arguments.
|
||||
"""
|
||||
# Try the given arity first
|
||||
key = name, argc
|
||||
if key in self._functions:
|
||||
return self._functions[key]
|
||||
|
||||
# Fall back to arbitrary-arity (or KeyError if neither exists)
|
||||
return self._functions[name, None]
|
||||
Reference in New Issue
Block a user