Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support CSS Nesting #2077

Merged
merged 11 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/first_steps.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 6 additions & 6 deletions tests/css/test_descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),))
Expand All @@ -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'),))
Expand All @@ -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),))

Expand All @@ -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'),))

Expand All @@ -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
Expand All @@ -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'),))
Expand Down
2 changes: 1 addition & 1 deletion tests/css/test_expanders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down
27 changes: 27 additions & 0 deletions tests/css/test_nesting.py
Original file line number Diff line number Diff line change
@@ -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>%s</style>
<div><p></p></div><p></p>
''' % style)
html, = page.children
body, = html.children
div, p = body.children
div_p, = div.children
assert div_p.width == 10
assert p.width != 10
2 changes: 1 addition & 1 deletion tests/css/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down
70 changes: 40 additions & 30 deletions weasyprint/css/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"""

from collections import namedtuple
from itertools import groupby
from logging import DEBUG, WARNING

import cssselect2
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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}',
Expand All @@ -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'):
Expand All @@ -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,
Expand Down
59 changes: 56 additions & 3 deletions weasyprint/css/validation/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -104,23 +107,69 @@
'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.

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(
'Error: %s at %d:%d.',
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

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion weasyprint/svg/css.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading