diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13c163c91..47b815ff0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,10 +8,10 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.11'] + python-version: ['3.12'] include: - os: ubuntu-latest - python-version: '3.7' + python-version: '3.8' - os: ubuntu-latest python-version: 'pypy-3.8' steps: diff --git a/README.rst b/README.rst index e6e8dffe7..aae4a147b 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ WebKit or Gecko. The CSS layout engine is written in Python, designed for pagination, and meant to be easy to hack on. * Free software: BSD license -* For Python 3.7+, tested on CPython and PyPy +* For Python 3.8+, tested on CPython and PyPy * Documentation: https://doc.courtbouillon.org/weasyprint * Examples: https://weasyprint.org/#samples * Changelog: https://github.com/Kozea/WeasyPrint/releases diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 377c21566..65fe2e63a 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -715,11 +715,9 @@ The `CSS Custom Properties for Cascading Variables Module Level 1`_ "introduces cascading variables as a new primitive value type that is accepted by all CSS properties, and custom properties for defining them." -The custom properties are supported. The ``var()`` notation is `only supported -in single-value properties`_. +The custom properties and the ``var()`` notation are supported. .. _CSS Custom Properties for Cascading Variables Module Level 1: https://www.w3.org/TR/css-variables/ -.. _only supported in single-value properties: https://github.com/Kozea/WeasyPrint/issues/1219 CSS Text Decoration Module Level 3 ++++++++++++++++++++++++++++++++++ diff --git a/docs/first_steps.rst b/docs/first_steps.rst index d952351ee..fa8bc7105 100644 --- a/docs/first_steps.rst +++ b/docs/first_steps.rst @@ -9,7 +9,7 @@ Installation WeasyPrint |version| depends on: -* Python_ ≥ 3.7.0 +* Python_ ≥ 3.8.0 * Pango_ ≥ 1.44.0 * pydyf_ ≥ 0.6.0 * CFFI_ ≥ 0.6 diff --git a/pyproject.toml b/pyproject.toml index fe73db7d1..c102706da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = 'The Awesome Document Factory' keywords = ['html', 'css', 'pdf', 'converter'] authors = [{name = 'Simon Sapin', email = 'simon.sapin@exyr.org'}] maintainers = [{name = 'CourtBouillon', email = 'contact@courtbouillon.org'}] -requires-python = '>=3.7' +requires-python = '>=3.8' readme = {file = 'README.rst', content-type = 'text/x-rst'} license = {file = 'LICENSE'} dependencies = [ @@ -29,11 +29,11 @@ classifiers = [ 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Internet :: WWW/HTTP', diff --git a/tests/test_variables.py b/tests/test_variables.py index 9ce2a19a4..1b358d906 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') @@ -120,7 +120,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(''' @@ -166,7 +315,7 @@ def test_variable_fallback(prop): @assert_no_logs -def test_variable_list(): +def test_variable_list_content(): # Regression test for https://github.com/Kozea/WeasyPrint/issues/1287 page, = render_pages(''' +
+ ''' % (var, display)) + html, = page.children + body, = html.children + section, = body.children + child, = section.children + assert type(child).__name__ == 'LineBox' + + +@assert_no_logs +@pytest.mark.parametrize('var, font', ( + ('weasyprint', 'var(--var)'), + ('"weasyprint"', 'var(--var)'), + ('weasyprint', 'var(--var), monospace'), + ('weasyprint, monospace', 'var(--var)'), + ('monospace', 'weasyprint, var(--var)'), +)) +def test_variable_list_font(var, font): + page, = render_pages(''' + +
aa
+ ''' % (var, font)) + html, = page.children + body, = html.children + div, = body.children + line, = div.children + text, = line.children + assert text.width == 4 + + +@assert_no_logs +def test_variable_in_function(): + page, = render_pages(''' + +
+

+
+

+
+
+ ''') + html, = page.children + body, = html.children + section, = body.children + h11, div1, h12, div2 = section.children + assert div1.children[0].children[0].children[0].text == '1' + assert div2.children[0].children[0].children[0].text == '2' + + +@assert_no_logs +def test_variable_in_function_multiple_values(): + page, = render_pages(''' + +
+

