File: //home/arjun/projects/env/lib/python3.10/site-packages/weasyprint/css/validation/expanders.py
"""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