Repository: Kozea/WeasyPrint
Pull request #2361 (2025-01-24 11:00:01)

Support text-underline-offset and text-decoration-thickness


Patch:
diff --git a/docs/api_reference.rst b/docs/api_reference.rst
index c7c23c8c..e07997e5 100644
--- a/docs/api_reference.rst
+++ b/docs/api_reference.rst
@@ -718,22 +718,25 @@ 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/
 
-CSS Text Decoration Module Level 3
-++++++++++++++++++++++++++++++++++
+CSS Text Decoration Module Level 3 / 4
+++++++++++++++++++++++++++++++++++++++
 
 The `CSS Text Decoration Module Level 3`_ "contains the features of CSS
 relating to text decoration, such as underlines, text shadows, and emphasis
 marks."
 
-The ``text-decoration-line``, ``text-decoration-style`` and
-``text-decoration-color`` properties are supported, except from the ``wavy``
-value of ``text-decoration-style``. The ``text-decoration`` shorthand is also
-supported.
+The `CSS Text Decoration Module Level 4`_ is a working draft on the same subject.
+
+The ``text-decoration-line``, ``text-decoration-style``,
+``text-decoration-color``, ``text-decoration-thickness`` and
+``text-underline-offset`` properties are supported. The ``text-decoration``
+shorthand is also supported.
 
 The other properties (``text-underline-position``, ``text-emphasis-*``,
 ``text-shadow``) are not supported.
 
 .. _CSS Text Decoration Module Level 3: https://www.w3.org/TR/css-text-decor-3/
+.. _CSS Text Decoration Module Level 4: https://www.w3.org/TR/css-text-decor-4/
 
 CSS Flexible Box Layout Module Level 1
 ++++++++++++++++++++++++++++++++++++++
diff --git a/weasyprint/css/__init__.py b/weasyprint/css/__init__.py
index 227ebc75..dd3e9af8 100644
--- a/weasyprint/css/__init__.py
+++ b/weasyprint/css/__init__.py
@@ -230,7 +230,9 @@ def text_decoration(key, value, parent_value, cascaded):
     # using specific rules.
     # See https://drafts.csswg.org/css-text-decor-3/#line-decoration
     # TODO: these rules don’t follow the specification.
-    if key in ('text_decoration_color', 'text_decoration_style'):
+    text_properties = (
+        'text_decoration_color', 'text_decoration_style', 'text_decoration_thickness')
+    if key in text_properties:
         if not cascaded:
             value = parent_value
     elif key == 'text_decoration_line':
diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py
index 413ae0f6..45b165fa 100644
--- a/weasyprint/css/computed_values.py
+++ b/weasyprint/css/computed_values.py
@@ -267,9 +267,11 @@ def break_before_after(style, name, value):
 @register_computer('text-indent')
 @register_computer('hyphenate-limit-zone')
 @register_computer('flex-basis')
+@register_computer('text-underline-offset')
+@register_computer('text-decoration-thickness')
 def length(style, name, value, font_size=None, pixels_only=False):
     """Compute a length ``value``."""
-    if value in ('auto', 'content'):
+    if value in ('auto', 'content', 'from-font'):
         return value
     if value.value == 0:
         return 0 if pixels_only else ZERO_PIXELS
diff --git a/weasyprint/css/properties.py b/weasyprint/css/properties.py
index 301e60b2..0e5f08cc 100644
--- a/weasyprint/css/properties.py
+++ b/weasyprint/css/properties.py
@@ -223,10 +223,12 @@ INITIAL_VALUES = {
     'column_gap': 'normal',
     'row_gap': 'normal',
 
-    # Text Decoration Module 3 (CR): https://www.w3.org/TR/css-text-decor-3/
+    # Text Decoration Module 3/4 (CR/WD): https://www.w3.org/TR/css-text-decor-4/
     'text_decoration_line': 'none',
     'text_decoration_color': 'currentcolor',
     'text_decoration_style': 'solid',
+    'text_decoration_thickness': 'auto',
+    'text_underline_offset': 'auto',
 
     # Overflow Module 3/4 (WD): https://www.w3.org/TR/css-overflow-4/
     'block_ellipsis': 'none',
@@ -310,6 +312,7 @@ INHERITED = {
     'text_align_last',
     'text_indent',
     'text_transform',
+    'text_underline_offset',
     'visibility',
     'white_space',
     'widows',
diff --git a/weasyprint/css/validation/expanders.py b/weasyprint/css/validation/expanders.py
index cffab845..8e4781e7 100644
--- a/weasyprint/css/validation/expanders.py
+++ b/weasyprint/css/validation/expanders.py
@@ -19,7 +19,7 @@ from .properties import ( # isort:skip
     flex_grow_shrink, flex_wrap, font_family, font_size, font_stretch,
     font_style, font_weight, gap, grid_line, grid_template, line_height,
     list_style_image, list_style_position, list_style_type, mask_border_mode,
-    other_colors, overflow_wrap, validate_non_shorthand)
+    other_colors, overflow_wrap, text_decoration_thickness, validate_non_shorthand)
 
 EXPANDERS = {}
 
@@ -520,43 +520,46 @@ def expand_background(tokens, name, base_url):
 
 
 @expander('text-decoration')
-@generic_expander('-line', '-color', '-style')
+@generic_expander('-line', '-color', '-style', '-thickness')
 def expand_text_decoration(tokens, name):
     """Expand the ``text-decoration`` shorthand property."""
-    text_decoration_line = []
-    text_decoration_color = []
-    text_decoration_style = []
+    line = []
+    color = []
+    style = []
+    thickness = []
     none_in_line = False
 
     for token in tokens:
         keyword = get_keyword(token)
-        if keyword in (
-                'none', 'underline', 'overline', 'line-through', 'blink'):
-            text_decoration_line.append(token)
+        if keyword in ('none', 'underline', 'overline', 'line-through', 'blink'):
+            line.append(token)
             if none_in_line:
                 raise InvalidValues
             elif keyword == 'none':
                 none_in_line = True
         elif keyword in ('solid', 'double', 'dotted', 'dashed', 'wavy'):
-            if text_decoration_style:
+            if style:
                 raise InvalidValues
-            else:
-                text_decoration_style.append(token)
-        else:
-            color = parse_color(token)
-            if color is None:
+            style.append(token)
+        elif parse_color(token):
+            if color:
                 raise InvalidValues
-            elif text_decoration_color:
+            color.append(token)
+        elif text_decoration_thickness([token]):
+            if thickness:
                 raise InvalidValues
-            else:
-                text_decoration_color.append(token)
-
-    if text_decoration_line:
-        yield '-line', text_decoration_line
-    if text_decoration_color:
-        yield '-color', text_decoration_color
-    if text_decoration_style:
-        yield '-style', text_decoration_style
+            thickness.append(token)
+        else:
+            raise InvalidValues
+
+    if line:
+        yield '-line', line
+    if color:
+        yield '-color', color
+    if style:
+        yield '-style', style
+    if thickness:
+        yield '-thickness', thickness
 
 
 def expand_page_break_before_after(tokens, name):
diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py
index 1f29d072..4b908a9c 100644
--- a/weasyprint/css/validation/properties.py
+++ b/weasyprint/css/validation/properties.py
@@ -648,9 +648,10 @@ def counter(tokens, default_integer):
 @property('margin-right')
 @property('margin-bottom')
 @property('margin-left')
+@property('text-underline-offset')
 @single_token
 def lenght_precentage_or_auto(token):
-    """``margin-*`` properties validation."""
+    """``margin-*`` and various other properties validation."""
     length = get_length(token, percentage=True)
     if length:
         return length
@@ -1236,6 +1237,17 @@ def text_decoration_style(keyword):
         return keyword
 
 
+@property()
+@single_token
+def text_decoration_thickness(token):
+    """``text-decoration-thickness`` property validation."""
+    length = get_length(token, percentage=True)
+    if length:
+        return length
+    if keyword := get_keyword(token) in ('auto', 'from-font'):
+        return keyword
+
+
 @property()
 @single_token
 def text_indent(token):
diff --git a/weasyprint/draw/border.py b/weasyprint/draw/border.py
index 54a8c5f6..5f9a129c 100644
--- a/weasyprint/draw/border.py
+++ b/weasyprint/draw/border.py
@@ -601,6 +601,7 @@ def draw_line(stream, x1, y1, x2, y2, thickness, style, color, offset=0):
             x = x1 - offset
             stream.move_to(x, y1)
             while x < x2:
+                stream.set_line_width(thickness)
                 stream.curve_to(
                     x + radius / 2, y1 + up * radius,
                     x + 3 * radius / 2, y1 + up * radius,
diff --git a/weasyprint/draw/text.py b/weasyprint/draw/text.py
index 8bb2ad91..fe46ad4f 100644
--- a/weasyprint/draw/text.py
+++ b/weasyprint/draw/text.py
@@ -27,18 +27,29 @@ def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis):
     # Draw underline and overline.
     text_decoration_values = textbox.style['text_decoration_line']
     text_decoration_color = get_color(textbox.style, 'text_decoration_color')
+    if 'underline' in text_decoration_values or 'overline' in text_decoration_values:
+        if textbox.style['text_decoration_thickness'] in ('auto', 'from-font'):
+            thickness = textbox.pango_layout.underline_thickness
+        elif textbox.style['text_decoration_thickness'].unit == '%':
+            ratio = textbox.style['text_decoration_thickness'].value / 100
+            thickness = textbox.style['font_size'] * ratio
+        else:
+            thickness = textbox.style['text_decoration_thickness'].value
     if 'overline' in text_decoration_values:
-        thickness = textbox.pango_layout.underline_thickness
         offset_y = (
             textbox.baseline - textbox.pango_layout.ascent + thickness / 2)
         draw_text_decoration(
             stream, textbox, offset_x, offset_y, thickness,
             text_decoration_color)
     if 'underline' in text_decoration_values:
-        thickness = textbox.pango_layout.underline_thickness
-        offset_y = (
-            textbox.baseline - textbox.pango_layout.underline_position +
-            thickness / 2)
+        if textbox.style['text_underline_offset'] == 'auto':
+            underline_offset = - textbox.pango_layout.underline_position
+        elif textbox.style['text_underline_offset'].unit == '%':
+            ratio = textbox.style['text_underline_offset'].value / 100
+            underline_offset = textbox.style['font_size'] * ratio
+        else:
+            underline_offset = textbox.style['text_underline_offset'].value
+        offset_y = textbox.baseline + underline_offset + thickness / 2
         draw_text_decoration(
             stream, textbox, offset_x, offset_y, thickness,
             text_decoration_color)


Test patch:
diff --git a/tests/css/test_expanders.py b/tests/css/test_expanders.py
index a2ca5e4d..b6e25b91 100644
--- a/tests/css/test_expanders.py
+++ b/tests/css/test_expanders.py
@@ -47,9 +47,17 @@ def test_empty_expander_value(expander):
         'text_decoration_line': {'blink', 'line-through', 'overline'},
     }),
     ('red', {'text_decoration_color': parse_color('red')}),
