1
1
mirror of https://github.com/Kozea/WeasyPrint.git synced 2024-09-11 20:47:56 +03:00
WeasyPrint/weasyprint/svg/__init__.py

820 lines
29 KiB
Python
Raw Normal View History

2022-03-25 13:47:27 +03:00
"""Render SVG images."""
2021-02-22 01:25:35 +03:00
import re
from contextlib import suppress
2021-04-16 16:33:04 +03:00
from math import cos, hypot, pi, radians, sin, sqrt
2021-02-22 01:25:35 +03:00
from xml.etree import ElementTree
2021-03-23 17:06:33 +03:00
from cssselect2 import ElementWrapper
from ..urls import get_url_attribute
2021-03-23 17:06:33 +03:00
from .css import parse_declarations, parse_stylesheets
2024-03-17 13:34:03 +03:00
from .defs import apply_filters, clip_path, draw_gradient_or_pattern, paint_mask, use
2021-04-18 17:01:26 +03:00
from .images import image, svg
2021-03-12 17:20:43 +03:00
from .path import path
2021-03-12 18:08:39 +03:00
from .shapes import circle, ellipse, line, polygon, polyline, rect
2021-04-10 23:15:43 +03:00
from .text import text
2024-03-17 13:34:03 +03:00
from .bounding_box import ( # isort:skip
EMPTY_BOUNDING_BOX, bounding_box, extend_bounding_box, is_valid_bounding_box)
from .utils import ( # isort:skip
PointError, alpha_value, color, normalize, parse_url, preserve_ratio, size,
transform)
2021-02-22 01:25:35 +03:00
TAGS = {
2021-04-10 23:15:43 +03:00
'a': text,
2021-02-22 01:25:35 +03:00
'circle': circle,
'clipPath': clip_path,
2021-02-22 01:25:35 +03:00
'ellipse': ellipse,
2021-03-23 18:22:41 +03:00
'image': image,
'line': line,
2021-03-12 17:20:43 +03:00
'path': path,
'polyline': polyline,
'polygon': polygon,
2021-02-22 01:25:35 +03:00
'rect': rect,
'svg': svg,
2021-04-10 23:15:43 +03:00
'text': text,
2021-04-11 17:13:59 +03:00
'textPath': text,
2021-04-10 23:15:43 +03:00
'tspan': text,
2021-03-15 16:21:36 +03:00
'use': use,
2021-02-22 01:25:35 +03:00
}
NOT_INHERITED_ATTRIBUTES = frozenset((
'clip',
'clip-path',
'filter',
'height',
'id',
'mask',
'opacity',
'overflow',
'rotate',
'stop-color',
'stop-opacity',
'style',
'transform',
'transform-origin',
'viewBox',
'width',
'x',
'y',
'dx',
'dy',
'{http://www.w3.org/1999/xlink}href',
'href',
))
COLOR_ATTRIBUTES = frozenset((
'fill',
'flood-color',
'lighting-color',
'stop-color',
'stroke',
))
2021-03-12 17:47:49 +03:00
DEF_TYPES = frozenset((
'clipPath',
2021-03-12 17:47:49 +03:00
'filter',
'gradient',
2021-03-12 17:47:49 +03:00
'image',
'marker',
'mask',
'path',
'pattern',
2021-03-12 17:47:49 +03:00
))
class Node:
2021-04-12 15:25:55 +03:00
"""An SVG document node."""
2021-03-23 17:06:33 +03:00
def __init__(self, wrapper, style):
self._wrapper = wrapper
2021-04-12 15:25:55 +03:00
self._etree_node = wrapper.etree_element
2021-03-23 17:06:33 +03:00
self._style = style
self.attrib = wrapper.etree_element.attrib.copy()
self.vertices = []
2021-03-14 11:29:33 +03:00
self.bounding_box = None
def copy(self):
"""Create a deep copy of the node as it was when first created."""
return Node(self._wrapper, self._style)
def get(self, key, default=None):
"""Get attribute."""
return self.attrib.get(key, default)
2021-03-24 16:10:50 +03:00
@property
def tag(self):
2021-04-12 15:25:55 +03:00
"""XML tag name with no namespace."""
2021-04-11 17:13:59 +03:00
return self._etree_node.tag.split('}', 1)[-1]
2021-03-24 16:10:50 +03:00
2021-04-10 23:15:43 +03:00
@property
def text(self):
2021-04-12 15:25:55 +03:00
"""XML node text."""
2021-04-10 23:15:43 +03:00
return self._etree_node.text
@property
def tail(self):
2021-04-12 15:25:55 +03:00
"""Text after the XML node."""
2021-04-10 23:15:43 +03:00
return self._etree_node.tail
@property
def display(self):
"""Whether node should be displayed."""
return self.get('display') != 'none'
@property
def visible(self):
"""Whether node is visible."""
return self.display and self.get('visibility') != 'hidden'
def __iter__(self):
2021-04-12 15:25:55 +03:00
"""Yield node children, handling cascade."""
2021-03-23 17:06:33 +03:00
for wrapper in self._wrapper:
2021-04-12 15:25:55 +03:00
child = Node(wrapper, self._style)
# Cascade
for key, value in self.attrib.items():
if key not in NOT_INHERITED_ATTRIBUTES:
if key not in child.attrib:
child.attrib[key] = value
# Apply style attribute
style_attr = child.get('style')
if style_attr:
normal_attr, important_attr = parse_declarations(style_attr)
else:
normal_attr, important_attr = [], []
normal_matcher, important_matcher = self._style
normal = [rule[-1] for rule in normal_matcher.match(wrapper)]
important = [rule[-1] for rule in important_matcher.match(wrapper)]
declarations_lists = (
normal, [normal_attr], important, [important_attr])
for declarations_list in declarations_lists:
for declarations in declarations_list:
for name, value in declarations:
2021-05-27 17:36:34 +03:00
child.attrib[name] = value.strip()
2021-04-12 15:25:55 +03:00
# Replace 'currentColor' value
for key in COLOR_ATTRIBUTES:
if child.get(key) == 'currentColor':
child.attrib[key] = child.get('color', 'black')
# Handle 'inherit' values
for key, value in child.attrib.copy().items():
2021-04-12 15:25:55 +03:00
if value == 'inherit':
value = self.get(key)
if value is None:
del child.attrib[key]
else:
child.attrib[key] = value
2021-04-12 15:25:55 +03:00
# Fix text in text tags
if child.tag in ('text', 'textPath', 'a'):
2021-07-16 16:17:15 +03:00
children, _ = child.text_children(
2021-04-12 15:25:55 +03:00
wrapper, trailing_space=True, text_root=True)
2021-07-16 16:17:15 +03:00
child._wrapper.etree_children = [
child._etree_node for child in children]
2021-04-12 15:25:55 +03:00
yield child
2021-02-22 01:25:35 +03:00
def get_viewbox(self):
2021-04-12 15:25:55 +03:00
"""Get node viewBox as a tuple of floats."""
2021-03-14 11:29:33 +03:00
viewbox = self.get('viewBox')
2021-02-22 01:25:35 +03:00
if viewbox:
return tuple(
float(number) for number in normalize(viewbox).split())
def get_href(self, base_url):
2021-04-12 15:25:55 +03:00
"""Get the href attribute, with or without a namespace."""
for attr_name in ('{http://www.w3.org/1999/xlink}href', 'href'):
url = get_url_attribute(
self, attr_name, base_url, allow_relative=True)
if url:
return url
2021-03-15 16:21:36 +03:00
def del_href(self):
"""Remove the href attributes, with or without a namespace."""
for attr_name in ('{http://www.w3.org/1999/xlink}href', 'href'):
self.attrib.pop(attr_name, None)
2021-04-11 17:13:59 +03:00
@staticmethod
2021-04-12 15:25:55 +03:00
def process_whitespace(string, preserve):
"""Replace newlines by spaces, and merge spaces if not preserved."""
# TODO: should be merged with build.process_whitespace
2021-04-11 17:13:59 +03:00
if not string:
return ''
if preserve:
return re.sub('[\n\r\t]', ' ', string)
else:
string = re.sub('[\n\r]', '', string)
string = re.sub('\t', ' ', string)
return re.sub(' +', ' ', string)
2021-03-15 16:21:36 +03:00
def get_child(self, id_):
2021-04-12 15:25:55 +03:00
"""Get a child with given id in the whole child tree."""
2021-03-15 16:21:36 +03:00
for child in self:
if child.get('id') == id_:
return child
grandchild = child.get_child(id_)
if grandchild:
return grandchild
2021-04-10 23:15:43 +03:00
def text_children(self, element, trailing_space, text_root=False):
2021-04-12 15:25:55 +03:00
"""Handle text node by fixing whitespaces and flattening tails."""
2021-04-10 23:15:43 +03:00
children = []
space = '{http://www.w3.org/XML/1998/namespace}space'
preserve = self.get(space) == 'preserve'
2021-04-12 15:25:55 +03:00
self._etree_node.text = self.process_whitespace(
2021-04-10 23:15:43 +03:00
element.etree_element.text, preserve)
if trailing_space and not preserve:
self._etree_node.text = self.text.lstrip(' ')
2021-04-11 17:13:59 +03:00
original_rotate = [
float(i) for i in
normalize(self.get('rotate')).strip().split(' ') if i]
rotate = original_rotate.copy()
2021-04-10 23:15:43 +03:00
if original_rotate:
2021-04-11 17:13:59 +03:00
self.pop_rotation(original_rotate, rotate)
2021-04-10 23:15:43 +03:00
if self.text:
trailing_space = self.text.endswith(' ')
2024-02-16 23:59:21 +03:00
element_children = tuple(element.iter_children())
for child_element in element_children:
2021-04-10 23:15:43 +03:00
child = child_element.etree_element
if child.tag in ('{http://www.w3.org/2000/svg}tref', 'tref'):
child_node = Node(child_element, self._style)
child_node._etree_node.tag = 'tspan'
# Retrieve the referenced node and get its flattened text
# and remove the node children.
child = child_node._etree_node
2021-04-11 17:13:59 +03:00
child._etree_node.text = child.flatten()
2021-04-10 23:15:43 +03:00
child_element = ElementWrapper.from_xml_root(child)
else:
child_node = Node(child_element, self._style)
child_preserve = child_node.get(space) == 'preserve'
2021-04-12 15:25:55 +03:00
child_node._etree_node.text = self.process_whitespace(
2021-04-10 23:15:43 +03:00
child.text, child_preserve)
child_node.children, trailing_space = child_node.text_children(
child_element, trailing_space)
trailing_space = child_node.text.endswith(' ')
if original_rotate and 'rotate' not in child_node:
2021-04-11 17:13:59 +03:00
child_node.pop_rotation(original_rotate, rotate)
2021-04-10 23:15:43 +03:00
children.append(child_node)
2024-02-16 23:59:21 +03:00
tail = self.process_whitespace(child.tail, preserve)
if text_root and child_element is element_children[-1]:
if not preserve:
tail = tail.rstrip(' ')
if tail:
2021-04-10 23:15:43 +03:00
anonymous_etree = ElementTree.Element(
'{http://www.w3.org/2000/svg}tspan')
anonymous = Node(
ElementWrapper.from_xml_root(anonymous_etree), self._style)
2024-02-16 23:59:21 +03:00
anonymous._etree_node.text = tail
2021-04-10 23:15:43 +03:00
if original_rotate:
2021-04-11 17:13:59 +03:00
anonymous.pop_rotation(original_rotate, rotate)
2021-04-10 23:15:43 +03:00
if trailing_space and not preserve:
2021-04-20 18:05:05 +03:00
anonymous._etree_node.text = anonymous.text.lstrip(' ')
2021-04-10 23:15:43 +03:00
if anonymous.text:
trailing_space = anonymous.text.endswith(' ')
children.append(anonymous)
if text_root and not children and not preserve:
self._etree_node.text = self.text.rstrip(' ')
return children, trailing_space
2021-04-11 17:13:59 +03:00
def flatten(self):
2021-04-12 15:25:55 +03:00
"""Flatten text in node and in its children."""
2021-04-11 17:13:59 +03:00
flattened_text = [self.text or '']
for child in list(self):
flattened_text.append(child.flatten())
flattened_text.append(child.tail or '')
self.remove(child)
return ''.join(flattened_text)
def pop_rotation(self, original_rotate, rotate):
2021-04-12 15:25:55 +03:00
"""Merge nested letter rotations."""
2021-04-11 17:13:59 +03:00
self.attrib['rotate'] = ' '.join(
str(rotate.pop(0) if rotate else original_rotate[-1])
for i in range(len(self.text)))
def override_iter(self, iterator):
"""Override nodes children iterator."""
# As special methods are bound to classes and not instances, we have to
# create and assign a new type.
self.__class__ = type(
'Node', (Node,), {'__iter__': lambda _: iterator})
2021-03-14 11:29:33 +03:00
class SVG:
2021-04-12 15:25:55 +03:00
"""An SVG document."""
def __init__(self, tree, url):
2021-03-23 17:06:33 +03:00
wrapper = ElementWrapper.from_xml_root(tree)
style = parse_stylesheets(wrapper, url)
self.tree = Node(wrapper, style)
2021-03-14 11:29:33 +03:00
self.url = url
# Replace 'currentColor' value
for key in COLOR_ATTRIBUTES:
if self.tree.get(key) == 'currentColor':
self.tree.attrib[key] = self.tree.get('color', 'black')
2021-03-14 11:29:33 +03:00
self.filters = {}
self.gradients = {}
self.images = {}
self.markers = {}
self.masks = {}
self.patterns = {}
self.paths = {}
2021-09-11 15:57:47 +03:00
self.use_cache = {}
2021-04-10 23:15:43 +03:00
self.cursor_position = [0, 0]
self.cursor_d_position = [0, 0]
2021-04-12 15:30:30 +03:00
self.text_path_width = 0
2021-04-10 23:15:43 +03:00
2021-04-12 15:25:55 +03:00
self.parse_defs(self.tree)
self.inherit_defs()
2021-03-15 16:21:36 +03:00
2021-03-14 11:29:33 +03:00
def get_intrinsic_size(self, font_size):
2021-04-12 15:25:55 +03:00
"""Get intrinsic size of the image."""
intrinsic_width = self.tree.get('width', '100%')
if '%' in intrinsic_width:
intrinsic_width = None
else:
intrinsic_width = size(intrinsic_width, font_size)
intrinsic_height = self.tree.get('height', '100%')
if '%' in intrinsic_height:
intrinsic_height = None
else:
intrinsic_height = size(intrinsic_height, font_size)
return intrinsic_width, intrinsic_height
2021-03-14 11:29:33 +03:00
def get_viewbox(self):
2021-04-12 15:25:55 +03:00
"""Get document viewBox as a tuple of floats."""
2021-03-14 11:29:33 +03:00
return self.tree.get_viewbox()
def point(self, x, y, font_size):
2021-04-12 15:25:55 +03:00
"""Compute size of an x/y or width/height couple."""
return (
2021-07-31 09:29:23 +03:00
size(x, font_size, self.inner_width),
size(y, font_size, self.inner_height))
def length(self, length, font_size):
2021-04-12 15:25:55 +03:00
"""Compute size of an arbirtary attribute."""
2021-07-31 09:29:23 +03:00
return size(length, font_size, self.inner_diagonal)
2021-02-22 01:25:35 +03:00
def draw(self, stream, concrete_width, concrete_height, base_url,
2021-04-14 17:56:06 +03:00
url_fetcher, context):
2021-04-12 15:25:55 +03:00
"""Draw image on a stream."""
2021-02-22 01:25:35 +03:00
self.stream = stream
2021-07-31 09:29:23 +03:00
2021-02-22 01:25:35 +03:00
self.concrete_width = concrete_width
self.concrete_height = concrete_height
self.normalized_diagonal = (
2021-03-23 15:58:31 +03:00
hypot(concrete_width, concrete_height) / sqrt(2))
2021-07-31 09:29:23 +03:00
viewbox = self.get_viewbox()
if viewbox:
self.inner_width, self.inner_height = viewbox[2], viewbox[3]
else:
self.inner_width = self.concrete_width
self.inner_height = self.concrete_height
self.inner_diagonal = (
hypot(self.inner_width, self.inner_height) / sqrt(2))
2021-02-22 01:25:35 +03:00
self.base_url = base_url
self.url_fetcher = url_fetcher
2021-04-14 17:56:06 +03:00
self.context = context
2021-02-22 01:25:35 +03:00
self.draw_node(self.tree, size('12pt'))
def draw_node(self, node, font_size, fill_stroke=True):
2021-04-12 15:25:55 +03:00
"""Draw a node."""
2021-03-12 17:47:49 +03:00
if node.tag == 'defs':
return
2021-04-12 15:32:57 +03:00
# Update font size
2021-02-22 01:25:35 +03:00
font_size = size(node.get('font-size', '1em'), font_size, font_size)
original_streams = []
if fill_stroke:
self.stream.push_state()
2021-02-22 01:25:35 +03:00
2021-04-12 15:32:57 +03:00
# Apply filters
2021-04-04 13:40:04 +03:00
filter_ = self.filters.get(parse_url(node.get('filter')).fragment)
if filter_:
apply_filters(self, node, filter_, font_size)
# Apply transform attribute
self.transform(node.get('transform'), font_size)
2021-04-12 15:32:57 +03:00
# Create substream for opacity
opacity = alpha_value(node.get('opacity', 1))
if fill_stroke and 0 <= opacity < 1:
original_streams.append(self.stream)
box = self.calculate_bounding_box(node, font_size)
if not is_valid_bounding_box(box):
box = (0, 0, self.inner_width, self.inner_height)
self.stream = self.stream.add_group(*box)
# 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)
original_tag = clip_path._etree_node.tag
clip_path._etree_node.tag = 'g'
self.draw_node(clip_path, font_size, fill_stroke=False)
clip_path._etree_node.tag = original_tag
# At least set the clipping area to an empty path, so that its
# 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)
2023-10-16 19:59:42 +03:00
# Handle text anchor
2024-02-16 23:59:21 +03:00
if node.tag == 'text':
text_anchor = node.get('text-anchor')
children = tuple(node)
if children and not node.text:
text_anchor = children[0].get('text-anchor')
if node.tag == 'text' and text_anchor in ('middle', 'end'):
2023-10-16 19:59:42 +03:00
group = self.stream.add_group(0, 0, 0, 0) # BBox set after drawing
original_streams.append(self.stream)
self.stream = group
2023-10-16 19:59:42 +03:00
# Set text bounding box
if node.display and TAGS.get(node.tag) == text:
node.text_bounding_box = EMPTY_BOUNDING_BOX
2021-04-12 15:32:57 +03:00
# Draw node
if node.visible and node.tag in TAGS:
with suppress(PointError):
2021-07-30 17:08:19 +03:00
TAGS[node.tag](self, node, font_size)
2021-02-22 01:25:35 +03:00
2021-04-12 15:32:57 +03:00
# Draw node children
if node.display and node.tag not in DEF_TYPES:
for child in node:
self.draw_node(child, font_size, fill_stroke)
visible_text_child = (
TAGS.get(node.tag) == text and
TAGS.get(child.tag) == text and
child.visible)
if visible_text_child:
2023-10-16 19:59:42 +03:00
if not is_valid_bounding_box(child.text_bounding_box):
continue
x1, y1 = child.text_bounding_box[:2]
x2 = x1 + child.text_bounding_box[2]
y2 = y1 + child.text_bounding_box[3]
node.text_bounding_box = extend_bounding_box(
node.text_bounding_box, ((x1, y1), (x2, y2)))
# Handle text anchor
2024-02-16 23:59:21 +03:00
if node.tag == 'text' and text_anchor in ('middle', 'end'):
2023-10-16 19:59:42 +03:00
group_id = self.stream.id
self.stream = original_streams.pop()
2023-10-16 19:59:42 +03:00
self.stream.push_state()
if is_valid_bounding_box(node.text_bounding_box):
x, y, width, height = node.text_bounding_box
# Add extra space to include ink extents
group.extra['BBox'][:] = (
x - font_size, y - font_size,
x + width + font_size, y + height + font_size)
2023-10-16 19:59:42 +03:00
x_align = width / 2 if text_anchor == 'middle' else width
self.stream.transform(e=-x_align)
self.stream.draw_x_object(group_id)
self.stream.pop_state()
2021-04-12 15:32:57 +03:00
# Apply mask
mask = self.masks.get(parse_url(node.get('mask')).fragment)
if mask:
paint_mask(self, node, mask, opacity)
2021-02-22 01:25:35 +03:00
2021-04-12 15:32:57 +03:00
# Fill and stroke
if fill_stroke:
self.fill_stroke(node, font_size)
2021-02-22 01:25:35 +03:00
2021-04-12 15:32:57 +03:00
# Draw markers
self.draw_markers(node, font_size, fill_stroke)
2021-03-14 11:29:33 +03:00
2021-04-12 15:32:57 +03:00
# Apply opacity stream and restore original stream
if fill_stroke and 0 <= opacity < 1:
2021-03-31 16:28:17 +03:00
group_id = self.stream.id
self.stream = original_streams.pop()
self.stream.set_alpha(opacity, stroke=True, fill=True)
2021-03-31 16:28:17 +03:00
self.stream.draw_x_object(group_id)
2021-04-12 15:32:57 +03:00
# Clean text tag
2021-04-12 15:30:30 +03:00
if node.tag == 'text':
self.cursor_position = [0, 0]
self.cursor_d_position = [0, 0]
self.text_path_width = 0
if fill_stroke:
self.stream.pop_state()
2021-02-22 01:25:35 +03:00
def draw_markers(self, node, font_size, fill_stroke):
2021-04-12 15:25:55 +03:00
"""Draw markers defined in a node."""
2021-03-14 11:29:33 +03:00
if not node.vertices:
return
markers = {}
common_marker = parse_url(node.get('marker')).fragment
for position in ('start', 'mid', 'end'):
2021-04-11 17:13:59 +03:00
attribute = f'marker-{position}'
2021-03-14 11:29:33 +03:00
if attribute in node.attrib:
markers[position] = parse_url(node.attrib[attribute]).fragment
else:
markers[position] = common_marker
angle1, angle2 = None, None
position = 'start'
while node.vertices:
# Calculate position and angle
point = node.vertices.pop(0)
angles = node.vertices.pop(0) if node.vertices else None
if angles:
if position == 'start':
angle = pi - angles[0]
else:
angle = (angle2 + pi - angles[0]) / 2
angle1, angle2 = angles
else:
angle = angle2
position = 'end'
2021-04-11 17:13:59 +03:00
# Draw marker
2021-03-14 11:29:33 +03:00
marker = markers[position]
2021-04-12 16:11:13 +03:00
if not marker:
position = 'mid' if angles else 'start'
continue
marker_node = self.markers.get(marker)
# Calculate position, scale and clipping
translate_x, translate_y = self.point(
marker_node.get('refX'), marker_node.get('refY'),
font_size)
marker_width, marker_height = self.point(
marker_node.get('markerWidth', 3),
marker_node.get('markerHeight', 3),
font_size)
2022-03-27 22:46:28 +03:00
if 'viewBox' in marker_node.attrib:
scale_x, scale_y, _, _ = preserve_ratio(
2022-03-27 22:46:28 +03:00
self, marker_node, font_size, marker_width, marker_height)
2021-04-12 16:11:13 +03:00
clip_x, clip_y, viewbox_width, viewbox_height = (
marker_node.get_viewbox())
align = marker_node.get(
'preserveAspectRatio', 'xMidYMid').split(' ')[0]
if align == 'none':
x_position = y_position = 'min'
else:
x_position = align[1:4].lower()
y_position = align[5:].lower()
if x_position == 'mid':
clip_x += (viewbox_width - marker_width / scale_x) / 2
elif x_position == 'max':
clip_x += viewbox_width - marker_width / scale_x
if y_position == 'mid':
clip_y += (
viewbox_height - marker_height / scale_y) / 2
elif y_position == 'max':
clip_y += viewbox_height - marker_height / scale_y
clip_box = (
clip_x, clip_y,
marker_width / scale_x, marker_height / scale_y)
else:
scale_x = scale_y = 1
clip_box = (0, 0, marker_width, marker_height)
2021-04-12 16:11:13 +03:00
# Scale
if marker_node.get('markerUnits') != 'userSpaceOnUse':
scale = self.length(node.get('stroke-width', 1), font_size)
scale_x *= scale
scale_y *= scale
# Override angle
node_angle = marker_node.get('orient', 0)
if node_angle not in ('auto', 'auto-start-reverse'):
angle = radians(float(node_angle))
elif node_angle == 'auto-start-reverse' and position == 'start':
angle += radians(180)
# Draw marker path
for child in marker_node:
self.stream.push_state()
self.stream.transform(
scale_x * cos(angle), scale_x * sin(angle),
-scale_y * sin(angle), scale_y * cos(angle),
*point)
self.stream.transform(e=-translate_x, f=-translate_y)
2021-04-12 16:11:13 +03:00
overflow = marker_node.get('overflow', 'hidden')
if overflow in ('hidden', 'scroll'):
2021-04-12 16:11:13 +03:00
self.stream.rectangle(*clip_box)
self.stream.clip()
self.stream.end()
2021-04-12 16:11:13 +03:00
self.draw_node(child, font_size, fill_stroke)
2021-04-12 16:11:13 +03:00
self.stream.pop_state()
2021-03-14 11:29:33 +03:00
position = 'mid' if angles else 'start'
2021-03-14 11:29:33 +03:00
2021-04-12 15:25:55 +03:00
@staticmethod
2021-05-30 10:50:30 +03:00
def get_paint(value):
"""Get paint fill or stroke attribute with a color or a URL."""
2021-04-12 15:25:55 +03:00
if not value or value == 'none':
return None, None
value = value.strip()
match = re.compile(r'(url\(.+\)) *(.*)').search(value)
if match:
source = parse_url(match.group(1)).fragment
color = match.group(2) or None
else:
source = None
color = value or None
return source, color
2021-04-20 18:15:17 +03:00
def fill_stroke(self, node, font_size, text=False):
2021-04-12 15:25:55 +03:00
"""Paint fill and stroke for a node."""
2021-04-20 18:15:17 +03:00
if node.tag in ('text', 'textPath', 'a') and not text:
return
2021-04-12 15:25:55 +03:00
# Get fill data
2021-05-30 10:50:30 +03:00
fill_source, fill_color = self.get_paint(node.get('fill', 'black'))
fill_opacity = alpha_value(node.get('fill-opacity', 1))
2021-03-19 17:58:22 +03:00
fill_drawn = draw_gradient_or_pattern(
self, node, fill_source, font_size, fill_opacity, stroke=False)
2021-03-19 17:58:22 +03:00
if fill_color and not fill_drawn:
2021-04-17 13:30:40 +03:00
red, green, blue, alpha = color(fill_color)
self.stream.set_color_rgb(red, green, blue)
self.stream.set_alpha(alpha * fill_opacity)
2021-03-19 17:58:22 +03:00
fill = fill_color or fill_drawn
2021-02-22 01:25:35 +03:00
2021-04-12 15:25:55 +03:00
# Get stroke data
2021-05-30 10:50:30 +03:00
stroke_source, stroke_color = self.get_paint(node.get('stroke'))
stroke_opacity = alpha_value(node.get('stroke-opacity', 1))
2021-03-19 17:58:22 +03:00
stroke_drawn = draw_gradient_or_pattern(
self, node, stroke_source, font_size, stroke_opacity, stroke=True)
2021-03-19 17:58:22 +03:00
if stroke_color and not stroke_drawn:
2021-04-17 13:30:40 +03:00
red, green, blue, alpha = color(stroke_color)
self.stream.set_color_rgb(red, green, blue, stroke=True)
self.stream.set_alpha(alpha * stroke_opacity, stroke=True)
2021-03-19 17:58:22 +03:00
stroke = stroke_color or stroke_drawn
2021-04-11 17:13:59 +03:00
stroke_width = self.length(node.get('stroke-width', '1px'), font_size)
2021-02-22 01:25:35 +03:00
if stroke_width:
self.stream.set_line_width(stroke_width)
else:
stroke = None
2021-03-14 11:29:33 +03:00
2021-04-12 15:25:55 +03:00
# Apply dash array
2021-03-14 11:29:33 +03:00
dash_array = tuple(
2021-07-30 16:15:50 +03:00
self.length(value, font_size) for value in
normalize(node.get('stroke-dasharray')).split() if value != 'none')
2021-04-12 15:25:55 +03:00
dash_condition = (
dash_array and
not all(value == 0 for value in dash_array) and
not any(value < 0 for value in dash_array))
if dash_condition:
offset = self.length(node.get('stroke-dashoffset'), font_size)
if offset < 0:
sum_dashes = sum(float(value) for value in dash_array)
offset = sum_dashes - abs(offset) % sum_dashes
self.stream.set_dash(dash_array, offset)
# Apply line cap
2021-04-11 17:13:59 +03:00
line_cap = node.get('stroke-linecap', 'butt')
if line_cap == 'round':
line_cap = 1
elif line_cap == 'square':
line_cap = 2
2021-02-22 01:25:35 +03:00
else:
line_cap = 0
2021-04-11 17:13:59 +03:00
self.stream.set_line_cap(line_cap)
2021-04-12 15:25:55 +03:00
# Apply line join
2021-04-11 17:13:59 +03:00
line_join = node.get('stroke-linejoin', 'miter')
if line_join == 'round':
2021-02-22 01:25:35 +03:00
line_join = 1
elif line_join == 'bevel':
line_join = 2
2021-04-11 17:13:59 +03:00
else:
line_join = 0
self.stream.set_line_join(line_join)
2021-04-12 15:25:55 +03:00
# Apply miter limit
2021-04-11 17:13:59 +03:00
miter_limit = float(node.get('stroke-miterlimit', 4))
2021-02-22 01:25:35 +03:00
if miter_limit < 0:
miter_limit = 4
self.stream.set_miter_limit(miter_limit)
2021-04-12 15:25:55 +03:00
# Fill and stroke
2021-03-19 17:58:22 +03:00
even_odd = node.get('fill-rule') == 'evenodd'
2021-04-20 18:15:17 +03:00
if text:
if stroke and fill:
text_rendering = 2
elif stroke:
text_rendering = 1
elif fill:
text_rendering = 0
else:
text_rendering = 3
self.stream.set_text_rendering(text_rendering)
2021-04-20 17:53:21 +03:00
else:
2021-04-20 18:15:17 +03:00
if fill and stroke:
self.stream.fill_and_stroke(even_odd)
elif stroke:
self.stream.stroke()
elif fill:
self.stream.fill(even_odd)
else:
self.stream.end()
def transform(self, transform_string, font_size):
2021-04-12 15:25:55 +03:00
"""Apply a transformation string to the node."""
if not transform_string:
return
2021-07-31 09:29:23 +03:00
matrix = transform(transform_string, font_size, self.inner_diagonal)
if matrix.determinant:
2021-07-17 14:46:46 +03:00
self.stream.transform(*matrix.values)
2021-03-12 17:47:49 +03:00
2021-04-12 15:25:55 +03:00
def parse_defs(self, node):
"""Parse defs included in a tree."""
2021-03-12 17:47:49 +03:00
for def_type in DEF_TYPES:
if def_type in node.tag.lower() and 'id' in node.attrib:
getattr(self, f'{def_type}s')[node.attrib['id']] = node
2021-04-12 15:25:55 +03:00
for child in node:
self.parse_defs(child)
2021-03-14 11:29:33 +03:00
def inherit_defs(self):
"""Handle inheritance of different defined elements lists."""
for defs in (self.gradients, self.patterns):
for element in defs.values():
self.inherit_element(element, defs)
def inherit_element(self, element, defs):
"""Recursively handle inheritance of defined element."""
href = element.get_href(self.url)
if not href:
return
element.del_href()
parent = defs.get(parse_url(href).fragment)
if not parent:
return
self.inherit_element(parent, defs)
for key, value in parent.attrib.items():
if key not in element.attrib:
element.attrib[key] = value
if next(iter(element), None) is None:
element.override_iter(parent.__iter__())
def calculate_bounding_box(self, node, font_size, stroke=True):
2021-04-12 15:25:55 +03:00
"""Calculate the bounding box of a node."""
if stroke or node.bounding_box is None:
box = bounding_box(self, node, font_size, stroke)
2021-04-12 16:11:13 +03:00
if is_valid_bounding_box(box) and 0 not in box[2:]:
if stroke:
return box
2021-04-12 16:11:13 +03:00
node.bounding_box = box
2021-03-14 11:29:33 +03:00
return node.bounding_box
2021-03-24 16:10:50 +03:00
class Pattern(SVG):
2021-04-12 15:25:55 +03:00
"""SVG node applied as a pattern."""
def __init__(self, tree, svg):
super().__init__(tree._etree_node, svg.url)
self.svg = svg
2021-03-24 16:10:50 +03:00
self.tree = tree
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, fill_stroke=True)