"""Validate properties. See https://www.w3.org/TR/CSS21/propidx.html and various CSS3 modules. """ from math import inf from tinycss2 import parse_component_value_list from tinycss2.color3 import parse_color from .. import computed_values from ..properties import KNOWN_PROPERTIES, ZERO_PIXELS, Dimension from ..utils import ( # isort:skip InvalidValues, Pending, check_var_function, comma_separated_list, get_angle, get_content_list, get_content_list_token, get_custom_ident, get_image, get_keyword, get_length, get_resolution, get_single_keyword, get_url, parse_2d_position, parse_function, parse_position, remove_whitespace, single_keyword, single_token) PREFIX = '-weasy-' PROPRIETARY = set() UNSTABLE = set() # 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. # For properties that take a single value, that value is returned by itself # instead of a list. PROPERTIES = {} class PendingProperty(Pending): """Property with validation done when defining calculated values.""" def validate(self, tokens, wanted_key): return validate_non_shorthand(tokens, self.name)[0][1] # Validators def property(property_name=None, proprietary=False, unstable=False, wants_base_url=False): """Decorator adding a function to the ``PROPERTIES``. 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 stylesheet’s base URL as an additional parameter. """ def decorator(function): """Add ``function`` to the ``PROPERTIES``.""" if property_name is None: name = function.__name__.replace('_', '-') else: name = property_name assert name in KNOWN_PROPERTIES, name assert name not in PROPERTIES, name function.wants_base_url = wants_base_url PROPERTIES[name] = function if proprietary: PROPRIETARY.add(name) if unstable: UNSTABLE.add(name) return function return decorator def validate_non_shorthand(tokens, name, base_url=None, required=False): """Validator for non-shorthand properties.""" if name.startswith('--'): # TODO: validate content return ((name, tokens),) if not required and name not in KNOWN_PROPERTIES: raise InvalidValues('unknown property') if not required and name not in PROPERTIES: raise InvalidValues('property not supported yet') function = PROPERTIES[name] for token in tokens: if check_var_function(token): # Found CSS variable, return pending-substitution values. return ((name, PendingProperty(tokens, name)),) keyword = get_single_keyword(tokens) if keyword in ('initial', 'inherit'): value = keyword else: if function.wants_base_url: value = function(tokens, base_url) else: value = function(tokens) if value is None: raise InvalidValues return ((name, value),) @property() @comma_separated_list @single_keyword def background_attachment(keyword): """``background-attachment`` property validation.""" return keyword in ('scroll', 'fixed', 'local') @property('background-color') @property('border-top-color') @property('border-right-color') @property('border-bottom-color') @property('border-left-color') @property('column-rule-color', unstable=True) @property('text-decoration-color') @single_token def other_colors(token): return parse_color(token) @property() @single_token def outline_color(token): if get_keyword(token) == 'invert': return 'currentColor' else: return parse_color(token) @property() @single_keyword def border_collapse(keyword): return keyword in ('separate', 'collapse') @property() @single_keyword def empty_cells(keyword): """``empty-cells`` property validation.""" return keyword in ('show', 'hide') @property('color') @single_token def color(token): """``*-color`` and ``color`` properties validation.""" result = parse_color(token) if result == 'currentColor': return 'inherit' else: return result @property('background-image', wants_base_url=True) @comma_separated_list @single_token def background_image(token, base_url): if get_keyword(token) == 'none': return 'none', None return get_image(token, base_url) @property('list-style-image', wants_base_url=True) @single_token def list_style_image(token, base_url): """``list-style-image`` property validation.""" if get_keyword(token) == 'none': return 'none', None parsed_url = get_url(token, base_url) if parsed_url: if parsed_url[0] == 'url' and parsed_url[1][0] == 'external': return 'url', parsed_url[1][1] @property() def transform_origin(tokens): """``transform-origin`` property validation.""" if len(tokens) == 3: # Ignore third parameter as 3D transforms are ignored. tokens = tokens[:2] return parse_2d_position(tokens) @property() @comma_separated_list def background_position(tokens): """``background-position`` property validation.""" return parse_position(tokens) @property() @comma_separated_list def object_position(tokens): """``object-position`` property validation.""" return parse_position(tokens) @property() @comma_separated_list def background_repeat(tokens): """``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') if keywords in (('no-repeat',), ('repeat',), ('space',), ('round',)): return keywords * 2 if len(keywords) == 2 and all( k in ('no-repeat', 'repeat', 'space', 'round') for k in keywords): return keywords @property() @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') 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') if len(values) == 2: return tuple(values) @property('background-clip') @property('background-origin') @comma_separated_list @single_keyword def box(keyword): """Validation for the ```` type used in ``background-clip`` and ``background-origin``.""" return keyword in ('border-box', 'padding-box', 'content-box') @property() 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) @property('border-top-right-radius') @property('border-bottom-right-radius') @property('border-bottom-left-radius') @property('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) @property('border-top-style') @property('border-right-style') @property('border-left-style') @property('border-bottom-style') @property('column-rule-style', unstable=True) @single_keyword def border_style(keyword): """``border-*-style`` properties validation.""" return keyword in ('none', 'hidden', 'dotted', 'dashed', 'double', 'inset', 'outset', 'groove', 'ridge', 'solid') @property('break-before') @property('break-after') @single_keyword def break_before_after(keyword): """``break-before`` and ``break-after`` properties validation.""" return keyword in ('auto', 'avoid', 'avoid-page', 'page', 'left', 'right', 'recto', 'verso', 'avoid-column', 'column', 'always') @property() @single_keyword def break_inside(keyword): """``break-inside`` property validation.""" return keyword in ('auto', 'avoid', 'avoid-page', 'avoid-column') @property() @single_keyword def box_decoration_break(keyword): """``box-decoration-break`` property validation.""" return keyword in ('slice', 'clone') @property() @single_token def block_ellipsis(token): """``box-ellipsis`` property validation.""" if token.type == 'string': return ('string', token.value) else: keyword = get_keyword(token) if keyword in ('none', 'auto'): return keyword @property('continue', unstable=True) @single_keyword def continue_(keyword): """``continue`` property validation.""" return keyword in ('auto', 'discard') @property(unstable=True) @single_token def max_lines(token): if token.type == 'number' and token.int_value is not None: if token.int_value >= 1: return token.int_value keyword = get_keyword(token) if keyword == 'none': return keyword @property(unstable=True) @single_keyword def margin_break(keyword): """``margin-break`` property validation.""" return keyword in ('auto', 'keep', 'discard') @property(unstable=True) @single_token def page(token): """``page`` property validation.""" if token.type == 'ident': return 'auto' if token.lower_value == 'auto' else token.value @property('bleed-left', unstable=True) @property('bleed-right', unstable=True) @property('bleed-top', unstable=True) @property('bleed-bottom', unstable=True) @single_token def bleed(token): """``bleed`` property validation.""" keyword = get_keyword(token) if keyword == 'auto': return 'auto' else: return get_length(token) @property(unstable=True) def marks(tokens): """``marks`` property validation.""" if len(tokens) == 2: keywords = tuple(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 () @property('outline-style') @single_keyword def outline_style(keyword): """``outline-style`` properties validation.""" return keyword in ('none', 'dotted', 'dashed', 'double', 'inset', 'outset', 'groove', 'ridge', 'solid') @property('border-top-width') @property('border-right-width') @property('border-left-width') @property('border-bottom-width') @property('column-rule-width', unstable=True) @property('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 @property(wants_base_url=True) @single_token def border_image_source(token, base_url): if get_keyword(token) == 'none': return 'none', None return get_image(token, base_url) @property() def border_image_slice(tokens): values = [] fill = False for i, token in enumerate(tokens): # Don't use get_length() because a dimension with a unit is disallowed. if token.type == 'percentage' and token.value >= 0: values.append(Dimension(token.value, '%')) elif token.type == 'number' and token.value >= 0: values.append(Dimension(token.value, None)) elif get_keyword(token) == 'fill' and not fill and i in (0, len(tokens) - 1): fill = True values.append('fill') else: return if 1 <= len(values) - int(fill) <= 4: return tuple(values) @property() def border_image_width(tokens): values = [] for token in tokens: if get_keyword(token) == 'auto': values.append('auto') elif token.type == 'number' and token.value >= 0: values.append(Dimension(token.value, None)) else: if length := get_length(token, negative=False, percentage=True): values.append(length) else: return if 1 <= len(values) <= 4: return tuple(values) @property() def border_image_outset(tokens): values = [] for token in tokens: if token.type == 'number' and token.value >= 0: values.append(Dimension(token.value, None)) else: if length := get_length(token, negative=False): values.append(length) else: return if 1 <= len(values) <= 4: return tuple(values) @property() def border_image_repeat(tokens): if 1 <= len(tokens) <= 2: keywords = tuple(get_keyword(token) for token in tokens) if set(keywords) <= {'stretch', 'repeat', 'round', 'space'}: return keywords @property(unstable=True) @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 @property(unstable=True) @single_keyword def column_span(keyword): """``column-span`` property validation.""" return keyword in ('all', 'none') @property() @single_keyword def box_sizing(keyword): """Validation for the ``box-sizing`` property from css3-ui""" return keyword in ('padding-box', 'border-box', 'content-box') @property() @single_keyword def caption_side(keyword): """``caption-side`` properties validation.""" return keyword in ('top', 'bottom') @property() @single_keyword def clear(keyword): """``clear`` property validation.""" return keyword in ('left', 'right', 'both', 'none') @property() @single_token def clip(token): """Validation for the ``clip`` property.""" function = parse_function(token) if function: name, args = function if name == 'rect' and len(args) == 4: values = [] for arg in args: if get_keyword(arg) == 'auto': values.append('auto') else: length = get_length(arg) if length: values.append(length) if len(values) == 4: return tuple(values) if get_keyword(token) == 'auto': return () @property(wants_base_url=True) def content(tokens, base_url): """``content`` property validation.""" # See https://www.w3.org/TR/css-content-3/#content-property tokens = list(tokens) parsed_tokens = [] while tokens: if len(tokens) >= 2 and ( tokens[1].type == 'literal' and tokens[1].value == ','): token, tokens = tokens[0], tokens[2:] parsed_token = ( get_image(token, base_url) or get_url(token, base_url)) if parsed_token: parsed_tokens.append(parsed_token) else: return else: break if len(tokens) == 0: return if len(tokens) >= 3 and tokens[-1].type == 'string' and ( tokens[-2].type == 'literal' and tokens[-2].value == '/'): # Ignore text for speech tokens = tokens[:-2] keyword = get_single_keyword(tokens) if keyword in ('normal', 'none'): return (keyword,) return get_content_list(tokens, base_url) @property() def counter_increment(tokens): """``counter-increment`` property validation.""" return counter(tokens, default_integer=1) @property() def counter_reset(tokens): """``counter-reset`` property validation.""" return counter(tokens, default_integer=0) @property() def counter_set(tokens): """``counter-set`` 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) assert token, 'got an empty token list' results = [] while token is not None: if token.type != 'ident': return # expected a keyword here counter_name = token.value if counter_name in ('none', 'initial', 'inherit'): raise InvalidValues(f'Invalid counter name: {counter_name}') token = next(tokens, None) 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 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) @property('top') @property('right') @property('left') @property('bottom') @property('margin-top') @property('margin-right') @property('margin-bottom') @property('margin-left') @single_token def lenght_precentage_or_auto(token): """``margin-*`` properties validation.""" length = get_length(token, percentage=True) if length: return length if get_keyword(token) == 'auto': return 'auto' @property('height') @property('width') @single_token def width_height(token): """Validation for the ``width`` and ``height`` properties.""" length = get_length(token, negative=False, percentage=True) if length: return length if get_keyword(token) == 'auto': return 'auto' @property('column-gap', unstable=True) @property('row-gap', unstable=True) @single_token def gap(token): """Validation for the ``column-gap`` and ``row-gap`` properties.""" length = get_length(token, percentage=True, negative=False) if length: return length keyword = get_keyword(token) if keyword == 'normal': return keyword @property(unstable=True) @single_keyword def column_fill(keyword): """``column-fill`` property validation.""" return keyword in ('auto', 'balance') @property() @single_keyword def direction(keyword): """``direction`` property validation.""" return keyword in ('ltr', 'rtl') @property() def display(tokens): """``display`` property validation.""" for token in tokens: if token.type != 'ident': return if len(tokens) == 1: value = tokens[0].value if value in ( 'none', 'table-caption', 'table-row-group', 'table-cell', 'table-header-group', 'table-footer-group', 'table-row', 'table-column-group', 'table-column'): return (value,) elif value in ('inline-table', 'inline-flex', 'inline-grid'): return tuple(value.split('-')) elif value == 'inline-block': return ('inline', 'flow-root') outside = inside = list_item = None for token in tokens: value = token.value if value in ('block', 'inline'): if outside: return outside = value elif value in ('flow', 'flow-root', 'table', 'flex', 'grid'): if inside: return inside = value elif value == 'list-item': if list_item: return list_item = value else: return outside = outside or 'block' inside = inside or 'flow' if list_item: if inside in ('flow', 'flow-root'): return (outside, inside, list_item) else: return (outside, inside) @property('float') @single_keyword def float_(keyword): # XXX do not hide the "float" builtin """``float`` property validation.""" return keyword in ('left', 'right', 'footnote', 'none') @property() @comma_separated_list def font_family(tokens): """``font-family`` property validation.""" if len(tokens) == 1 and tokens[0].type == 'string': return tokens[0].value elif tokens and all(token.type == 'ident' for token in tokens): return ' '.join(token.value for token in tokens) @property() @single_keyword def font_kerning(keyword): return keyword in ('auto', 'normal', 'none') @property() @single_token def font_language_override(token): keyword = get_keyword(token) if keyword == 'normal': return keyword elif token.type == 'string': return token.value @property() 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: if token.type != 'ident': return None if token.value in all_values: concurrent_values = next( couple for couple in couples if token.value in couple) 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) @property() @single_keyword def font_variant_position(keyword): return keyword in ('normal', 'sub', 'super') @property() @single_keyword def font_variant_caps(keyword): return keyword in ( 'normal', 'small-caps', 'all-small-caps', 'petite-caps', 'all-petite-caps', 'unicase', 'titling-caps') @property() 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: if token.type != 'ident': return None if token.value in all_values: concurrent_values = next( couple for couple in couples if token.value in couple) 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) @property() def font_feature_settings(tokens): """``font-feature-settings`` property validation.""" if len(tokens) == 1 and get_keyword(tokens[0]) == 'normal': return 'normal' @comma_separated_list def font_feature_settings_list(tokens): feature, value = None, None if len(tokens) == 2: tokens, token = tokens[:-1], tokens[-1] if token.type == 'ident': value = {'on': 1, 'off': 0}.get(token.value) 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 if len(tokens) == 1: token, = tokens 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) @property() @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') @property() 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: if token.type != 'ident': return None if token.value in all_values: concurrent_values = next( couple for couple in couples if token.value in couple) 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) @property() def font_variation_settings(tokens): """``font-variation-settings`` property validation.""" if len(tokens) == 1 and get_keyword(tokens[0]) == 'normal': return 'normal' @comma_separated_list def font_variation_settings_list(tokens): if len(tokens) == 2: key, value = tokens if key.type == 'string' and value.type == 'number': return key.value, value.value return font_variation_settings_list(tokens) @property() @single_token def font_size(token): """``font-size`` property validation.""" length = get_length(token, negative=False, percentage=True) if length: return length font_size_keyword = get_keyword(token) if font_size_keyword in ('smaller', 'larger'): return font_size_keyword if font_size_keyword in computed_values.FONT_SIZE_KEYWORDS: return font_size_keyword @property() @single_keyword def font_style(keyword): """``font-style`` property validation.""" return keyword in ('normal', 'italic', 'oblique') @property() @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') @property() @single_token def font_weight(token): """``font-weight`` property validation.""" keyword = get_keyword(token) if keyword in ('normal', 'bold', 'bolder', 'lighter'): return keyword 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): return token.int_value @property() @single_keyword def object_fit(keyword): # TODO: Figure out what the spec means by "'scale-down' flag". # As of this writing, neither Firefox nor chrome support # anything other than a single keyword as is done here. return keyword in ('fill', 'contain', 'cover', 'none', 'scale-down') @property(unstable=True) @single_token def image_resolution(token): # TODO: support 'snap' and 'from-image' return get_resolution(token) @property('letter-spacing') @property('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 @property() @single_token def line_height(token): """``line-height`` property validation.""" if get_keyword(token) == 'normal': return 'normal' 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) @property() @single_keyword def list_style_position(keyword): """``list-style-position`` property validation.""" return keyword in ('inside', 'outside') @property() @single_token def list_style_type(token): """``list-style-type`` property validation.""" if token.type == 'ident': return token.value elif token.type == 'string': return ('string', token.value) elif token.type == 'function' and token.name == 'symbols': allowed_types = ( 'cyclic', 'numeric', 'alphabetic', 'symbolic', 'fixed') function_arguments = remove_whitespace(token.arguments) if len(function_arguments) >= 1: arguments = [] if function_arguments[0].type == 'ident': if function_arguments[0].value in allowed_types: index = 1 arguments.append(function_arguments[0].value) else: return else: arguments.append('symbolic') index = 0 if len(function_arguments) < index + 1: return for i in range(index, len(function_arguments)): if function_arguments[i].type != 'string': return arguments.append(function_arguments[i].value) if arguments[0] in ('alphabetic', 'numeric'): if len(arguments) < 3: return return ('symbols()', tuple(arguments)) @property('min-width') @property('min-height') @single_token def min_width_height(token): """``min-width`` and ``min-height`` properties validation.""" # See https://www.w3.org/TR/css-flexbox-1/#min-size-auto keyword = get_keyword(token) if keyword == 'auto': return keyword else: return length_or_precentage([token]) @property('padding-top') @property('padding-right') @property('padding-bottom') @property('padding-left') @single_token def length_or_precentage(token): """``padding-*`` properties validation.""" length = get_length(token, negative=False, percentage=True) if length: return length @property('max-width') @property('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(inf, 'px') @property() @single_token def opacity(token): """Validation for the ``opacity`` property.""" if token.type == 'number': return min(1, max(0, token.value)) if token.type == 'percentage': return min(1, max(0, token.value / 100)) @property() @single_token def z_index(token): """Validation for the ``z-index`` property.""" if get_keyword(token) == 'auto': return 'auto' if token.type == 'number' and token.int_value is not None: return token.int_value @property('orphans') @property('widows') @single_token def orphans_widows(token): """Validation for the ``orphans`` and ``widows`` properties.""" if token.type == 'number' and token.int_value is not None: value = token.int_value if value >= 1: return value @property(unstable=True) @single_token def column_count(token): """Validation for the ``column-count`` property.""" if token.type == 'number' and token.int_value is not None: value = token.int_value if value >= 1: return value if get_keyword(token) == 'auto': return 'auto' @property() @single_keyword def overflow(keyword): """Validation for the ``overflow`` property.""" return keyword in ('auto', 'visible', 'hidden', 'scroll') @property() @single_keyword def text_overflow(keyword): """Validation for the ``text-overflow`` property.""" return keyword in ('clip', 'ellipsis') @property() @single_token def position(token): """``position`` property validation.""" if token.type == 'function' and token.name == 'running': if len(token.arguments) == 1 and token.arguments[0].type == 'ident': return ('running()', token.arguments[0].value) keyword = get_single_keyword([token]) if keyword in ('static', 'relative', 'absolute', 'fixed'): return keyword @property() def quotes(tokens): """``quotes`` property validation.""" if len(tokens) == 1: if (keyword := get_keyword(tokens[0])) in ('auto', 'none'): return keyword if (tokens and len(tokens) % 2 == 0 and all(token.type == 'string' for token in tokens)): strings = tuple(token.value for token in tokens) # Separate open and close quotes. # eg. ('«', '»', '“', '”') -> (('«', '“'), ('»', '”')) return strings[::2], strings[1::2] @property() @single_keyword def table_layout(keyword): """Validation for the ``table-layout`` property""" if keyword in ('fixed', 'auto'): return keyword @property() @single_keyword def text_align_all(keyword): """``text-align-all`` property validation.""" return keyword in ('left', 'right', 'center', 'justify', 'start', 'end') @property() @single_keyword def text_align_last(keyword): """``text-align-last`` property validation.""" return keyword in ( 'auto', 'left', 'right', 'center', 'justify', 'start', 'end') @property() def text_decoration_line(tokens): """``text-decoration-line`` property validation.""" keywords = {get_keyword(token) for token in tokens} if keywords == {'none'}: return 'none' allowed_values = {'underline', 'overline', 'line-through', 'blink'} if len(tokens) == len(keywords) and keywords.issubset(allowed_values): return keywords @property() @single_keyword def text_decoration_style(keyword): """``text-decoration-style`` property validation.""" if keyword in ('solid', 'double', 'dotted', 'dashed', 'wavy'): return keyword @property() @single_token def text_indent(token): """``text-indent`` property validation.""" length = get_length(token, percentage=True) if length: return length @property() @single_keyword def text_transform(keyword): """``text-align`` property validation.""" return keyword in ( 'none', 'uppercase', 'lowercase', 'capitalize', 'full-width') @property() @single_token def vertical_align(token): """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 @property() @single_keyword def visibility(keyword): """``white-space`` property validation.""" return keyword in ('visible', 'hidden', 'collapse') @property() @single_keyword def white_space(keyword): """``white-space`` property validation.""" return keyword in ('normal', 'pre', 'nowrap', 'pre-wrap', 'pre-line') @property() @single_keyword def overflow_wrap(keyword): """``overflow-wrap`` property validation.""" return keyword in ('anywhere', 'normal', 'break-word') @property() @single_keyword def word_break(keyword): """``word-break`` property validation.""" return keyword in ('normal', 'break-all') @property() @single_token def flex_basis(token): """``flex-basis`` property validation.""" basis = width_height([token]) if basis is not None: return basis if get_keyword(token) == 'content': return 'content' @property() @single_keyword def flex_direction(keyword): """``flex-direction`` property validation.""" return keyword in ('row', 'row-reverse', 'column', 'column-reverse') @property('flex-grow') @property('flex-shrink') @single_token def flex_grow_shrink(token): if token.type == 'number': return token.value def _inflexible_breadth(token): """Parse ``inflexible-breadth``.""" keyword = get_keyword(token) if keyword in ('auto', 'min-content', 'max-content'): return keyword elif keyword: return length = get_length(token, negative=False, percentage=True) if length: return length def _track_breadth(token): """Parse ``track-breadth``.""" if token.type == 'dimension' and token.value >= 0 and token.unit == 'fr': return Dimension(token.value, token.unit) return _inflexible_breadth(token) def _track_size(token): """Parse ``track-size``.""" track_breadth = _track_breadth(token) if track_breadth: return track_breadth function = parse_function(token) if function: name, args = function if name == 'minmax': if len(args) == 2: inflexible_breadth = _inflexible_breadth(args[0]) track_breadth = _track_breadth(args[1]) if inflexible_breadth and track_breadth: return ('minmax()', inflexible_breadth, track_breadth) elif name == 'fit-content': if len(args) == 1: length = get_length(args[0], negative=False, percentage=True) if length: return ('fit-content()', length) def _fixed_size(token): """Parse ``fixed-size``.""" length = get_length(token, negative=False, percentage=True) if length: return length function = parse_function(token) if function: name, args = function if name == 'minmax' and len(args) == 2: length = get_length(args[0], negative=False, percentage=True) if length: track_breadth = _track_breadth(args[1]) if track_breadth: return ('minmax()', length, track_breadth) keyword = get_keyword(args[0]) if keyword in ('min-content', 'max-content', 'auto') or length: fixed_breadth = get_length( args[1], negative=False, percentage=True) if fixed_breadth: return ('minmax()', length or keyword, fixed_breadth) def _line_names(arg): """Parse ``line-names``.""" return_line_names = [] if arg.type == '[] block': for token in arg.content: if token.type == 'ident': return_line_names.append(token.value) elif token.type != 'whitespace': return return tuple(return_line_names) @property('grid-auto-columns') @property('grid-auto-rows') def grid_auto(tokens): """``grid-auto-columns`` and ``grid-auto-rows`` properties validation.""" return_tokens = [] for token in tokens: track_size = _track_size(token) if track_size: return_tokens.append(track_size) continue return return tuple(return_tokens) @property() def grid_auto_flow(tokens): """``grid-auto-flow`` property validation.""" if len(tokens) == 1: keyword = get_keyword(tokens[0]) if keyword in ('row', 'column'): return (keyword,) elif keyword == 'dense': return (keyword, 'row') elif len(tokens) == 2: keywords = [get_keyword(token) for token in tokens] if 'dense' in keywords and ('row' in keywords or 'column' in keywords): return tuple(keywords) @property('grid-template-columns') @property('grid-template-rows') def grid_template(tokens): """``grid-template-columns`` and ``grid-template-rows`` validation.""" return_tokens = [] if len(tokens) == 1 and get_keyword(tokens[0]) == 'none': return 'none' if get_keyword(tokens[0]) == 'subgrid': return_tokens.append('subgrid') subgrid_tokens = [] for token in tokens[1:]: line_names = _line_names(token) if line_names is not None: subgrid_tokens.append(line_names) continue function = parse_function(token) if function: name, args = function if name == 'repeat' and len(args) >= 2: if (args[0].type == 'number' and args[0].is_integer and args[0].value >= 1): number = args[0].int_value elif get_keyword(args[0]) == 'auto-fill': number = 'auto-fill' else: return line_names_list = [] for arg in args[1:]: line_names = _line_names(arg) if line_names is not None: line_names_list.append(line_names) subgrid_tokens.append( ('repeat()', number, tuple(line_names_list))) continue return return_tokens.append(tuple(subgrid_tokens)) else: includes_auto_repeat = False includes_track = False last_is_line_name = False for token in tokens: line_names = _line_names(token) if line_names is not None: if last_is_line_name: return last_is_line_name = True return_tokens.append(line_names) continue fixed_size = _fixed_size(token) if fixed_size: if not last_is_line_name: return_tokens.append(()) last_is_line_name = False return_tokens.append(fixed_size) continue track_size = _track_size(token) if track_size: if not last_is_line_name: return_tokens.append(()) last_is_line_name = False return_tokens.append(track_size) includes_track = True continue function = parse_function(token) if function: name, args = function if name == 'repeat' and len(args) >= 2: if (args[0].type == 'number' and args[0].is_integer and args[0].value >= 1): number = args[0].int_value elif get_keyword(args[0]) in ('auto-fill', 'auto-fit'): # auto-repeat if includes_auto_repeat: return number = args[0].value includes_auto_repeat = True else: return names_and_sizes = [] repeat_last_is_line_name = False for arg in args[1:]: line_names = _line_names(arg) if line_names is not None: if repeat_last_is_line_name: return names_and_sizes.append(line_names) repeat_last_is_line_name = True continue # fixed-repead fixed_size = _fixed_size(arg) if fixed_size: if not repeat_last_is_line_name: names_and_sizes.append(()) repeat_last_is_line_name = False names_and_sizes.append(fixed_size) continue # track-repeat track_size = _track_size(arg) if track_size: includes_track = True if not repeat_last_is_line_name: names_and_sizes.append(()) repeat_last_is_line_name = False names_and_sizes.append(track_size) continue return if not last_is_line_name: return_tokens.append(()) last_is_line_name = False if not repeat_last_is_line_name: names_and_sizes.append(()) return_tokens.append( ('repeat()', number, tuple(names_and_sizes))) continue return if includes_auto_repeat and includes_track: return if not last_is_line_name: return_tokens.append(()) return tuple(return_tokens) @property() def grid_template_areas(tokens): """``grid-template-areas`` property validation.""" if len(tokens) == 1 and get_keyword(tokens[0]) == 'none': return 'none' grid_areas = [] for token in tokens: if token.type != 'string': return component_values = parse_component_value_list(token.value) row = [] last_is_dot = False for value in component_values: if value.type == 'ident': row.append(value.value) last_is_dot = False elif value.type == 'literal' and value.value == '.': if last_is_dot: continue row.append(None) last_is_dot = True elif value.type == 'whitespace': last_is_dot = False else: return if not row: return grid_areas.append(tuple(row)) # check row / column have the same sizes if len(set(len(row) for row in grid_areas)) == 1: # check areas are continuous rectangles coordinates = set() areas = set() for y, row in enumerate(grid_areas): for x, area in enumerate(row): if (x, y) in coordinates or area is None: continue if area in areas: return areas.add(area) coordinates.add((x, y)) nx = x + 1 for nx, narea in enumerate(row[x+1:], start=x+1): if narea != area: break coordinates.add((nx, y)) for ny, nrow in enumerate(grid_areas[y+1:], start=y+1): if set(nrow[x:nx]) == {area}: for nnx in range(x, nx): coordinates.add((nnx, ny)) else: break return tuple(grid_areas) @property('grid-row-start') @property('grid-row-end') @property('grid-column-start') @property('grid-column-end') def grid_line(tokens): """``grid-[row|column]-[start—end]`` properties validation.""" if len(tokens) == 1: token = tokens[0] if keyword := get_keyword(token): if keyword == 'auto': return keyword elif keyword != 'span': return (None, None, keyword) elif token.type == 'number' and token.is_integer and token.value: return (None, token.int_value, None) return number = ident = span = None for token in tokens: if keyword := get_keyword(token): if keyword == 'auto': return if keyword == 'span': if span is None: span = 'span' continue elif keyword and ident is None: ident = keyword continue elif token.type == 'number' and token.is_integer and token.value: if number is None: number = token.int_value continue return if span: if number and number < 0: return elif ident or number: return (span, number, ident) elif number: return (span, number, ident) @property() @single_keyword def flex_wrap(keyword): """``flex-wrap`` property validation.""" return keyword in ('nowrap', 'wrap', 'wrap-reverse') @property() def justify_content(tokens): """``justify-content`` property validation.""" if len(tokens) == 1: keyword = get_keyword(tokens[0]) if keyword in ( 'center', 'space-between', 'space-around', 'space-evenly', 'stretch', 'normal', 'flex-start', 'flex-end', 'start', 'end', 'left', 'right'): return (keyword,) elif len(tokens) == 2: keywords = tuple(get_keyword(token) for token in tokens) if keywords[0] in ('safe', 'unsafe'): if keywords[1] in ( 'center', 'start', 'end', 'flex-start', 'flex-end', 'left', 'right'): return keywords @property() def justify_items(tokens): """``justify-items`` property validation.""" if len(tokens) == 1: keyword = get_keyword(tokens[0]) if keyword in ( 'normal', 'stretch', 'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end', 'left', 'right', 'legacy'): return (keyword,) elif keyword == 'baseline': return ('first', keyword) elif len(tokens) == 2: keywords = tuple(get_keyword(token) for token in tokens) if keywords[0] in ('safe', 'unsafe'): if keywords[1] in ( 'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end', 'left', 'right'): return keywords elif 'baseline' in keywords: if 'first' in keywords or 'last' in keywords: return keywords elif 'legacy' in keywords: if set(keywords) & {'left', 'right', 'center'}: return keywords @property() def justify_self(tokens): """``justify-self`` property validation.""" if len(tokens) == 1: keyword = get_keyword(tokens[0]) if keyword in ( 'auto', 'normal', 'stretch', 'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end', 'left', 'right'): return (keyword,) elif keyword == 'baseline': return ('first', keyword) elif len(tokens) == 2: keywords = tuple(get_keyword(token) for token in tokens) if keywords[0] in ('safe', 'unsafe'): if keywords[1] in ( 'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end', 'left', 'right'): return keywords elif 'baseline' in keywords: if 'first' in keywords or 'last' in keywords: return keywords @property() def align_items(tokens): """``align-items`` property validation.""" if len(tokens) == 1: keyword = get_keyword(tokens[0]) if keyword in ( 'normal', 'stretch', 'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end'): return (keyword,) elif keyword == 'baseline': return ('first', keyword) elif len(tokens) == 2: keywords = tuple(get_keyword(token) for token in tokens) if keywords[0] in ('safe', 'unsafe'): if keywords[1] in ( 'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end'): return keywords elif 'baseline' in keywords: if 'first' in keywords or 'last' in keywords: return keywords @property() def align_self(tokens): """``align-self`` property validation.""" if len(tokens) == 1: keyword = get_keyword(tokens[0]) if keyword in ( 'auto', 'normal', 'stretch', 'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end'): return (keyword,) elif keyword == 'baseline': return ('first', keyword) elif len(tokens) == 2: keywords = tuple(get_keyword(token) for token in tokens) if keywords[0] in ('safe', 'unsafe'): if keywords[1] in ( 'center', 'start', 'end', 'self-start', 'self-end', 'flex-start', 'flex-end'): return keywords elif 'baseline' in keywords: if 'first' in keywords or 'last' in keywords: return keywords @property() def align_content(tokens): """``align-content`` property validation.""" if len(tokens) == 1: keyword = get_keyword(tokens[0]) if keyword in ( 'center', 'space-between', 'space-around', 'space-evenly', 'stretch', 'normal', 'flex-start', 'flex-end', 'start', 'end'): return (keyword,) elif keyword == 'baseline': return ('first', keyword) elif len(tokens) == 2: keywords = tuple(get_keyword(token) for token in tokens) if keywords[0] in ('safe', 'unsafe'): if keywords[1] in ( 'center', 'start', 'end', 'flex-start', 'flex-end'): return keywords elif 'baseline' in keywords: if 'first' in keywords or 'last' in keywords: return keywords @property() @single_token def order(token): if token.type == 'number' and token.int_value is not None: return token.int_value @property(unstable=True) @single_keyword def image_rendering(keyword): """Validation for ``image-rendering``.""" return keyword in ('auto', 'crisp-edges', 'pixelated') @property(unstable=True) def image_orientation(tokens): """Validation for ``image-orientation``.""" keyword = get_single_keyword(tokens) if keyword in ('none', 'from-image'): return keyword angle, flip = None, None for token in tokens: keyword = get_keyword(token) if keyword == 'flip': if flip is not None: return flip = True continue if angle is None: angle = get_angle(token) if angle is not None: continue return angle = 0 if angle is None else angle flip = False if flip is None else flip return (angle, flip) @property(unstable=True) def size(tokens): """``size`` property validation. See https://www.w3.org/TR/css-page-3/#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(token) for token 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'): orientation, page_size = keywords elif keywords[1] in ('portrait', 'landscape'): page_size, orientation = keywords else: page_size = None if page_size in computed_values.PAGE_SIZES: width_height = computed_values.PAGE_SIZES[page_size] if orientation == 'portrait': return width_height else: height, width = width_height return width, height @property(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, [arg.type for arg in args]) if prototype == ('attr', ['ident']): return ('attr()', args[0].value) @property(proprietary=True, wants_base_url=True) @single_token def link(token, base_url): """Validation for ``link``.""" if get_keyword(token) == 'none': return 'none' parsed_url = get_url(token, base_url) if parsed_url: return parsed_url function = parse_function(token) if function: name, args = function prototype = (name, [arg.type for arg in args]) if prototype == ('attr', ['ident']): return ('attr()', args[0].value) @property() @single_token def tab_size(token): """Validation for ``tab-size``. See https://www.w3.org/TR/css-text-3/#tab-size """ if token.type == 'number' and token.int_value is not None: value = token.int_value if value >= 0: return value return get_length(token, negative=False) @property(unstable=True) @single_token def hyphens(token): """Validation for ``hyphens``.""" keyword = get_keyword(token) if keyword in ('none', 'manual', 'auto'): return keyword @property(unstable=True) @single_token def hyphenate_character(token): """Validation for ``hyphenate-character``.""" keyword = get_keyword(token) if keyword == 'auto': return '‐' elif token.type == 'string': return token.value @property(unstable=True) @single_token def hyphenate_limit_zone(token): """Validation for ``hyphenate-limit-zone``.""" return get_length(token, negative=False, percentage=True) @property(unstable=True) 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) elif token.type == 'number' and token.int_value is not None: return (token.int_value, 2, 2) elif len(tokens) == 2: total, left = tokens total_keyword = get_keyword(total) left_keyword = get_keyword(left) 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) elif left_keyword == 'auto': return (total.value, 2, 2) elif total_keyword == 'auto': if left.type == 'number' and left.int_value is not None: return (5, left.int_value, left.int_value) elif left_keyword == 'auto': return (5, 2, 2) elif len(tokens) == 3: total, left, right = tokens if ( (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)) ): 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 return (total, left, right) @property(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, [arg.type for arg in args]) if prototype == ('attr', ['ident']): return ('attr()', args[0].value) elif token.type == 'string': return ('string', token.value) @property(unstable=True, wants_base_url=True) def bookmark_label(tokens, base_url): """Validation for ``bookmark-label``.""" parsed_tokens = tuple( get_content_list_token(token, base_url) for token in tokens) if None not in parsed_tokens: return parsed_tokens @property(unstable=True) @single_token def bookmark_level(token): """Validation for ``bookmark-level``.""" 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' @property(unstable=True) @single_keyword def bookmark_state(keyword): """Validation for ``bookmark-state``.""" return keyword in ('open', 'closed') @property(unstable=True) @single_keyword def footnote_display(keyword): """Validation for ``footnote-display``.""" return keyword in ('block', 'inline', 'compact') @property(unstable=True) @single_keyword def footnote_policy(keyword): """Validation for ``footnote-policy``.""" return keyword in ('auto', 'line', 'block') @property(unstable=True, wants_base_url=True) @comma_separated_list def string_set(tokens, base_url): """Validation for ``string-set``.""" # Spec asks for strings after custom keywords, but we allow content-lists if len(tokens) >= 2: var_name = get_custom_ident(tokens[0]) if var_name is None: return parsed_tokens = tuple( get_content_list_token(token, base_url) for token in tokens[1:]) if None not in parsed_tokens: return (var_name, parsed_tokens) elif tokens and get_keyword(tokens[0]) == 'none': return 'none', () @property() def transform(tokens): """Validation for ``transform``.""" if get_single_keyword(tokens) == 'none': return () else: transforms = [] for token in tokens: function = parse_function(token) if not function: return name, args = function if len(args) == 1: angle = get_angle(args[0]) length = get_length(args[0], percentage=True) if name == 'rotate' and angle is not None: transforms.append((name, angle)) elif name in ('skewx', 'skew') and angle is not None: transforms.append(('skew', (angle, 0))) elif name == 'skewy' and angle is not None: transforms.append(('skew', (0, angle))) elif name in ('translatex', 'translate') and length: transforms.append(('translate', (length, ZERO_PIXELS))) elif name == 'translatey' and length: transforms.append(('translate', (ZERO_PIXELS, length))) elif name == 'scalex' and args[0].type == 'number': transforms.append(('scale', (args[0].value, 1))) elif name == 'scaley' and args[0].type == 'number': transforms.append(('scale', (1, args[0].value))) elif name == 'scale' and args[0].type == 'number': transforms.append(('scale', (args[0].value,) * 2)) else: return elif len(args) == 2: if name == 'scale' and all(a.type == 'number' for a in args): transforms.append((name, tuple(arg.value for arg in args))) elif name == 'translate': lengths = tuple( get_length(token, percentage=True) for token in args) if all(lengths): transforms.append((name, lengths)) else: return elif name == 'skew': angles = tuple(get_angle(token) for token in args) if all(angle is not None for angle in angles): transforms.append((name, angles)) else: return else: return elif len(args) == 6 and name == 'matrix' and all( a.type == 'number' for a in args): transforms.append((name, tuple(arg.value for arg in args))) else: return return tuple(transforms) @property() @single_token def appearance(token): """``appearance`` property validation.""" keyword = get_keyword(token) if keyword in ('none', 'auto'): return keyword