Repository: Kozea/WeasyPrint
Pull request #2343 (2025-01-09 05:46:02)

Add support for outline-offset CSS property.
outline-offset provides a convenient way to give a box two different borders with space between them, useful for common ways of doing slightly fancier boxes without needing to go to the trouble of an image border. This pull request adds support for outline-offset and basic unit test coverage.


Patch:
diff --git a/docs/api_reference.rst b/docs/api_reference.rst
index 3d315400..f66ae511 100644
--- a/docs/api_reference.rst
+++ b/docs/api_reference.rst
@@ -814,7 +814,7 @@ The `CSS Basic User Interface Module Level 3/4`_ "enables authors to style user
 interface related properties and values."
 
 The ``outline-width``, ``outline-style``, ``outline-color`` properties and the
-``outline`` shorthand are supported. The ``outline-offset`` property is **not**
+``outline`` shorthand are supported. The ``outline-offset`` property is also
 supported.
 
 The ``resize``, ``cursor``, ``caret-*`` and ``nav-*`` properties are **not**
diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py
index 6186cb2d..413ae0f6 100644
--- a/weasyprint/css/computed_values.py
+++ b/weasyprint/css/computed_values.py
@@ -429,8 +429,9 @@ def border_image_repeat(style, name, values):
 
 
 @register_computer('column-width')
-def column_width(style, name, value):
-    """Compute the ``column-width`` property."""
+@register_computer('outline-offset')
+def length_pixels_only(style, name, value):
+    """Compute a pixel length property."""
     return length(style, name, value, pixels_only=True)
 
 
diff --git a/weasyprint/css/properties.py b/weasyprint/css/properties.py
index 947d52ac..301e60b2 100644
--- a/weasyprint/css/properties.py
+++ b/weasyprint/css/properties.py
@@ -182,6 +182,7 @@ INITIAL_VALUES = {
     'outline_color': 'currentcolor',  # invert is not supported
     'outline_style': 'none',
     'outline_width': 3,  # computed value for 'medium'
+    'outline_offset': 0,
 
     # Sizing 3 (WD): https://www.w3.org/TR/css-sizing-3/
     'box_sizing': 'content-box',
diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py
index e7c98c6e..1f29d072 100644
--- a/weasyprint/css/validation/properties.py
+++ b/weasyprint/css/validation/properties.py
@@ -1009,6 +1009,15 @@ def spacing(token):
         return length
 
 
+@property()
+@single_token
+def outline_offset(token):
+    """Validation for ``outline-offset``."""
+    length = get_length(token)
+    if length:
+        return length
+
+
 @property()
 @single_token
 def line_height(token):
diff --git a/weasyprint/draw/border.py b/weasyprint/draw/border.py
index 27ffb017..54a8c5f6 100644
--- a/weasyprint/draw/border.py
+++ b/weasyprint/draw/border.py
@@ -616,12 +616,15 @@ def draw_line(stream, x1, y1, x2, y2, thickness, style, color, offset=0):
 
 def draw_outline(stream, box):
     width = box.style['outline_width']
+    offset = box.style['outline_offset']
     color = get_color(box.style, 'outline_color')
     style = box.style['outline_style']
     if box.style['visibility'] == 'visible' and width and color.alpha:
         outline_box = (
-            box.border_box_x() - width, box.border_box_y() - width,
-            box.border_width() + 2 * width, box.border_height() + 2 * width)
+            box.border_box_x() - width - offset,
+            box.border_box_y() - width - offset,
+            box.border_width() + 2 * width + 2 * offset,
+            box.border_height() + 2 * width + 2 * offset)
         for side in SIDES:
             with stacked(stream):
                 clip_border_segment(stream, style, width, side, outline_box)


Test patch:
diff --git a/tests/draw/test_box.py b/tests/draw/test_box.py
index 69104c64..d390ec8d 100644
--- a/tests/draw/test_box.py
+++ b/tests/draw/test_box.py
@@ -657,3 +657,64 @@ def test_mask_border_fill(assert_pixels):
       </style>
       <div></div>
     ''')
+
+
+@assert_no_logs
+def test_outline_and_border(assert_pixels):
+    assert_pixels('''
+        __________
+        _BBBBBBBB_
+        _BRRRRRRB_
+        _BR____RB_
+        _BRRRRRRB_
+        _BBBBBBBB_
+        __________
+    ''', '''
+      <style>
+        @page {
+          size: 10px 7px;
+        }
+        div {
+          border: 1px solid red;
+          outline: 1px solid blue;
+          height: 1px;
+          margin: 2px;
+          min-height: auto;
+          min-width: auto;
+          width: 4px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_outline_offset(assert_pixels):
+    assert_pixels('''
+        ____________
+        _BBBBBBBBBB_
+        _B________B_
+        _B_RRRRRR_B_
+        _B_R____R_B_
+        _B_RRRRRR_B_
+        _B________B_
+        _BBBBBBBBBB_
+        ____________
+    ''', '''
+      <style>
+        @page {
+          size: 12px 9px;
+        }
+        div {
+          border: 1px solid red;
+          outline: 1px solid blue;
+          outline-offset: 1px;
+          height: 1px;
+          margin: 3px;
+          min-height: auto;
+          min-width: auto;
+          width: 4px;
+        }
+      </style>
+      <div></div>
+    ''')