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