Repository: Kozea/WeasyPrint (python)

Issue:
Add support for CSS Custom Properties
This would allow CSS authors to define shared variables ("custom properties") and reference them using [the `var()` function](https://developer.mozilla.org/en-US/docs/Web/CSS/var).

See the [CSS Custom Properties for Cascading Variables Module Level 1](https://drafts.csswg.org/css-variables/) spec.

Pull request:
Add CSS variables
Fix #546. @whitelynx @LukasKlement @esmail feel free to test!

Patch:
diff --git a/docs/install.rst b/docs/install.rst
index 342ec22c..aa1da27e 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -10,7 +10,7 @@ WeasyPrint |version| depends on:
 * CFFI_ ≥ 0.6
 * html5lib_ ≥ 0.999999999
 * cairocffi_ ≥ 0.9.0
-* tinycss2_ ≥ 0.5
+* tinycss2_ ≥ 1.0.0
 * cssselect2_ ≥ 0.1
 * CairoSVG_ ≥ 1.0.20
 * Pyphen_ ≥ 0.8
diff --git a/setup.cfg b/setup.cfg
index 6448c1cb..94461793 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -45,7 +45,7 @@ install_requires =
   cffi>=0.6
   html5lib>=0.999999999
   cairocffi>=0.9.0
-  tinycss2>=0.5
+  tinycss2>=1.0.0
   cssselect2>=0.1
   CairoSVG>=1.0.20
   Pyphen>=0.8
diff --git a/weasyprint/css/__init__.py b/weasyprint/css/__init__.py
index e2df8750..34ff7cb3 100644
--- a/weasyprint/css/__init__.py
+++ b/weasyprint/css/__init__.py
@@ -489,8 +489,9 @@ def computed_from_cascaded(element, cascaded, parent_style, pseudo_type=None,
         # Fast path for anonymous boxes:
         # no cascaded style, only implicitly initial or inherited values.
         computed = dict(INITIAL_VALUES)
-        for name in INHERITED:
-            computed[name] = parent_style[name]
+        for name in parent_style:
+            if name in INHERITED or name.startswith('__'):
+                computed[name] = parent_style[name]
         # page is not inherited but taken from the ancestor if 'auto'
         computed['page'] = parent_style['page']
         # border-*-style is none, so border-width computes to zero.
@@ -504,6 +505,15 @@ def computed_from_cascaded(element, cascaded, parent_style, pseudo_type=None,
     # Handle inheritance and initial values
     specified = {}
     computed = {}
+
+    if parent_style:
+        for name in parent_style:
+            if name.startswith('__'):
+                specified[name] = parent_style[name]
+    for name in cascaded:
+        if name.startswith('__'):
+            specified[name] = cascaded[name][0]
+
     for name, initial in INITIAL_VALUES.items():
         if name in cascaded:
             value, _precedence = cascaded[name]
diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py
index 6c5476e9..bdce0b1f 100644
--- a/weasyprint/css/computed_values.py
+++ b/weasyprint/css/computed_values.py
@@ -17,9 +17,10 @@ from tinycss2.color3 import parse_color
 from .. import text
 from ..logger import LOGGER
 from ..urls import get_link_attribute
-from .properties import INITIAL_VALUES, Dimension
+from .properties import INHERITED, INITIAL_VALUES, Dimension
 from .utils import (
-    ANGLE_TO_RADIANS, LENGTH_UNITS, LENGTHS_TO_PIXELS, safe_urljoin)
+    ANGLE_TO_RADIANS, LENGTH_UNITS, LENGTHS_TO_PIXELS, check_var_function,
+    safe_urljoin)
 
 ZERO_PIXELS = Dimension(0, 'px')
 
@@ -139,6 +140,33 @@ COMPUTING_ORDER = _computing_order()
 COMPUTER_FUNCTIONS = {}
 
 
+def _resolve_var(computed, variable_name, default):
+    known_variable_names = [variable_name]
+
+    computed_value = computed.get(variable_name)
+    if computed_value and len(computed_value) == 1:
+        value = computed_value[0]
+        if value.type == 'ident' and value.value == 'initial':
+            return default
+
+    computed_value = computed.get(variable_name, default)
+    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:
+                computed_value = default
+                break
+            known_variable_names.append(new_variable_name)
+            computed_value = computed.get(new_variable_name, new_default)
+            default = new_default
+        else:
+            break
+    return computed_value
+
+
 def register_computer(name):
     """Decorator registering a property ``name`` for a function."""
     name = name.replace('-', '_')
@@ -167,6 +195,7 @@ def compute(element, pseudo_type, specified, computed, parent_style,
     :param target_collector: A target collector used to get computed targets.
 
     """
+    from .validation.properties import PROPERTIES
 
     def computer():
         """Dummy object that holds attributes."""
@@ -187,6 +216,10 @@ def compute(element, pseudo_type, specified, computed, parent_style,
 
     getter = COMPUTER_FUNCTIONS.get
 
+    for name in specified:
+        if name.startswith('__'):
+            computed[name] = specified[name]
+
     for name in COMPUTING_ORDER:
         if name in computed:
             # Already computed
@@ -194,6 +227,33 @@ def compute(element, pseudo_type, specified, computed, parent_style,
 
         value = specified[name]
         function = getter(name)
+
+        if value and isinstance(value, tuple) and value[0] == 'var()':
+            variable_name, default = value[1]
+            computed_value = _resolve_var(computed, variable_name, default)
+            if computed_value is None:
+                new_value = None
+            else:
+                new_value = PROPERTIES[name.replace('_', '-')](computed_value)
+
+            # See https://drafts.csswg.org/css-variables/#invalid-variables
+            if new_value is None:
+                try:
+                    computed_value = ''.join(
+                        token.serialize() for token in computed_value)
+                except BaseException:
+                    pass
+                LOGGER.warning(
+                    'Unsupported computed value `%s` set in variable `%s` '
+                    'for property `%s`.', computed_value,
+                    variable_name.replace('_', '-'), name.replace('_', '-'))
+                if name in INHERITED and parent_style:
+                    value = parent_style[name]
+                else:
+                    value = INITIAL_VALUES[name]
+            else:
+                value = new_value
+
         if function is not None:
             value = function(computer, name, value)
         # else: same as specified
diff --git a/weasyprint/css/utils.py b/weasyprint/css/utils.py
index 3de4c901..e1ca951b 100644
--- a/weasyprint/css/utils.py
+++ b/weasyprint/css/utils.py
@@ -377,7 +377,7 @@ def parse_function(function_token):
     space-separated arguments. Return ``None`` otherwise.
 
     """
-    if not function_token.type == 'function':
+    if not getattr(function_token, 'type', None) == 'function':
         return
 
     content = list(remove_whitespace(function_token.arguments))
@@ -397,6 +397,8 @@ def parse_function(function_token):
                 if argument_function is None:
                     return
             arguments.append(token)
+    if last_is_comma:
+        return
     return function_token.lower_name, arguments
 
 
@@ -499,6 +501,21 @@ def check_string_function(token):
         return ('string()', (custom_ident, ident))
 
 
+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))
+
+
 def get_string(token):
     """Parse a <string> token."""
     if token.type == 'string':
diff --git a/weasyprint/css/validation/__init__.py b/weasyprint/css/validation/__init__.py
index 450a02ef..e61b40a2 100644
--- a/weasyprint/css/validation/__init__.py
+++ b/weasyprint/css/validation/__init__.py
@@ -78,7 +78,9 @@ def preprocess_declarations(base_url, declarations):
         if declaration.type != 'declaration':
             continue
 
-        name = declaration.lower_name
+        name = declaration.name
+        if not name.startswith('--'):
+            name = declaration.lower_name
 
         def validation_error(level, reason):
             getattr(LOGGER, level)(
@@ -114,7 +116,7 @@ def preprocess_declarations(base_url, declarations):
                     unprefixed_name)
                 continue
 
-        if name.startswith('-'):
+        if name.startswith('-') and not name.startswith('--'):
             validation_error('debug', 'prefixed selectors are ignored')
             continue
 
diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py
index db610598..436bfa17 100644
--- a/weasyprint/css/validation/properties.py
+++ b/weasyprint/css/validation/properties.py
@@ -16,10 +16,11 @@ from ...formatting_structure import counters
 from .. import computed_values
 from ..properties import KNOWN_PROPERTIES, Dimension
 from ..utils import (
-    InvalidValues, 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_background_position, parse_function, single_keyword, single_token)
+    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_background_position, parse_function,
+    single_keyword, single_token)
 
 PREFIX = '-weasy-'
 PROPRIETARY = set()
@@ -80,6 +81,10 @@ def property(property_name=None, proprietary=False, unstable=False,
 
 def validate_non_shorthand(base_url, name, tokens, required=False):
     """Default validator for non-shorthand properties."""
+    if name.startswith('--'):
+        # TODO: validate content
+        return ((name, tokens),)
+
     if not required and name not in KNOWN_PROPERTIES:
         hyphens_name = name.replace('_', '-')
         if hyphens_name in KNOWN_PROPERTIES:
@@ -90,6 +95,11 @@ def validate_non_shorthand(base_url, name, tokens, required=False):
     if not required and name not in PROPERTIES:
         raise InvalidValues('property not supported yet')
 
+    for token in tokens:
+        var_function = check_var_function(token)
+        if var_function:
+            return ((name, var_function),)
+
     keyword = get_single_keyword(tokens)
     if keyword in ('initial', 'inherit'):
         value = keyword
diff --git a/weasyprint/layout/inlines.py b/weasyprint/layout/inlines.py
index c54ac6bc..d2324c7b 100644
--- a/weasyprint/layout/inlines.py
+++ b/weasyprint/layout/inlines.py
@@ -1230,14 +1230,13 @@ def add_word_spacing(context, box, justification_spacing, x_advance):
         box.position_x += x_advance
         nb_spaces = count_spaces(box)
         if nb_spaces > 0:
-            layout, _, resume_at, width, _, _ = split_first_line(
+            layout, _, resume_at, _, _, _ = split_first_line(
                 box.text, box.style, context, float('inf'),
                 box.justification_spacing)
             assert resume_at is None
-            # XXX new_box.width - box.width is always 0???
-            # x_advance +=  new_box.width - box.width
-            x_advance += justification_spacing * nb_spaces
-            box.width = width
+            extra_space = justification_spacing * nb_spaces
+            x_advance += extra_space
+            box.width += extra_space
             box.pango_layout = layout
     elif isinstance(box, (boxes.LineBox, boxes.InlineBox)):
         box.position_x += x_advance


Test patch:
diff --git a/weasyprint/tests/test_variables.py b/weasyprint/tests/test_variables.py
new file mode 100644
index 00000000..60b89bc4
--- /dev/null
+++ b/weasyprint/tests/test_variables.py
@@ -0,0 +1,97 @@
+"""
+    weasyprint.tests.test_variables
+    -------------------------------
+
+    Test CSS custom proproperties, also known as CSS variables.
+
+    :copyright: Copyright 2011-2019 Simon Sapin and contributors, see AUTHORS.
+    :license: BSD, see LICENSE for details.
+
+"""
+
+from .test_boxes import render_pages as parse
+
+
+def test_variable_simple():
+    page, = parse('''
+      <style>
+        p { --var: 10px; width: var(--var) }
+      </style>
+      <p></p>
+    ''')
+    html, = page.children
+    body, = html.children
+    paragraph, = body.children
+    assert paragraph.width == 10
+
+
+def test_variable_inherit():
+    page, = parse('''
+      <style>
+        html { --var: 10px }
+        p { width: var(--var) }
+      </style>
+      <p></p>
+    ''')
+    html, = page.children
+    body, = html.children
+    paragraph, = body.children
+    assert paragraph.width == 10
+
+
+def test_variable_inherit_override():
+    page, = parse('''
+      <style>
+        html { --var: 20px }
+        p { width: var(--var); --var: 10px }
+      </style>
+      <p></p>
+    ''')
+    html, = page.children
+    body, = html.children
+    paragraph, = body.children
+    assert paragraph.width == 10
+
+
+def test_variable_case_sensitive():
+    page, = parse('''
+      <style>
+        html { --var: 20px }
+        body { --VAR: 10px }
+        p { width: var(--VAR) }
+      </style>
+      <p></p>
+    ''')
+    html, = page.children
+    body, = html.children
+    paragraph, = body.children
+    assert paragraph.width == 10
+
+
+def test_variable_chain():
+    page, = parse('''
+      <style>
+        html { --foo: 10px }
+        body { --var: var(--foo) }
+        p { width: var(--var) }
+      </style>
+      <p></p>
+    ''')
+    html, = page.children
+    body, = html.children
+    paragraph, = body.children
+    assert paragraph.width == 10
+
+
+def test_variable_initial():
+    page, = parse('''
+      <style>
+        html { --var: initial }
+        p { width: var(--var, 10px) }
+      </style>
+      <p></p>
+    ''')
+    html, = page.children
+    body, = html.children
+    paragraph, = body.children
+    assert paragraph.width == 10