debian-weasyprint/weasyprint/css/validation/expanders.py

599 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
weasyprint.css.expanders
------------------------
Validate properties expanders.
:copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
import functools
from tinycss2.color3 import parse_color
from ..properties import INITIAL_VALUES, Dimension
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, 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(
'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:
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):
"""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(
'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
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):
"""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 list_style_image([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):
"""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')
@expander('outline')
@generic_expander('-width', '-color', '-style')
def expand_border_side(name, tokens):
"""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):
"""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 = {}
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:
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:
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
@expander('text-decoration')
def expand_text_decoration(base_url, name, tokens):
text_decoration_line = set()
text_decoration_color = None
text_decoration_style = None
for token in tokens:
keyword = get_keyword(token)
if keyword in (
'none', 'underline', 'overline', 'line-through', 'blink'):
text_decoration_line.add(keyword)
elif keyword in ('solid', 'double', 'dotted', 'dashed', 'wavy'):
if text_decoration_style is not None:
raise InvalidValues
else:
text_decoration_style = keyword
else:
color = parse_color(token)
if color is None:
raise InvalidValues
elif text_decoration_color is not None:
raise InvalidValues
else:
text_decoration_color = color
if 'none' in text_decoration_line:
if len(text_decoration_line) != 1:
raise InvalidValues
text_decoration_line = 'none'
elif not text_decoration_line:
text_decoration_line = 'none'
yield 'text_decoration_line', text_decoration_line
yield 'text_decoration_color', text_decoration_color or 'currentColor'
yield 'text_decoration_style', text_decoration_style or 'solid'
@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."""
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]
@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:
# Were done with these four, continue with font-size
break
yield suffix, [token]
if not tokens:
raise InvalidValues
else:
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')
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
@expander('flex')
def expand_flex(base_url, name, tokens):
"""Expand the ``flex`` property."""
keyword = get_single_keyword(tokens)
if keyword == 'none':
yield 'flex-grow', 0
yield 'flex-shrink', 0
yield 'flex-basis', 'auto'
else:
grow, shrink, basis = 1, 1, Dimension(0, 'px')
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 = new_basis
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
yield 'flex-grow', grow
yield 'flex-shrink', shrink
yield 'flex-basis', basis
@expander('flex-flow')
def expand_flex_flow(base_url, 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', direction
yield 'flex-wrap', wrap
break
else:
raise InvalidValues
elif len(tokens) == 1:
direction = flex_direction([tokens[0]])
if direction:
yield 'flex-direction', direction
else:
wrap = flex_wrap([tokens[0]])
if wrap:
yield 'flex-wrap', wrap
else:
raise InvalidValues
else:
raise InvalidValues