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 = (