Repository: Kozea/WeasyPrint
Pull request #2125 (2024-04-10 12:28:14)

Add basic support for border-image-source and border-image-slice.
This adds basic support for border-image-source and border-image-slice.

This is sufficient for common uses of image borders in CSS.

It does not yet support non-default values for border-image-width, border-image-outset, or border-image-repeat, which I plan on addressing in future pull requests.

This is my first substantial contribution, so let me know if I should be putting this logic in a different place or otherwise approaching it differently. Thanks for your review!

First part of #2124.

liZe: That’s great, thanks a lot! The code seems to be almost perfect. ❤️

I’ll fix a couple of things and push small commits so that you can easily track what I’ve changed.

I think that we’ll merge this PR just after 42 is released (hopefully on Friday), because it already contains a lot of features and may cause a lot of bug reports.
xavidotron: Great, thanks for your review!

Patch:
diff --git a/docs/api_reference.rst b/docs/api_reference.rst
index 4d3a4342..6826269d 100644
--- a/docs/api_reference.rst
+++ b/docs/api_reference.rst
@@ -558,10 +558,10 @@ background layers per box), including the ``background``, ``background-color``,
 WeasyPrint also supports the `rounded corners part`_ of this module, including
 the ``border-radius`` property.
 
-WeasyPrint does **not** support the `border images part`_ of this module,
-including the ``border-image``, ``border-image-source``,
-``border-image-slice``, ``border-image-width``, ``border-image-outset`` and
-``border-image-repeat`` properties.
+WeasyPrint also supports the `border images part`_ of this module, including the
+``border-image``, ``border-image-source``, ``border-image-slice``,
+``border-image-width``, ``border-image-outset`` and ``border-image-repeat``
+properties.
 
 WeasyPrint does **not** support the `box shadow part`_ of this module,
 including the ``box-shadow`` property. This feature has been implemented in a
diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py
index 6ea74041..a9f83375 100644
--- a/weasyprint/css/computed_values.py
+++ b/weasyprint/css/computed_values.py
@@ -361,6 +361,69 @@ def border_width(style, name, value):
     return length(style, name, value, pixels_only=True)
 
 
+@register_computer('border-image-slice')
+def border_image_slice(style, name, values):
+    """Compute the ``border-image-slice`` property."""
+    computed_values = []
+    fill = None
+    for value in values:
+        if value == 'fill':
+            fill = value
+        else:
+            number, unit = value
+            if unit is None:
+                computed_values.append(number)
+            else:
+                computed_values.append(Dimension(number, '%'))
+    if len(computed_values) == 1:
+        computed_values *= 4
+    elif len(computed_values) == 2:
+        computed_values *= 2
+    elif len(computed_values) == 3:
+        computed_values.append(computed_values[1])
+    return (*computed_values, fill)
+
+
+@register_computer('border-image-width')
+def border_image_width(style, name, values):
+    """Compute the ``border-image-width`` property."""
+    computed_values = []
+    for value in values:
+        if value == 'auto':
+            computed_values.append(value)
+        else:
+            number, unit = value
+            computed_values.append(number if unit is None else value)
+    if len(computed_values) == 1:
+        computed_values *= 4
+    elif len(computed_values) == 2:
+        computed_values *= 2
+    elif len(computed_values) == 3:
+        computed_values.append(computed_values[1])
+    return tuple(computed_values)
+
+
+@register_computer('border-image-outset')
+def border_image_outset(style, name, values):
+    """Compute the ``border-image-outset`` property."""
+    computed_values = [
+        value if isinstance(value, (int, float)) else length(style, name, value)
+        for value in values]
+    if len(computed_values) == 1:
+        computed_values *= 4
+    elif len(computed_values) == 2:
+        computed_values *= 2
+    elif len(computed_values) == 3:
+        computed_values.append(computed_values[1])
+    return tuple(computed_values)
+
+
+@register_computer('border-image-repeat')
+def border_image_repeat(style, name, values):
+    """Compute the ``border-image-repeat`` property."""
+    return (values * 2) if len(values) == 1 else values
+
+
 @register_computer('column-width')
 def column_width(style, name, value):
     """Compute the ``column-width`` property."""
