debian-weasyprint/weasyprint/css/validation.py

2268 lines
70 KiB
Python
Raw Normal View History

# coding: utf-8
"""
weasyprint.css.validation
-------------------------
Expand shorthands and validate property values.
See http://www.w3.org/TR/CSS21/propidx.html and various CSS3 modules.
2014-01-10 15:27:02 +01:00
:copyright: Copyright 2011-2014 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from __future__ import division, unicode_literals
import functools
import math
2017-03-26 11:42:50 +02:00
import tinycss2
from tinycss2.color3 import parse_color
2017-03-25 00:33:36 +01:00
from . import computed_values
from ..compat import unquote, urljoin
from ..formatting_structure import counters
2013-04-11 10:39:23 +02:00
from ..images import LinearGradient, RadialGradient
2017-03-25 00:33:36 +01:00
from ..logger import LOGGER
from ..urls import iri_to_uri, url_is_absolute
from .properties import (
INITIAL_VALUES, KNOWN_PROPERTIES, NOT_PRINT_MEDIA, Dimension)
# TODO: unit-test these validators
# get the sets of keys
2017-03-25 00:24:27 +01:00
LENGTH_UNITS = (
set(computed_values.LENGTHS_TO_PIXELS) | set(['ex', 'em', 'ch', 'rem']))
2011-12-02 18:02:58 +01:00
# keyword -> (open, insert)
CONTENT_QUOTE_KEYWORDS = {
'open-quote': (True, True),
'close-quote': (False, True),
'no-open-quote': (True, False),
'no-close-quote': (False, False),
}
ZERO_PERCENT = Dimension(0, '%')
FIFTY_PERCENT = Dimension(50, '%')
HUNDRED_PERCENT = Dimension(100, '%')
BACKGROUND_POSITION_PERCENTAGES = {
'top': ZERO_PERCENT,
'left': ZERO_PERCENT,
'center': FIFTY_PERCENT,
'bottom': HUNDRED_PERCENT,
'right': HUNDRED_PERCENT,
}
# yes/no validators for non-shorthand properties
# Maps property names to functions taking a property name and a value list,
# returning a value or None for invalid.
# Also transform values: keyword and URLs are returned as strings.
# For properties that take a single value, that value is returned by itself
# instead of a list.
VALIDATORS = {}
EXPANDERS = {}
PROPRIETARY = set()
UNSTABLE = set()
PREFIX = '-weasy-'
class InvalidValues(ValueError):
2011-08-23 17:35:10 +02:00
"""Invalid or unsupported values for a known CSS property."""
2011-08-23 17:35:10 +02:00
# Validators
def validator(property_name=None, proprietary=False, unstable=False,
wants_base_url=False):
2011-08-23 17:35:10 +02:00
"""Decorator adding a function to the ``VALIDATORS``.
The name of the property covered by the decorated function is set to
``property_name`` if given, or is inferred from the function name
(replacing underscores by hyphens).
:param proprietary:
Proprietary (vendor-specific, non-standard) are prefixed: anchors can
for example be set using ``-weasy-anchor: attr(id)``.
See https://www.w3.org/TR/CSS/#proprietary
:param unstable:
Mark properties that are defined in specifications that didn't reach
the Candidate Recommandation stage. They can be used both
vendor-prefixed or unprefixed.
See https://www.w3.org/TR/CSS/#unstable-syntax
:param wants_base_url:
The function takes the stylesheets base URL as an additional
parameter.
"""
def decorator(function):
2011-08-23 17:35:10 +02:00
"""Add ``function`` to the ``VALIDATORS``."""
if property_name is None:
name = function.__name__.replace('_', '-')
else:
name = property_name
assert name in KNOWN_PROPERTIES, name
assert name not in VALIDATORS, name
function.wants_base_url = wants_base_url
VALIDATORS[name] = function
if proprietary:
PROPRIETARY.add(name)
if unstable:
UNSTABLE.add(name)
return function
return decorator
def get_keyword(token):
"""If ``value`` is a keyword, return its name.
Otherwise return ``None``.
"""
2017-03-26 11:42:50 +02:00
if token.type == 'ident':
return token.lower_value
def get_single_keyword(tokens):
"""If ``values`` is a 1-element list of keywords, return its name.
Otherwise return ``None``.
"""
if len(tokens) == 1:
token = tokens[0]
2017-03-26 11:42:50 +02:00
if token.type == 'ident':
return token.lower_value
def single_keyword(function):
"""Decorator for validators that only accept a single keyword."""
@functools.wraps(function)
def keyword_validator(tokens):
"""Wrap a validator to call get_single_keyword on tokens."""
keyword = get_single_keyword(tokens)
if function(keyword):
return keyword
2011-08-23 17:35:10 +02:00
return keyword_validator
def single_token(function):
"""Decorator for validators that only accept a single token."""
@functools.wraps(function)
def single_token_validator(tokens, *args):
"""Validate a property whose token is single."""
if len(tokens) == 1:
return function(tokens[0], *args)
single_token_validator.__func__ = function
return single_token_validator
def comma_separated_list(function):
"""Decorator for validators that accept a comma separated list."""
@functools.wraps(function)
def wrapper(tokens, *args):
results = []
for part in split_on_comma(tokens):
result = function(remove_whitespace(part), *args)
if result is None:
return None
results.append(result)
return tuple(results)
wrapper.single_value = function
return wrapper
def get_length(token, negative=True, percentage=False):
2017-03-26 11:42:50 +02:00
if percentage and token.type == 'percentage':
if negative or token.value >= 0:
return Dimension(token.value, '%')
if token.type == 'dimension' and token.unit in LENGTH_UNITS:
2015-04-30 10:58:51 +02:00
if negative or token.value >= 0:
return Dimension(token.value, token.unit)
2017-03-26 11:42:50 +02:00
if token.type == 'number' and token.value == 0:
return Dimension(0, None)
2011-08-23 17:35:10 +02:00
# http://dev.w3.org/csswg/css3-values/#angles
# 1<unit> is this many radians.
ANGLE_TO_RADIANS = {
'rad': 1,
'turn': 2 * math.pi,
'deg': math.pi / 180,
'grad': math.pi / 200,
}
def get_angle(token):
"""Return the value in radians of an <angle> token, or None."""
2017-03-26 11:42:50 +02:00
if token.type == 'dimension':
factor = ANGLE_TO_RADIANS.get(token.unit)
if factor is not None:
return token.value * factor
2012-02-08 15:44:03 +01:00
# http://dev.w3.org/csswg/css-values/#resolution
RESOLUTION_TO_DPPX = {
'dppx': 1,
'dpi': 1 / computed_values.LENGTHS_TO_PIXELS['in'],
'dpcm': 1 / computed_values.LENGTHS_TO_PIXELS['cm'],
}
def get_resolution(token):
"""Return the value in dppx of a <resolution> token, or None."""
if token.type == 'dimension':
factor = RESOLUTION_TO_DPPX.get(token.unit)
if factor is not None:
return token.value * factor
def safe_urljoin(base_url, url):
if url_is_absolute(url):
return iri_to_uri(url)
elif base_url:
return iri_to_uri(urljoin(base_url, url))
else:
raise InvalidValues(
'Relative URI reference without a base URI: %r' % url)
@validator()
@comma_separated_list
@single_keyword
def background_attachment(keyword):
2011-08-23 17:35:10 +02:00
"""``background-attachment`` property validation."""
return keyword in ('scroll', 'fixed', 'local')
@validator('background-color')
@validator('border-top-color')
@validator('border-right-color')
@validator('border-bottom-color')
@validator('border-left-color')
@validator('column-rule-color')
@single_token
def other_colors(token):
return parse_color(token)
2012-04-02 14:45:44 +02:00
2012-08-03 16:21:47 +02:00
@validator()
@single_token
def outline_color(token):
if get_keyword(token) == 'invert':
return 'currentColor'
else:
return parse_color(token)
@validator()
@single_keyword
def border_collapse(keyword):
return keyword in ('separate', 'collapse')
2016-01-24 14:38:29 +01:00
@validator()
@single_keyword
def empty_cells(keyword):
"""``empty-cells`` property validation."""
return keyword in ('show', 'hide')
@validator('color')
@single_token
def color(token):
2011-08-23 17:35:10 +02:00
"""``*-color`` and ``color`` properties validation."""
result = parse_color(token)
if result == 'currentColor':
return 'inherit'
else:
return result
@validator('background-image', wants_base_url=True)
@comma_separated_list
@single_token
def background_image(token, base_url):
2017-03-26 11:42:50 +02:00
if token.type != 'function':
return image_url([token], base_url)
2017-03-26 11:42:50 +02:00
arguments = split_on_comma(remove_whitespace(token.arguments))
name = token.lower_name
if name in ('linear-gradient', 'repeating-linear-gradient'):
direction, color_stops = parse_linear_gradient_parameters(arguments)
if color_stops:
2013-04-11 10:39:23 +02:00
return 'linear-gradient', LinearGradient(
[parse_color_stop(stop) for stop in color_stops],
direction, 'repeating' in name)
2013-04-11 10:39:23 +02:00
elif name in ('radial-gradient', 'repeating-radial-gradient'):
result = parse_radial_gradient_parameters(arguments)
if result is not None:
shape, size, position, color_stops = result
else:
shape = 'ellipse'
size = 'keyword', 'farthest-corner'
position = 'left', FIFTY_PERCENT, 'top', FIFTY_PERCENT
color_stops = arguments
if color_stops:
return 'radial-gradient', RadialGradient(
[parse_color_stop(stop) for stop in color_stops],
shape, size, position, 'repeating' in name)
DIRECTION_KEYWORDS = {
# ('angle', radians) 0 upwards, then clockwise
('to', 'top'): ('angle', 0),
('to', 'right'): ('angle', math.pi / 2),
('to', 'bottom'): ('angle', math.pi),
('to', 'left'): ('angle', math.pi * 3 / 2),
# ('corner', keyword)
('to', 'top', 'left'): ('corner', 'top_left'),
('to', 'left', 'top'): ('corner', 'top_left'),
('to', 'top', 'right'): ('corner', 'top_right'),
('to', 'right', 'top'): ('corner', 'top_right'),
('to', 'bottom', 'left'): ('corner', 'bottom_left'),
('to', 'left', 'bottom'): ('corner', 'bottom_left'),
('to', 'bottom', 'right'): ('corner', 'bottom_right'),
('to', 'right', 'bottom'): ('corner', 'bottom_right'),
}
def parse_linear_gradient_parameters(arguments):
first_arg = arguments[0]
if len(first_arg) == 1:
angle = get_angle(first_arg[0])
if angle is not None:
return ('angle', angle), arguments[1:]
else:
result = DIRECTION_KEYWORDS.get(tuple(map(get_keyword, first_arg)))
if result is not None:
return result, arguments[1:]
2013-04-11 16:06:28 +02:00
return ('angle', math.pi), arguments # Default direction is 'to bottom'
2013-04-11 10:39:23 +02:00
def parse_radial_gradient_parameters(arguments):
shape = None
position = None
size = None
size_shape = None
stack = arguments[0][::-1]
while stack:
token = stack.pop()
keyword = get_keyword(token)
if keyword == 'at':
position = background_position.single_value(stack[::-1])
2013-04-11 16:06:28 +02:00
if position is None:
return
2013-04-11 10:39:23 +02:00
break
elif keyword in ('circle', 'ellipse') and shape is None:
shape = keyword
elif keyword in ('closest-corner', 'farthest-corner',
'closest-side', 'farthest-side') and size is None:
size = 'keyword', keyword
else:
if stack and size is None:
length_1 = get_length(token, percentage=True)
length_2 = get_length(stack[-1], percentage=True)
if None not in (length_1, length_2):
size = 'explicit', (length_1, length_2)
size_shape = 'ellipse'
stack.pop()
if size is None:
length_1 = get_length(token)
if length_1 is not None:
size = 'explicit', (length_1, length_1)
size_shape = 'circle'
if size is None:
return
if (shape, size_shape) in (('circle', 'ellipse'), ('circle', 'ellipse')):
return
return (
shape or size_shape or 'ellipse',
2013-04-11 16:06:28 +02:00
size or ('keyword', 'farthest-corner'),
2013-04-11 10:39:23 +02:00
position or ('left', FIFTY_PERCENT, 'top', FIFTY_PERCENT),
arguments[1:])
def parse_color_stop(tokens):
if len(tokens) == 1:
color = parse_color(tokens[0])
if color is not None:
return color, None
elif len(tokens) == 2:
color = parse_color(tokens[0])
position = get_length(tokens[1], negative=True, percentage=True)
if color is not None and position is not None:
return color, position
raise InvalidValues
@validator('list-style-image', wants_base_url=True)
@single_token
def image_url(token, base_url):
2011-08-23 17:35:10 +02:00
"""``*-image`` properties validation."""
if get_keyword(token) == 'none':
return 'none', None
2017-03-26 11:42:50 +02:00
if token.type == 'url':
return 'url', safe_urljoin(base_url, token.value)
class CenterKeywordFakeToken(object):
2017-03-26 11:42:50 +02:00
type = 'ident'
lower_value = 'center'
unit = None
@validator(unstable=True)
def transform_origin(tokens):
# TODO: parse (and ignore) a third value for Z.
return simple_2d_position(tokens)
@validator()
@comma_separated_list
def background_position(tokens):
"""``background-position`` property validation.
See http://dev.w3.org/csswg/css3-background/#the-background-position
"""
result = simple_2d_position(tokens)
if result is not None:
pos_x, pos_y = result
return 'left', pos_x, 'top', pos_y
if len(tokens) == 4:
keyword_1 = get_keyword(tokens[0])
keyword_2 = get_keyword(tokens[2])
length_1 = get_length(tokens[1], percentage=True)
length_2 = get_length(tokens[3], percentage=True)
if length_1 and length_2:
if (keyword_1 in ('left', 'right') and
2013-04-11 10:39:23 +02:00
keyword_2 in ('top', 'bottom')):
return keyword_1, length_1, keyword_2, length_2
if (keyword_2 in ('left', 'right') and
2013-04-11 10:39:23 +02:00
keyword_1 in ('top', 'bottom')):
return keyword_2, length_2, keyword_1, length_1
if len(tokens) == 3:
length = get_length(tokens[2], percentage=True)
if length is not None:
keyword = get_keyword(tokens[1])
other_keyword = get_keyword(tokens[0])
else:
length = get_length(tokens[1], percentage=True)
other_keyword = get_keyword(tokens[2])
keyword = get_keyword(tokens[0])
if length is not None:
if other_keyword == 'center':
if keyword in ('top', 'bottom'):
return 'left', FIFTY_PERCENT, keyword, length
if keyword in ('left', 'right'):
return keyword, length, 'top', FIFTY_PERCENT
elif (keyword in ('left', 'right') and
other_keyword in ('top', 'bottom')):
return keyword, length, other_keyword, ZERO_PERCENT
elif (keyword in ('top', 'bottom') and
other_keyword in ('left', 'right')):
return other_keyword, ZERO_PERCENT, keyword, length
def simple_2d_position(tokens):
"""Common syntax of background-position and transform-origin."""
if len(tokens) == 1:
tokens = [tokens[0], CenterKeywordFakeToken]
elif len(tokens) != 2:
return None
token_1, token_2 = tokens
length_1 = get_length(token_1, percentage=True)
length_2 = get_length(token_2, percentage=True)
if length_1 and length_2:
return length_1, length_2
keyword_1, keyword_2 = map(get_keyword, tokens)
if length_1 and keyword_2 in ('top', 'center', 'bottom'):
return length_1, BACKGROUND_POSITION_PERCENTAGES[keyword_2]
elif length_2 and keyword_1 in ('left', 'center', 'right'):
return BACKGROUND_POSITION_PERCENTAGES[keyword_1], length_2
elif (keyword_1 in ('left', 'center', 'right') and
keyword_2 in ('top', 'center', 'bottom')):
return (BACKGROUND_POSITION_PERCENTAGES[keyword_1],
BACKGROUND_POSITION_PERCENTAGES[keyword_2])
elif (keyword_1 in ('top', 'center', 'bottom') and
keyword_2 in ('left', 'center', 'right')):
# Swap tokens. They need to be in (horizontal, vertical) order.
return (BACKGROUND_POSITION_PERCENTAGES[keyword_2],
BACKGROUND_POSITION_PERCENTAGES[keyword_1])
@validator()
@comma_separated_list
def background_repeat(tokens):
2011-08-23 17:35:10 +02:00
"""``background-repeat`` property validation."""
keywords = tuple(map(get_keyword, tokens))
if keywords == ('repeat-x',):
return ('repeat', 'no-repeat')
if keywords == ('repeat-y',):
return ('no-repeat', 'repeat')
2013-03-30 23:32:14 +01:00
if keywords in (('no-repeat',), ('repeat',), ('space',), ('round',)):
return keywords * 2
if len(keywords) == 2 and all(
2013-03-30 23:32:14 +01:00
k in ('no-repeat', 'repeat', 'space', 'round')
for k in keywords):
return keywords
@validator()
@comma_separated_list
def background_size(tokens):
"""Validation for ``background-size``."""
if len(tokens) == 1:
token = tokens[0]
keyword = get_keyword(token)
if keyword in ('contain', 'cover'):
return keyword
if keyword == 'auto':
return ('auto', 'auto')
2013-03-21 12:53:53 +01:00
length = get_length(token, negative=False, percentage=True)
if length:
return (length, 'auto')
elif len(tokens) == 2:
values = []
for token in tokens:
length = get_length(token, negative=False, percentage=True)
if length:
values.append(length)
elif get_keyword(token) == 'auto':
values.append('auto')
2012-04-03 19:02:14 +02:00
if len(values) == 2:
return tuple(values)
@validator('background-clip')
@validator('background-origin')
@comma_separated_list
@single_keyword
def box(keyword):
"""Validation for the ``<box>`` type used in ``background-clip``
and ``background-origin``."""
return keyword in ('border-box', 'padding-box', 'content-box')
@validator()
def border_spacing(tokens):
"""Validator for the `border-spacing` property."""
lengths = [get_length(token, negative=False) for token in tokens]
if all(lengths):
if len(lengths) == 1:
return (lengths[0], lengths[0])
elif len(lengths) == 2:
return tuple(lengths)
@validator('border-top-right-radius')
@validator('border-bottom-right-radius')
@validator('border-bottom-left-radius')
@validator('border-top-left-radius')
def border_corner_radius(tokens):
"""Validator for the `border-*-radius` properties."""
lengths = [
get_length(token, negative=False, percentage=True) for token in tokens]
if all(lengths):
if len(lengths) == 1:
return (lengths[0], lengths[0])
elif len(lengths) == 2:
return tuple(lengths)
@validator('border-top-style')
@validator('border-right-style')
@validator('border-left-style')
@validator('border-bottom-style')
@validator('column-rule-style')
@single_keyword
def border_style(keyword):
2011-08-23 17:35:10 +02:00
"""``border-*-style`` properties validation."""
return keyword in ('none', 'hidden', 'dotted', 'dashed', 'double',
'inset', 'outset', 'groove', 'ridge', 'solid')
2016-08-30 13:35:23 +02:00
@validator('break-before')
@validator('break-after')
@single_keyword
def break_before_after(keyword):
"""``break-before`` and ``break-after`` properties validation."""
2016-08-30 17:54:15 +02:00
# 'always' is defined as an alias to 'page' in multi-column
# https://www.w3.org/TR/css3-multicol/#column-breaks
2016-08-30 13:35:23 +02:00
return keyword in ('auto', 'avoid', 'avoid-page', 'page', 'left', 'right',
2016-08-30 17:54:15 +02:00
'recto', 'verso', 'avoid-column', 'column', 'always')
2016-08-30 13:35:23 +02:00
@validator()
@single_keyword
def break_inside(keyword):
"""``break-inside`` property validation."""
return keyword in ('auto', 'avoid', 'avoid-page', 'avoid-column')
@validator(unstable=True)
2017-07-18 02:01:36 +02:00
@single_token
def page(token):
"""``page`` property validation."""
if token.type == 'ident':
return 'auto' if token.lower_value == 'auto' else token.value
@validator("bleed-left")
@validator("bleed-right")
@validator("bleed-top")
@validator("bleed-bottom")
@single_token
2017-10-05 08:45:50 +02:00
def bleed(token):
"""``bleed`` property validation."""
keyword = get_keyword(token)
if keyword == 'auto':
return 'auto'
else:
return get_length(token)
@validator()
def marks(tokens):
"""``marks`` property validation."""
if len(tokens) == 2:
keywords = [get_keyword(token) for token in tokens]
if 'crop' in keywords and 'cross' in keywords:
return keywords
elif len(tokens) == 1:
keyword = get_keyword(tokens[0])
if keyword in ('crop', 'cross'):
return [keyword]
elif keyword == 'none':
return 'none'
@validator('outline-style')
@single_keyword
def outline_style(keyword):
"""``outline-style`` properties validation."""
return keyword in ('none', 'dotted', 'dashed', 'double', 'inset',
'outset', 'groove', 'ridge', 'solid')
@validator('border-top-width')
@validator('border-right-width')
@validator('border-left-width')
@validator('border-bottom-width')
@validator('column-rule-width')
2012-08-03 16:21:47 +02:00
@validator('outline-width')
@single_token
def border_width(token):
"""Border, column rule and outline widths properties validation."""
length = get_length(token, negative=False)
if length:
return length
keyword = get_keyword(token)
if keyword in ('thin', 'medium', 'thick'):
return keyword
@validator()
@single_token
def column_width(token):
"""``column-width`` property validation."""
length = get_length(token, negative=False)
if length:
return length
keyword = get_keyword(token)
if keyword == 'auto':
return keyword
@validator()
@single_keyword
def column_span(keyword):
"""``column-span`` property validation."""
# TODO: uncomment this when it is supported
# return keyword in ('all', 'none')
@validator()
@single_keyword
def box_sizing(keyword):
2011-12-26 12:47:26 +01:00
"""Validation for the ``box-sizing`` property from css3-ui"""
2012-04-06 15:56:36 +02:00
return keyword in ('padding-box', 'border-box', 'content-box')
@validator()
@single_keyword
def caption_side(keyword):
"""``caption-side`` properties validation."""
return keyword in ('top', 'bottom')
2012-05-29 16:01:59 +02:00
@validator()
@single_keyword
def clear(keyword):
"""``clear`` property validation."""
return keyword in ('left', 'right', 'both', 'none')
2012-02-07 18:06:59 +01:00
@validator()
@single_token
def clip(token):
2012-02-07 18:06:59 +01:00
"""Validation for the ``clip`` property."""
function = parse_function(token)
2012-02-07 18:06:59 +01:00
if function:
name, args = function
if name == 'rect' and len(args) == 4:
2012-04-03 19:02:14 +02:00
values = []
2012-02-07 18:06:59 +01:00
for arg in args:
if get_keyword(arg) == 'auto':
values.append('auto')
2012-02-07 18:06:59 +01:00
else:
length = get_length(arg)
if length:
2012-04-03 19:02:14 +02:00
values.append(length)
if len(values) == 4:
return tuple(values)
if get_keyword(token) == 'auto':
return ()
2012-02-07 18:06:59 +01:00
@validator(wants_base_url=True)
def content(tokens, base_url):
"""``content`` property validation."""
keyword = get_single_keyword(tokens)
if keyword in ('normal', 'none'):
return keyword
parsed_tokens = [validate_content_token(base_url, v) for v in tokens]
if None not in parsed_tokens:
return parsed_tokens
def validate_content_token(base_url, token):
"""Validation for a single token for the ``content`` property.
Return (type, content) or False for invalid tokens.
"""
quote_type = CONTENT_QUOTE_KEYWORDS.get(get_keyword(token))
2011-12-02 18:02:58 +01:00
if quote_type is not None:
return ('QUOTE', quote_type)
type_ = token.type
2017-03-26 11:42:50 +02:00
if type_ == 'string':
return ('STRING', token.value)
2017-03-26 11:42:50 +02:00
if type_ == 'url':
return ('URI', safe_urljoin(base_url, token.value))
function = parse_function(token)
2012-02-07 18:06:59 +01:00
if function:
name, args = function
prototype = (name, [a.type for a in args])
args = [getattr(a, 'value', a) for a in args]
2017-03-26 11:42:50 +02:00
if prototype == ('attr', ['ident']):
2012-02-07 18:06:59 +01:00
return (name, args[0])
2017-03-26 11:42:50 +02:00
elif prototype in (('counter', ['ident']),
('counters', ['ident', 'string'])):
2012-02-07 18:06:59 +01:00
args.append('decimal')
return (name, args)
2017-03-26 11:42:50 +02:00
elif prototype in (('counter', ['ident', 'ident']),
('counters', ['ident', 'string', 'ident'])):
2012-02-07 18:06:59 +01:00
style = args[-1]
if style in ('none', 'decimal') or style in counters.STYLES:
return (name, args)
2017-03-26 11:42:50 +02:00
elif prototype in (('string', ['ident']),
('string', ['ident', 'ident'])):
if len(args) > 1:
args[1] = args[1].lower()
if args[1] not in ('first', 'start', 'last', 'first-except'):
raise InvalidValues()
return (name, args)
2012-02-07 18:06:59 +01:00
def parse_function(function_token):
"""Return ``(name, args)`` if the given token is a function
2012-02-07 18:06:59 +01:00
with comma-separated arguments, or None.
.
"""
2017-03-26 11:42:50 +02:00
if function_token.type == 'function':
content = remove_whitespace(function_token.arguments)
if not content or len(content) % 2:
for token in content[1::2]:
2017-03-26 11:42:50 +02:00
if token.type != 'literal' or token.value != ',':
break
else:
2017-03-26 11:42:50 +02:00
return function_token.lower_name, content[::2]
@validator()
def counter_increment(tokens):
"""``counter-increment`` property validation."""
return counter(tokens, default_integer=1)
@validator()
def counter_reset(tokens):
"""``counter-reset`` property validation."""
return counter(tokens, default_integer=0)
def counter(tokens, default_integer):
"""``counter-increment`` and ``counter-reset`` properties validation."""
if get_single_keyword(tokens) == 'none':
return ()
tokens = iter(tokens)
token = next(tokens, None)
2012-04-03 19:02:14 +02:00
assert token, 'got an empty token list'
results = []
while token is not None:
2017-03-26 11:42:50 +02:00
if token.type != 'ident':
return # expected a keyword here
2013-05-10 12:13:21 +02:00
counter_name = token.value
if counter_name in ('none', 'initial', 'inherit'):
raise InvalidValues('Invalid counter name: ' + counter_name)
token = next(tokens, None)
2017-03-26 11:42:50 +02:00
if token is not None and (
token.type == 'number' and token.int_value is not None):
# Found an integer. Use it and get the next token
2017-03-26 11:42:50 +02:00
integer = token.int_value
token = next(tokens, None)
else:
# Not an integer. Might be the next counter name.
# Keep `token` for the next loop iteration.
integer = default_integer
results.append((counter_name, integer))
return tuple(results)
2012-05-09 13:15:13 +02:00
@validator('top')
@validator('right')
@validator('left')
@validator('bottom')
@validator('margin-top')
@validator('margin-right')
@validator('margin-bottom')
@validator('margin-left')
@single_token
2012-04-06 15:37:43 +02:00
def lenght_precentage_or_auto(token):
2011-08-23 17:35:10 +02:00
"""``margin-*`` properties validation."""
2012-04-06 15:37:43 +02:00
length = get_length(token, percentage=True)
if length:
return length
if get_keyword(token) == 'auto':
return 'auto'
@validator('height')
@validator('width')
@single_token
def width_height(token):
2011-12-26 12:47:26 +01:00
"""Validation for the ``width`` and ``height`` properties."""
2012-04-06 15:37:43 +02:00
length = get_length(token, negative=False, percentage=True)
if length:
return length
if get_keyword(token) == 'auto':
return 'auto'
@validator()
@single_token
def column_gap(token):
"""Validation for the ``column-gap`` property."""
length = get_length(token, negative=False)
if length:
return length
keyword = get_keyword(token)
if keyword == 'normal':
return keyword
@validator()
@single_keyword
def column_fill(keyword):
"""``column-fill`` property validation."""
return keyword in ('auto', 'balance')
@validator()
@single_keyword
def direction(keyword):
2011-08-23 17:35:10 +02:00
"""``direction`` property validation."""
return keyword in ('ltr', 'rtl')
@validator()
@single_keyword
def display(keyword):
2011-08-23 17:35:10 +02:00
"""``display`` property validation."""
return keyword in (
'inline', 'block', 'inline-block', 'list-item', 'none',
2011-11-15 18:19:36 +01:00
'table', 'inline-table', 'table-caption',
'table-row-group', 'table-header-group', 'table-footer-group',
'table-row', 'table-column-group', 'table-column', 'table-cell')
@validator('float')
2012-05-29 16:01:59 +02:00
@single_keyword
def float_(keyword): # XXX do not hide the "float" builtin
2012-05-29 16:01:59 +02:00
"""``float`` property validation."""
return keyword in ('left', 'right', 'none')
@validator()
@comma_separated_list
def font_family(tokens):
2011-08-23 17:35:10 +02:00
"""``font-family`` property validation."""
2017-03-26 11:42:50 +02:00
if len(tokens) == 1 and tokens[0].type == 'string':
return tokens[0].value
2017-03-26 11:42:50 +02:00
elif tokens and all(token.type == 'ident' for token in tokens):
return ' '.join(token.value for token in tokens)
2016-09-12 00:23:52 +02:00
@validator()
@single_keyword
def font_kerning(keyword):
return keyword in ('auto', 'normal', 'none')
2016-09-13 05:02:36 +02:00
@validator()
@single_token
def font_language_override(token):
keyword = get_keyword(token)
if keyword == 'normal':
return keyword
2017-03-26 11:42:50 +02:00
elif token.type == 'string':
2016-09-13 05:02:36 +02:00
return token.value
2016-09-12 00:23:52 +02:00
@validator()
def font_variant_ligatures(tokens):
if len(tokens) == 1:
keyword = get_keyword(tokens[0])
if keyword in ('normal', 'none'):
return keyword
values = []
couples = (
('common-ligatures', 'no-common-ligatures'),
('historical-ligatures', 'no-historical-ligatures'),
('discretionary-ligatures', 'no-discretionary-ligatures'),
('contextual', 'no-contextual'))
all_values = []
for couple in couples:
all_values.extend(couple)
for token in tokens:
2017-03-26 11:42:50 +02:00
if token.type != 'ident':
2016-09-12 00:23:52 +02:00
return None
if token.value in all_values:
concurrent_values = [
couple for couple in couples if token.value in couple][0]
if any(value in values for value in concurrent_values):
return None
else:
values.append(token.value)
else:
return None
if values:
return tuple(values)
2016-09-12 00:23:52 +02:00
@validator()
@single_keyword
def font_variant_position(keyword):
return keyword in ('normal', 'sub', 'super')
@validator()
@single_keyword
def font_variant_caps(keyword):
return keyword in (
'normal', 'small-caps', 'all-small-caps', 'petite-caps',
'all-petite-caps', 'unicase', 'titling-caps')
@validator()
def font_variant_numeric(tokens):
if len(tokens) == 1:
keyword = get_keyword(tokens[0])
if keyword == 'normal':
return keyword
values = []
couples = (
('lining-nums', 'oldstyle-nums'),
('proportional-nums', 'tabular-nums'),
('diagonal-fractions', 'stacked-fractions'),
('ordinal',), ('slashed-zero',))
all_values = []
for couple in couples:
all_values.extend(couple)
for token in tokens:
2017-03-26 11:42:50 +02:00
if token.type != 'ident':
2016-09-12 00:23:52 +02:00
return None
if token.value in all_values:
concurrent_values = [
couple for couple in couples if token.value in couple][0]
if any(value in values for value in concurrent_values):
return None
else:
values.append(token.value)
else:
return None
if values:
return tuple(values)
2016-09-12 00:23:52 +02:00
@validator()
def font_feature_settings(tokens):
"""``font-feature-settings`` property validation."""
if len(tokens) == 1 and get_keyword(tokens[0]) == 'normal':
return 'normal'
2016-09-12 00:23:52 +02:00
@comma_separated_list
def font_feature_settings_list(tokens):
feature, value = None, None
2016-09-12 00:23:52 +02:00
if len(tokens) == 2:
tokens, token = tokens[:-1], tokens[-1]
2017-03-26 11:42:50 +02:00
if token.type == 'ident':
value = {'on': 1, 'off': 0}.get(token.value)
2017-03-26 11:42:50 +02:00
elif (token.type == 'number' and
token.int_value is not None and token.int_value >= 0):
value = token.int_value
elif len(tokens) == 1:
value = 1
2016-09-12 00:23:52 +02:00
if len(tokens) == 1:
token, = tokens
2017-03-26 11:42:50 +02:00
if token.type == 'string' and len(token.value) == 4:
if all(0x20 <= ord(letter) <= 0x7f for letter in token.value):
feature = token.value
if feature is not None and value is not None:
return feature, value
return font_feature_settings_list(tokens)
2016-09-12 00:23:52 +02:00
@validator()
@single_keyword
def font_variant_alternates(keyword):
# TODO: support other values
# See https://www.w3.org/TR/css-fonts-3/#font-variant-caps-prop
return keyword in ('normal', 'historical-forms')
@validator()
def font_variant_east_asian(tokens):
if len(tokens) == 1:
keyword = get_keyword(tokens[0])
if keyword == 'normal':
return keyword
values = []
couples = (
('jis78', 'jis83', 'jis90', 'jis04', 'simplified', 'traditional'),
('full-width', 'proportional-width'),
('ruby',))
all_values = []
for couple in couples:
all_values.extend(couple)
for token in tokens:
2017-03-26 11:42:50 +02:00
if token.type != 'ident':
2016-09-12 00:23:52 +02:00
return None
if token.value in all_values:
concurrent_values = [
couple for couple in couples if token.value in couple][0]
if any(value in values for value in concurrent_values):
return None
else:
values.append(token.value)
else:
return None
if values:
return tuple(values)
2016-09-12 00:23:52 +02:00
@validator()
@single_token
def font_size(token):
2011-08-23 17:35:10 +02:00
"""``font-size`` property validation."""
2013-04-02 18:32:15 +02:00
length = get_length(token, negative=False, percentage=True)
if length:
return length
font_size_keyword = get_keyword(token)
2011-08-23 17:35:10 +02:00
if font_size_keyword in ('smaller', 'larger'):
raise InvalidValues('value not supported yet')
2013-04-11 12:08:53 +02:00
if font_size_keyword in computed_values.FONT_SIZE_KEYWORDS:
# or keyword in ('smaller', 'larger')
return font_size_keyword
@validator()
@single_keyword
def font_style(keyword):
2011-08-23 17:35:10 +02:00
"""``font-style`` property validation."""
return keyword in ('normal', 'italic', 'oblique')
@validator()
@single_keyword
def font_stretch(keyword):
"""Validation for the ``font-stretch`` property."""
return keyword in (
'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed',
'normal',
'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded')
@validator()
@single_token
def font_weight(token):
2011-08-23 17:35:10 +02:00
"""``font-weight`` property validation."""
keyword = get_keyword(token)
if keyword in ('normal', 'bold', 'bolder', 'lighter'):
return keyword
2017-03-26 11:42:50 +02:00
if token.type == 'number' and token.int_value is not None:
if token.int_value in (100, 200, 300, 400, 500, 600, 700, 800, 900):
2017-03-26 11:42:50 +02:00
return token.int_value
@validator(unstable=True)
@single_token
def image_resolution(token):
# TODO: support 'snap' and 'from-image'
return get_resolution(token)
@validator('letter-spacing')
@validator('word-spacing')
@single_token
def spacing(token):
"""Validation for ``letter-spacing`` and ``word-spacing``."""
if get_keyword(token) == 'normal':
return 'normal'
length = get_length(token)
if length:
return length
@validator()
@single_token
def line_height(token):
2011-08-23 17:35:10 +02:00
"""``line-height`` property validation."""
if get_keyword(token) == 'normal':
return 'normal'
2017-03-26 11:42:50 +02:00
if token.type == 'number' and token.value >= 0:
return Dimension(token.value, None)
if token.type == 'percentage' and token.value >= 0:
return Dimension(token.value, '%')
elif token.type == 'dimension' and token.value >= 0:
return get_length(token)
@validator()
@single_keyword
def list_style_position(keyword):
2011-08-23 17:35:10 +02:00
"""``list-style-position`` property validation."""
return keyword in ('inside', 'outside')
@validator()
@single_keyword
def list_style_type(keyword):
2011-08-23 17:35:10 +02:00
"""``list-style-type`` property validation."""
return keyword in ('none', 'decimal') or keyword in counters.STYLES
@validator('padding-top')
@validator('padding-right')
@validator('padding-bottom')
@validator('padding-left')
@validator('min-width')
@validator('min-height')
@single_token
def length_or_precentage(token):
2011-08-23 17:35:10 +02:00
"""``padding-*`` properties validation."""
2012-04-06 15:37:43 +02:00
length = get_length(token, negative=False, percentage=True)
if length:
return length
@validator('max-width')
@validator('max-height')
@single_token
def max_width_height(token):
"""Validation for max-width and max-height"""
length = get_length(token, negative=False, percentage=True)
if length:
return length
if get_keyword(token) == 'none':
return Dimension(float('inf'), 'px')
2012-02-07 18:26:23 +01:00
@validator()
@single_token
def opacity(token):
2012-02-07 18:26:23 +01:00
"""Validation for the ``opacity`` property."""
2017-03-26 11:42:50 +02:00
if token.type == 'number':
return min(1, max(0, token.value))
2012-02-07 18:26:23 +01:00
2012-05-11 16:07:14 +02:00
@validator()
@single_token
def z_index(token):
"""Validation for the ``z-index`` property."""
if get_keyword(token) == 'auto':
return 'auto'
2017-03-26 11:42:50 +02:00
if token.type == 'number' and token.int_value is not None:
return token.int_value
2012-05-11 16:07:14 +02:00
2012-03-14 19:33:24 +01:00
@validator('orphans')
@validator('widows')
@single_token
def orphans_widows(token):
"""Validation for the ``orphans`` and ``widows`` properties."""
2017-03-26 11:42:50 +02:00
if token.type == 'number' and token.int_value is not None:
value = token.int_value
if value >= 1:
2012-03-14 19:33:24 +01:00
return value
@validator()
@single_token
def column_count(token):
"""Validation for the ``column-count`` property."""
2017-03-26 11:42:50 +02:00
if token.type == 'number' and token.int_value is not None:
value = token.int_value
if value >= 1:
2012-03-14 19:33:24 +01:00
return value
if get_keyword(token) == 'auto':
return 'auto'
2012-03-14 19:33:24 +01:00
2012-02-07 16:59:22 +01:00
@validator()
@single_keyword
def overflow(keyword):
"""Validation for the ``overflow`` property."""
return keyword in ('auto', 'visible', 'hidden', 'scroll')
@validator()
@single_keyword
def position(keyword):
2011-08-23 17:35:10 +02:00
"""``position`` property validation."""
return keyword in ('static', 'relative', 'absolute', 'fixed')
2011-12-02 18:02:58 +01:00
@validator()
def quotes(tokens):
2011-12-02 18:02:58 +01:00
"""``quotes`` property validation."""
2015-04-30 10:58:51 +02:00
if (tokens and len(tokens) % 2 == 0 and
2017-03-26 11:42:50 +02:00
all(v.type == 'string' for v in tokens)):
strings = tuple(token.value for token in tokens)
2011-12-02 18:02:58 +01:00
# Separate open and close quotes.
# eg. ('«', '»', '“', '”') -> (('«', '“'), ('»', '”'))
2011-12-02 18:02:58 +01:00
return strings[::2], strings[1::2]
2012-03-23 19:31:54 +01:00
@validator()
@single_keyword
def table_layout(keyword):
"""Validation for the ``table-layout`` property"""
if keyword in ('fixed', 'auto'):
return keyword
@validator()
@single_keyword
def text_align(keyword):
2011-08-23 17:35:10 +02:00
"""``text-align`` property validation."""
return keyword in ('left', 'right', 'center', 'justify')
@validator()
def text_decoration(tokens):
2011-08-23 17:35:10 +02:00
"""``text-decoration`` property validation."""
keywords = [get_keyword(v) for v in tokens]
if keywords == ['none']:
return 'none'
if all(keyword in ('underline', 'overline', 'line-through', 'blink')
for keyword in keywords):
2012-04-03 19:02:14 +02:00
unique = set(keywords)
if len(unique) == len(keywords):
# No duplicate
2012-04-03 19:02:14 +02:00
# blink is accepted but ignored
# "Conforming user agents may simply not blink the text."
return frozenset(unique - set(['blink']))
@validator()
@single_token
def text_indent(token):
"""``text-indent`` property validation."""
length = get_length(token, percentage=True)
if length:
return length
@validator()
@single_keyword
def text_transform(keyword):
"""``text-align`` property validation."""
2016-08-21 00:47:13 +02:00
return keyword in (
'none', 'uppercase', 'lowercase', 'capitalize', 'full-width')
@validator()
@single_token
def vertical_align(token):
2011-12-26 12:47:26 +01:00
"""Validation for the ``vertical-align`` property"""
length = get_length(token, percentage=True)
if length:
return length
keyword = get_keyword(token)
if keyword in ('baseline', 'middle', 'sub', 'super',
'text-top', 'text-bottom', 'top', 'bottom'):
return keyword
2011-10-21 11:15:06 +02:00
@validator()
@single_keyword
def visibility(keyword):
"""``white-space`` property validation."""
return keyword in ('visible', 'hidden', 'collapse')
@validator()
@single_keyword
def white_space(keyword):
2011-08-23 17:35:10 +02:00
"""``white-space`` property validation."""
return keyword in ('normal', 'pre', 'nowrap', 'pre-wrap', 'pre-line')
2014-04-27 13:29:55 +02:00
@validator()
@single_keyword
def overflow_wrap(keyword):
"""``overflow-wrap`` property validation."""
return keyword in ('normal', 'break-word')
@validator(unstable=True)
@single_keyword
def image_rendering(keyword):
"""Validation for ``image-rendering``."""
return keyword in ('auto', 'crisp-edges', 'pixelated')
@validator(unstable=True)
def size(tokens):
2011-08-23 17:35:10 +02:00
"""``size`` property validation.
See http://www.w3.org/TR/css3-page/#page-size-prop
"""
lengths = [get_length(token, negative=False) for token in tokens]
if all(lengths):
if len(lengths) == 1:
return (lengths[0], lengths[0])
elif len(lengths) == 2:
return tuple(lengths)
keywords = [get_keyword(v) for v in tokens]
if len(keywords) == 1:
keyword = keywords[0]
if keyword in computed_values.PAGE_SIZES:
return computed_values.PAGE_SIZES[keyword]
elif keyword in ('auto', 'portrait'):
return computed_values.INITIAL_PAGE_SIZE
elif keyword == 'landscape':
return computed_values.INITIAL_PAGE_SIZE[::-1]
if len(keywords) == 2:
if keywords[0] in ('portrait', 'landscape'):
2011-12-26 12:47:26 +01:00
orientation, page_size = keywords
elif keywords[1] in ('portrait', 'landscape'):
2011-12-26 12:47:26 +01:00
page_size, orientation = keywords
else:
2011-12-26 12:47:26 +01:00
page_size = None
if page_size in computed_values.PAGE_SIZES:
width_height = computed_values.PAGE_SIZES[page_size]
if orientation == 'portrait':
2011-12-26 12:47:26 +01:00
return width_height
else:
2011-12-26 12:47:26 +01:00
height, width = width_height
return width, height
@validator(proprietary=True)
@single_token
def anchor(token):
"""Validation for ``anchor``."""
if get_keyword(token) == 'none':
return 'none'
function = parse_function(token)
if function:
name, args = function
prototype = (name, [a.type for a in args])
args = [getattr(a, 'value', a) for a in args]
2017-03-26 11:42:50 +02:00
if prototype == ('attr', ['ident']):
return (name, args[0])
@validator(proprietary=True, wants_base_url=True)
2012-05-14 19:40:38 +02:00
@single_token
def link(token, base_url):
"""Validation for ``link``."""
2012-05-14 19:40:38 +02:00
if get_keyword(token) == 'none':
return 'none'
2017-03-26 11:42:50 +02:00
elif token.type == 'url':
if token.value.startswith('#'):
return 'internal', unquote(token.value[1:])
else:
return 'external', safe_urljoin(base_url, token.value)
2012-05-14 19:40:38 +02:00
function = parse_function(token)
if function:
name, args = function
prototype = (name, [a.type for a in args])
args = [getattr(a, 'value', a) for a in args]
2017-03-26 11:42:50 +02:00
if prototype == ('attr', ['ident']):
2012-05-14 19:40:38 +02:00
return (name, args[0])
2016-08-20 20:11:03 +02:00
@validator()
@single_token
def tab_size(token):
"""Validation for ``tab-size``.
See https://www.w3.org/TR/css-text-3/#tab-size
"""
2017-03-26 11:42:50 +02:00
if token.type == 'number' and token.int_value is not None:
value = token.int_value
2016-08-20 20:11:03 +02:00
if value >= 0:
return value
return get_length(token, negative=False)
@validator(unstable=True)
2012-12-08 22:50:08 +01:00
@single_token
def hyphens(token):
"""Validation for ``hyphens``."""
keyword = get_keyword(token)
if keyword in ('none', 'manual', 'auto'):
return keyword
2012-12-08 22:50:08 +01:00
@validator(unstable=True)
2013-03-02 02:45:48 +01:00
@single_token
def hyphenate_character(token):
"""Validation for ``hyphenate-character``."""
keyword = get_keyword(token)
if keyword == 'auto':
return ''
2017-03-26 11:42:50 +02:00
elif token.type == 'string':
2012-12-08 22:50:08 +01:00
return token.value
@validator(unstable=True)
2013-03-02 03:42:36 +01:00
@single_token
def hyphenate_limit_zone(token):
"""Validation for ``hyphenate-limit-zone``."""
return get_length(token, negative=False, percentage=True)
@validator(unstable=True)
2013-03-02 04:41:32 +01:00
def hyphenate_limit_chars(tokens):
"""Validation for ``hyphenate-limit-chars``."""
if len(tokens) == 1:
token, = tokens
keyword = get_keyword(token)
if keyword == 'auto':
return (5, 2, 2)
2017-03-26 11:42:50 +02:00
elif token.type == 'number' and token.int_value is not None:
return (token.int_value, 2, 2)
2013-03-02 04:41:32 +01:00
elif len(tokens) == 2:
total, left = tokens
total_keyword = get_keyword(total)
left_keyword = get_keyword(left)
2017-03-26 11:42:50 +02:00
if total.type == 'number' and total.int_value is not None:
if left.type == 'number' and left.int_value is not None:
return (total.int_value, left.int_value, left.int_value)
2013-03-02 04:41:32 +01:00
elif left_keyword == 'auto':
return (total.value, 2, 2)
elif total_keyword == 'auto':
2017-03-26 11:42:50 +02:00
if left.type == 'number' and left.int_value is not None:
return (5, left.int_value, left.int_value)
2013-03-02 04:41:32 +01:00
elif left_keyword == 'auto':
return (5, 2, 2)
elif len(tokens) == 3:
total, left, right = tokens
2013-04-11 12:08:53 +02:00
if (
2017-03-26 11:42:50 +02:00
(get_keyword(total) == 'auto' or
(total.type == 'number' and total.int_value is not None)) and
(get_keyword(left) == 'auto' or
(left.type == 'number' and left.int_value is not None)) and
(get_keyword(right) == 'auto' or
(right.type == 'number' and right.int_value is not None))
2013-04-11 12:08:53 +02:00
):
2017-03-26 11:42:50 +02:00
total = total.int_value if total.type == 'number' else 5
left = left.int_value if left.type == 'number' else 2
right = right.int_value if right.type == 'number' else 2
2013-03-02 04:41:32 +01:00
return (total, left, right)
@validator(proprietary=True)
@single_token
def lang(token):
"""Validation for ``lang``."""
if get_keyword(token) == 'none':
return 'none'
function = parse_function(token)
if function:
name, args = function
prototype = (name, [a.type for a in args])
args = [getattr(a, 'value', a) for a in args]
2017-03-26 11:42:50 +02:00
if prototype == ('attr', ['ident']):
return (name, args[0])
2017-03-26 11:42:50 +02:00
elif token.type == 'string':
return ('string', token.value)
@validator(unstable=True)
def bookmark_label(tokens):
"""Validation for ``bookmark-label``."""
parsed_tokens = tuple(validate_content_list_token(v) for v in tokens)
if None not in parsed_tokens:
return parsed_tokens
@validator(unstable=True)
@single_token
def bookmark_level(token):
"""Validation for ``bookmark-level``."""
2017-03-26 11:42:50 +02:00
if token.type == 'number' and token.int_value is not None:
value = token.int_value
if value >= 1:
return value
elif get_keyword(token) == 'none':
return 'none'
2012-05-14 19:40:38 +02:00
@validator(unstable=True)
@comma_separated_list
def string_set(tokens):
"""Validation for ``string-set``."""
if len(tokens) >= 2:
var_name = get_keyword(tokens[0])
parsed_tokens = tuple(
validate_content_list_token(v) for v in tokens[1:])
if None not in parsed_tokens:
return (var_name, parsed_tokens)
2015-04-30 10:58:51 +02:00
elif tokens and tokens[0].value == 'none':
return 'none'
def validate_content_list_token(token):
"""Validation for a single token of <content-list> used in GCPM.
Return (type, content) or False for invalid tokens.
"""
type_ = token.type
2017-03-26 11:42:50 +02:00
if type_ == 'string':
return ('STRING', token.value)
function = parse_function(token)
if function:
name, args = function
prototype = (name, tuple(a.type for a in args))
args = tuple(getattr(a, 'value', a) for a in args)
if prototype == ('attr', ('ident',)):
return (name, args[0])
elif prototype in (('content', ()), ('content', ('ident',))):
if not args:
return (name, 'text')
2018-01-23 00:45:28 +01:00
elif args[0] in ('text', 'after', 'before', 'first-letter'):
return (name, args[0])
elif prototype in (('counter', ('ident',)),
('counters', ('ident', 'string'))):
args += ('decimal',)
return (name, args)
elif prototype in (('counter', ('ident', 'ident')),
('counters', ('ident', 'string', 'ident'))):
style = args[-1]
if style in ('none', 'decimal') or style in counters.STYLES:
return (name, args)
@validator(unstable=True)
def transform(tokens):
if get_single_keyword(tokens) == 'none':
return ()
2012-02-08 15:44:03 +01:00
else:
return tuple(transform_function(v) for v in tokens)
2012-02-08 15:44:03 +01:00
def transform_function(token):
function = parse_function(token)
2012-02-08 15:44:03 +01:00
if not function:
raise InvalidValues
name, args = function
2012-02-08 15:44:03 +01:00
if len(args) == 1:
angle = get_angle(args[0])
length = get_length(args[0], percentage=True)
if name in ('rotate', 'skewx', 'skewy') and angle:
return name, angle
elif name in ('translatex', 'translate') and length:
return 'translate', (length, computed_values.ZERO_PIXELS)
elif name == 'translatey' and length:
return 'translate', (computed_values.ZERO_PIXELS, length)
2017-03-26 11:42:50 +02:00
elif name == 'scalex' and args[0].type == 'number':
2012-02-08 15:44:03 +01:00
return 'scale', (args[0].value, 1)
2017-03-26 11:42:50 +02:00
elif name == 'scaley' and args[0].type == 'number':
2012-02-08 15:44:03 +01:00
return 'scale', (1, args[0].value)
2017-03-26 11:42:50 +02:00
elif name == 'scale' and args[0].type == 'number':
return 'scale', (args[0].value,) * 2
2012-02-08 15:44:03 +01:00
elif len(args) == 2:
2017-03-26 11:42:50 +02:00
if name == 'scale' and all(a.type == 'number' for a in args):
return name, tuple(arg.value for arg in args)
2012-04-03 19:02:14 +02:00
lengths = tuple(get_length(token, percentage=True) for token in args)
if name == 'translate' and all(lengths):
return name, lengths
2012-02-08 15:44:03 +01:00
elif len(args) == 6 and name == 'matrix' and all(
2017-03-26 11:42:50 +02:00
a.type == 'number' for a in args):
return name, tuple(arg.value for arg in args)
2012-02-08 15:44:03 +01:00
raise InvalidValues
2011-08-23 17:35:10 +02:00
# Expanders
# Let's be consistent, always use ``name`` as an argument even
# when it is useless.
2011-08-23 17:35:10 +02:00
# pylint: disable=W0613
def expander(property_name):
2011-08-23 17:35:10 +02:00
"""Decorator adding a function to the ``EXPANDERS``."""
def expander_decorator(function):
"""Add ``function`` to the ``EXPANDERS``."""
assert property_name not in EXPANDERS, property_name
EXPANDERS[property_name] = function
return function
2011-08-23 17:35:10 +02:00
return expander_decorator
@expander('border-color')
@expander('border-style')
@expander('border-width')
@expander('margin')
@expander('padding')
@expander('bleed')
def expand_four_sides(base_url, name, tokens):
"""Expand properties setting a token for the four sides of a box."""
# Make sure we have 4 tokens
if len(tokens) == 1:
tokens *= 4
elif len(tokens) == 2:
tokens *= 2 # (bottom, left) defaults to (top, right)
elif len(tokens) == 3:
tokens += (tokens[1],) # left defaults to right
elif len(tokens) != 4:
2011-08-23 17:35:10 +02:00
raise InvalidValues(
'Expected 1 to 4 token components got %i' % len(tokens))
for suffix, token in zip(('-top', '-right', '-bottom', '-left'), tokens):
i = name.rfind('-')
if i == -1:
new_name = name + suffix
else:
# eg. border-color becomes border-*-color, not border-color-*
new_name = name[:i] + suffix + name[i:]
# validate_non_shorthand returns ((name, value),), we want
# to yield (name, value)
result, = validate_non_shorthand(
base_url, new_name, [token], required=True)
yield result
@expander('border-radius')
def border_radius(base_url, name, tokens):
"""Validator for the `border-radius` property."""
current = horizontal = []
vertical = []
for token in tokens:
2017-03-26 11:42:50 +02:00
if token.type == 'literal' and token.value == '/':
if current is horizontal:
if token == tokens[-1]:
raise InvalidValues('Expected value after "/" separator')
else:
current = vertical
else:
raise InvalidValues('Expected only one "/" separator')
else:
current.append(token)
if not vertical:
vertical = horizontal[:]
for values in horizontal, vertical:
# Make sure we have 4 tokens
if len(values) == 1:
values *= 4
elif len(values) == 2:
values *= 2 # (br, bl) defaults to (tl, tr)
elif len(values) == 3:
values.append(values[1]) # bl defaults to tr
elif len(values) != 4:
raise InvalidValues(
'Expected 1 to 4 token components got %i' % len(values))
corners = ('top-left', 'top-right', 'bottom-right', 'bottom-left')
for corner, tokens in zip(corners, zip(horizontal, vertical)):
new_name = 'border-%s-radius' % corner
# validate_non_shorthand returns [(name, value)], we want
# to yield (name, value)
result, = validate_non_shorthand(
base_url, new_name, tokens, required=True)
yield result
def generic_expander(*expanded_names, **kwargs):
2011-08-23 17:35:10 +02:00
"""Decorator helping expanders to handle ``inherit`` and ``initial``.
Wrap an expander so that it does not have to handle the 'inherit' and
'initial' cases, and can just yield name suffixes. Missing suffixes
get the initial value.
2011-08-23 17:35:10 +02:00
"""
wants_base_url = kwargs.pop('wants_base_url', False)
assert not kwargs
2013-04-11 12:08:53 +02:00
2011-08-23 17:35:10 +02:00
def generic_expander_decorator(wrapped):
"""Decorate the ``wrapped`` expander."""
@functools.wraps(wrapped)
def generic_expander_wrapper(base_url, name, tokens):
2011-08-23 17:35:10 +02:00
"""Wrap the expander."""
keyword = get_single_keyword(tokens)
if keyword in ('inherit', 'initial'):
results = dict.fromkeys(expanded_names, keyword)
skip_validation = True
else:
skip_validation = False
results = {}
if wants_base_url:
result = wrapped(name, tokens, base_url)
else:
result = wrapped(name, tokens)
for new_name, new_token in result:
assert new_name in expanded_names, new_name
if new_name in results:
raise InvalidValues(
'got multiple %s values in a %s shorthand'
% (new_name.strip('-'), name))
results[new_name] = new_token
for new_name in expanded_names:
if new_name.startswith('-'):
# new_name is a suffix
actual_new_name = name + new_name
else:
actual_new_name = new_name
if new_name in results:
value = results[new_name]
if not skip_validation:
# validate_non_shorthand returns ((name, value),)
(actual_new_name, value), = validate_non_shorthand(
base_url, actual_new_name, value, required=True)
else:
value = 'initial'
yield actual_new_name, value
2011-08-23 17:35:10 +02:00
return generic_expander_wrapper
return generic_expander_decorator
@expander('list-style')
@generic_expander('-type', '-position', '-image', wants_base_url=True)
def expand_list_style(name, tokens, base_url):
2011-08-23 17:35:10 +02:00
"""Expand the ``list-style`` shorthand property.
See http://www.w3.org/TR/CSS21/generate.html#propdef-list-style
"""
type_specified = image_specified = False
none_count = 0
for token in tokens:
if get_keyword(token) == 'none':
# Can be either -style or -image, see at the end which is not
# otherwise specified.
none_count += 1
none_token = token
continue
if list_style_type([token]) is not None:
suffix = '-type'
type_specified = True
elif list_style_position([token]) is not None:
suffix = '-position'
elif image_url([token], base_url) is not None:
suffix = '-image'
image_specified = True
else:
raise InvalidValues
yield suffix, [token]
if not type_specified and none_count:
yield '-type', [none_token]
none_count -= 1
if not image_specified and none_count:
yield '-image', [none_token]
none_count -= 1
if none_count:
# Too many none tokens.
raise InvalidValues
@expander('border')
def expand_border(base_url, name, tokens):
2011-08-23 17:35:10 +02:00
"""Expand the ``border`` shorthand property.
See http://www.w3.org/TR/CSS21/box.html#propdef-border
"""
for suffix in ('-top', '-right', '-bottom', '-left'):
for new_prop in expand_border_side(base_url, name + suffix, tokens):
yield new_prop
@expander('border-top')
@expander('border-right')
@expander('border-bottom')
@expander('border-left')
@expander('column-rule')
2012-08-03 16:21:47 +02:00
@expander('outline')
@generic_expander('-width', '-color', '-style')
def expand_border_side(name, tokens):
2011-08-23 17:35:10 +02:00
"""Expand the ``border-*`` shorthand properties.
See http://www.w3.org/TR/CSS21/box.html#propdef-border-top
"""
for token in tokens:
if parse_color(token) is not None:
suffix = '-color'
elif border_width([token]) is not None:
suffix = '-width'
elif border_style([token]) is not None:
suffix = '-style'
else:
raise InvalidValues
yield suffix, [token]
@expander('background')
def expand_background(base_url, name, tokens):
2011-08-23 17:35:10 +02:00
"""Expand the ``background`` shorthand property.
See http://dev.w3.org/csswg/css3-background/#the-background
"""
properties = [
'background_color', 'background_image', 'background_repeat',
'background_attachment', 'background_position', 'background_size',
'background_clip', 'background_origin']
keyword = get_single_keyword(tokens)
if keyword in ('initial', 'inherit'):
for name in properties:
yield name, keyword
return
def parse_layer(tokens, final_layer=False):
results = {}
2013-04-11 12:08:53 +02:00
def add(name, value):
if value is None:
return False
name = 'background_' + name
if name in results:
raise InvalidValues
results[name] = value
return True
# Make `tokens` a stack
tokens = tokens[::-1]
while tokens:
2013-04-11 12:08:53 +02:00
if add('repeat',
background_repeat.single_value(tokens[-2:][::-1])):
del tokens[-2:]
continue
token = tokens[-1:]
2015-04-30 10:58:51 +02:00
if final_layer and add('color', other_colors(token)):
tokens.pop()
continue
if add('image', background_image.single_value(token, base_url)):
tokens.pop()
continue
if add('repeat', background_repeat.single_value(token)):
tokens.pop()
continue
if add('attachment', background_attachment.single_value(token)):
tokens.pop()
continue
for n in (4, 3, 2, 1)[-len(tokens):]:
n_tokens = tokens[-n:][::-1]
position = background_position.single_value(n_tokens)
if position is not None:
assert add('position', position)
del tokens[-n:]
2017-03-26 11:42:50 +02:00
if (tokens and tokens[-1].type == 'literal' and
2015-04-30 10:58:51 +02:00
tokens[-1].value == '/'):
for n in (3, 2)[-len(tokens):]:
# n includes the '/' delimiter.
n_tokens = tokens[-n:-1][::-1]
size = background_size.single_value(n_tokens)
if size is not None:
assert add('size', size)
del tokens[-n:]
break
if position is not None:
continue
if add('origin', box.single_value(token)):
tokens.pop()
next_token = tokens[-1:]
if add('clip', box.single_value(next_token)):
tokens.pop()
else:
# The same keyword sets both:
assert add('clip', box.single_value(token))
continue
raise InvalidValues
color = results.pop(
'background_color', INITIAL_VALUES['background_color'])
for name in properties:
if name not in results:
results[name] = INITIAL_VALUES[name][0]
return color, results
layers = reversed(split_on_comma(tokens))
color, last_layer = parse_layer(next(layers), final_layer=True)
results = dict((k, [v]) for k, v in last_layer.items())
for tokens in layers:
_, layer = parse_layer(tokens)
for name, value in layer.items():
results[name].append(value)
for name, values in results.items():
yield name, values[::-1] # "Un-reverse"
yield 'background-color', color
2016-08-30 13:35:23 +02:00
@expander('page-break-after')
@expander('page-break-before')
def expand_page_break_before_after(base_url, name, tokens):
"""Expand legacy ``page-break-before`` and ``page-break-after`` properties.
See https://www.w3.org/TR/css-break-3/#page-break-properties
"""
keyword = get_single_keyword(tokens)
new_name = name.split('-', 1)[1]
if keyword in ('auto', 'left', 'right', 'avoid'):
yield new_name, keyword
elif keyword == 'always':
yield new_name, 'page'
@expander('page-break-inside')
def expand_page_break_inside(base_url, name, tokens):
"""Expand the legacy ``page-break-inside`` property.
See https://www.w3.org/TR/css-break-3/#page-break-properties
"""
keyword = get_single_keyword(tokens)
if keyword in ('auto', 'avoid'):
yield 'break-inside', keyword
@expander('columns')
@generic_expander('column-width', 'column-count')
def expand_columns(name, tokens):
"""Expand the ``columns`` shorthand property."""
2016-08-15 17:15:24 +02:00
name = None
if len(tokens) == 2 and get_keyword(tokens[0]) == 'auto':
tokens = tokens[::-1]
for token in tokens:
2016-08-15 17:15:24 +02:00
if column_width([token]) is not None and name != 'column-width':
name = 'column-width'
elif column_count([token]) is not None:
name = 'column-count'
else:
raise InvalidValues
yield name, [token]
2016-09-12 00:23:52 +02:00
class NoneFakeToken(object):
2017-03-26 11:42:50 +02:00
type = 'ident'
lower_value = 'none'
2016-09-12 00:23:52 +02:00
class NormalFakeToken(object):
2017-03-26 11:42:50 +02:00
type = 'ident'
lower_value = 'normal'
2016-09-12 00:23:52 +02:00
@expander('font-variant')
@generic_expander('-alternates', '-caps', '-east-asian', '-ligatures',
'-numeric', '-position')
def font_variant(name, tokens):
2016-09-12 00:23:52 +02:00
"""Expand the ``font-variant`` shorthand property.
https://www.w3.org/TR/css-fonts-3/#font-variant-prop
"""
return expand_font_variant(tokens)
def expand_font_variant(tokens):
2016-09-12 00:23:52 +02:00
keyword = get_single_keyword(tokens)
if keyword in ('normal', 'none'):
for suffix in (
'-alternates', '-caps', '-east-asian', '-numeric',
'-position'):
yield suffix, [NormalFakeToken]
token = NormalFakeToken if keyword == 'normal' else NoneFakeToken
yield '-ligatures', [token]
else:
features = {
'alternates': [],
'caps': [],
'east-asian': [],
'ligatures': [],
'numeric': [],
'position': []}
for token in tokens:
keyword = get_keyword(token)
if keyword == 'normal':
# We don't allow 'normal', only the specific values
raise InvalidValues
for feature in features:
function_name = 'font_variant_%s' % feature.replace('-', '_')
if globals()[function_name]([token]):
features[feature].append(token)
break
else:
raise InvalidValues
for feature, tokens in features.items():
if tokens:
yield '-%s' % feature, tokens
@expander('font')
2016-09-12 00:23:52 +02:00
@generic_expander('-style', '-variant-caps', '-weight', '-stretch', '-size',
'line-height', '-family') # line-height is not a suffix
def expand_font(name, tokens):
2011-08-23 17:35:10 +02:00
"""Expand the ``font`` shorthand property.
2016-09-12 00:23:52 +02:00
https://www.w3.org/TR/css-fonts-3/#font-prop
"""
expand_font_keyword = get_single_keyword(tokens)
2011-08-23 17:35:10 +02:00
if expand_font_keyword in ('caption', 'icon', 'menu', 'message-box',
'small-caption', 'status-bar'):
2012-04-03 19:02:14 +02:00
raise InvalidValues('System fonts are not supported')
# Make `tokens` a stack
tokens = list(reversed(tokens))
# Values for font-style font-variant and font-weight can come in any
# order and are all optional.
while tokens:
token = tokens.pop()
if get_keyword(token) == 'normal':
# Just ignore 'normal' keywords. Unspecified properties will get
# their initial token, which is 'normal' for all three here.
2012-04-03 19:02:14 +02:00
# TODO: fail if there is too many 'normal' values?
continue
if font_style([token]) is not None:
suffix = '-style'
2016-09-12 00:23:52 +02:00
elif get_keyword(token) in ('normal', 'small-caps'):
suffix = '-variant-caps'
elif font_weight([token]) is not None:
suffix = '-weight'
elif font_stretch([token]) is not None:
suffix = '-stretch'
else:
# Were done with these three, continue with font-size
break
yield suffix, [token]
# Then font-size is mandatory
# Latest `token` from the loop.
if font_size([token]) is None:
raise InvalidValues
yield '-size', [token]
# Then line-height is optional, but font-family is not so the list
# must not be empty yet
if not tokens:
2011-10-11 12:09:37 +02:00
raise InvalidValues
token = tokens.pop()
2017-03-26 11:42:50 +02:00
if token.type == 'literal' and token.value == '/':
token = tokens.pop()
if line_height([token]) is None:
raise InvalidValues
yield 'line-height', [token]
else:
# We pop()ed a font-family, add it back
tokens.append(token)
# Reverse the stack to get normal list
tokens.reverse()
if font_family(tokens) is None:
raise InvalidValues
yield '-family', tokens
2014-04-27 13:29:55 +02:00
@expander('word-wrap')
def expand_word_wrap(base_url, name, tokens):
"""Expand the ``word-wrap`` legacy property.
See http://http://www.w3.org/TR/css3-text/#overflow-wrap
"""
keyword = overflow_wrap(tokens)
if keyword is None:
raise InvalidValues
yield 'overflow-wrap', keyword
def validate_non_shorthand(base_url, name, tokens, required=False):
2011-08-23 17:35:10 +02:00
"""Default validator for non-shorthand properties."""
if not required and name not in KNOWN_PROPERTIES:
hyphens_name = name.replace('_', '-')
if hyphens_name in KNOWN_PROPERTIES:
raise InvalidValues('did you mean %s?' % hyphens_name)
else:
raise InvalidValues('unknown property')
if not required and name not in VALIDATORS:
raise InvalidValues('property not supported yet')
keyword = get_single_keyword(tokens)
if keyword in ('initial', 'inherit'):
value = keyword
else:
function = VALIDATORS[name]
if function.wants_base_url:
value = function(tokens, base_url)
else:
value = function(tokens)
if value is None:
raise InvalidValues
return ((name, value),)
2012-04-05 16:31:32 +02:00
def preprocess_declarations(base_url, declarations):
"""
Expand shorthand properties and filter unsupported properties and values.
2011-08-23 17:35:10 +02:00
Log a warning for every ignored declaration.
2011-08-23 17:35:10 +02:00
2017-03-26 11:42:50 +02:00
Return a iterable of ``(name, value, important)`` tuples.
"""
2012-04-05 16:31:32 +02:00
for declaration in declarations:
2017-03-26 11:42:50 +02:00
if declaration.type == 'error':
LOGGER.warning(
'Error: %s at %i:%i.',
declaration.message,
declaration.source_line, declaration.source_column)
if declaration.type != 'declaration':
continue
name = declaration.lower_name
2012-04-05 16:31:32 +02:00
2017-03-26 11:42:50 +02:00
def validation_error(level, reason):
getattr(LOGGER, level)(
'Ignored `%s:%s` at %i:%i, %s.',
declaration.name, tinycss2.serialize(declaration.value),
declaration.source_line, declaration.source_column, reason)
2012-04-05 16:31:32 +02:00
if name in NOT_PRINT_MEDIA:
2013-04-11 12:08:53 +02:00
validation_error(
'warning', 'the property does not apply for the print media')
2012-04-05 16:31:32 +02:00
continue
if name.startswith(PREFIX):
unprefixed_name = name[len(PREFIX):]
if unprefixed_name in PROPRIETARY:
name = unprefixed_name
elif unprefixed_name in UNSTABLE:
LOGGER.warning(
'Deprecated `%s:%s` at %i:%i, '
'prefixes on unstable attributes are deprecated, '
'use `%s` instead.',
declaration.name, tinycss2.serialize(declaration.value),
declaration.source_line, declaration.source_column,
unprefixed_name)
name = unprefixed_name
else:
LOGGER.warning(
'Ignored `%s:%s` at %i:%i, '
'prefix on this attribute is not supported, '
'use `%s` instead.',
declaration.name, tinycss2.serialize(declaration.value),
declaration.source_line, declaration.source_column,
unprefixed_name)
continue
2012-04-05 16:31:32 +02:00
2011-08-23 17:35:10 +02:00
expander_ = EXPANDERS.get(name, validate_non_shorthand)
2012-04-05 16:31:32 +02:00
tokens = remove_whitespace(declaration.value)
try:
2012-04-05 16:31:32 +02:00
# Use list() to consume generators now and catch any error.
result = list(expander_(base_url, name, tokens))
except InvalidValues as exc:
2013-04-11 12:08:53 +02:00
validation_error(
2013-08-19 14:40:51 +02:00
'warning',
2012-04-05 16:31:32 +02:00
exc.args[0] if exc.args and exc.args[0] else 'invalid value')
continue
2017-03-26 11:42:50 +02:00
important = declaration.important
2012-04-05 16:31:32 +02:00
for long_name, value in result:
2017-03-26 11:42:50 +02:00
yield long_name.replace('-', '_'), value, important
def remove_whitespace(tokens):
"""Remove any top-level whitespace in a token list."""
return tuple(
token for token in tokens
if token.type not in ('whitespace', 'comment'))
2017-03-26 11:42:50 +02:00
def split_on_comma(tokens):
"""Split a list of tokens on commas, ie ``LiteralToken(',')``.
Only "top-level" comma tokens are splitting points, not commas inside a
function or blocks.
"""
parts = []
this_part = []
for token in tokens:
if token.type == 'literal' and token.value == ',':
parts.append(this_part)
this_part = []
else:
this_part.append(token)
parts.append(this_part)
return tuple(parts)