diff --git a/tests/draw/svg/test_clip.py b/tests/draw/svg/test_clip.py
new file mode 100644
index 000000000..034667a05
--- /dev/null
+++ b/tests/draw/svg/test_clip.py
@@ -0,0 +1,105 @@
+"""
+ weasyprint.tests.test_draw.svg.test_clip
+ ----------------------------------------
+
+ Test clip-path attribute.
+
+"""
+
+import pytest
+
+from ...testing_utils import assert_no_logs
+from .. import assert_pixels
+
+
+@assert_no_logs
+def test_clip_path():
+ assert_pixels('clip_path', 9, 9, '''
+ _________
+ _________
+ __RRRRR__
+ __RBBBR__
+ __RBBBR__
+ __RBBBR__
+ __RRRRR__
+ _________
+ _________
+ ''', '''
+
+
+ ''')
+
+
+@assert_no_logs
+def test_clip_path_on_group():
+ assert_pixels('clip_path_on_group', 9, 9, '''
+ _________
+ _________
+ __BBBB___
+ __BRRRR__
+ __BRRRR__
+ __BRRRR__
+ ___RRRR__
+ _________
+ _________
+ ''', '''
+
+
+ ''')
+
+
+@pytest.mark.xfail
+@assert_no_logs
+def test_clip_path_group_on_group():
+ assert_pixels('clip_path_group_on_group', 9, 9, '''
+ _________
+ _________
+ __BB_____
+ __BR_____
+ _________
+ _____RR__
+ _____RR__
+ _________
+ _________
+ ''', '''
+
+
+ ''')
diff --git a/weasyprint/document.py b/weasyprint/document.py
index 9c7ae87c0..d99c6f7fd 100644
--- a/weasyprint/document.py
+++ b/weasyprint/document.py
@@ -562,6 +562,27 @@ def __matmul__(self, other):
[sum(self[i][k] * other[k][j] for k in range(3)) for j in range(3)]
for i in range(len(self))])
+ @property
+ def invert(self):
+ d = self.determinant
+ return Matrix(matrix=[
+ [
+ (self[1][1] * self[2][2] - self[1][2] * self[2][1]) / d,
+ (self[0][1] * self[2][2] - self[0][2] * self[2][1]) / -d,
+ (self[0][1] * self[1][2] - self[0][2] * self[1][1]) / d,
+ ],
+ [
+ (self[1][0] * self[2][2] - self[1][2] * self[2][0]) / -d,
+ (self[0][0] * self[2][2] - self[0][2] * self[2][0]) / d,
+ (self[0][0] * self[1][2] - self[0][2] * self[1][0]) / -d,
+ ],
+ [
+ (self[1][0] * self[2][1] - self[1][1] * self[2][0]) / d,
+ (self[0][0] * self[2][1] - self[0][1] * self[2][0]) / -d,
+ (self[0][0] * self[1][1] - self[0][1] * self[1][0]) / d,
+ ],
+ ])
+
@property
def determinant(self):
assert len(self) == len(self[0]) == 3
diff --git a/weasyprint/svg/__init__.py b/weasyprint/svg/__init__.py
index 78ce6f8de..3f3fe0579 100644
--- a/weasyprint/svg/__init__.py
+++ b/weasyprint/svg/__init__.py
@@ -14,17 +14,18 @@
from .bounding_box import bounding_box, is_valid_bounding_box
from .css import parse_declarations, parse_stylesheets
-from .defs import apply_filters, draw_gradient_or_pattern, paint_mask, use
+from .defs import (
+ apply_filters, clip_path, draw_gradient_or_pattern, paint_mask, use)
from .images import image, svg
from .path import path
from .shapes import circle, ellipse, line, polygon, polyline, rect
from .text import text
from .utils import color, normalize, parse_url, preserve_ratio, size, transform
-# TODO: clipPath
TAGS = {
'a': text,
'circle': circle,
+ 'clipPath': clip_path,
'ellipse': ellipse,
'image': image,
'line': line,
@@ -73,13 +74,14 @@
))
DEF_TYPES = frozenset((
- 'marker',
- 'gradient',
- 'pattern',
- 'path',
- 'mask',
+ 'clipPath',
'filter',
+ 'gradient',
'image',
+ 'marker',
+ 'mask',
+ 'path',
+ 'pattern',
))
@@ -341,7 +343,7 @@ def draw(self, stream, concrete_width, concrete_height, base_url,
self.draw_node(self.tree, size('12pt'))
- def draw_node(self, node, font_size):
+ def draw_node(self, node, font_size, fill_stroke=True):
"""Draw a node."""
if node.tag == 'defs':
return
@@ -349,7 +351,8 @@ def draw_node(self, node, font_size):
# Update font size
font_size = size(node.get('font-size', '1em'), font_size, font_size)
- self.stream.push_state()
+ if fill_stroke:
+ self.stream.push_state()
# Apply filters
filter_ = self.filters.get(parse_url(node.get('filter')).fragment)
@@ -358,7 +361,7 @@ def draw_node(self, node, font_size):
# Create substream for opacity
opacity = float(node.get('opacity', 1))
- if 0 <= opacity < 1:
+ if fill_stroke and 0 <= opacity < 1:
original_stream = self.stream
box = self.calculate_bounding_box(node, font_size)
if is_valid_bounding_box(box):
@@ -367,8 +370,31 @@ def draw_node(self, node, font_size):
coords = (0, 0, self.concrete_width, self.concrete_height)
self.stream = self.stream.add_group(coords)
- # Apply transformations
+ # Apply transform attribute
self.transform(node.get('transform'), font_size)
+
+ # Clip
+ clip_path = parse_url(node.get('clip-path')).fragment
+ if clip_path and clip_path in self.paths:
+ old_ctm = self.stream.ctm
+ clip_path = self.paths[clip_path]
+ if clip_path.get('clipPathUnits') == 'objectBoundingBox':
+ x, y = self.point(node.get('x'), node.get('y'), font_size)
+ width, height = self.point(
+ node.get('width'), node.get('height'), font_size)
+ self.stream.transform(a=width, d=height, e=x, f=y)
+ clip_path._etree_node.tag = 'g'
+ self.draw_node(clip_path, font_size, fill_stroke=False)
+ # At least set the clipping area to an empty path, so that it’s
+ # totally clipped when the clipping path is empty.
+ self.stream.rectangle(0, 0, 0, 0)
+ self.stream.clip()
+ self.stream.end()
+ new_ctm = self.stream.ctm
+ if new_ctm.determinant:
+ self.stream.transform(*(old_ctm @ new_ctm.invert).values)
+
+ # Apply x/y transformations
x, y = self.point(node.get('x'), node.get('y'), font_size)
self.stream.transform(e=x, f=y)
@@ -383,7 +409,7 @@ def draw_node(self, node, font_size):
# Draw node children
if display and node.tag not in DEF_TYPES:
for child in node:
- self.draw_node(child, font_size)
+ self.draw_node(child, font_size, fill_stroke)
# Apply mask
mask = self.masks.get(parse_url(node.get('mask')).fragment)
@@ -391,13 +417,14 @@ def draw_node(self, node, font_size):
paint_mask(self, node, mask, opacity)
# Fill and stroke
- self.fill_stroke(node, font_size)
+ if fill_stroke:
+ self.fill_stroke(node, font_size)
# Draw markers
- self.draw_markers(node, font_size)
+ self.draw_markers(node, font_size, fill_stroke)
# Apply opacity stream and restore original stream
- if 0 <= opacity < 1:
+ if fill_stroke and 0 <= opacity < 1:
group_id = self.stream.id
self.stream = original_stream
self.stream.set_alpha(opacity, stroke=True, fill=True)
@@ -409,9 +436,10 @@ def draw_node(self, node, font_size):
self.cursor_d_position = [0, 0]
self.text_path_width = 0
- self.stream.pop_state()
+ if fill_stroke:
+ self.stream.pop_state()
- def draw_markers(self, node, font_size):
+ def draw_markers(self, node, font_size, fill_stroke):
"""Draw markers defined in a node."""
if not node.vertices:
return
@@ -530,7 +558,7 @@ def draw_markers(self, node, font_size):
self.stream.pop_state()
self.stream.clip()
- self.draw_node(child, font_size)
+ self.draw_node(child, font_size, fill_stroke)
self.stream.pop_state()
position = 'mid' if angles else 'start'
@@ -689,9 +717,9 @@ def __init__(self, tree, svg):
self.patterns = {}
self.paths = {}
- def draw_node(self, node, font_size):
+ def draw_node(self, node, font_size, fill_stroke=True):
# Store the original tree in self.tree when calling draw(), so that we
# can reach defs outside the pattern
if node == self.tree:
self.tree = self.svg.tree
- super().draw_node(node, font_size)
+ super().draw_node(node, font_size, fill_stroke=True)
diff --git a/weasyprint/svg/defs.py b/weasyprint/svg/defs.py
index 2d67675c2..cc2514abf 100644
--- a/weasyprint/svg/defs.py
+++ b/weasyprint/svg/defs.py
@@ -585,3 +585,9 @@ def paint_mask(svg, node, mask, font_size):
svg.stream = alpha_stream
svg.draw_node(mask, font_size)
svg.stream = svg_stream
+
+
+def clip_path(svg, node, font_size):
+ """Store a clip path definition."""
+ if 'id' in node.attrib:
+ svg.paths[node.attrib['id']] = node