diff --git a/weasyprint/css/properties.py b/weasyprint/css/properties.py
index 2c51366c..1917fe12 100644
--- a/weasyprint/css/properties.py
+++ b/weasyprint/css/properties.py
@@ -67,6 +67,17 @@ INITIAL_VALUES = {
     'border_top_right_radius': (ZERO_PIXELS, ZERO_PIXELS),
     'border_top_style': 'none',
     'border_top_width': 3,  # computed value for 'medium'
+    'border_image_source': ('none', None),
+    'border_image_slice': (
+        Dimension(100, '%'), Dimension(100, '%'),
+        Dimension(100, '%'), Dimension(100, '%'),
+        None),
+    'border_image_width': (1, 1, 1, 1),
+    'border_image_outset': (
+        Dimension(0, None), Dimension(0, None),
+        Dimension(0, None), Dimension(0, None)),
+    'border_image_repeat': ('stretch', 'stretch'),
+
 
     # Color 3 (REC): https://www.w3.org/TR/css-color-3/
     'opacity': 1,
diff --git a/weasyprint/css/validation/expanders.py b/weasyprint/css/validation/expanders.py
index bb95d6ef..4a9de12f 100644
--- a/weasyprint/css/validation/expanders.py
+++ b/weasyprint/css/validation/expanders.py
@@ -11,14 +11,14 @@ from .descriptors import expand_font_variant
 from ..utils import (  # isort:skip
     InvalidValues, Pending, check_var_function, get_keyword, get_single_keyword,
     split_on_comma)
-from .properties import (  # isort:skip
-    background_attachment, background_image, background_position,
-    background_repeat, background_size, block_ellipsis, border_style,
+from .properties import ( # isort:skip
+    background_attachment, background_image, background_position, background_repeat,
+    background_size, block_ellipsis, border_image_source, border_image_slice,
+    border_image_width, border_image_outset, border_image_repeat, border_style,
     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, line_height, list_style_image,
-    list_style_position, list_style_type, other_colors, overflow_wrap,
-    validate_non_shorthand)
+    flex_grow_shrink, flex_wrap, font_family, font_size, font_stretch, font_style,
+    font_weight, line_height, list_style_image, list_style_position, list_style_type,
+    other_colors, overflow_wrap, validate_non_shorthand)
 
 EXPANDERS = {}
 
@@ -285,6 +285,70 @@ def expand_border_side(tokens, name):
         yield suffix, [token]
 
 
+@expander('border-image')
+@generic_expander('-outset', '-repeat', '-slice', '-source', '-width',
+                  wants_base_url=True)
+def expand_border_image(tokens, name, base_url):
+    """Expand the ``border-image-*`` shorthand properties.
+
+    See https://drafts.csswg.org/css-backgrounds/#the-border-image
+
+    """
+    tokens = list(tokens)
+    while tokens:
+        if border_image_source(tokens[:1], base_url):
+            yield '-source', [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 36727376..377a75ac 100644
--- a/weasyprint/css/validation/properties.py
+++ b/weasyprint/css/validation/properties.py
@@ -167,9 +167,8 @@ def color(token):
 @comma_separated_list
 @single_token
 def background_image(token, base_url):
-    if token.type != 'function':
-        if get_keyword(token) == 'none':
-            return 'none', None
+    if get_keyword(token) == 'none':
+        return 'none', None
     return get_image(token, base_url)
 
 
@@ -421,6 +420,76 @@ def border_width(token):
         return keyword
 
 
+@property(wants_base_url=True)
+@single_token
+def border_image_source(token, base_url):
+    if get_keyword(token) == 'none':
+        return 'none', None
+    return get_image(token, base_url)
+
+
+@property()
+def border_image_slice(tokens):
+    values = []
+    fill = False
+    for i, token in enumerate(tokens):
+        # Don't use get_length() because a dimension with a unit is disallowed.
+        if token.type == 'percentage' and token.value >= 0:
+            values.append(Dimension(token.value, '%'))
+        elif token.type == 'number' and token.value >= 0:
+            values.append(Dimension(token.value, None))
+        elif get_keyword(token) == 'fill' and not fill and i in (0, len(tokens) - 1):
+            fill = True
+            values.append('fill')
+        else:
+            return
+
+    if 1 <= len(values) - int(fill) <= 4:
+        return tuple(values)
+
+
+@property()
+def border_image_width(tokens):
+    values = []
+    for token in tokens:
+        if get_keyword(token) == 'auto':
+            values.append('auto')
+        elif token.type == 'number' and token.value >= 0:
+            values.append(Dimension(token.value, None))
+        else:
+            if length := get_length(token, negative=False, percentage=True):
+                values.append(length)
+            else:
+                return
+
+    if 1 <= len(values) <= 4:
+        return tuple(values)
+
+
+@property()
+def border_image_outset(tokens):
+    values = []
+    for token in tokens:
+        if token.type == 'number' and token.value >= 0:
+            values.append(Dimension(token.value, None))
+        else:
+            if length := get_length(token, negative=False):
+                values.append(length)
+            else:
+                return
+
+    if 1 <= len(values) <= 4:
+        return tuple(values)
+
+
+@property()
+def border_image_repeat(tokens):
+    if 1 <= len(tokens) <= 2:
+        keywords = tuple(get_keyword(token) for token in tokens)
+        if set(keywords) <= {'stretch', 'repeat', 'round', 'space'}:
+            return keywords
+
+
 @property(unstable=True)
 @single_token
 def column_width(token):
diff --git a/weasyprint/draw.py b/weasyprint/draw.py
index 8cfa56f3..dbacd4fa 100644
--- a/weasyprint/draw.py
+++ b/weasyprint/draw.py
@@ -446,6 +446,12 @@ def draw_border(stream, box):
         draw_column_border()
         return
 
+    # 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_column_border()
+        return
+
     widths = [getattr(box, f'border_{side}_width') for side in SIDES]
 
     # No border, return early.
@@ -460,8 +466,7 @@ def draw_border(stream, box):
 
     # The 4 sides are solid or double, and they have the same color. Oh yeah!
     # We can draw them so easily!
-    if set(styles) in (set(('solid',)), set(('double',))) and (
-            len(set(colors)) == 1):
+    if set(styles) in (set(('solid',)), set(('double',))) and (len(set(colors)) == 1):
         draw_rounded_border(stream, box, styles[0], colors[0])
         draw_column_border()
         return
@@ -484,6 +489,217 @@ def draw_border(stream, box):
     draw_column_border()
 
 
+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
+    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]
+
+    def compute_slice_dimension(dimension, intrinsic):
+        if isinstance(dimension, (int, float)):
+            return min(dimension, intrinsic)
+        else:
+            assert dimension.unit == '%'
+            return min(100, dimension.value) / 100 * intrinsic
+
+    slice_top = compute_slice_dimension(image_slice[0], intrinsic_height)
+    slice_right = compute_slice_dimension(image_slice[1], intrinsic_width)
+    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']
+
+    x, y, w, h, tl, tr, br, bl = box.rounded_border_box()
+    px, py, pw, ph, ptl, ptr, pbr, pbl = box.rounded_padding_box()
+    border_left = px - x
+    border_top = py - y
+    border_right = w - pw - border_left
+    border_bottom = h - ph - border_top
+
+    def compute_outset_dimension(dimension, from_border):
+        if dimension.unit is None:
+            return dimension.value * from_border
+        else:
+            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)
+
+    x -= outset_left
+    y -= outset_top
+    w += outset_left + outset_right
+    h += outset_top + outset_bottom
+
+    def compute_width_adjustment(dimension, original, intrinsic,
+                                 area_dimension):
+        if dimension == 'auto':
+            return intrinsic
+        elif isinstance(dimension, (int, float)):
+            return dimension * original
+        elif dimension.unit == '%':
+            return dimension.value / 100 * area_dimension
+        else:
+            assert dimension.unit == 'px'
+            return dimension.value
+
+    # We make adjustments to the border_* variables after handling outsets
+    # because numerical outsets are relative to border-width, not
+    # 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_right = compute_width_adjustment(
+        widths[1], border_right, slice_right, w)
+    border_bottom = compute_width_adjustment(
+        widths[2], border_bottom, slice_bottom, h)
+    border_left = compute_width_adjustment(
+        widths[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):
+        if 0 in (intrinsic_width, width, slice_width):
+            scale_x = 0
+        else:
+            extra_dx = 0
+            if not scale_x:
+                scale_x = (height / slice_height) if height and slice_height else 1
+            if repeat_x == 'repeat':
+                n_repeats_x = ceil(width / slice_width / scale_x)
+            elif repeat_x == 'space':
+                n_repeats_x = floor(width / slice_width / scale_x)
+                # Space is before the first repeat and after the last,
+                # so there's one more space than repeat.
+                extra_dx = (
+                    (width / scale_x - n_repeats_x * slice_width) / (n_repeats_x + 1))
+            elif repeat_x == 'round':
+                n_repeats_x = max(1, round(width / slice_width / scale_x))
+                scale_x = width / (n_repeats_x * slice_width)
+            else:
+                n_repeats_x = 1
+                scale_x = width / slice_width
+
+        if 0 in (intrinsic_height, height, slice_height):
+            scale_y = 0
+        else:
+            extra_dy = 0
+            if not scale_y:
+                scale_y = (width / slice_width) if width and slice_width else 1
+            if repeat_y == 'repeat':
+                n_repeats_y = ceil(height / slice_height / scale_y)
+            elif repeat_y == 'space':
+                n_repeats_y = floor(height / slice_height / scale_y)
+                # Space is before the first repeat and after the last,
+                # so there's one more space than repeat.
+                extra_dy = (
+                    (height / scale_y - n_repeats_y * slice_height) / (n_repeats_y + 1))
+            elif repeat_y == 'round':
+                n_repeats_y = max(1, round(height / slice_height / scale_y))
+                scale_y = height / (n_repeats_y * slice_height)
+            else:
+                n_repeats_y = 1
+                scale_y = height / slice_height
+
+        if 0 in (scale_x, scale_y):
+            return scale_x, scale_y
+
+        rendered_width = intrinsic_width * scale_x
+        rendered_height = intrinsic_height * scale_y
+        offset_x = rendered_width * slice_x / intrinsic_width
+        offset_y = rendered_height * slice_y / intrinsic_height
+
+        with stacked(stream):
+            stream.rectangle(x, y, width, height)
+            stream.clip()
+            stream.end()
+            stream.transform(e=x - offset_x + extra_dx, f=y - offset_y + extra_dy)
+            stream.transform(a=scale_x, d=scale_y)
+            for i in range(n_repeats_x):
+                for j in range(n_repeats_y):
+                    with stacked(stream):
+                        translate_x = i * (slice_width + extra_dx)
+                        translate_y = j * (slice_height + extra_dy)
+                        stream.transform(e=translate_x, f=translate_y)
+                        stream.rectangle(
+                            offset_x / scale_x, offset_y / scale_y,
+                            slice_width, slice_height)
+                        stream.clip()
+                        stream.end()
+                        image.draw(
+                            stream, intrinsic_width, intrinsic_height,
+                            box.style['image_rendering'])
+
+        return scale_x, scale_y
+
+    # Top left.
+    scale_left, scale_top = draw_border_image(
+        x, y, border_left, border_top, 0, 0, slice_left, slice_top)
+    # Top right.
+    draw_border_image(
+        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(
+        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(
+        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:
+        # Top middle.
+        draw_border_image(
+            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)
+        # Bottom middle.
+        draw_border_image(
+            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:
+        # Right middle.
+        draw_border_image(
+            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)
+        # Left middle.
+        draw_border_image(
+            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):
+        # Fill middle.
+        draw_border_image(
+            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,
+            scale_x=scale_left or scale_right, scale_y=scale_top or scale_bottom)
+
+
 def clip_border_segment(stream, style, width, side, border_box,
                         border_widths=None, radii=None):
     """Clip one segment of box border.
diff --git a/weasyprint/layout/background.py b/weasyprint/layout/background.py
index 65ee937a..09599165 100644
--- a/weasyprint/layout/background.py
+++ b/weasyprint/layout/background.py
@@ -47,6 +47,15 @@ def layout_box_backgrounds(page, box, get_image_from_uri, layout_children=True,
     if style is None:
         style = box.style
 
+    # This is for the border image, not the background, but this is a
+    # convenient place to get the image.
+    if style['border_image_source'][0] != 'none':
+        type_, value = style['border_image_source']
+        if type_ == 'url':
+            box.border_image = get_image_from_uri(url=value)
+        else:
+            box.border_image = value
+
     if style['visibility'] == 'hidden':
         images = []
         color = parse_color('transparent')


Test patch:
diff --git a/tests/css/test_expanders.py b/tests/css/test_expanders.py
index 64573749..c41ff5ec 100644
--- a/tests/css/test_expanders.py
+++ b/tests/css/test_expanders.py
@@ -398,6 +398,62 @@ def test_border_radius_invalid(rule, message):
     assert_invalid(f'border-radius: {rule}', message)
 
 
+@assert_no_logs
+@pytest.mark.parametrize('rule, result', (
+    ('url(border.png) 27', {
+        'border_image_source': ('url', 'https://weasyprint.org/foo/border.png'),
+        'border_image_slice': ((27, None),),
+    }),
+    ('url(border.png) 10 / 4 / 2 round stretch', {
+        'border_image_source': ('url', 'https://weasyprint.org/foo/border.png'),
+        'border_image_slice': ((10, None),),
+        'border_image_width': ((4, None),),
+        'border_image_outset': ((2, None),),
+        'border_image_repeat': (('round', 'stretch')),
+    }),
+    ('10 // 2', {
+        'border_image_slice': ((10, None),),
+        'border_image_outset': ((2, None),),
+    }),
+    ('5.5%', {
+        'border_image_slice': ((5.5, '%'),),
+    }),
+    ('stretch 2 url("border.png")', {
+        'border_image_source': ('url', 'https://weasyprint.org/foo/border.png'),
+        'border_image_slice': ((2, None),),
+        'border_image_repeat': (('stretch',)),
+    }),
+    ('1/2 round', {
+        'border_image_slice': ((1, None),),
+        'border_image_width': ((2, None),),
+        'border_image_repeat': (('round',)),
+    }),
+    ('none', {
+        'border_image_source': ('none', None),
+    }),
+))
+def test_border_image(rule, result):
+    assert expand_to_dict(f'border-image: {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'),
+))
+def test_border_image_invalid(rule, reason):
+    assert_invalid(f'border-image: {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 1a428b88..97718ad7 100644
--- a/tests/css/test_validation.py
+++ b/tests/css/test_validation.py
@@ -362,6 +362,114 @@ def test_image_orientation_invalid(rule):
     assert_invalid(f'image-orientation: {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_border_image_slice(rule, value):
+    assert get_value(f'border-image-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_border_image_slice_invalid(rule):
+    assert_invalid(f'border-image-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_border_image_width(rule, value):
+    assert get_value(f'border-image-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_border_image_width_invalid(rule):
+    assert_invalid(f'border-image-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_border_image_outset(rule, value):
+    assert get_value(f'border-image-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_border_image_outset_invalid(rule):
+    assert_invalid(f'border-image-outset: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('stretch', ('stretch',)),
+    ('repeat repeat', ('repeat', 'repeat')),
+    ('round     space', ('round', 'space')),
+))
+def test_border_image_repeat(rule, value):
+    assert get_value(f'border-image-repeat: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'none',
+    'test',
+    'round round round',
+    'stretch space round',
+    'repeat test',
+))
+def test_border_image_repeat_invalid(rule):
+    assert_invalid(f'border-image-repeat: {rule}')
+
+
 @assert_no_logs
 @pytest.mark.parametrize('rule, value', (
     ('test content(text)', (('test', (('content()', 'text'),)),)),
diff --git a/tests/draw/__init__.py b/tests/draw/__init__.py
index e0d757fe..8d758f7b 100644
--- a/tests/draw/__init__.py
+++ b/tests/draw/__init__.py
@@ -17,6 +17,9 @@ PIXELS_BY_CHAR = {
     'G': (0, 255, 0),  # lime green
     'V': (191, 0, 64),  # average of 1*B and 3*R
     'S': (255, 63, 63),  # R above R above _
+    'C': (0, 255, 255),  # cyan
+    'M': (255, 0, 255),  # magenta
+    'Y': (255, 255, 0),  # yellow
     'K': (0, 0, 0),  # black
     'r': (255, 0, 0),  # red
     'g': (0, 128, 0),  # half green
diff --git a/tests/draw/test_box.py b/tests/draw/test_box.py
index 731df99a..168aeb19 100644
--- a/tests/draw/test_box.py
+++ b/tests/draw/test_box.py
@@ -273,3 +273,306 @@ def test_draw_split_border_radius(assert_pixels):
       </style>
       <div>a b c</div>
     ''')
+
+
+@assert_no_logs
+def test_border_image_stretch(assert_pixels):
+    assert_pixels('''
+        __________
+        _RYYYMMMG_
+        _M______C_
+        _M______C_
+        _Y______Y_
+        _Y______Y_
+        _BYYYCCCK_
+        __________
+    ''', '''
+      <style>
+        @page {
+          size: 10px 8px;
+        }
+        div {
+          border: 1px solid black;
+          border-image-source: url(border.svg);
+          border-image-slice: 25%;
+          height: 4px;
+          margin: 1px;
+          width: 6px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_border_image_fill(assert_pixels):
+    assert_pixels('''
+        __________
+        _RYYYMMMG_
+        _MbbbgggC_
+        _MbbbgggC_
+        _YgggbbbY_
+        _YgggbbbY_
+        _BYYYCCCK_
+        __________
+    ''', '''
+      <style>
+        @page {
+          size: 10px 8px;
+        }
+        div {
+          border: 1px solid black;
+          border-image-source: url(border.svg);
+          border-image-slice: 25% fill;
+          height: 4px;
+          margin: 1px;
+          width: 6px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_border_image_default_slice(assert_pixels):
+    assert_pixels('''
+        _____________
+        _RYMG___RYMG_
+        _MbgC___MbgC_
+        _YgbY___YgbY_
+        _BYCK___BYCK_
+        _____________
+        _____________
+        _RYMG___RYMG_
+        _MbgC___MbgC_
+        _YgbY___YgbY_
+        _BYCK___BYCK_
+        _____________
+    ''', '''
+      <style>
+        @page {
+          size: 13px 12px;
+        }
+        div {
+          border: 4px solid black;
+          border-image-source: url(border.svg);
+          height: 2px;
+          margin: 1px;
+          width: 3px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_border_image_uneven_width(assert_pixels):
+    assert_pixels('''
+        ____________
+        _RRRYYYMMMG_
+        _MMM______C_
+        _MMM______C_
+        _YYY______Y_
+        _YYY______Y_
+        _BBBYYYCCCK_
+        ____________
+    ''', '''
+      <style>
+        @page {
+          size: 12px 8px;
+        }
+        div {
+          border: 1px solid black;
+          border-left-width: 3px;
+          border-image-source: url(border.svg);
+          border-image-slice: 25%;
+          height: 4px;
+          margin: 1px;
+          width: 6px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_border_image_not_percent(assert_pixels):
+    assert_pixels('''
+        __________
+        _RYYYMMMG_
+        _M______C_
+        _M______C_
+        _Y______Y_
+        _Y______Y_
+        _BYYYCCCK_
+        __________
+    ''', '''
+      <style>
+        @page {
+          size: 10px 8px;
+        }
+        div {
+          border: 1px solid black;
+          border-image-source: url(border.svg);
+          border-image-slice: 1;
+          height: 4px;
+          margin: 1px;
+          width: 6px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_border_image_repeat(assert_pixels):
+    assert_pixels('''
+        ___________
+        _RYMYMYMYG_
+        _M_______C_
+        _Y_______Y_
+        _M_______C_
+        _Y_______Y_
+        _BYCYCYCYK_
+        ___________
+    ''', '''
+      <style>
+        @page {
+          size: 11px 8px;
+        }
+        div {
+          border: 1px solid black;
+          border-image-source: url(border.svg);
+          border-image-slice: 25%;
+          border-image-repeat: repeat;
+          height: 4px;
+          margin: 1px;
+          width: 7px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_border_image_space(assert_pixels):
+    assert_pixels('''
+        _________
+        _R_YMC_G_
+        _________
+        _M_____C_
+        _Y_____Y_
+        _C_____M_
+        _________
+        _B_YCM_K_
+        _________
+    ''', '''
+      <style>
+        @page {
+          size: 9px 9px;
+        }
+        div {
+          border: 1px solid black;
+          border-image-source: url(border2.svg);
+          border-image-slice: 20%;
+          border-image-repeat: space;
+          height: 5px;
+          margin: 1px;
+          width: 5px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_border_image_outset(assert_pixels):
+    assert_pixels('''
+        ____________
+        _RYYYYMMMMG_
+        _M________C_
+        _M_bbbbbb_C_
+        _M_bbbbbb_C_
+        _Y_bbbbbb_Y_
+        _Y_bbbbbb_Y_
+        _Y________Y_
+        _BYYYYCCCCK_
+        ____________
+    ''', '''
+      <style>
+        @page {
+          size: 12px 10px;
+        }
+        div {
+          border: 1px solid black;
+          border-image-source: url(border.svg);
+          border-image-slice: 25%;
+          border-image-outset: 2px;
+          height: 2px;
+          margin: 3px;
+          width: 4px;
+          background: #000080
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_border_image_width(assert_pixels):
+    assert_pixels('''
+        __________
+        _RRYYMMGG_
+        _RRYYMMGG_
+        _MM____CC_
+        _YY____YY_
+        _BBYYCCKK_
+        _BBYYCCKK_
+        __________
+    ''', '''
+      <style>
+        @page {
+          size: 10px 8px;
+        }
+        div {
+          border: 1px solid black;
+          border-image-source: url(border.svg);
+          border-image-slice: 25%;
+          border-image-width: 2;
+          height: 4px;
+          margin: 1px;
+          width: 6px;
+        }
+      </style>
+      <div></div>
+    ''')
+
+
+@assert_no_logs
+def test_border_image_gradient(assert_pixels):
+    assert_pixels('''
+        __________
+        _RRRRRRRR_
+        _RRRRRRRR_
+        _RR____RR_
+        _BB____BB_
+        _BBBBBBBB_
+        _BBBBBBBB_
+        __________
+    ''', '''
+      <style>
+        @page {
+          size: 10px 8px;
+        }
+        div {
+          border: 1px solid black;
+          border-image-source: linear-gradient(to bottom, red, red 50%, blue 50%, blue);
+          border-image-slice: 25%;
+          border-image-width: 2;
+          height: 4px;
+          margin: 1px;
+          width: 6px;
+        }
+      </style>
+      <div></div>
+    ''')
diff --git a/tests/resources/border.svg b/tests/resources/border.svg
new file mode 100644
index 00000000..3da4da4d
--- /dev/null
+++ b/tests/resources/border.svg
@@ -0,0 +1,18 @@
+<svg height="4" width="4" viewBox="0 0 4 4" xmlns="http://www.w3.org/2000/svg">
+  <rect fill="#ff0000" width="1" height="1" x="0" y="0" />
+  <rect fill="#ffff00" width="1" height="1" x="1" y="0" />
+  <rect fill="#ff00ff" width="1" height="1" x="2" y="0" />
+  <rect fill="#00ff00" width="1" height="1" x="3" y="0" />
+  <rect fill="#ff00ff" width="1" height="1" x="0" y="1" />
+  <rect fill="#000080" width="1" height="1" x="1" y="1" />
+  <rect fill="#008000" width="1" height="1" x="2" y="1" />
+  <rect fill="#00ffff" width="1" height="1" x="3" y="1" />
+  <rect fill="#ffff00" width="1" height="1" x="0" y="2" />
+  <rect fill="#008000" width="1" height="1" x="1" y="2" />
+  <rect fill="#000080" width="1" height="1" x="2" y="2" />
+  <rect fill="#ffff00" width="1" height="1" x="3" y="2" />
+  <rect fill="#0000ff" width="1" height="1" x="0" y="3" />
+  <rect fill="#ffff00" width="1" height="1" x="1" y="3" />
+  <rect fill="#00ffff" width="1" height="1" x="2" y="3" />
+  <rect fill="#000000" width="1" height="1" x="3" y="3" />
+</svg>
diff --git a/tests/resources/border2.svg b/tests/resources/border2.svg
new file mode 100644
index 00000000..1a6458ca
--- /dev/null
+++ b/tests/resources/border2.svg
@@ -0,0 +1,191 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   height="5"
+   width="5"
+   viewBox="0 0 5 5"
+   version="1.1"
+   id="svg16"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs16" />
+  <rect
+     fill="#ff0000"
+     width="1"
+     height="1"
+     x="0"
+     y="0"
+     id="rect1" />
+  <rect
+     fill="#ffff00"
+     width="1"
+     height="1"
+     x="1"
+     y="0"
+     id="rect2" />
+  <rect
+     fill="#ff00ff"
+     width="1"
+     height="1"
+     x="2"
+     y="0"
+     id="rect3" />
+  <rect
+     fill="#00ff00"
+     width="1"
+     height="1"
+     x="4"
+     y="0"
+     id="rect4" />
+  <rect
+     fill="#ff00ff"
+     width="1"
+     height="1"
+     x="0"
+     y="1"
+     id="rect5" />
+  <rect
+     fill="#000080"
+     width="1"
+     height="1"
+     x="1"
+     y="1"
+     id="rect6" />
+  <rect
+     fill="#008000"
+     width="1"
+     height="1"
+     x="2"
+     y="1"
+     id="rect7" />
+  <rect
+     fill="#00ffff"
+     width="1"
+     height="1"
+     x="4"
+     y="1"
+     id="rect8" />
+  <rect
+     fill="#ffff00"
+     width="1"
+     height="1"
+     x="0"
+     y="2"
+     id="rect9" />
+  <rect
+     fill="#008000"
+     width="1"
+     height="1"
+     x="1"
+     y="2"
+     id="rect10" />
+  <rect
+     fill="#000080"
+     width="1"
+     height="1"
+     x="2"
+     y="2"
+     id="rect11" />
+  <rect
+     fill="#ffff00"
+     width="1"
+     height="1"
+     x="4"
+     y="2"
+     id="rect12" />
+  <rect
+     fill="#0000ff"
+     width="1"
+     height="1"
+     x="0"
+     y="4"
+     id="rect13" />
+  <rect
+     fill="#ffff00"
+     width="1"
+     height="1"
+     x="1"
+     y="4"
+     id="rect14" />
+  <rect
+     fill="#00ffff"
+     width="1"
+     height="1"
+     x="2"
+     y="4"
+     id="rect15" />
+  <rect
+     fill="#000000"
+     width="1"
+     height="1"
+     x="4"
+     y="4"
+     id="rect16" />
+  <rect
+     fill="#000080"
+     width="1"
+     height="1"
+     x="1"
+     y="3"
+     id="rect17" />
+  <rect
+     fill="#008000"
+     width="1"
+     height="1"
+     x="2"
+     y="3"
+     id="rect18" />
+  <rect
+     fill="#000080"
+     width="1"
+     height="1"
+     x="3"
+     y="1"
+     id="rect19" />
+  <rect
+     fill="#008000"
+     width="1"
+     height="1"
+     x="3"
+     y="2"
+     id="rect20" />
+  <rect
+     fill="#000080"
+     width="1"
+     height="1"
+     x="3"
+     y="3"
+     id="rect21" />
+  <rect
+     fill="#ff00ff"
+     width="1"
+     height="1"
+     x="3"
+     y="0"
+     id="rect22"
+     style="fill:#00ffff" />
+  <rect
+     fill="#ffff00"
+     width="1"
+     height="1"
+     x="4"
+     y="3"
+     id="rect23"
+     style="fill:#ff00ff" />
+  <rect
+     fill="#00ffff"
+     width="1"
+     height="1"
+     x="3"
+     y="4"
+     id="rect24"
+     style="fill:#ff00ff" />
+  <rect
+     fill="#ffff00"
+     width="1"
+     height="1"
+     x="0"
+     y="3"
+     id="rect25"
+     style="fill:#00ffff" />
+</svg>