Repository: Kozea/WeasyPrint
Pull request #2139 (2024-04-28 17:10:23)

Add support for mask-border-* properties
Add support for the mask-border-* properties as specified in https://drafts.fxtf.org/css-masking/#the-mask-border.

These can be handy when you want a background to extend exactly to an irregular image border, but don't want the background to stretch the same way the border does. It can also be useful for other cases where you want a non-rectangular box with a size depending on its content, and such.

This was surprisingly straightforward to add since I could reuse a lot of code from #2125.

Note for any comparison testing that Chrome uses -webkit-mask-box-image-* properties rather than the "standard" names.

Let me know if you need anything else. Thanks for your consideration!


Patch:
diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py
index 4e10f942..d02fbf3e 100644
--- a/weasyprint/css/computed_values.py
+++ b/weasyprint/css/computed_values.py
@@ -362,6 +362,7 @@ def border_width(style, name, value):
 
 
 @register_computer('border-image-slice')
+@register_computer('mask-border-slice')
 def border_image_slice(style, name, values):
     """Compute the ``border-image-slice`` property."""
     computed_values = []
@@ -385,6 +386,7 @@ def border_image_slice(style, name, values):
 
 
 @register_computer('border-image-width')
+@register_computer('mask-border-width')
 def border_image_width(style, name, values):
     """Compute the ``border-image-width`` property."""
     computed_values = []
@@ -404,6 +406,7 @@ def border_image_width(style, name, values):
 
 
 @register_computer('border-image-outset')
+@register_computer('mask-border-outset')
 def border_image_outset(style, name, values):
     """Compute the ``border-image-outset`` property."""
     computed_values = [
@@ -419,6 +422,7 @@ def border_image_outset(style, name, values):
 
 
 @register_computer('border-image-repeat')
+@register_computer('mask-border-repeat')
 def border_image_repeat(style, name, values):
     """Compute the ``border-image-repeat`` property."""
     return (values * 2) if len(values) == 1 else values
diff --git a/weasyprint/css/properties.py b/weasyprint/css/properties.py
index 450b7235..365b5cd1 100644
--- a/weasyprint/css/properties.py
+++ b/weasyprint/css/properties.py
@@ -77,6 +77,17 @@ INITIAL_VALUES = {
         Dimension(0, None), Dimension(0, None),
         Dimension(0, None), Dimension(0, None)),
     'border_image_repeat': ('stretch', 'stretch'),
+    'mask_border_source': ('none', None),
+    'mask_border_slice': (
+        Dimension(100, '%'), Dimension(100, '%'),
+        Dimension(100, '%'), Dimension(100, '%'),
+        None),
+    'mask_border_width': ('auto', 'auto', 'auto', 'auto'),
+    'mask_border_outset': (
+        Dimension(0, None), Dimension(0, None),
+        Dimension(0, None), Dimension(0, None)),
+    'mask_border_repeat': ('stretch', 'stretch'),
+    'mask_border_mode': 'alpha',
 
 
     # Color 3 (REC): https://www.w3.org/TR/css-color-3/
diff --git a/weasyprint/css/validation/expanders.py b/weasyprint/css/validation/expanders.py
index 51794f62..e6358571 100644
--- a/weasyprint/css/validation/expanders.py
+++ b/weasyprint/css/validation/expanders.py
@@ -18,8 +18,8 @@ from .properties import ( # isort:skip
     border_width, box, column_count, column_width, flex_basis, flex_direction,
     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, other_colors,
-    overflow_wrap, validate_non_shorthand)
+    list_style_image, list_style_position, list_style_type, mask_border_mode,
+    other_colors, overflow_wrap, validate_non_shorthand)
 
 EXPANDERS = {}
 
@@ -350,6 +350,72 @@ def expand_border_image(tokens, name, base_url):
             raise InvalidValues
 
 
