Skip to content

Commit

Permalink
Test and clean variable validation and resolution
Browse files Browse the repository at this point in the history
  • Loading branch information
liZe committed Jan 1, 2024
1 parent 1d7cf97 commit a09129a
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 99 deletions.
94 changes: 94 additions & 0 deletions tests/test_variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,97 @@ def test_variable_list_font(var, font):
line, = div.children
text, = line.children
assert text.width == 4


@assert_no_logs
def test_variable_in_function():
page, = render_pages('''
<style>
html { --var: title }
h1 { counter-increment: var(--var) }
div::before { content: counter(var(--var)) }
</style>
<section>
<h1></h1>
<div></div>
<h1></h1>
<div></div>
</section>
''')
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('''
<style>
html { --name: title; --counter: title, upper-roman }
h1 { counter-increment: var(--name) }
div::before { content: counter(var(--counter)) }
</style>
<section>
<h1></h1>
<div></div>
<h1></h1>
<div></div>
</section>
''')
html, = page.children
body, = html.children
section, = body.children
h11, div1, h12, div2 = section.children
assert div1.children[0].children[0].children[0].text == 'I'
assert div2.children[0].children[0].children[0].text == 'II'


@assert_no_logs
def test_variable_in_variable_in_function():
page, = render_pages('''
<style>
html { --name: title; --counter: var(--name), upper-roman }
h1 { counter-increment: var(--name) }
div::before { content: counter(var(--counter)) }
</style>
<section>
<h1></h1>
<div></div>
<h1></h1>
<div></div>
</section>
''')
html, = page.children
body, = html.children
section, = body.children
h11, div1, h12, div2 = section.children
assert div1.children[0].children[0].children[0].text == 'I'
assert div2.children[0].children[0].children[0].text == 'II'


def test_variable_in_function_missing():
with capture_logs() as logs:
page, = render_pages('''
<style>
h1 { counter-increment: var(--var) }
div::before { content: counter(var(--var)) }
</style>
<section>
<h1></h1>
<div></div>
<h1></h1>
<div></div>
</section>
''')
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
17 changes: 6 additions & 11 deletions weasyprint/css/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,9 @@
from . import counters, media_queries
from .computed_values import COMPUTER_FUNCTIONS, ZERO_PIXELS, resolve_var
from .properties import INHERITED, INITIAL_NOT_COMPUTED, INITIAL_VALUES
from .utils import (
InvalidValues, check_var_function, get_url, remove_whitespace)
from .utils import InvalidValues, Pending, get_url, remove_whitespace
from .validation import preprocess_declarations
from .validation.descriptors import preprocess_descriptors
from .validation.expanders import PendingExpander
from .validation.properties import PendingProperty

# Reject anything not in here:
PSEUDO_ELEMENTS = (
Expand Down Expand Up @@ -682,17 +679,15 @@ def __missing__(self, key):
# On the root element, 'inherit' from initial values
value = 'initial'

if isinstance(value, (PendingProperty, PendingExpander)):
if isinstance(value, Pending):
# Property with pending values, validate them.
solved_tokens = []
for token in value.tokens:
if variable := check_var_function(token):
variable_name, default = variable[1]
tokens = resolve_var(
self, variable_name, default, parent_style)
solved_tokens.extend(tokens)
else:
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)
Expand Down
43 changes: 25 additions & 18 deletions weasyprint/css/computed_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .properties import INITIAL_VALUES, Dimension
from .utils import (
ANGLE_TO_RADIANS, LENGTH_UNITS, LENGTHS_TO_PIXELS, check_var_function,
safe_urljoin)
parse_function, safe_urljoin)

ZERO_PIXELS = Dimension(0, 'px')

Expand Down Expand Up @@ -147,34 +147,41 @@ def _computing_order():
COMPUTER_FUNCTIONS = {}


def resolve_var(computed, variable_name, default, parent_style):
known_variable_names = [variable_name]
def resolve_var(computed, token, parent_style):
if not check_var_function(token):
return

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
if token.lower_name != 'var':
for i, argument in enumerate(token.arguments.copy()):
if argument.type == 'function' and argument.lower_name == 'var':
token.arguments[i:i+1] = resolve_var(
computed, argument, parent_style)
return resolve_var(computed, token, parent_style)

computed_value = computed.get(variable_name, default)
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
Expand Down
64 changes: 47 additions & 17 deletions weasyprint/css/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(',')``.
Expand Down Expand Up @@ -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))
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):
Expand Down Expand Up @@ -700,11 +735,6 @@ def get_content_list_token(token, base_url):
if string is not None:
return string

# <var>
var = check_var_function(token)
if var is not None:
return var

# contents
if get_keyword(token) == 'contents':
return ('content()', 'text')
Expand Down
40 changes: 11 additions & 29 deletions weasyprint/css/validation/expanders.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
from tinycss2.ast import DimensionToken, IdentToken, NumberToken
from tinycss2.color3 import parse_color

from ...logger import LOGGER
from ..properties import INITIAL_VALUES
from ..utils import (
InvalidValues, check_var_function, 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,
Expand All @@ -23,35 +22,18 @@
EXPANDERS = {}


class PendingExpander:
class PendingExpander(Pending):
"""Expander with validation done when defining calculated values."""
# See https://drafts.csswg.org/css-variables-2/#variables-in-shorthands.
def __init__(self, tokens, validator):
self.tokens = tokens
super().__init__(tokens, validator.keywords['name'])
self.validator = validator
self._reported_error = False

def solve(self, tokens, wanted_key):
"""Get validated value for wanted key."""
try:
for key, value in self.validator(tokens):
if key.startswith('-'):
key = f'{self.validator.keywords["name"]}{key}'
if key == wanted_key:
return value
except InvalidValues as exc:
if self._reported_error:
raise exc
prop = self.validator.keywords['name']
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.',
prop, value, source_line, source_column, message)
self._reported_error = True
raise exc

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


Expand Down
Loading

0 comments on commit a09129a

Please sign in to comment.