"""Validate properties expanders.""" import functools from tinycss2.ast import DimensionToken, IdentToken, NumberToken from tinycss2.color3 import parse_color from ..properties import INITIAL_VALUES from ..utils import ( InvalidValues, get_keyword, get_single_keyword, split_on_comma) from .descriptors import expand_font_variant from .properties import ( background_attachment, background_image, background_position, background_repeat, background_size, block_ellipsis, border_style, border_width, box, column_count, column_width, flex_basis, flex_direction, flex_grow_shrink, flex_wrap, font_family, font_size, font_stretch, font_style, font_weight, line_height, list_style_image, list_style_position, list_style_type, other_colors, overflow_wrap, validate_non_shorthand) EXPANDERS = {} def expander(property_name): """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 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: raise InvalidValues( f'Expected 1 to 4 token components got {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 def generic_expander(*expanded_names, **kwargs): """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. """ wants_base_url = kwargs.pop('wants_base_url', False) assert not kwargs def generic_expander_decorator(wrapped): """Decorate the ``wrapped`` expander.""" @functools.wraps(wrapped) def generic_expander_wrapper(base_url, name, tokens): """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( f'got multiple {new_name.strip("-")} values ' f'in a {name} shorthand') 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 return generic_expander_wrapper return generic_expander_decorator @expander('border-radius') @generic_expander( 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius', wants_base_url=True) def border_radius(name, tokens, base_url): """Validator for the ``border-radius`` property.""" current = horizontal = [] vertical = [] for token in tokens: 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( f'Expected 1 to 4 token components got {len(values)}') corners = ('top-left', 'top-right', 'bottom-right', 'bottom-left') for corner, tokens in zip(corners, zip(horizontal, vertical)): new_name = f'border-{corner}-radius' validate_non_shorthand(base_url, new_name, tokens, required=True) yield new_name, tokens @expander('list-style') @generic_expander('-type', '-position', '-image', wants_base_url=True) def expand_list_style(name, tokens, base_url): """Expand the ``list-style`` shorthand property. See https://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_image([token], base_url) is not None: suffix = '-image' image_specified = True elif list_style_position([token]) is not None: suffix = '-position' elif list_style_type([token]) is not None: suffix = '-type' type_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): """Expand the ``border`` shorthand property. See https://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') @expander('outline') @generic_expander('-width', '-color', '-style') def expand_border_side(name, tokens): """Expand the ``border-*`` shorthand properties. See https://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): """Expand the ``background`` shorthand property. See https://drafts.csswg.org/css-backgrounds-3/#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 = {} def add(name, value): if value is None: return False name = f'background_{name}' if name in results: raise InvalidValues results[name] = value return True # Make `tokens` a stack tokens = tokens[::-1] while tokens: if add('repeat', background_repeat.single_value(tokens[-2:][::-1])): del tokens[-2:] continue token = tokens[-1:] 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:] if (tokens and tokens[-1].type == 'literal' and 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 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 @expander('text-decoration') @generic_expander('-line', '-color', '-style') def expand_text_decoration(name, tokens): """Expand the ``text-decoration`` shorthand property.""" text_decoration_line = [] text_decoration_color = [] text_decoration_style = [] none_in_line = False for token in tokens: keyword = get_keyword(token) if keyword in ( 'none', 'underline', 'overline', 'line-through', 'blink'): text_decoration_line.append(token) if none_in_line: raise InvalidValues elif keyword == 'none': none_in_line = True elif keyword in ('solid', 'double', 'dotted', 'dashed', 'wavy'): if text_decoration_style: raise InvalidValues else: text_decoration_style.append(token) else: color = parse_color(token) if color is None: raise InvalidValues elif text_decoration_color: raise InvalidValues else: text_decoration_color.append(token) if text_decoration_line: yield '-line', text_decoration_line if text_decoration_color: yield '-color', text_decoration_color if text_decoration_style: yield '-style', text_decoration_style def expand_page_break_before_after(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, tokens elif keyword == 'always': token = IdentToken( tokens[0].source_line, tokens[0].source_column, 'page') yield new_name, [token] else: raise InvalidValues @expander('page-break-after') @generic_expander('break-after') def expand_page_break_after(name, tokens): """Expand legacy ``page-break-after`` property. See https://www.w3.org/TR/css-break-3/#page-break-properties """ return expand_page_break_before_after(name, tokens) @expander('page-break-before') @generic_expander('break-before') def expand_page_break_before(name, tokens): """Expand legacy ``page-break-before`` property. See https://www.w3.org/TR/css-break-3/#page-break-properties """ return expand_page_break_before_after(name, tokens) @expander('page-break-inside') @generic_expander('break-inside') def expand_page_break_inside(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', tokens else: raise InvalidValues @expander('columns') @generic_expander('column-width', 'column-count') def expand_columns(name, tokens): """Expand the ``columns`` shorthand property.""" name = None if len(tokens) == 2 and get_keyword(tokens[0]) == 'auto': tokens = tokens[::-1] for token in tokens: 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] if len(tokens) == 1: name = 'column-width' if name == 'column-count' else 'column-count' token = IdentToken( tokens[0].source_line, tokens[0].source_column, 'auto') yield name, [token] @expander('font-variant') @generic_expander('-alternates', '-caps', '-east-asian', '-ligatures', '-numeric', '-position') def font_variant(name, tokens): """Expand the ``font-variant`` shorthand property. https://www.w3.org/TR/css-fonts-3/#font-variant-prop """ return expand_font_variant(tokens) @expander('font') @generic_expander('-style', '-variant-caps', '-weight', '-stretch', '-size', 'line-height', '-family') # line-height is not a suffix def expand_font(name, tokens): """Expand the ``font`` shorthand property. https://www.w3.org/TR/css-fonts-3/#font-prop """ expand_font_keyword = get_single_keyword(tokens) if expand_font_keyword in ('caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar'): raise InvalidValues('System fonts are not supported') # Make `tokens` a stack tokens = list(reversed(tokens)) # Values for font-style, font-variant-caps, font-weight and font-stretch # can come in any order and are all optional. for _ in range(4): token = tokens.pop() if get_keyword(token) == 'normal': # Just ignore 'normal' keywords. Unspecified properties will get # their initial token, which is 'normal' for all four here. continue if font_style([token]) is not None: suffix = '-style' 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: # We’re done with these four, continue with font-size break yield suffix, [token] if not tokens: raise InvalidValues else: if not tokens: raise InvalidValues token = tokens.pop() # 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: raise InvalidValues token = tokens.pop() 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 @expander('word-wrap') @generic_expander('overflow-wrap') def expand_word_wrap(name, tokens): """Expand the ``word-wrap`` legacy property. See https://www.w3.org/TR/css-text-3/#overflow-wrap """ keyword = overflow_wrap(tokens) if keyword is None: raise InvalidValues yield 'overflow-wrap', tokens @expander('flex') @generic_expander('-grow', '-shrink', '-basis') def expand_flex(name, tokens): """Expand the ``flex`` property.""" keyword = get_single_keyword(tokens) if keyword == 'none': line, column = tokens[0].source_line, tokens[0].source_column zero_token = NumberToken(line, column, 0, 0, '0') auto_token = IdentToken(line, column, 'auto') yield '-grow', [zero_token] yield '-shrink', [zero_token] yield '-basis', [auto_token] else: grow, shrink, basis = 1, 1, None grow_found, shrink_found, basis_found = False, False, False for token in tokens: # "A unitless zero that is not already preceded by two flex factors # must be interpreted as a flex factor." forced_flex_factor = ( token.type == 'number' and token.int_value == 0 and not all((grow_found, shrink_found))) if not basis_found and not forced_flex_factor: new_basis = flex_basis([token]) if new_basis is not None: basis = token basis_found = True continue if not grow_found: new_grow = flex_grow_shrink([token]) if new_grow is None: raise InvalidValues else: grow = new_grow grow_found = True continue elif not shrink_found: new_shrink = flex_grow_shrink([token]) if new_shrink is None: raise InvalidValues else: shrink = new_shrink shrink_found = True continue else: raise InvalidValues line, column = tokens[0].source_line, tokens[0].source_column int_grow = int(grow) if float(grow).is_integer() else None int_shrink = int(shrink) if float(shrink).is_integer() else None grow_token = NumberToken(line, column, grow, int_grow, str(grow)) shrink_token = NumberToken( line, column, shrink, int_shrink, str(shrink)) if not basis_found: basis = DimensionToken(line, column, 0, 0, '0', 'px') yield '-grow', [grow_token] yield '-shrink', [shrink_token] yield '-basis', [basis] @expander('flex-flow') @generic_expander('flex-direction', 'flex-wrap') def expand_flex_flow(name, tokens): """Expand the ``flex-flow`` property.""" if len(tokens) == 2: for sorted_tokens in tokens, tokens[::-1]: direction = flex_direction([sorted_tokens[0]]) wrap = flex_wrap([sorted_tokens[1]]) if direction and wrap: yield 'flex-direction', [sorted_tokens[0]] yield 'flex-wrap', [sorted_tokens[1]] break else: raise InvalidValues elif len(tokens) == 1: direction = flex_direction([tokens[0]]) if direction: yield 'flex-direction', [tokens[0]] else: wrap = flex_wrap([tokens[0]]) if wrap: yield 'flex-wrap', [tokens[0]] else: raise InvalidValues else: raise InvalidValues @expander('line-clamp') @generic_expander('max-lines', 'continue', 'block-ellipsis') def expand_line_clamp(name, tokens): """Expand the ``line-clamp`` property.""" if len(tokens) == 1: keyword = get_single_keyword(tokens) if keyword == 'none': line, column = tokens[0].source_line, tokens[0].source_column none_token = IdentToken(line, column, 'none') auto_token = IdentToken(line, column, 'auto') yield 'max-lines', [none_token] yield 'continue', [auto_token] yield 'block-ellipsis', [none_token] elif tokens[0].type == 'number' and tokens[0].int_value is not None: line, column = tokens[0].source_line, tokens[0].source_column auto_token = IdentToken(line, column, 'auto') discard_token = IdentToken(line, column, 'discard') yield 'max-lines', [tokens[0]] yield 'continue', [discard_token] yield 'block-ellipsis', [auto_token] else: raise InvalidValues elif len(tokens) == 2: if tokens[0].type == 'number': max_lines = tokens[0].int_value ellipsis = block_ellipsis([tokens[1]]) if max_lines and ellipsis is not None: line, column = tokens[0].source_line, tokens[0].source_column discard_token = IdentToken(line, column, 'discard') yield 'max-lines', [tokens[0]] yield 'continue', [discard_token] yield 'block-ellipsis', [tokens[1]] else: raise InvalidValues else: raise InvalidValues else: raise InvalidValues @expander('text-align') @generic_expander('-all', '-last') def expand_text_align(name, tokens): """Expand the ``text-align`` property.""" if len(tokens) == 1: keyword = get_single_keyword(tokens) if keyword is None: raise InvalidValues if keyword == 'justify-all': line, column = tokens[0].source_line, tokens[0].source_column align_all = IdentToken(line, column, 'justify') else: align_all = tokens[0] yield '-all', [align_all] if keyword == 'justify': line, column = tokens[0].source_line, tokens[0].source_column align_last = IdentToken(line, column, 'start') else: align_last = align_all yield '-last', [align_last] else: raise InvalidValues