+@expander('mask-border')
+@generic_expander('-outset', '-repeat', '-slice', '-source', '-width', '-mode',
+                  wants_base_url=True)
+def expand_mask_border(tokens, name, base_url):
+    """Expand the ``mask-border-*`` shorthand properties.
+
+    See https://drafts.fxtf.org/css-masking/#the-mask-border
+
+    """
+    tokens = list(tokens)
+    while tokens:
+        if border_image_source(tokens[:1], base_url):
+            yield '-source', [tokens.pop(0)]
+        elif mask_border_mode(tokens[:1]):
+            yield '-mode', [tokens.pop(0)]
+        elif border_image_repeat(tokens[:1]):
+            repeats = [tokens.pop(0)]
+            while tokens and border_image_repeat(tokens[:1]):
+                repeats.append(tokens.pop(0))
+            yield '-repeat', repeats
+        elif border_image_slice(tokens[:1]) or get_keyword(tokens[0]) == 'fill':
+            slices = [tokens.pop(0)]
+            while tokens and border_image_slice(slices + tokens[:1]):
+                slices.append(tokens.pop(0))
+            yield '-slice', slices
+            if tokens and tokens[0].type == 'literal' and tokens[0].value == '/':
+                # slices / *
+                tokens.pop(0)
+            else:
+                # slices other
+                continue
+            if not tokens:
+                # slices /
+                raise InvalidValues
+            if border_image_width(tokens[:1]):
+                widths = [tokens.pop(0)]
+                while tokens and border_image_width(widths + tokens[:1]):
+                    widths.append(tokens.pop(0))
+                yield '-width', widths
+                if tokens and tokens[0].type == 'literal' and tokens[0].value == '/':
+                    # slices / widths / slash *
+                    tokens.pop(0)
+                else:
+                    # slices / widths other
+                    continue
+            elif tokens and tokens[0].type == 'literal' and tokens[0].value == '/':
+                # slices / / *
+                tokens.pop(0)
+            else:
+                # slices / other
+                raise InvalidValues
+            if not tokens:
+                # slices / * /
+                raise InvalidValues
+            if border_image_outset(tokens[:1]):
+                outsets = [tokens.pop(0)]
+                while tokens and border_image_outset(outsets + tokens[:1]):
+                    outsets.append(tokens.pop(0))
+                yield '-outset', outsets
+            else:
+                # slash / * / other
+                raise InvalidValues
+        else:
+            raise InvalidValues
+
+
 @expander('background')
 def expand_background(tokens, name, base_url):
     """Expand the ``background`` shorthand property.
diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py
index 57ae504c..27e64568 100644
--- a/weasyprint/css/validation/properties.py
+++ b/weasyprint/css/validation/properties.py
@@ -421,7 +421,8 @@ def border_width(token):
         return keyword
 
 
-@property(wants_base_url=True)
+@property('border-image-source', wants_base_url=True)
+@property('mask-border-source', wants_base_url=True)
 @single_token
 def border_image_source(token, base_url):
     if get_keyword(token) == 'none':
@@ -429,7 +430,8 @@ def border_image_source(token, base_url):
     return get_image(token, base_url)
 
 
-@property()
+@property('border-image-slice')
+@property('mask-border-slice')
 def border_image_slice(tokens):
     values = []
     fill = False
@@ -449,7 +451,8 @@ def border_image_slice(tokens):
         return tuple(values)
 
 
-@property()
+@property('border-image-width')
+@property('mask-border-width')
 def border_image_width(tokens):
     values = []
     for token in tokens:
@@ -467,7 +470,8 @@ def border_image_width(tokens):
         return tuple(values)
 
 
-@property()
+@property('border-image-outset')
+@property('mask-border-outset')
 def border_image_outset(tokens):
     values = []
     for token in tokens:
@@ -483,7 +487,8 @@ def border_image_outset(tokens):
         return tuple(values)
 
 
-@property()
+@property('border-image-repeat')
+@property('mask-border-repeat')
 def border_image_repeat(tokens):
     if 1 <= len(tokens) <= 2:
         keywords = tuple(get_keyword(token) for token in tokens)
@@ -491,6 +496,12 @@ def border_image_repeat(tokens):
             return keywords
 
 
+@property()
+@single_keyword
+def mask_border_mode(keyword):
+    return keyword in ('luminance', 'alpha')
+
+
 @property(unstable=True)
 @single_token
 def column_width(token):
diff --git a/weasyprint/draw.py b/weasyprint/draw.py
index f619ed36..e76e1833 100644
--- a/weasyprint/draw.py
+++ b/weasyprint/draw.py
@@ -62,6 +62,7 @@ def draw_page(page, stream):
     draw_background(
         stream, stacking_context.box.background, clip_box=False,
         bleed=page.bleed, marks=marks)
+    set_mask_border(stream, page)
     draw_background(stream, page.canvas_background, clip_box=False)
     draw_border(stream, page)
     draw_stacking_context(stream, stacking_context)
@@ -116,6 +117,7 @@ def draw_stacking_context(stream, stacking_context):
         if isinstance(box, (boxes.BlockBox, boxes.MarginBox,
                             boxes.InlineBlockBox, boxes.TableCellBox,
                             boxes.FlexContainerBox, boxes.ReplacedBox)):
+            set_mask_border(stream, box)
             # The canvas background was removed by layout_backgrounds
             draw_background(stream, box.background)
             draw_border(stream, box)
@@ -137,6 +139,8 @@ def draw_stacking_context(stream, stacking_context):
 
             # Point 4
             for block in stacking_context.block_level_boxes:
+                set_mask_border(stream, block)
+
                 if isinstance(block, boxes.TableBox):
                     draw_table(stream, block)
                 else:
@@ -441,7 +445,10 @@ def draw_border(stream, box):
 
     # If there's a border image, that takes precedence.
     if box.style['border_image_source'][0] != 'none' and box.border_image is not None:
-        draw_border_image(box, stream)
+        draw_border_image(
+            box, stream, box.border_image, box.style['border_image_slice'],
+            box.style['border_image_repeat'], box.style['border_image_outset'],
+            box.style['border_image_width'])
         return
 
     widths = [getattr(box, f'border_{side}_width') for side in SIDES]
@@ -479,18 +486,18 @@ def draw_border(stream, box):
                 stream, box, style, styled_color(style, color, side))
 
 
-def draw_border_image(box, stream):
-    """Draw ``box`` border image on ``stream``."""
-    # See https://drafts.csswg.org/css-backgrounds-3/#border-images
-    image = box.border_image
+def draw_border_image(box, stream, image, border_slice, border_repeat, border_outset,
+                      border_width):
+    """Draw ``image`` as a border image for ``box`` on ``stream`` as specified."""
+    # Shared by border-image-* and mask-border-*
     width, height, ratio = image.get_intrinsic_size(
         box.style['image_resolution'], box.style['font_size'])
     intrinsic_width, intrinsic_height = replaced.default_image_sizing(
         width, height, ratio, specified_width=None, specified_height=None,
         default_width=box.border_width(), default_height=box.border_height())
 
-    image_slice = box.style['border_image_slice'][:4]
-    should_fill = box.style['border_image_slice'][4]
+    image_slice = border_slice[:4]
+    should_fill = border_slice[4]
 
     def compute_slice_dimension(dimension, intrinsic):
         if isinstance(dimension, (int, float)):
@@ -504,7 +511,7 @@ def draw_border_image(box, stream):
     slice_bottom = compute_slice_dimension(image_slice[2], intrinsic_height)
     slice_left = compute_slice_dimension(image_slice[3], intrinsic_width)
 
-    style_repeat_x, style_repeat_y = box.style['border_image_repeat']
+    repeat_x, repeat_y = border_repeat
 
     x, y, w, h, tl, tr, br, bl = box.rounded_border_box()
     px, py, pw, ph, ptl, ptr, pbr, pbl = box.rounded_padding_box()
@@ -520,11 +527,10 @@ def draw_border_image(box, stream):
             assert dimension.unit == 'px'
             return dimension.value
 
-    outsets = box.style['border_image_outset']
-    outset_top = compute_outset_dimension(outsets[0], border_top)
-    outset_right = compute_outset_dimension(outsets[1], border_right)
-    outset_bottom = compute_outset_dimension(outsets[2], border_bottom)
-    outset_left = compute_outset_dimension(outsets[3], border_left)
+    outset_top = compute_outset_dimension(border_outset[0], border_top)
+    outset_right = compute_outset_dimension(border_outset[1], border_right)
+    outset_bottom = compute_outset_dimension(border_outset[2], border_bottom)
+    outset_left = compute_outset_dimension(border_outset[3], border_left)
 
     x -= outset_left
     y -= outset_top
@@ -548,20 +554,18 @@ def draw_border_image(box, stream):
     # border-image-width. Also, the border image area that is used
     # for percentage-based border-image-width values includes any expanded
     # area due to border-image-outset.
-    widths = box.style['border_image_width']
     border_top = compute_width_adjustment(
-        widths[0], border_top, slice_top, h)
+        border_width[0], border_top, slice_top, h)
     border_right = compute_width_adjustment(
-        widths[1], border_right, slice_right, w)
+        border_width[1], border_right, slice_right, w)
     border_bottom = compute_width_adjustment(
-        widths[2], border_bottom, slice_bottom, h)
+        border_width[2], border_bottom, slice_bottom, h)
     border_left = compute_width_adjustment(
-        widths[3], border_left, slice_left, w)
+        border_width[3], border_left, slice_left, w)
 
-    def draw_border_image(x, y, width, height, slice_x, slice_y,
-                          slice_width, slice_height,
-                          repeat_x='stretch', repeat_y='stretch',
-                          scale_x=None, scale_y=None):
+    def draw_border_image_region(x, y, width, height, slice_x, slice_y, slice_width,
+                                 slice_height, repeat_x='stretch', repeat_y='stretch',
+                                 scale_x=None, scale_y=None):
         if 0 in (intrinsic_width, width, slice_width):
             scale_x = 0
         else:
@@ -636,60 +640,73 @@ def draw_border_image(box, stream):
         return scale_x, scale_y
 
     # Top left.
-    scale_left, scale_top = draw_border_image(
+    scale_left, scale_top = draw_border_image_region(
         x, y, border_left, border_top, 0, 0, slice_left, slice_top)
     # Top right.
-    draw_border_image(
+    draw_border_image_region(
         x + w - border_right, y, border_right, border_top,
         intrinsic_width - slice_right, 0, slice_right, slice_top)
     # Bottom right.
-    scale_right, scale_bottom = draw_border_image(
+    scale_right, scale_bottom = draw_border_image_region(
         x + w - border_right, y + h - border_bottom, border_right, border_bottom,
         intrinsic_width - slice_right, intrinsic_height - slice_bottom,
         slice_right, slice_bottom)
     # Bottom left.
-    draw_border_image(
+    draw_border_image_region(
         x, y + h - border_bottom, border_left, border_bottom,
         0, intrinsic_height - slice_bottom, slice_left, slice_bottom)
-    if slice_left + slice_right < intrinsic_width:
+    if x_middle := slice_left + slice_right < intrinsic_width:
         # Top middle.
-        draw_border_image(
+        draw_border_image_region(
             x + border_left, y, w - border_left - border_right, border_top,
             slice_left, 0, intrinsic_width - slice_left - slice_right,
-            slice_top, repeat_x=style_repeat_x)
+            slice_top, repeat_x=repeat_x)
         # Bottom middle.
-        draw_border_image(
+        draw_border_image_region(
             x + border_left, y + h - border_bottom,
             w - border_left - border_right, border_bottom,
             slice_left, intrinsic_height - slice_bottom,
             intrinsic_width - slice_left - slice_right, slice_bottom,
-            repeat_x=style_repeat_x)
-    if slice_top + slice_bottom < intrinsic_height:
+            repeat_x=repeat_x)
+    if y_middle := slice_top + slice_bottom < intrinsic_height:
         # Right middle.
-        draw_border_image(
+        draw_border_image_region(
             x + w - border_right, y + border_top,
             border_right, h - border_top - border_bottom,
             intrinsic_width - slice_right, slice_top,
             slice_right, intrinsic_height - slice_top - slice_bottom,
-            repeat_y=style_repeat_y)
+            repeat_y=repeat_y)
         # Left middle.
-        draw_border_image(
+        draw_border_image_region(
             x, y + border_top, border_left, h - border_top - border_bottom,
             0, slice_top, slice_left,
             intrinsic_height - slice_top - slice_bottom,
-            repeat_y=style_repeat_y)
-    if (should_fill and slice_left + slice_right < intrinsic_width
-            and slice_top + slice_bottom < intrinsic_height):
+            repeat_y=repeat_y)
+    if should_fill and x_middle and y_middle:
         # Fill middle.
-        draw_border_image(
+        draw_border_image_region(
             x + border_left, y + border_top, w - border_left - border_right,
             h - border_top - border_bottom, slice_left, slice_top,
             intrinsic_width - slice_left - slice_right,
             intrinsic_height - slice_top - slice_bottom,
-            repeat_x=style_repeat_x, repeat_y=style_repeat_y,
+            repeat_x=repeat_x, repeat_y=repeat_y,
             scale_x=scale_left or scale_right, scale_y=scale_top or scale_bottom)
 
 
+def set_mask_border(stream, box):
+    """Set ``box`` mask border as alpha state on ``stream``."""
+    if box.style['mask_border_source'][0] == 'none' or box.mask_border_image is None:
+        return
+    x, y, w, h, tl, tr, br, bl = box.rounded_border_box()
+    matrix = Matrix(e=x, f=y)
+    matrix @= stream.ctm
+    mask_stream = stream.set_alpha_state(x, y, w, h, box.style['mask_border_mode'])
+    draw_border_image(
+        box, mask_stream, box.mask_border_image, box.style['mask_border_slice'],
+        box.style['mask_border_repeat'], box.style['mask_border_outset'],
+        box.style['mask_border_width'])
+
+
 def clip_border_segment(stream, style, width, side, border_box,
                         border_widths=None, radii=None):
     """Clip one segment of box border.