+    ('blue 1px', {
+        'text_decoration_color': parse_color('blue'),
+        'text_decoration_thickness': (1, 'px'),
+    }),
+    ('100% none', {
+        'text_decoration_line': 'none',
+        'text_decoration_thickness': (100, '%'),
+    }),
     ('inherit', {
         f'text_decoration_{key}': 'inherit'
-        for key in ('color', 'line', 'style')}),
+        for key in ('color', 'line', 'style', 'thickness')}),
 ))
 def test_text_decoration(rule, result):
     assert expand_to_dict(f'text-decoration: {rule}') == result
@@ -59,8 +67,8 @@ def test_text_decoration(rule, result):
 @pytest.mark.parametrize('rule', (
     'solid solid',
     'red red',
-    '1px',
     'underline none',
+    '1px 100%',
     'none none',
 ))
 def test_text_decoration_invalid(rule):
diff --git a/tests/draw/test_text.py b/tests/draw/test_text.py
index 27e70ca0..b6b26547 100644
--- a/tests/draw/test_text.py
+++ b/tests/draw/test_text.py
@@ -528,6 +528,118 @@ def test_text_underline(assert_pixels):
       <div>abc</div>''')
 
 
+def test_text_underline_offset(assert_pixels):
+    assert_pixels('''
+        _____________
+        _zzzzzzzzzzz_
+        _zRRRRRRRRRz_
+        _zRRRRRRRRRz_
+        _zzzzzzzzzzz_
+        _zzzzzzzzzzz_
+        _zBBBBBBBBBz_
+        _zzzzzzzzzzz_
+        _____________
+    ''', '''
+      <style>
+        @page {
+          size: 13px 9px;
+          margin: 2px;
+        }
+        body {
+          color: red;
+          font-family: weasyprint;
+          font-size: 3px;
+          text-decoration: underline blue;
+          text-underline-offset: 2px;
+        }
+      </style>
+      <div>abc</div>''')
+
+
+def test_text_underline_offset_percentage(assert_pixels):
+    assert_pixels('''
+        _____________
+        _zzzzzzzzzzz_
+        _zRRRRRRRRRz_
+        _zRRRRRRRRRz_
+        _zzzzzzzzzzz_
+        _zzzzzzzzzzz_
+        _zBBBBBBBBBz_
+        _zzzzzzzzzzz_
+        _____________
+    ''', '''
+      <style>
+        @page {
+          size: 13px 9px;
+          margin: 2px;
+        }
+        body {
+          color: red;
+          font-family: weasyprint;
+          font-size: 3px;
+          text-decoration: underline blue;
+          text-underline-offset: 70%;
+        }
+      </style>
+      <div>abc</div>''')
+
+
+def test_text_underline_thickness(assert_pixels):
+    assert_pixels('''
+        _____________
+        _zzzzzzzzzzz_
+        _zRRRRRRRRRz_
+        _zRRRRRRRRRz_
+        _zzzzzzzzzzz_
+        _zzzzzzzzzzz_
+        _zBBBBBBBBBz_
+        _zBBBBBBBBBz_
+        _zzzzzzzzzzz_
+    ''', '''
+      <style>
+        @page {
+          size: 13px 9px;
+          margin: 2px;
+        }
+        body {
+          color: red;
+          font-family: weasyprint;
+          font-size: 3px;
+          text-decoration: underline blue 3px;
+          text-underline-offset: 2px;
+        }
+      </style>
+      <div>abc</div>''')
+
+
+def test_text_underline_thickness_percentage(assert_pixels):
+    assert_pixels('''
+        _____________
+        _zzzzzzzzzzz_
+        _zRRRRRRRRRz_
+        _zRRRRRRRRRz_
+        _zzzzzzzzzzz_
+        _zzzzzzzzzzz_
+        _zBBBBBBBBBz_
+        _zBBBBBBBBBz_
+        _zzzzzzzzzzz_
+    ''', '''
+      <style>
+        @page {
+          size: 13px 9px;
+          margin: 2px;
+        }
+        body {
+          color: red;
+          font-family: weasyprint;
+          font-size: 3px;
+          text-decoration: underline blue 100%;
+          text-underline-offset: 2px;
+        }
+      </style>
+      <div>abc</div>''')
+
+
 def test_text_overline(assert_pixels):
     # Ascent value seems to be a bit random, don’t try to get the exact
     # position of the line