diff --git a/tests/test_variables.py b/tests/test_variables.py index a10471bd0..a335cac29 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -3,7 +3,7 @@ import pytest from weasyprint.css.properties import KNOWN_PROPERTIES -from .testing_utils import assert_no_logs, render_pages +from .testing_utils import assert_no_logs, capture_logs, render_pages SIDES = ('top', 'right', 'bottom', 'left') @@ -106,7 +106,7 @@ def test_variable_chain_root_missing(): @assert_no_logs -def test_variable_partial_1(): +def test_variable_shorthand_margin(): page, = render_pages(''' +
+ ''') + html, = page.children + body, = html.children + div, = body.children + assert div.margin_top == 20 + assert div.margin_right == 0 + assert div.margin_bottom == 0 + assert div.margin_left == 10 + + +@assert_no_logs +def test_variable_shorthand_margin_invalid(): + with capture_logs() as logs: + page, = render_pages(''' + + + ''') + log, = logs + assert 'invalid value' in log + html, = page.children + body, = html.children + div, = body.children + assert div.margin_top == 0 + assert div.margin_right == 0 + assert div.margin_bottom == 0 + assert div.margin_left == 0 + + +@assert_no_logs +def test_variable_shorthand_border(): + page, = render_pages(''' + + + ''') + html, = page.children + body, = html.children + div, = body.children + assert div.border_top_width == 1 + assert div.border_right_width == 1 + assert div.border_bottom_width == 1 + assert div.border_left_width == 1 + + +@assert_no_logs +def test_variable_shorthand_border_side(): + page, = render_pages(''' + + + ''') + html, = page.children + body, = html.children + div, = body.children + assert div.border_top_width == 1 + assert div.border_right_width == 0 + assert div.border_bottom_width == 0 + assert div.border_left_width == 0 + + +@assert_no_logs +def test_variable_shorthand_border_mixed(): + page, = render_pages(''' + + + ''') + html, = page.children + body, = html.children + div, = body.children + assert div.border_top_width == 1 + assert div.border_right_width == 1 + assert div.border_bottom_width == 1 + assert div.border_left_width == 1 + + +def test_variable_shorthand_border_mixed_invalid(): + with capture_logs() as logs: + page, = render_pages(''' + + + ''') + # TODO: we should only get one warning here + assert 'multiple color values' in logs[0] + html, = page.children + body, = html.children + div, = body.children + assert div.border_top_width == 0 + assert div.border_right_width == 0 + assert div.border_bottom_width == 0 + assert div.border_left_width == 0 + + +@assert_no_logs +@pytest.mark.parametrize('var, background', ( + ('blue', 'var(--v)'), + ('padding-box url(pattern.png)', 'var(--v)'), + ('padding-box url(pattern.png)', 'white var(--v) center'), + ('100%', 'url(pattern.png) var(--v) var(--v) / var(--v) var(--v)'), + ('left / 100%', 'url(pattern.png) top var(--v) 100%'), +)) +def test_variable_shorthand_background(var, background): + page, = render_pages(''' + + + ''' % (var, background)) + + +@pytest.mark.parametrize('var, background', ( + ('invalid', 'var(--v)'), + ('blue', 'var(--v) var(--v)'), + ('100%', 'url(pattern.png) var(--v) var(--v) var(--v)'), +)) +def test_variable_shorthand_background_invalid(var, background): + with capture_logs() as logs: + page, = render_pages(''' + + + ''' % (var, background)) + log, = logs + assert 'invalid value' in log + + @assert_no_logs def test_variable_initial(): page, = render_pages(''' diff --git a/weasyprint/css/__init__.py b/weasyprint/css/__init__.py index 94b852af5..8ada03e6c 100644 --- a/weasyprint/css/__init__.py +++ b/weasyprint/css/__init__.py @@ -26,11 +26,11 @@ from .computed_values import ( COMPUTER_FUNCTIONS, ZERO_PIXELS, compute_var, resolve_var) from .properties import INHERITED, INITIAL_NOT_COMPUTED, INITIAL_VALUES -from .utils import check_var_function, get_url, remove_whitespace +from .utils import ( + InvalidValues, check_var_function, get_url, remove_whitespace) from .validation import preprocess_declarations from .validation.descriptors import preprocess_descriptors from .validation.expanders import PendingExpander -from .validation.properties import validate_non_shorthand # Reject anything not in here: PSEUDO_ELEMENTS = ( @@ -732,6 +732,8 @@ def __missing__(self, key): return self[key] if isinstance(value, PendingExpander): + # Expander with pending values, validate them. + computed = False solved_tokens = [] for token in value.tokens: variable = check_var_function(token) @@ -743,8 +745,19 @@ def __missing__(self, key): else: solved_tokens.append(token) original_key = key.replace('_', '-') - solved = value.solve(solved_tokens, original_key) - value = validate_non_shorthand(None, original_key, solved)[0][1] + try: + value = value.solve(solved_tokens, original_key) + except InvalidValues: + if key in INHERITED: + # Values in parent_style are already computed. + self[key] = value = parent_style[key] + return value + else: + value = INITIAL_VALUES[key] + if key not in INITIAL_NOT_COMPUTED: + # The value is the same as when computed. + self[key] = value + return value if not computed and key in COMPUTER_FUNCTIONS: # Value not computed yet: compute. diff --git a/weasyprint/css/validation/__init__.py b/weasyprint/css/validation/__init__.py index cd860a96e..6da52769e 100644 --- a/weasyprint/css/validation/__init__.py +++ b/weasyprint/css/validation/__init__.py @@ -115,7 +115,7 @@ def validation_error(level, reason): tokens = remove_whitespace(declaration.value) try: # Use list() to consume generators now and catch any error. - result = list(expander(base_url, name, tokens)) + result = list(expander(tokens, name, base_url)) except InvalidValues as exc: validation_error( 'warning', diff --git a/weasyprint/css/validation/descriptors.py b/weasyprint/css/validation/descriptors.py index 096231d54..138f6c3b3 100644 --- a/weasyprint/css/validation/descriptors.py +++ b/weasyprint/css/validation/descriptors.py @@ -198,7 +198,7 @@ def font_variant(tokens): for name, sub_tokens in expand_font_variant(tokens): try: values.append(properties.validate_non_shorthand( - None, f'font-variant{name}', sub_tokens, required=True)) + sub_tokens, f'font-variant{name}', None, required=True)) except InvalidValues: return None return values diff --git a/weasyprint/css/validation/expanders.py b/weasyprint/css/validation/expanders.py index 4a14d955a..d5022fc73 100644 --- a/weasyprint/css/validation/expanders.py +++ b/weasyprint/css/validation/expanders.py @@ -5,6 +5,7 @@ from tinycss2.ast import DimensionToken, IdentToken, NumberToken from tinycss2.color3 import parse_color +from ...logger import LOGGER from ..properties import INITIAL_VALUES from ..utils import ( InvalidValues, check_var_function, get_keyword, get_single_keyword, @@ -23,19 +24,49 @@ class PendingExpander: + """Expander with validation done when defining calculated values.""" + # See https://drafts.csswg.org/css-variables-2/#variables-in-shorthands. def __init__(self, tokens, expander): self.tokens = tokens self.expander = expander + self._reported_error = False def solve(self, tokens, wanted_key): - for key, value in self.expander(tokens=tokens): - if key.startswith('-'): - key = f'{self.expander.keywords["name"]}{key}' - if key == wanted_key: - return value + """Get validated value for wanted key.""" + try: + for key, value in self.expander(tokens): + if key.startswith('-'): + key = f'{self.expander.keywords["name"]}{key}' + if key == wanted_key: + return value + except InvalidValues as exc: + if self._reported_error: + raise exc + source_line = tokens[0].source_line + source_column = tokens[0].source_column + prop = self.expander.keywords["name"] + value = ' '.join(token.serialize() for token in tokens) + if exc.args and exc.args[0]: + message = exc.args[0] + else: + message = 'invalid value' + LOGGER.warning( + 'Ignored `%s: %s` at %d:%d, %s.', + prop, value, source_line, source_column, message) + self._reported_error = True + raise exc raise KeyError +def _find_var(tokens, expander, expanded_names): + """Return pending expanders when var is found in tokens.""" + for token in tokens: + if check_var_function(token): + # Found CSS variable, keep pending-substitution values. + pending = PendingExpander(tokens, expander) + return {name: pending for name in expanded_names} + + def expander(property_name): """Decorator adding a function to the ``EXPANDERS``.""" def expander_decorator(function): @@ -60,29 +91,27 @@ def generic_expander(*expanded_names, **kwargs): def generic_expander_decorator(wrapped): """Decorate the ``wrapped`` expander.""" @functools.wraps(wrapped) - def generic_expander_wrapper(base_url, name, tokens): + def generic_expander_wrapper(tokens, name, base_url): """Wrap the expander.""" - expander = functools.partial(wrapped, name=name) - if wants_base_url: - expander = functools.partial(expander, base_url=base_url) + expander = functools.partial( + generic_expander_wrapper, name=name, base_url=base_url) skip_validation = False keyword = get_single_keyword(tokens) if keyword in ('inherit', 'initial'): - results = dict.fromkeys(expanded_names, keyword) + results = {name: keyword for name in expanded_names} skip_validation = True else: - for token in tokens: - if check_var_function(token): - # Found CSS variable, keep pending-substitution values. - pending = PendingExpander(tokens, expander) - results = dict.fromkeys(expanded_names, pending) - skip_validation = True - break + results = _find_var(tokens, expander, expanded_names) + if results: + skip_validation = True if not skip_validation: results = {} - result = expander(tokens=tokens) + if wants_base_url: + result = wrapped(tokens, name, base_url) + else: + result = wrapped(tokens, name) for new_name, new_token in result: assert new_name in expanded_names, new_name if new_name in results: @@ -94,7 +123,7 @@ def generic_expander_wrapper(base_url, name, tokens): for new_name in expanded_names: if new_name.startswith('-'): # new_name is a suffix - actual_new_name = name + new_name + actual_new_name = f'{name}{new_name}' else: actual_new_name = new_name @@ -103,7 +132,7 @@ def generic_expander_wrapper(base_url, name, tokens): 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) + value, actual_new_name, base_url, required=True) else: value = 'initial' @@ -118,9 +147,25 @@ def generic_expander_wrapper(base_url, name, tokens): @expander('margin') @expander('padding') @expander('bleed') -def expand_four_sides(base_url, name, tokens): +def expand_four_sides(tokens, name, base_url): """Expand properties setting a token for the four sides of a box.""" - # Make sure we have 4 tokens + # Define expanded names. + expanded_names = [] + for suffix in ('-top', '-right', '-bottom', '-left'): + if (i := name.rfind('-')) == -1: + expanded_names.append(f'{name}{suffix}') + else: + # eg. border-color becomes border-*-color, not border-color-* + expanded_names.append(f'{name[:i]}{suffix}{name[i:]}') + + # Return pending expanders if var is found. + expander = functools.partial( + expand_four_sides, name=name, base_url=base_url) + if result := _find_var(tokens, expander, expanded_names): + yield from result.items() + return + + # Make sure we have 4 tokens. if len(tokens) == 1: tokens *= 4 elif len(tokens) == 2: @@ -130,18 +175,11 @@ def expand_four_sides(base_url, name, tokens): 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:] - + for expanded_name, token in zip(expanded_names, tokens): # validate_non_shorthand returns ((name, value),), we want - # to yield (name, value) + # to yield (name, value). result, = validate_non_shorthand( - base_url, new_name, [token], required=True) + [token], expanded_name, base_url, required=True) yield result @@ -150,7 +188,7 @@ def expand_four_sides(base_url, name, tokens): '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): +def border_radius(tokens, name, base_url): """Validator for the ``border-radius`` property.""" current = horizontal = [] vertical = [] @@ -183,13 +221,13 @@ def border_radius(name, tokens, base_url): 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) + validate_non_shorthand(tokens, new_name, base_url, 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): +def expand_list_style(tokens, name, base_url): """Expand the ``list-style`` shorthand property. See https://www.w3.org/TR/CSS21/generate.html#propdef-list-style @@ -231,15 +269,14 @@ def expand_list_style(name, tokens, base_url): @expander('border') -def expand_border(base_url, name, tokens): +def expand_border(tokens, name, base_url): """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 + yield from expand_border_side(tokens, f'{name}{suffix}', base_url) @expander('border-top') @@ -249,7 +286,7 @@ def expand_border(base_url, name, tokens): @expander('column-rule') @expander('outline') @generic_expander('-width', '-color', '-style') -def expand_border_side(name, tokens): +def expand_border_side(tokens, name): """Expand the ``border-*`` shorthand properties. See https://www.w3.org/TR/CSS21/box.html#propdef-border-top @@ -268,29 +305,35 @@ def expand_border_side(name, tokens): @expander('background') -def expand_background(base_url, name, tokens): +def expand_background(tokens, name, base_url): """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'] + expanded_names = ( + '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: + for name in expanded_names: yield name, keyword return + expander = functools.partial( + expand_background, name=name, base_url=base_url) + if result := _find_var(tokens, expander, expanded_names): + yield from result.items() + return + def parse_layer(tokens, final_layer=False): results = {} def add(name, value): if value is None: return False - name = f'background_{name}' + name = f'background-{name}' if name in results: raise InvalidValues results[name] = value @@ -346,15 +389,15 @@ def add(name, value): 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] + 'background-color', INITIAL_VALUES['background_color']) + for name in expanded_names: + if name not in results and name != 'background-color': + results[name] = INITIAL_VALUES[name.replace('-', '_')][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()) + results = {key: [value] for key, value in last_layer.items()} for tokens in layers: _, layer = parse_layer(tokens) for name, value in layer.items(): @@ -366,7 +409,7 @@ def add(name, value): @expander('text-decoration') @generic_expander('-line', '-color', '-style') -def expand_text_decoration(name, tokens): +def expand_text_decoration(tokens, name): """Expand the ``text-decoration`` shorthand property.""" text_decoration_line = [] text_decoration_color = [] @@ -404,7 +447,7 @@ def expand_text_decoration(name, tokens): yield '-style', text_decoration_style -def expand_page_break_before_after(name, tokens): +def expand_page_break_before_after(tokens, name): """Expand legacy ``page-break-before`` and ``page-break-after`` properties. See https://www.w3.org/TR/css-break-3/#page-break-properties @@ -424,29 +467,29 @@ def expand_page_break_before_after(name, tokens): @expander('page-break-after') @generic_expander('break-after') -def expand_page_break_after(name, tokens): +def expand_page_break_after(tokens, name): """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) + return expand_page_break_before_after(tokens, name) @expander('page-break-before') @generic_expander('break-before') -def expand_page_break_before(name, tokens): +def expand_page_break_before(tokens, name): """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) + return expand_page_break_before_after(tokens, name) @expander('page-break-inside') @generic_expander('break-inside') -def expand_page_break_inside(name, tokens): +def expand_page_break_inside(tokens, name): """Expand the legacy ``page-break-inside`` property. See https://www.w3.org/TR/css-break-3/#page-break-properties @@ -461,7 +504,7 @@ def expand_page_break_inside(name, tokens): @expander('columns') @generic_expander('column-width', 'column-count') -def expand_columns(name, tokens): +def expand_columns(tokens, name): """Expand the ``columns`` shorthand property.""" name = None if len(tokens) == 2 and get_keyword(tokens[0]) == 'auto': @@ -484,7 +527,7 @@ def expand_columns(name, tokens): @expander('font-variant') @generic_expander('-alternates', '-caps', '-east-asian', '-ligatures', '-numeric', '-position') -def font_variant(name, tokens): +def font_variant(tokens, name): """Expand the ``font-variant`` shorthand property. https://www.w3.org/TR/css-fonts-3/#font-variant-prop @@ -496,7 +539,7 @@ def font_variant(name, 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): +def expand_font(tokens, name): """Expand the ``font`` shorthand property. https://www.w3.org/TR/css-fonts-3/#font-prop @@ -568,7 +611,7 @@ def expand_font(name, tokens): @expander('word-wrap') @generic_expander('overflow-wrap') -def expand_word_wrap(name, tokens): +def expand_word_wrap(tokens, name): """Expand the ``word-wrap`` legacy property. See https://www.w3.org/TR/css-text-3/#overflow-wrap @@ -582,7 +625,7 @@ def expand_word_wrap(name, tokens): @expander('flex') @generic_expander('-grow', '-shrink', '-basis') -def expand_flex(name, tokens): +def expand_flex(tokens, name): """Expand the ``flex`` property.""" keyword = get_single_keyword(tokens) if keyword == 'none': @@ -640,7 +683,7 @@ def expand_flex(name, tokens): @expander('flex-flow') @generic_expander('flex-direction', 'flex-wrap') -def expand_flex_flow(name, tokens): +def expand_flex_flow(tokens, name): """Expand the ``flex-flow`` property.""" if len(tokens) == 2: for sorted_tokens in tokens, tokens[::-1]: @@ -668,7 +711,7 @@ def expand_flex_flow(name, tokens): @expander('line-clamp') @generic_expander('max-lines', 'continue', 'block-ellipsis') -def expand_line_clamp(name, tokens): +def expand_line_clamp(tokens, name): """Expand the ``line-clamp`` property.""" if len(tokens) == 1: keyword = get_single_keyword(tokens) @@ -708,7 +751,7 @@ def expand_line_clamp(name, tokens): @expander('text-align') @generic_expander('-all', '-last') -def expand_text_align(name, tokens): +def expand_text_align(tokens, name): """Expand the ``text-align`` property.""" if len(tokens) == 1: keyword = get_single_keyword(tokens) diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py index 6cdfcb003..8392b92da 100644 --- a/weasyprint/css/validation/properties.py +++ b/weasyprint/css/validation/properties.py @@ -75,7 +75,7 @@ def decorator(function): return decorator -def validate_non_shorthand(base_url, name, tokens, required=False): +def validate_non_shorthand(tokens, name, base_url, required=False): """Validator for non-shorthand properties.""" if name.startswith('--'): # TODO: validate content