+
+

+
+

+
+
+ ''') + html, = page.children + body, = html.children + section, = body.children + h11, div1, h12, div2, h13, div3 = section.children + assert div1.children[0].children[0].children[0].text == 'I' + assert div2.children[0].children[0].children[0].text == 'II' + assert div3.children[0].children[0].children[0].text == 'iii' + + +@assert_no_logs +def test_variable_in_variable_in_function(): + page, = render_pages(''' + +
+

+
+

+
+

+
+
+ ''') + html, = page.children + body, = html.children + section, = body.children + h11, div1, h12, div2, h13, div3 = section.children + assert div1.children[0].children[0].children[0].text == 'I' + assert div2.children[0].children[0].children[0].text == 'II' + assert div3.children[0].children[0].children[0].text == 'iii' + + +def test_variable_in_function_missing(): + with capture_logs() as logs: + page, = render_pages(''' + +
+

+
+

+
+
+ ''') + assert len(logs) == 2 + assert 'no value' in logs[0] + assert 'invalid value' in logs[1] + html, = page.children + body, = html.children + section, = body.children + h11, div1, h12, div2 = section.children + assert not div1.children + assert not div2.children diff --git a/weasyprint/css/__init__.py b/weasyprint/css/__init__.py index 2a83d0eaf..7bbb2b27f 100644 --- a/weasyprint/css/__init__.py +++ b/weasyprint/css/__init__.py @@ -23,9 +23,9 @@ from ..logger import LOGGER, PROGRESS_LOGGER from ..urls import URLFetchingError, get_url_attribute, url_join from . import counters, media_queries -from .computed_values import COMPUTER_FUNCTIONS, ZERO_PIXELS, compute_var +from .computed_values import COMPUTER_FUNCTIONS, ZERO_PIXELS, resolve_var from .properties import INHERITED, INITIAL_NOT_COMPUTED, INITIAL_VALUES -from .utils import get_url, remove_whitespace +from .utils import InvalidValues, Pending, get_url, remove_whitespace from .validation import preprocess_declarations from .validation.descriptors import preprocess_descriptors @@ -164,18 +164,6 @@ def set_computed_styles(self, element, parent, root=None, pseudo_type=None, element, cascaded, parent_style, pseudo_type, root_style, base_url, target_collector) - # The style of marker is deleted when display is different from - # list-item. - if pseudo_type is None: - for pseudo in (None, 'before', 'after'): - pseudo_style = cascaded_styles.get((element, pseudo), {}) - if 'display' in pseudo_style: - if 'list-item' in pseudo_style['display'][0]: - break - else: - if (element, 'marker') in cascaded_styles: - del cascaded_styles[element, 'marker'] - def add_page_declarations(self, page_type): for sheet, origin, sheet_specificity in self._sheets: for _rule, selector_list, declarations in sheet.page_rules: @@ -678,27 +666,47 @@ def __missing__(self, key): if key in self.cascaded: # Property defined in cascaded properties. - keyword, computed = compute_var(key, self, parent_style) - value = keyword + value = self.cascaded[key][0] else: # Property not defined in cascaded properties, define as inherited # or initial value. - computed = False if key in INHERITED or key[:2] == '__': - keyword = 'inherit' + value = 'inherit' else: - keyword = 'initial' + value = 'initial' - if keyword == 'inherit' and parent_style is None: + if value == 'inherit' and parent_style is None: # On the root element, 'inherit' from initial values - keyword = 'initial' + value = 'initial' + + if isinstance(value, Pending): + # Property with pending values, validate them. + solved_tokens = [] + for token in value.tokens: + tokens = resolve_var(self, token, parent_style) + if tokens is None: + solved_tokens.append(token) + else: + solved_tokens.extend(tokens) + original_key = key.replace('_', '-') + 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] + else: + value = INITIAL_VALUES[key] + if key not in INITIAL_NOT_COMPUTED: + # The value is the same as when computed. + self[key] = value - if keyword == 'initial': - value = None if key[:2] == '__' else INITIAL_VALUES[key] + if value == 'initial': + value = [] if key[:2] == '__' else INITIAL_VALUES[key] if key not in INITIAL_NOT_COMPUTED: # The value is the same as when computed. self[key] = value - elif keyword == 'inherit': + elif value == 'inherit': # Values in parent_style are already computed. self[key] = value = parent_style[key] @@ -728,7 +736,7 @@ def __missing__(self, key): # Value already computed and saved: return. return self[key] - if not computed and key in COMPUTER_FUNCTIONS: + if key in COMPUTER_FUNCTIONS: # Value not computed yet: compute. value = COMPUTER_FUNCTIONS[key](self, key, value) diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py index 1d9302e16..88ca73141 100644 --- a/weasyprint/css/computed_values.py +++ b/weasyprint/css/computed_values.py @@ -1,22 +1,20 @@ """Convert specified property values into computed values.""" from collections import OrderedDict -from contextlib import suppress from math import pi from urllib.parse import unquote +from tinycss2.ast import FunctionBlock from tinycss2.color3 import parse_color from ..logger import LOGGER from ..text.ffi import ffi, pango, units_to_double from ..text.line_break import Layout, first_line_metrics from ..urls import get_link_attribute -from .properties import ( - INHERITED, INITIAL_NOT_COMPUTED, INITIAL_VALUES, Dimension) +from .properties import INITIAL_VALUES, Dimension from .utils import ( ANGLE_TO_RADIANS, LENGTH_UNITS, LENGTHS_TO_PIXELS, check_var_function, - safe_urljoin) -from .validation.properties import MULTIVAL_PROPERTIES, PROPERTIES + parse_function, safe_urljoin) ZERO_PIXELS = Dimension(0, 'px') @@ -150,34 +148,45 @@ def _computing_order(): COMPUTER_FUNCTIONS = {} -def _resolve_var(computed, variable_name, default, parent_style): - known_variable_names = [variable_name] - - computed_value = computed[variable_name] - if computed_value and len(computed_value) == 1: - value = computed_value[0] - if value.type == 'ident' and value.value == 'initial': - return default +def resolve_var(computed, token, parent_style): + if not check_var_function(token): + return - computed_value = computed.get(variable_name, default) + if token.lower_name != 'var': + arguments = [] + for i, argument in enumerate(token.arguments): + if argument.type == 'function' and argument.lower_name == 'var': + arguments.extend(resolve_var(computed, argument, parent_style)) + else: + arguments.append(argument) + token = FunctionBlock( + token.source_line, token.source_column, token.name, arguments) + return resolve_var(computed, token, parent_style) or (token,) + + default = None # just for the linter + known_variable_names = set() + computed_value = (token,) while (computed_value and isinstance(computed_value, tuple) and len(computed_value) == 1): - var_function = check_var_function(computed_value[0]) - if var_function: - new_variable_name, new_default = var_function[1] - if new_variable_name in known_variable_names: + value = computed_value[0] + if value.type == 'ident' and value.value == 'initial': + return default + if check_var_function(value): + args = parse_function(value)[1] + variable_name = args.pop(0).value.replace('-', '_') + if variable_name in known_variable_names: computed_value = default break - known_variable_names.append(new_variable_name) - default = new_default - computed_value = computed[new_variable_name] + known_variable_names.add(variable_name) + default = args + computed_value = computed[variable_name] if computed_value is not None: continue if parent_style is None: - computed_value = new_default + computed_value = default else: - computed_value = parent_style[new_variable_name] or new_default + computed_value = parent_style[variable_name] or default else: break return computed_value @@ -211,77 +220,6 @@ def decorator(function): return decorator -def compute_var(name, computed_style, parent_style): - original_values = computed_style.cascaded[name][0] - validation_name = name.replace('_', '-') - multiple_values = validation_name in MULTIVAL_PROPERTIES - - if multiple_values: - # Property with multiple values. - values = original_values - else: - # Property with single value, put in a list. - values = [original_values] - - # Find variables. - variables = { - i: value[1] for i, value in enumerate(values) - if value and isinstance(value, tuple) and value[0] == 'var()'} - if not variables: - # No variable, return early. - return original_values, False - - if not multiple_values: - # Don’t modify original list of values. - values = values.copy() - - if name in INHERITED and parent_style: - inherited = True - computed = True - else: - inherited = False - computed = name not in INITIAL_NOT_COMPUTED - - # Replace variables by real values. - validator = PROPERTIES[validation_name] - for i, variable in variables.items(): - variable_name, default = variable - value = _resolve_var( - computed_style, variable_name, default, parent_style) - - if value is not None: - # Validate value. - if validator.wants_base_url: - value = validator(value, computed_style.base_url) - else: - value = validator(value) - - if value is None: - # Invalid variable value, see - # https://www.w3.org/TR/css-variables-1/#invalid-variables. - with suppress(BaseException): - value = ''.join(token.serialize() for token in value) - LOGGER.warning( - 'Unsupported computed value "%s" set in variable %r ' - 'for property %r.', value, variable_name.replace('_', '-'), - validation_name) - values[i] = (parent_style if inherited else INITIAL_VALUES)[name] - elif multiple_values: - # Replace original variable by possibly multiple validated values. - values[i:i+1] = value - computed = False - else: - # Save variable by single validated value. - values[i] = value - computed = False - - if not multiple_values: - # Property with single value, unpack list. - values, = values - - return values, computed - - def compute_attr(style, values): # TODO: use real token parsing instead of casting with Python types func_name, value = values diff --git a/weasyprint/css/utils.py b/weasyprint/css/utils.py index a8ef37ca6..bbc920fa3 100644 --- a/weasyprint/css/utils.py +++ b/weasyprint/css/utils.py @@ -2,10 +2,12 @@ import functools import math +from abc import ABC, abstractmethod from urllib.parse import unquote, urljoin from tinycss2.color3 import parse_color +from .. import LOGGER from ..urls import iri_to_uri, url_is_absolute from .properties import Dimension @@ -95,6 +97,41 @@ class CenterKeywordFakeToken: unit = None +class Pending(ABC): + """Abstract class representing property value with pending validation.""" + # See https://drafts.csswg.org/css-variables-2/#variables-in-shorthands. + def __init__(self, tokens, name): + self.tokens = tokens + self.name = name + self._reported_error = False + + @abstractmethod + def validate(self, tokens, wanted_key): + """Get validated value for wanted key.""" + raise NotImplementedError + + def solve(self, tokens, wanted_key): + """Get validated value or raise error.""" + try: + if not tokens: + # Having no tokens is allowed by grammar but refused by all + # properties and expanders. + raise InvalidValues('no value') + return self.validate(tokens, wanted_key) + except InvalidValues as exc: + if self._reported_error: + raise exc + source_line = self.tokens[0].source_line + source_column = self.tokens[0].source_column + value = ' '.join(token.serialize() for token in tokens) + message = (exc.args and exc.args[0]) or 'invalid value' + LOGGER.warning( + 'Ignored `%s: %s` at %d:%d, %s.', + self.name, value, source_line, source_column, message) + self._reported_error = True + raise exc + + def split_on_comma(tokens): """Split a list of tokens on commas, ie ``LiteralToken(',')``. @@ -496,18 +533,16 @@ def check_string_or_element_function(string_or_element, token): def check_var_function(token): - function = parse_function(token) - if function is None: - return - name, args = function - if name == 'var' and args: - ident = args.pop(0) - if ident.type != 'ident' or not ident.value.startswith('--'): - return - - # TODO: we should check authorized tokens - # https://drafts.csswg.org/css-syntax-3/#typedef-declaration-value - return ('var()', (ident.value.replace('-', '_'), args or None)) + if function := parse_function(token): + name, args = function + if name == 'var' and args: + ident = args.pop(0) + # TODO: we should check authorized tokens + # https://drafts.csswg.org/css-syntax-3/#typedef-declaration-value + return ident.type == 'ident' and ident.value.startswith('--') + for arg in args: + if check_var_function(arg): + return True def get_string(token): @@ -700,11 +735,6 @@ def get_content_list_token(token, base_url): if string is not None: return string - # - var = check_var_function(token) - if var is not None: - return var - # contents if get_keyword(token) == 'contents': return ('content()', 'text') diff --git a/weasyprint/css/validation/__init__.py b/weasyprint/css/validation/__init__.py index 00582840d..1e24b1e7f 100644 --- a/weasyprint/css/validation/__init__.py +++ b/weasyprint/css/validation/__init__.py @@ -166,7 +166,7 @@ def validation_error(level, reason): validation_error('debug', 'prefixed selectors are ignored') continue - expander_ = EXPANDERS.get(name, validate_non_shorthand) + validator = EXPANDERS.get(name, validate_non_shorthand) tokens = remove_whitespace(declaration.value) try: # Having no tokens is allowed by grammar but refused by all @@ -174,7 +174,7 @@ def validation_error(level, reason): if not tokens: raise InvalidValues('no value') # Use list() to consume generators now and catch any error. - result = list(expander_(name, tokens, base_url)) + result = list(validator(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 a931297ce..1e76dfb64 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( - f'font-variant{name}', sub_tokens, required=True)) + sub_tokens, f'font-variant{name}', required=True)) except InvalidValues: return None return values diff --git a/weasyprint/css/validation/expanders.py b/weasyprint/css/validation/expanders.py index a2b23ac18..5c2f31119 100644 --- a/weasyprint/css/validation/expanders.py +++ b/weasyprint/css/validation/expanders.py @@ -7,7 +7,8 @@ from ..properties import INITIAL_VALUES from ..utils import ( - InvalidValues, get_keyword, get_single_keyword, split_on_comma) + InvalidValues, Pending, check_var_function, get_keyword, + get_single_keyword, split_on_comma) from .descriptors import expand_font_variant from .properties import ( background_attachment, background_image, background_position, @@ -21,6 +22,30 @@ EXPANDERS = {} +class PendingExpander(Pending): + """Expander with validation done when defining calculated values.""" + def __init__(self, tokens, validator): + super().__init__(tokens, validator.keywords['name']) + self.validator = validator + + def validate(self, tokens, wanted_key): + for key, value in self.validator(tokens): + if key.startswith('-'): + key = f'{self.validator.keywords["name"]}{key}' + if key == wanted_key: + return value + 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): @@ -31,39 +56,6 @@ def expander_decorator(function): return expander_decorator -@expander('border-color') -@expander('border-style') -@expander('border-width') -@expander('margin') -@expander('padding') -@expander('bleed') -def expand_four_sides(name, tokens, base_url): - """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( - new_name, [token], base_url, required=True) - yield result - - def generic_expander(*expanded_names, **kwargs): """Decorator helping expanders to handle ``inherit`` and ``initial``. @@ -78,19 +70,27 @@ def generic_expander(*expanded_names, **kwargs): def generic_expander_decorator(wrapped): """Decorate the ``wrapped`` expander.""" @functools.wraps(wrapped) - def generic_expander_wrapper(name, tokens, base_url): + def generic_expander_wrapper(tokens, name, base_url): """Wrap the expander.""" + 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: - skip_validation = False + results = _find_var(tokens, expander, expanded_names) + if results: + skip_validation = True + + if not skip_validation: results = {} if wants_base_url: - result = wrapped(name, tokens, base_url) + result = wrapped(tokens, name, base_url) else: - result = wrapped(name, tokens) + result = wrapped(tokens, name) for new_name, new_token in result: assert new_name in expanded_names, new_name if new_name in results: @@ -102,7 +102,7 @@ def generic_expander_wrapper(name, tokens, base_url): 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 @@ -111,7 +111,7 @@ def generic_expander_wrapper(name, tokens, base_url): if not skip_validation: # validate_non_shorthand returns ((name, value),) (actual_new_name, value), = validate_non_shorthand( - actual_new_name, value, base_url, required=True) + value, actual_new_name, base_url, required=True) else: value = 'initial' @@ -120,12 +120,54 @@ def generic_expander_wrapper(name, tokens, base_url): return generic_expander_decorator +@expander('border-color') +@expander('border-style') +@expander('border-width') +@expander('margin') +@expander('padding') +@expander('bleed') +def expand_four_sides(tokens, name, base_url): + """Expand properties setting a token for the four sides of a box.""" + # 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: + 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 expanded_name, token in zip(expanded_names, tokens): + # validate_non_shorthand returns ((name, value),), we want + # to yield (name, value). + result, = validate_non_shorthand( + [token], expanded_name, base_url, required=True) + yield result + + @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): +def border_radius(tokens, name, base_url): """Validator for the ``border-radius`` property.""" current = horizontal = [] vertical = [] @@ -157,14 +199,14 @@ def border_radius(name, tokens, base_url): 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(new_name, tokens, base_url, required=True) - yield new_name, tokens + name = f'border-{corner}-radius' + validate_non_shorthand(tokens, name, base_url, required=True) + yield 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 @@ -206,14 +248,14 @@ def expand_list_style(name, tokens, base_url): @expander('border') -def expand_border(name, tokens, base_url): +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(name + suffix, tokens, base_url): + for new_prop in expand_border_side(tokens, name + suffix, base_url): yield new_prop @@ -224,7 +266,7 @@ def expand_border(name, tokens, base_url): @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 @@ -243,29 +285,35 @@ def expand_border_side(name, tokens): @expander('background') -def expand_background(name, tokens, base_url): +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 @@ -321,15 +369,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(): @@ -341,7 +389,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 = [] @@ -379,7 +427,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 @@ -399,29 +447,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 @@ -436,7 +484,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': @@ -459,7 +507,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 @@ -471,7 +519,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 @@ -543,7 +591,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 @@ -557,7 +605,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': @@ -615,7 +663,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]: @@ -643,7 +691,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) @@ -683,7 +731,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 df00c59bf..a5a7cc013 100644 --- a/weasyprint/css/validation/properties.py +++ b/weasyprint/css/validation/properties.py @@ -11,16 +11,15 @@ from .. import computed_values from ..properties import KNOWN_PROPERTIES, Dimension from ..utils import ( - InvalidValues, 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) + 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() -MULTIVAL_PROPERTIES = set() # Yes/no validators for non-shorthand properties # Maps property names to functions taking a property name and a value list, @@ -30,10 +29,16 @@ 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, - multiple_values=False, wants_base_url=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 @@ -69,14 +74,12 @@ def decorator(function): PROPRIETARY.add(name) if unstable: UNSTABLE.add(name) - if multiple_values: - MULTIVAL_PROPERTIES.add(name) return function return decorator -def validate_non_shorthand(name, tokens, base_url=None, required=False): - """Default validator for non-shorthand properties.""" +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),) @@ -91,17 +94,16 @@ def validate_non_shorthand(name, tokens, base_url=None, required=False): if not required and name not in PROPERTIES: raise InvalidValues('property not supported yet') - if name not in MULTIVAL_PROPERTIES: - for token in tokens: - var_function = check_var_function(token) - if var_function: - return ((name, var_function),) + 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: - function = PROPERTIES[name] if function.wants_base_url: value = function(tokens, base_url) else: @@ -484,7 +486,7 @@ def clip(token): return () -@property(multiple_values=True, wants_base_url=True) +@property(wants_base_url=True) def content(tokens, base_url): """``content`` property validation.""" # See https://www.w3.org/TR/css-content-3/#content-property diff --git a/weasyprint/draw.py b/weasyprint/draw.py index ae67ebfee..ce15dd834 100644 --- a/weasyprint/draw.py +++ b/weasyprint/draw.py @@ -1053,9 +1053,7 @@ def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis): return text_decoration_values = textbox.style['text_decoration_line'] - text_decoration_color = textbox.style['text_decoration_color'] - if text_decoration_color == 'currentColor': - text_decoration_color = textbox.style['color'] + text_decoration_color = get_color(textbox.style, 'text_decoration_color') if 'overline' in text_decoration_values: thickness = textbox.pango_layout.underline_thickness offset_y = (