@@ -1205,6 +1222,7 @@ def draw_inline_level(stream, page, box, offset_x=0, text_overflow='clip',
             boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox))
         draw_stacking_context(stream, stacking_context)
     else:
+        set_mask_border(stream, box)
         draw_background(stream, box.background)
         draw_border(stream, box)
         if isinstance(box, (boxes.InlineBox, boxes.LineBox)):
diff --git a/weasyprint/layout/background.py b/weasyprint/layout/background.py
index 09599165..3ca357ef 100644
--- a/weasyprint/layout/background.py
+++ b/weasyprint/layout/background.py
@@ -56,6 +56,13 @@ def layout_box_backgrounds(page, box, get_image_from_uri, layout_children=True,
         else:
             box.border_image = value
 
+    if style['mask_border_source'][0] != 'none':
+        type_, value = style['mask_border_source']
+        if type_ == 'url':
+            box.mask_border_image = get_image_from_uri(url=value)
+        else:
+            box.mask_border_image = value
+
     if style['visibility'] == 'hidden':
         images = []
         color = parse_color('transparent')
diff --git a/weasyprint/pdf/stream.py b/weasyprint/pdf/stream.py
index a1d7abb0..0c7ba66d 100644
--- a/weasyprint/pdf/stream.py
+++ b/weasyprint/pdf/stream.py
@@ -112,13 +112,13 @@ class Stream(pydyf.Stream):
                     self._states[key] = pydyf.Dictionary({'ca': alpha})
                 super().set_state(key)
 
-    def set_alpha_state(self, x, y, width, height):
+    def set_alpha_state(self, x, y, width, height, mode='luminosity'):
         alpha_stream = self.add_group(x, y, width, height)
         alpha_state = pydyf.Dictionary({
             'Type': '/ExtGState',
             'SMask': pydyf.Dictionary({
                 'Type': '/Mask',
-                'S': '/Luminosity',
+                'S': f'/{mode.capitalize()}',
                 'G': alpha_stream,
             }),
             'ca': 1,


Test patch:
diff --git a/tests/css/test_expanders.py b/tests/css/test_expanders.py
index e5654e61..1c2b830a 100644
--- a/tests/css/test_expanders.py
+++ b/tests/css/test_expanders.py
@@ -454,6 +454,74 @@ def test_border_image_invalid(rule, reason):
     assert_invalid(f'border-image: {rule}', reason)
 
 
+@assert_no_logs
+@pytest.mark.parametrize('rule, result', (
+    ('url(border.png) 27', {
+        'mask_border_source': ('url', 'https://weasyprint.org/foo/border.png'),
+        'mask_border_slice': ((27, None),),
+    }),
+    ('url(border.png) 10 / 4 / 2 round stretch', {
+        'mask_border_source': ('url', 'https://weasyprint.org/foo/border.png'),
+        'mask_border_slice': ((10, None),),
+        'mask_border_width': ((4, None),),
+        'mask_border_outset': ((2, None),),
+        'mask_border_repeat': (('round', 'stretch')),
+    }),
+    ('10 // 2', {
+        'mask_border_slice': ((10, None),),
+        'mask_border_outset': ((2, None),),
+    }),
+    ('5.5%', {
+        'mask_border_slice': ((5.5, '%'),),
+    }),
+    ('stretch 2 url("border.png")', {
+        'mask_border_source': ('url', 'https://weasyprint.org/foo/border.png'),
+        'mask_border_slice': ((2, None),),
+        'mask_border_repeat': (('stretch',)),
+    }),
+    ('1/2 round', {
+        'mask_border_slice': ((1, None),),
+        'mask_border_width': ((2, None),),
+        'mask_border_repeat': (('round',)),
+    }),
+    ('none', {
+        'mask_border_source': ('none', None),
+    }),
+    ('url(border.png) 27 alpha', {
+        'mask_border_source': ('url', 'https://weasyprint.org/foo/border.png'),
+        'mask_border_slice': ((27, None),),
+        'mask_border_mode': 'alpha',
+    }),
+    ('url(border.png) 27 luminance', {
+        'mask_border_source': ('url', 'https://weasyprint.org/foo/border.png'),
+        'mask_border_slice': ((27, None),),
+        'mask_border_mode': 'luminance',
+    }),
+))
+def test_mask_border(rule, result):
+    assert expand_to_dict(f'mask-border: {rule}') == result
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, reason', (
+    ('url(border.png) url(border.png)', 'multiple source'),
+    ('10 10 10 10 10', 'multiple slice'),
+    ('1 / 2 / 3 / 4', 'invalid'),
+    ('/1', 'invalid'),
+    ('/1', 'invalid'),
+    ('round round round', 'invalid'),
+    ('-1', 'invalid'),
+    ('1 repeat 2', 'multiple slice'),
+    ('1% // 1%', 'invalid'),
+    ('1 / repeat', 'invalid'),
+    ('', 'no value'),
+    ('alpha alpha', 'multiple mode'),
+    ('alpha luminance', 'multiple mode'),
+))
+def test_mask_border_invalid(rule, reason):
+    assert_invalid(f'mask-border: {rule}', reason)
+
+
 @assert_no_logs
 @pytest.mark.parametrize('rule, result', (
     ('12px My Fancy Font, serif', {
diff --git a/tests/css/test_validation.py b/tests/css/test_validation.py
index cf01bba2..c91adac6 100644
--- a/tests/css/test_validation.py
+++ b/tests/css/test_validation.py
@@ -470,6 +470,135 @@ def test_border_image_repeat_invalid(rule):
     assert_invalid(f'border-image-repeat: {rule}')
 
 
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('1', ((1, None),)),
+    ('1 2    3 4', ((1, None), (2, None), (3, None), (4, None))),
+    ('50% 1000.1 0', ((50, '%'), (1000.1, None), (0, None))),
+    ('1% 2% 3% 4%', ((1, '%'), (2, '%'), (3, '%'), (4, '%'))),
+    ('fill 10% 20', ('fill', (10, '%'), (20, None))),
+    ('0 1 0.5 fill', ((0, None), (1, None), (0.5, None), 'fill')),
+))
+def test_mask_border_slice(rule, value):
+    assert get_value(f'mask-border-slice: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'none',
+    '1, 2',
+    '-10',
+    '-10%',
+    '1 2 3 -10%',
+    '-0.3',
+    '1 fill 2',
+    'fill 1 2 3 fill',
+))
+def test_mask_border_slice_invalid(rule):
+    assert_invalid(f'mask-border-slice: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('1', ((1, None),)),
+    ('1 2    3 4', ((1, None), (2, None), (3, None), (4, None))),
+    ('50% 1000.1 0', ((50, '%'), (1000.1, None), (0, None))),
+    ('1% 2px 3em 4', ((1, '%'), (2, 'px'), (3, 'em'), (4, None))),
+    ('auto', ('auto',)),
+    ('1 auto', ((1, None), 'auto')),
+    ('auto auto', ('auto', 'auto')),
+    ('auto auto auto 2', ('auto', 'auto', 'auto', (2, None))),
+))
+def test_mask_border_width(rule, value):
+    assert get_value(f'mask-border-width: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'none',
+    '1, 2',
+    '1 -2',
+    '-10',
+    '-10%',
+    '1px 2px 3px -10%',
+    '-3px',
+    'auto auto auto auto auto',
+    '1 2 3 4 5',
+))
+def test_mask_border_width_invalid(rule):
+    assert_invalid(f'mask-border-width: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('1', ((1, None),)),
+    ('1 2    3 4', ((1, None), (2, None), (3, None), (4, None))),
+    ('50px 1000.1 0', ((50, 'px'), (1000.1, None), (0, None))),
+    ('1in 2px 3em 4', ((1, 'in'), (2, 'px'), (3, 'em'), (4, None))),
+))
+def test_mask_border_outset(rule, value):
+    assert get_value(f'mask-border-outset: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'none',
+    'auto',
+    '1, 2',
+    '-10',
+    '1 -2',
+    '10%',
+    '1px 2px 3px -10px',
+    '-3px',
+    '1 2 3 4 5',
+))
+def test_mask_border_outset_invalid(rule):
+    assert_invalid(f'mask-border-outset: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('stretch', ('stretch',)),
+    ('repeat repeat', ('repeat', 'repeat')),
+    ('round     space', ('round', 'space')),
+))
+def test_mask_border_repeat(rule, value):
+    assert get_value(f'mask-border-repeat: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'none',
+    'test',
+    'round round round',
+    'stretch space round',
+    'repeat test',
+))
+def test_mask_border_repeat_invalid(rule):
+    assert_invalid(f'mask-border-repeat: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('alpha', 'alpha'),
+    ('luminance', 'luminance'),
+    ('alpha     ', 'alpha'),
+))
+def test_mask_border_mode(rule, value):
+    assert get_value(f'mask-border-mode: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'none',
+    'test',
+    'alpha alpha',
+    'alpha luminance',
+))
+def test_mask_border_mode_invalid(rule):
+    assert_invalid(f'mask-border-mode: {rule}')
+
+
 @assert_no_logs
 @pytest.mark.parametrize('rule, value', (
     ('test content(text)', (('test', (('content()', 'text'),)),)),
diff --git a/tests/draw/test_box.py b/tests/draw/test_box.py
index 168aeb19..dc070374 100644
--- a/tests/draw/test_box.py
+++ b/tests/draw/test_box.py
@@ -576,3 +576,65 @@ def test_border_image_gradient(assert_pixels):
       </style>
       <div></div>
     ''')
+
+
+@assert_no_logs
+def test_mask_border(assert_pixels):
+    assert_pixels('''
+        __________
+        __RR__RRR_
+        _R______R_
+        _R______R_
+        _s______R_
+        _s______R_
+        _R______R_
+        _R______R_
+        __RRRRRRR_
+        __________
+    ''', '''
+      <style>
+        @page {
+          size: 10px 10px;
+        }
+        div {
+          background: red;
+          mask-border-source: url(mask.svg);
+          mask-border-slice: 20%;
+          height: 8px;
+          width: 8px;
+          margin: 1px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_mask_border_fill(assert_pixels):
+    assert_pixels('''
+        __________
+        __RR__RRR_
+        _RRRRRRRR_
+        _RRRRRRRR_
+        _sRR__RRR_
+        _sRR__RRR_
+        _RRRRRRRR_
+        _RRRRRRRR_
+        __RRRRRRR_
+        __________
+    ''', '''
+      <style>
+        @page {
+          size: 10px 10px;
+        }
+        div {
+          background: red;
+          mask-border-source: url(mask.svg);
+          mask-border-slice: 20% fill;
+          height: 8px;
+          width: 8px;
+          margin: 1px;
+        }
+      </style>
+      <div></div>
+    ''')
diff --git a/tests/resources/mask.svg b/tests/resources/mask.svg
new file mode 100644
index 00000000..7c3469e7
--- /dev/null
+++ b/tests/resources/mask.svg
@@ -0,0 +1,4 @@
+<svg height="5" width="5" xmlns="http://www.w3.org/2000/svg">
+  <path d="M 1 0 L 1 1 L 0 1 L 0 2 L 1 2 L 1 3 L 0 3 L 0 4 L 1 4 L 1 5 L 2 5 L 3 5 L 4 5 L 5 5 L 5 4 L 5 3 L 5 2 L 5 1 L 5 0 L 4 0 L 3 0 L 3 1 L 2 1 L 2 0 L 1 0 z M 2 2 L 3 2 L 3 3 L 2 3 L 2 2 z " />
+  <rect fill="black" opacity="0.5" width="1" height="1" x="0" y="2" />
+</svg>