diff --git a/docs/first_steps.rst b/docs/first_steps.rst index b5833aa9d..ac89e1e13 100644 --- a/docs/first_steps.rst +++ b/docs/first_steps.rst @@ -14,7 +14,7 @@ WeasyPrint |version| depends on: * pydyf_ ≥ 0.8.0 * CFFI_ ≥ 0.6 * html5lib_ ≥ 1.1 -* tinycss2_ ≥ 1.0.0 +* tinycss2_ ≥ 1.3.0 * cssselect2_ ≥ 0.1 * Pyphen_ ≥ 0.9.1 * Pillow_ ≥ 9.1.0 diff --git a/pyproject.toml b/pyproject.toml index d3e1eb2e1..d793c988c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ 'pydyf >=0.8.0', 'cffi >=0.6', 'html5lib >=1.1', - 'tinycss2 >=1.0.0', + 'tinycss2 >=1.3.0', 'cssselect2 >=0.1', 'Pyphen >=0.9.1', 'Pillow >=9.1.0', diff --git a/tests/css/test_descriptors.py b/tests/css/test_descriptors.py index 0f7a4c386..0ee38b84c 100644 --- a/tests/css/test_descriptors.py +++ b/tests/css/test_descriptors.py @@ -19,7 +19,7 @@ def test_font_face_1(): assert at_rule.at_keyword == 'font-face' font_family, src = list(preprocess_descriptors( 'font-face', 'https://weasyprint.org/foo/', - tinycss2.parse_declaration_list(at_rule.content))) + tinycss2.parse_blocks_contents(at_rule.content))) assert font_family == ('font_family', 'Gentium Hard') assert src == ( 'src', (('external', 'https://example.com/fonts/Gentium.woff'),)) @@ -40,7 +40,7 @@ def test_font_face_2(): font_family, src, font_style, font_weight, font_stretch = list( preprocess_descriptors( 'font-face', 'https://weasyprint.org/foo/', - tinycss2.parse_declaration_list(at_rule.content))) + tinycss2.parse_blocks_contents(at_rule.content))) assert font_family == ('font_family', 'Fonty Smiley') assert src == ( 'src', (('external', 'https://weasyprint.org/foo/Fonty-Smiley.woff'),)) @@ -60,7 +60,7 @@ def test_font_face_3(): assert at_rule.at_keyword == 'font-face' font_family, src = list(preprocess_descriptors( 'font-face', 'https://weasyprint.org/foo/', - tinycss2.parse_declaration_list(at_rule.content))) + tinycss2.parse_blocks_contents(at_rule.content))) assert font_family == ('font_family', 'Gentium Hard') assert src == ('src', (('local', None),)) @@ -77,7 +77,7 @@ def test_font_face_4(): assert at_rule.at_keyword == 'font-face' font_family, src = list(preprocess_descriptors( 'font-face', 'https://weasyprint.org/foo/', - tinycss2.parse_declaration_list(at_rule.content))) + tinycss2.parse_blocks_contents(at_rule.content))) assert font_family == ('font_family', 'Gentium Hard') assert src == ('src', (('local', 'Gentium Hard'),)) @@ -96,7 +96,7 @@ def test_font_face_5(): with capture_logs() as logs: font_family, src = list(preprocess_descriptors( 'font-face', 'https://weasyprint.org/foo/', - tinycss2.parse_declaration_list(at_rule.content))) + tinycss2.parse_blocks_contents(at_rule.content))) assert font_family == ('font_family', 'Gentium Hard') assert src == ('src', (('local', 'Gentium Hard'),)) assert len(logs) == 1 @@ -119,7 +119,7 @@ def test_font_face_bad_1(): font_family, src, font_stretch = list( preprocess_descriptors( 'font-face', 'https://weasyprint.org/foo/', - tinycss2.parse_declaration_list(at_rule.content))) + tinycss2.parse_blocks_contents(at_rule.content))) assert font_family == ('font_family', 'Bad Font') assert src == ( 'src', (('external', 'https://weasyprint.org/foo/BadFont.woff'),)) diff --git a/tests/css/test_expanders.py b/tests/css/test_expanders.py index d780fc9ac..f7c2c2e05 100644 --- a/tests/css/test_expanders.py +++ b/tests/css/test_expanders.py @@ -12,7 +12,7 @@ def expand_to_dict(css, expected_error=None): """Helper to test shorthand properties expander functions.""" - declarations = tinycss2.parse_declaration_list(css) + declarations = tinycss2.parse_blocks_contents(css) with capture_logs() as logs: base_url = 'https://weasyprint.org/foo/' diff --git a/tests/css/test_nesting.py b/tests/css/test_nesting.py new file mode 100644 index 000000000..f3b4630cd --- /dev/null +++ b/tests/css/test_nesting.py @@ -0,0 +1,27 @@ +"""Test CSS nesting.""" + +import pytest + +from ..testing_utils import assert_no_logs, render_pages + + +@assert_no_logs +@pytest.mark.parametrize('style', ( + 'div { p { width: 10px } }', + 'p { div & { width: 10px } }', + 'p { width: 20px; div & { width: 10px } }', + 'p { div & { width: 10px } width: 20px }', + 'div { & { & { p { & { width: 10px } } } } }', + '@media print { div { p { width: 10px } } }', +)) +def test_nesting_block(style): + page, = render_pages(''' + +

+ ''' % style) + html, = page.children + body, = html.children + div, p = body.children + div_p, = div.children + assert div_p.width == 10 + assert p.width != 10 diff --git a/tests/css/test_validation.py b/tests/css/test_validation.py index 643166307..f3308a99d 100644 --- a/tests/css/test_validation.py +++ b/tests/css/test_validation.py @@ -12,7 +12,7 @@ def get_value(css, expected_error=None): - declarations = tinycss2.parse_declaration_list(css) + declarations = tinycss2.parse_blocks_contents(css) with capture_logs() as logs: base_url = 'https://weasyprint.org/foo/' diff --git a/weasyprint/css/__init__.py b/weasyprint/css/__init__.py index eae07bc71..eac13793c 100644 --- a/weasyprint/css/__init__.py +++ b/weasyprint/css/__init__.py @@ -13,6 +13,7 @@ """ from collections import namedtuple +from itertools import groupby from logging import DEBUG, WARNING import cssselect2 @@ -297,7 +298,7 @@ def find_style_attributes(tree, presentational_hints=False, base_url=None): """ def check_style_attribute(element, style_attribute): - declarations = tinycss2.parse_declaration_list(style_attribute) + declarations = tinycss2.parse_blocks_contents(style_attribute) return element, declarations, base_url for element in tree.iter(): @@ -919,33 +920,42 @@ def preprocess_stylesheet(device_media_type, base_url, stylesheet_rules, continue if rule.type == 'qualified-rule': - declarations = list(preprocess_declarations( - base_url, tinycss2.parse_declaration_list(rule.content))) - if declarations: + try: logger_level = WARNING - try: - selectors = cssselect2.compile_selector_list(rule.prelude) - for selector in selectors: - matcher.add_selector(selector, declarations) - if selector.pseudo_element not in PSEUDO_ELEMENTS: - if selector.pseudo_element.startswith('-'): - logger_level = DEBUG - raise cssselect2.SelectorError( - 'ignored prefixed pseudo-element: ' - f'{selector.pseudo_element}') - else: - raise cssselect2.SelectorError( - 'unknown pseudo-element: ' - f'{selector.pseudo_element}') + selectors_declarations = list( + preprocess_declarations( + base_url, tinycss2.parse_blocks_contents(rule.content), + rule.prelude)) + + if selectors_declarations: + selectors_declarations = groupby( + selectors_declarations, key=lambda x: x[0]) + for selectors, declarations in selectors_declarations: + declarations = [ + declaration[1] for declaration in declarations] + for selector in selectors: + matcher.add_selector(selector, declarations) + if selector.pseudo_element not in PSEUDO_ELEMENTS: + prelude = tinycss2.serialize(rule.prelude) + if selector.pseudo_element.startswith('-'): + logger_level = DEBUG + raise cssselect2.SelectorError( + f"'{prelude}', " + 'ignored prefixed pseudo-element: ' + f'{selector.pseudo_element}') + else: + raise cssselect2.SelectorError( + f"'{prelude}', " + 'unknown pseudo-element: ' + f'{selector.pseudo_element}') + ignore_imports = True + else: ignore_imports = True - except cssselect2.SelectorError as exc: - LOGGER.log( - logger_level, - "Invalid or unsupported selector '%s', %s", - tinycss2.serialize(rule.prelude), exc) - continue - else: - ignore_imports = True + except cssselect2.SelectorError as exc: + LOGGER.log( + logger_level, + "Invalid or unsupported selector, %s", exc) + continue elif rule.type == 'at-rule' and rule.lower_at_keyword == 'import': if ignore_imports: @@ -1026,7 +1036,7 @@ def preprocess_stylesheet(device_media_type, base_url, stylesheet_rules, for page_type in data: specificity = page_type.pop('specificity') page_type = PageType(**page_type) - content = tinycss2.parse_declaration_list(rule.content) + content = tinycss2.parse_blocks_contents(rule.content) declarations = list(preprocess_declarations(base_url, content)) if declarations: @@ -1039,7 +1049,7 @@ def preprocess_stylesheet(device_media_type, base_url, stylesheet_rules, continue declarations = list(preprocess_declarations( base_url, - tinycss2.parse_declaration_list(margin_rule.content))) + tinycss2.parse_blocks_contents(margin_rule.content))) if declarations: selector_list = [( specificity, f'@{margin_rule.lower_at_keyword}', @@ -1049,7 +1059,7 @@ def preprocess_stylesheet(device_media_type, base_url, stylesheet_rules, elif rule.type == 'at-rule' and rule.lower_at_keyword == 'font-face': ignore_imports = True - content = tinycss2.parse_declaration_list(rule.content) + content = tinycss2.parse_blocks_contents(rule.content) rule_descriptors = dict( preprocess_descriptors('font-face', base_url, content)) for key in ('src', 'font_family'): @@ -1076,7 +1086,7 @@ def preprocess_stylesheet(device_media_type, base_url, stylesheet_rules, continue ignore_imports = True - content = tinycss2.parse_declaration_list(rule.content) + content = tinycss2.parse_blocks_contents(rule.content) counter = { 'system': None, 'negative': None, diff --git a/weasyprint/css/validation/__init__.py b/weasyprint/css/validation/__init__.py index 1e24b1e7f..9b084fbbc 100644 --- a/weasyprint/css/validation/__init__.py +++ b/weasyprint/css/validation/__init__.py @@ -1,7 +1,10 @@ """Validate properties, expanders and descriptors.""" -from tinycss2 import serialize +from cssselect2 import SelectorError, compile_selector_list +from tinycss2 import parse_blocks_contents, serialize +from tinycss2.ast import ( + FunctionBlock, IdentToken, LiteralToken, WhitespaceToken) from ... import LOGGER from ..utils import InvalidValues, remove_whitespace @@ -104,9 +107,11 @@ 'scrollbar-gutter', 'scrollbar-width', } +NESTING_SELECTOR = LiteralToken(1, 1, '&') +ROOT_TOKEN = LiteralToken(1, 1, ':'), IdentToken(1, 1, 'root') -def preprocess_declarations(base_url, declarations): +def preprocess_declarations(base_url, declarations, prelude=None): """Expand shorthand properties, filter unsupported properties and values. Log a warning for every ignored declaration. @@ -114,6 +119,25 @@ def preprocess_declarations(base_url, declarations): Return a iterable of ``(name, value, important)`` tuples. """ + # Compile list of selectors. + if prelude is not None: + try: + if NESTING_SELECTOR in prelude: + # Handle & selector in non-nested rule. MDN explains that & is + # then equivalent to :scope, and :scope is equivalent to :root + # as we don’t support :scope yet. + original_prelude, prelude = prelude, [] + for token in original_prelude: + if token == NESTING_SELECTOR: + prelude.extend(ROOT_TOKEN) + else: + prelude.append(token) + selectors = compile_selector_list(prelude) + except SelectorError: + raise SelectorError(f"'{serialize(prelude)}'") + + # Yield declarations. + is_token = LiteralToken(1, 1, ':'), FunctionBlock(1, 1, 'is', prelude) for declaration in declarations: if declaration.type == 'error': LOGGER.warning( @@ -121,6 +145,31 @@ def preprocess_declarations(base_url, declarations): declaration.message, declaration.source_line, declaration.source_column) + if declaration.type == 'qualified-rule': + # Nested rule. + if prelude is None: + continue + declaration_prelude = declaration.prelude + if NESTING_SELECTOR in declaration.prelude: + # Replace & selector by parent. + declaration_prelude = [] + for token in declaration.prelude: + if token == NESTING_SELECTOR: + declaration_prelude.extend(is_token) + else: + declaration_prelude.append(token) + else: + # No & selector, prepend parent. + is_token = ( + LiteralToken(1, 1, ':'), + FunctionBlock(1, 1, 'is', prelude)) + declaration_prelude = [ + *is_token, WhitespaceToken(1, 1, ' '), + *declaration.prelude] + yield from preprocess_declarations( + base_url, parse_blocks_contents(declaration.content), + declaration_prelude) + if declaration.type != 'declaration': continue @@ -183,4 +232,8 @@ def validation_error(level, reason): important = declaration.important for long_name, value in result: - yield long_name.replace('-', '_'), value, important + if prelude is not None: + declaration = (long_name.replace('-', '_'), value, important) + yield selectors, declaration + else: + yield long_name.replace('-', '_'), value, important diff --git a/weasyprint/svg/css.py b/weasyprint/svg/css.py index 6a4c18009..39d61ec73 100644 --- a/weasyprint/svg/css.py +++ b/weasyprint/svg/css.py @@ -35,7 +35,7 @@ def parse_declarations(input): """Parse declarations in a given rule content.""" normal_declarations = [] important_declarations = [] - for declaration in tinycss2.parse_declaration_list(input): + for declaration in tinycss2.parse_blocks_contents(input): # TODO: warn on error # if declaration.type == 'error': if (declaration.type == 'declaration' and