feat: add comprehensive GitHub workflow and development tools

This commit is contained in:
Stiftung Development
2025-09-06 18:31:54 +02:00
commit ab23d7187e
10224 changed files with 2075210 additions and 0 deletions

View File

@@ -0,0 +1,819 @@
"""Render SVG images."""
import re
from contextlib import suppress
from math import cos, hypot, pi, radians, sin, sqrt
from xml.etree import ElementTree
from cssselect2 import ElementWrapper
from ..urls import get_url_attribute
from .css import parse_declarations, parse_stylesheets
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 .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)
TAGS = {
'a': text,
'circle': circle,
'clipPath': clip_path,
'ellipse': ellipse,
'image': image,
'line': line,
'path': path,
'polyline': polyline,
'polygon': polygon,
'rect': rect,
'svg': svg,
'text': text,
'textPath': text,
'tspan': text,
'use': use,
}
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',
))
DEF_TYPES = frozenset((
'clipPath',
'filter',
'gradient',
'image',
'marker',
'mask',
'path',
'pattern',
))
class Node:
"""An SVG document node."""
def __init__(self, wrapper, style):
self._wrapper = wrapper
self._etree_node = wrapper.etree_element
self._style = style
self.attrib = wrapper.etree_element.attrib.copy()
self.vertices = []
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)
@property
def tag(self):
"""XML tag name with no namespace."""
return self._etree_node.tag.split('}', 1)[-1]
@property
def text(self):
"""XML node text."""
return self._etree_node.text
@property
def tail(self):
"""Text after the XML node."""
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):
"""Yield node children, handling cascade."""
for wrapper in self._wrapper:
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:
child.attrib[name] = value.strip()
# 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():
if value == 'inherit':
value = self.get(key)
if value is None:
del child.attrib[key]
else:
child.attrib[key] = value
# Fix text in text tags
if child.tag in ('text', 'textPath', 'a'):
children, _ = child.text_children(
wrapper, trailing_space=True, text_root=True)
child._wrapper.etree_children = [
child._etree_node for child in children]
yield child
def get_viewbox(self):
"""Get node viewBox as a tuple of floats."""
viewbox = self.get('viewBox')
if viewbox:
return tuple(
float(number) for number in normalize(viewbox).split())
def get_href(self, base_url):
"""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
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)
@staticmethod
def process_whitespace(string, preserve):
"""Replace newlines by spaces, and merge spaces if not preserved."""
# TODO: should be merged with build.process_whitespace
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)
def get_child(self, id_):
"""Get a child with given id in the whole child tree."""
for child in self:
if child.get('id') == id_:
return child
grandchild = child.get_child(id_)
if grandchild:
return grandchild
def text_children(self, element, trailing_space, text_root=False):
"""Handle text node by fixing whitespaces and flattening tails."""
children = []
space = '{http://www.w3.org/XML/1998/namespace}space'
preserve = self.get(space) == 'preserve'
self._etree_node.text = self.process_whitespace(
element.etree_element.text, preserve)
if trailing_space and not preserve:
self._etree_node.text = self.text.lstrip(' ')
original_rotate = [
float(i) for i in
normalize(self.get('rotate')).strip().split(' ') if i]
rotate = original_rotate.copy()
if original_rotate:
self.pop_rotation(original_rotate, rotate)
if self.text:
trailing_space = self.text.endswith(' ')
element_children = tuple(element.iter_children())
for child_element in element_children:
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
child._etree_node.text = child.flatten()
child_element = ElementWrapper.from_xml_root(child)
else:
child_node = Node(child_element, self._style)
child_preserve = child_node.get(space) == 'preserve'
child_node._etree_node.text = self.process_whitespace(
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:
child_node.pop_rotation(original_rotate, rotate)
children.append(child_node)
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:
anonymous_etree = ElementTree.Element(
'{http://www.w3.org/2000/svg}tspan')
anonymous = Node(
ElementWrapper.from_xml_root(anonymous_etree), self._style)
anonymous._etree_node.text = tail
if original_rotate:
anonymous.pop_rotation(original_rotate, rotate)
if trailing_space and not preserve:
anonymous._etree_node.text = anonymous.text.lstrip(' ')
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
def flatten(self):
"""Flatten text in node and in its children."""
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):
"""Merge nested letter rotations."""
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})
class SVG:
"""An SVG document."""
def __init__(self, tree, url):
wrapper = ElementWrapper.from_xml_root(tree)
style = parse_stylesheets(wrapper, url)
self.tree = Node(wrapper, style)
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')
self.filters = {}
self.gradients = {}
self.images = {}
self.markers = {}
self.masks = {}
self.patterns = {}
self.paths = {}
self.use_cache = {}
self.cursor_position = [0, 0]
self.cursor_d_position = [0, 0]
self.text_path_width = 0
self.parse_defs(self.tree)
self.inherit_defs()
def get_intrinsic_size(self, font_size):
"""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
def get_viewbox(self):
"""Get document viewBox as a tuple of floats."""
return self.tree.get_viewbox()
def point(self, x, y, font_size):
"""Compute size of an x/y or width/height couple."""
return (
size(x, font_size, self.inner_width),
size(y, font_size, self.inner_height))
def length(self, length, font_size):
"""Compute size of an arbirtary attribute."""
return size(length, font_size, self.inner_diagonal)
def draw(self, stream, concrete_width, concrete_height, base_url,
url_fetcher, context):
"""Draw image on a stream."""
self.stream = stream
self.concrete_width = concrete_width
self.concrete_height = concrete_height
self.normalized_diagonal = (
hypot(concrete_width, concrete_height) / sqrt(2))
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))
self.base_url = base_url
self.url_fetcher = url_fetcher
self.context = context
self.draw_node(self.tree, size('12pt'))
def draw_node(self, node, font_size, fill_stroke=True):
"""Draw a node."""
if node.tag == 'defs':
return
# Update font size
font_size = size(node.get('font-size', '1em'), font_size, font_size)
original_streams = []
if fill_stroke:
self.stream.push_state()
# Apply filters
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)
# 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)
# Handle text anchor
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'):
group = self.stream.add_group(0, 0, 0, 0) # BBox set after drawing
original_streams.append(self.stream)
self.stream = group
# Set text bounding box
if node.display and TAGS.get(node.tag) == text:
node.text_bounding_box = EMPTY_BOUNDING_BOX
# Draw node
if node.visible and node.tag in TAGS:
with suppress(PointError):
TAGS[node.tag](self, node, font_size)
# 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:
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
if node.tag == 'text' and text_anchor in ('middle', 'end'):
group_id = self.stream.id
self.stream = original_streams.pop()
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)
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()
# Apply mask
mask = self.masks.get(parse_url(node.get('mask')).fragment)
if mask:
paint_mask(self, node, mask, opacity)
# Fill and stroke
if fill_stroke:
self.fill_stroke(node, font_size)
# Draw markers
self.draw_markers(node, font_size, fill_stroke)
# Apply opacity stream and restore original stream
if fill_stroke and 0 <= opacity < 1:
group_id = self.stream.id
self.stream = original_streams.pop()
self.stream.set_alpha(opacity, stroke=True, fill=True)
self.stream.draw_x_object(group_id)
# Clean text tag
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()
def draw_markers(self, node, font_size, fill_stroke):
"""Draw markers defined in a node."""
if not node.vertices:
return
markers = {}
common_marker = parse_url(node.get('marker')).fragment
for position in ('start', 'mid', 'end'):
attribute = f'marker-{position}'
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'
# Draw marker
marker = markers[position]
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)
if 'viewBox' in marker_node.attrib:
scale_x, scale_y, _, _ = preserve_ratio(
self, marker_node, font_size, marker_width, marker_height)
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)
# 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)
overflow = marker_node.get('overflow', 'hidden')
if overflow in ('hidden', 'scroll'):
self.stream.rectangle(*clip_box)
self.stream.clip()
self.stream.end()
self.draw_node(child, font_size, fill_stroke)
self.stream.pop_state()
position = 'mid' if angles else 'start'
@staticmethod
def get_paint(value):
"""Get paint fill or stroke attribute with a color or a URL."""
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
def fill_stroke(self, node, font_size, text=False):
"""Paint fill and stroke for a node."""
if node.tag in ('text', 'textPath', 'a') and not text:
return
# Get fill data
fill_source, fill_color = self.get_paint(node.get('fill', 'black'))
fill_opacity = alpha_value(node.get('fill-opacity', 1))
fill_drawn = draw_gradient_or_pattern(
self, node, fill_source, font_size, fill_opacity, stroke=False)
if fill_color and not fill_drawn:
red, green, blue, alpha = color(fill_color)
self.stream.set_color_rgb(red, green, blue)
self.stream.set_alpha(alpha * fill_opacity)
fill = fill_color or fill_drawn
# Get stroke data
stroke_source, stroke_color = self.get_paint(node.get('stroke'))
stroke_opacity = alpha_value(node.get('stroke-opacity', 1))
stroke_drawn = draw_gradient_or_pattern(
self, node, stroke_source, font_size, stroke_opacity, stroke=True)
if stroke_color and not stroke_drawn:
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)
stroke = stroke_color or stroke_drawn
stroke_width = self.length(node.get('stroke-width', '1px'), font_size)
if stroke_width:
self.stream.set_line_width(stroke_width)
else:
stroke = None
# Apply dash array
dash_array = tuple(
self.length(value, font_size) for value in
normalize(node.get('stroke-dasharray')).split() if value != 'none')
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
line_cap = node.get('stroke-linecap', 'butt')
if line_cap == 'round':
line_cap = 1
elif line_cap == 'square':
line_cap = 2
else:
line_cap = 0
self.stream.set_line_cap(line_cap)
# Apply line join
line_join = node.get('stroke-linejoin', 'miter')
if line_join == 'round':
line_join = 1
elif line_join == 'bevel':
line_join = 2
else:
line_join = 0
self.stream.set_line_join(line_join)
# Apply miter limit
miter_limit = float(node.get('stroke-miterlimit', 4))
if miter_limit < 0:
miter_limit = 4
self.stream.set_miter_limit(miter_limit)
# Fill and stroke
even_odd = node.get('fill-rule') == 'evenodd'
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)
else:
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):
"""Apply a transformation string to the node."""
if not transform_string:
return
matrix = transform(transform_string, font_size, self.inner_diagonal)
if matrix.determinant:
self.stream.transform(*matrix.values)
def parse_defs(self, node):
"""Parse defs included in a tree."""
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
for child in node:
self.parse_defs(child)
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):
"""Calculate the bounding box of a node."""
if stroke or node.bounding_box is None:
box = bounding_box(self, node, font_size, stroke)
if is_valid_bounding_box(box) and 0 not in box[2:]:
if stroke:
return box
node.bounding_box = box
return node.bounding_box
class Pattern(SVG):
"""SVG node applied as a pattern."""
def __init__(self, tree, svg):
super().__init__(tree._etree_node, svg.url)
self.svg = svg
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)

View File

@@ -0,0 +1,356 @@
"""Calculate bounding boxes of SVG tags."""
from math import atan, atan2, cos, inf, isinf, pi, radians, sin, sqrt, tan
from .path import PATH_LETTERS
from .utils import normalize, point
EMPTY_BOUNDING_BOX = inf, inf, 0, 0
def bounding_box(svg, node, font_size, stroke):
"""Bounding box for any node."""
if node.tag not in BOUNDING_BOX_METHODS:
return EMPTY_BOUNDING_BOX
box = BOUNDING_BOX_METHODS[node.tag](svg, node, font_size)
if not is_valid_bounding_box(box):
return EMPTY_BOUNDING_BOX
if stroke and node.tag != 'g' and any(svg.get_paint(node.get('stroke'))):
stroke_width = svg.length(node.get('stroke-width', '1px'), font_size)
box = (
box[0] - stroke_width / 2, box[1] - stroke_width / 2,
box[2] + stroke_width, box[3] + stroke_width)
return box
def bounding_box_rect(svg, node, font_size):
"""Bounding box for rect node."""
x, y = svg.point(node.get('x'), node.get('y'), font_size)
width, height = svg.point(
node.get('width'), node.get('height'), font_size)
return x, y, width, height
def bounding_box_circle(svg, node, font_size):
"""Bounding box for circle node."""
cx, cy = svg.point(node.get('cx'), node.get('cy'), font_size)
r = svg.length(node.get('r'), font_size)
return cx - r, cy - r, 2 * r, 2 * r
def bounding_box_ellipse(svg, node, font_size):
"""Bounding box for ellipse node."""
rx, ry = svg.point(node.get('rx'), node.get('ry'), font_size)
cx, cy = svg.point(node.get('cx'), node.get('cy'), font_size)
return cx - rx, cy - ry, 2 * rx, 2 * ry
def bounding_box_line(svg, node, font_size):
"""Bounding box for line node."""
x1, y1 = svg.point(node.get('x1'), node.get('y1'), font_size)
x2, y2 = svg.point(node.get('x2'), node.get('y2'), font_size)
x, y = min(x1, x2), min(y1, y2)
width, height = max(x1, x2) - x, max(y1, y2) - y
return x, y, width, height
def bounding_box_polyline(svg, node, font_size):
"""Bounding box for polyline node."""
bounding_box = EMPTY_BOUNDING_BOX
points = []
normalized_points = normalize(node.get('points', ''))
while normalized_points:
x, y, normalized_points = point(svg, normalized_points, font_size)
points.append((x, y))
return extend_bounding_box(bounding_box, points)
def bounding_box_path(svg, node, font_size):
"""Bounding box for path node."""
path_data = node.get('d', '')
# Normalize path data for correct parsing
for letter in PATH_LETTERS:
path_data = path_data.replace(letter, f' {letter} ')
path_data = normalize(path_data)
bounding_box = EMPTY_BOUNDING_BOX
previous_x = 0
previous_y = 0
letter = 'M' # Move as default
while path_data:
path_data = path_data.strip()
if path_data.split(' ', 1)[0] in PATH_LETTERS:
letter, path_data = (f'{path_data} ').split(' ', 1)
if letter in 'aA':
# Elliptical arc curve
rx, ry, path_data = point(svg, path_data, font_size)
rotation, path_data = path_data.split(' ', 1)
rotation = radians(float(rotation))
# The large and sweep values are not always separated from the
# following values, here is the crazy parser
large, path_data = path_data[0], path_data[1:].strip()
while not large[-1].isdigit():
large, path_data = large + path_data[0], path_data[1:].strip()
sweep, path_data = path_data[0], path_data[1:].strip()
while not sweep[-1].isdigit():
sweep, path_data = sweep + path_data[0], path_data[1:].strip()
large, sweep = bool(int(large)), bool(int(sweep))
x, y, path_data = point(svg, path_data, font_size)
# Relative coordinate, convert to absolute
if letter == 'a':
x += previous_x
y += previous_y
# Extend bounding box with start and end coordinates
arc_bounding_box = _bounding_box_elliptical_arc(
previous_x, previous_y, rx, ry, rotation, large, sweep, x, y)
x1, y1, width, height = arc_bounding_box
x2 = x1 + width
y2 = y1 + height
points = (x1, y1), (x2, y2)
bounding_box = extend_bounding_box(bounding_box, points)
previous_x = x
previous_y = y
elif letter in 'cC':
# Curve
x1, y1, path_data = point(svg, path_data, font_size)
x2, y2, path_data = point(svg, path_data, font_size)
x, y, path_data = point(svg, path_data, font_size)
# Relative coordinates, convert to absolute
if letter == 'c':
x1 += previous_x
y1 += previous_y
x2 += previous_x
y2 += previous_y
x += previous_x
y += previous_y
# Extend bounding box with all coordinates
bounding_box = extend_bounding_box(
bounding_box, ((x1, y1), (x2, y2), (x, y)))
previous_x = x
previous_y = y
elif letter in 'hH':
# Horizontal line
x, path_data = (f'{path_data} ').split(' ', 1)
x, _ = svg.point(x, 0, font_size)
# Relative coordinate, convert to absolute
if letter == 'h':
x += previous_x
# Extend bounding box with coordinate
bounding_box = extend_bounding_box(
bounding_box, ((x, previous_y),))
previous_x = x
elif letter in 'lLmMtT':
# Line/Move/Smooth quadratic curve
x, y, path_data = point(svg, path_data, font_size)
# Relative coordinate, convert to absolute
if letter in 'lmt':
x += previous_x
y += previous_y
# Extend bounding box with coordinate
bounding_box = extend_bounding_box(bounding_box, ((x, y),))
previous_x = x
previous_y = y
elif letter in 'qQsS':
# Quadratic curve/Smooth curve
x1, y1, path_data = point(svg, path_data, font_size)
x, y, path_data = point(svg, path_data, font_size)
# Relative coordinates, convert to absolute
if letter in 'qs':
x1 += previous_x
y1 += previous_y
x += previous_x
y += previous_y
# Extend bounding box with coordinates
bounding_box = extend_bounding_box(
bounding_box, ((x1, y1), (x, y)))
previous_x = x
previous_y = y
elif letter in 'vV':
# Vertical line
y, path_data = (f'{path_data} ').split(' ', 1)
_, y = svg.point(0, y, font_size)
# Relative coordinate, convert to absolute
if letter == 'v':
y += previous_y
# Extend bounding box with coordinate
bounding_box = extend_bounding_box(
bounding_box, ((previous_x, y),))
previous_y = y
path_data = path_data.strip()
return bounding_box
def bounding_box_text(svg, node, font_size):
"""Bounding box for text node."""
return node.get('text_bounding_box')
def bounding_box_g(svg, node, font_size):
"""Bounding box for g node."""
bounding_box = EMPTY_BOUNDING_BOX
for child in node:
child_bounding_box = svg.calculate_bounding_box(child, font_size)
if is_valid_bounding_box(child_bounding_box):
minx, miny, width, height = child_bounding_box
maxx, maxy = minx + width, miny + height
bounding_box = extend_bounding_box(
bounding_box, ((minx, miny), (maxx, maxy)))
return bounding_box
def _bounding_box_elliptical_arc(x1, y1, rx, ry, phi, large, sweep, x, y):
"""Bounding box of an elliptical arc in path node."""
rx, ry = abs(rx), abs(ry)
if 0 in (rx, ry):
return min(x, x1), min(y, y1), abs(x - x1), abs(y - y1)
x1prime = cos(phi) * (x1 - x) / 2 + sin(phi) * (y1 - y) / 2
y1prime = -sin(phi) * (x1 - x) / 2 + cos(phi) * (y1 - y) / 2
radicant = (
rx ** 2 * ry ** 2 - rx ** 2 * y1prime ** 2 - ry ** 2 * x1prime ** 2)
radicant /= rx ** 2 * y1prime ** 2 + ry ** 2 * x1prime ** 2
cxprime = cyprime = 0
if radicant < 0:
ratio = rx / ry
radicant = y1prime ** 2 + x1prime ** 2 / ratio ** 2
if radicant < 0:
return min(x, x1), min(y, y1), abs(x - x1), abs(y - y1)
ry = sqrt(radicant)
rx = ratio * ry
else:
factor = (-1 if large == sweep else 1) * sqrt(radicant)
cxprime = factor * rx * y1prime / ry
cyprime = -factor * ry * x1prime / rx
cx = cxprime * cos(phi) - cyprime * sin(phi) + (x1 + x) / 2
cy = cxprime * sin(phi) + cyprime * cos(phi) + (y1 + y) / 2
if phi in (0, pi):
minx = cx - rx
tminx = atan2(0, -rx)
maxx = cx + rx
tmaxx = atan2(0, rx)
miny = cy - ry
tminy = atan2(-ry, 0)
maxy = cy + ry
tmaxy = atan2(ry, 0)
elif phi in (pi / 2, 3 * pi / 2):
minx = cx - ry
tminx = atan2(0, -ry)
maxx = cx + ry
tmaxx = atan2(0, ry)
miny = cy - rx
tminy = atan2(-rx, 0)
maxy = cy + rx
tmaxy = atan2(rx, 0)
else:
tminx = -atan(ry * tan(phi) / rx)
tmaxx = pi - atan(ry * tan(phi) / rx)
minx = cx + rx * cos(tminx) * cos(phi) - ry * sin(tminx) * sin(phi)
maxx = cx + rx * cos(tmaxx) * cos(phi) - ry * sin(tmaxx) * sin(phi)
if minx > maxx:
minx, maxx = maxx, minx
tminx, tmaxx = tmaxx, tminx
tmp_y = cy + rx * cos(tminx) * sin(phi) + ry * sin(tminx) * cos(phi)
tminx = atan2(minx - cx, tmp_y - cy)
tmp_y = cy + rx * cos(tmaxx) * sin(phi) + ry * sin(tmaxx) * cos(phi)
tmaxx = atan2(maxx - cx, tmp_y - cy)
tminy = atan(ry / (tan(phi) * rx))
tmaxy = atan(ry / (tan(phi) * rx)) + pi
miny = cy + rx * cos(tminy) * sin(phi) + ry * sin(tminy) * cos(phi)
maxy = cy + rx * cos(tmaxy) * sin(phi) + ry * sin(tmaxy) * cos(phi)
if miny > maxy:
miny, maxy = maxy, miny
tminy, tmaxy = tmaxy, tminy
tmp_x = cx + rx * cos(tminy) * cos(phi) - ry * sin(tminy) * sin(phi)
tminy = atan2(tmp_x - cx, miny - cy)
tmp_x = cx + rx * cos(tmaxy) * cos(phi) - ry * sin(tmaxy) * sin(phi)
tmaxy = atan2(maxy - cy, tmp_x - cx)
angle1 = atan2(y1 - cy, x1 - cx)
angle2 = atan2(y - cy, x - cx)
if not sweep:
angle1, angle2 = angle2, angle1
other_arc = False
if angle1 > angle2:
angle1, angle2 = angle2, angle1
other_arc = True
if ((not other_arc and (angle1 > tminx or angle2 < tminx)) or
(other_arc and not (angle1 > tminx or angle2 < tminx))):
minx = min(x, x1)
if ((not other_arc and (angle1 > tmaxx or angle2 < tmaxx)) or
(other_arc and not (angle1 > tmaxx or angle2 < tmaxx))):
maxx = max(x, x1)
if ((not other_arc and (angle1 > tminy or angle2 < tminy)) or
(other_arc and not (angle1 > tminy or angle2 < tminy))):
miny = min(y, y1)
if ((not other_arc and (angle1 > tmaxy or angle2 < tmaxy)) or
(other_arc and not (angle1 > tmaxy or angle2 < tmaxy))):
maxy = max(y, y1)
return minx, miny, maxx - minx, maxy - miny
def extend_bounding_box(bounding_box, points):
"""Extend a bounding box to include given points."""
minx, miny, width, height = bounding_box
maxx, maxy = (
-inf if isinf(minx) else minx + width,
-inf if isinf(miny) else miny + height)
x_list, y_list = zip(*points)
minx, miny, maxx, maxy = (
min(minx, *x_list), min(miny, *y_list),
max(maxx, *x_list), max(maxy, *y_list))
return minx, miny, maxx - minx, maxy - miny
def is_valid_bounding_box(bounding_box):
"""Check that a bounding box doesnt have infinite boundaries."""
return bounding_box and not isinf(bounding_box[0] + bounding_box[1])
BOUNDING_BOX_METHODS = {
'rect': bounding_box_rect,
'circle': bounding_box_circle,
'ellipse': bounding_box_ellipse,
'line': bounding_box_line,
'polyline': bounding_box_polyline,
'polygon': bounding_box_polyline,
'path': bounding_box_path,
'g': bounding_box_g,
'marker': bounding_box_g,
'text': bounding_box_text,
'tspan': bounding_box_text,
'textPath': bounding_box_text,
}

View File

@@ -0,0 +1,92 @@
"""Apply CSS to SVG documents."""
from urllib.parse import urljoin
import cssselect2
import tinycss2
from ..logger import LOGGER
from .utils import parse_url
def find_stylesheets_rules(tree, stylesheet_rules, url):
"""Find rules among stylesheet rules and imports."""
for rule in stylesheet_rules:
if rule.type == 'at-rule':
if rule.lower_at_keyword == 'import' and rule.content is None:
# TODO: support media types in @import
url_token = tinycss2.parse_one_component_value(rule.prelude)
if url_token.type not in ('string', 'url'):
continue
css_url = parse_url(urljoin(url, url_token.value))
stylesheet = tinycss2.parse_stylesheet(
tree.fetch_url(css_url, 'text/css').decode())
url = css_url.geturl()
yield from find_stylesheets_rules(tree, stylesheet, url)
# TODO: support media types
# if rule.lower_at_keyword == 'media':
elif rule.type == 'qualified-rule':
yield rule
# TODO: warn on error
# if rule.type == 'error':
def parse_declarations(input):
"""Parse declarations in a given rule content."""
normal_declarations = []
important_declarations = []
for declaration in tinycss2.parse_blocks_contents(input):
# TODO: warn on error
# if declaration.type == 'error':
if (declaration.type == 'declaration' and
not declaration.name.startswith('-')):
# Serializing perfectly good tokens just to re-parse them later :(
value = tinycss2.serialize(declaration.value).strip()
declarations = (
important_declarations if declaration.important
else normal_declarations)
declarations.append((declaration.lower_name, value))
return normal_declarations, important_declarations
def parse_stylesheets(tree, url):
"""Find stylesheets and return rule matchers in given tree."""
normal_matcher = cssselect2.Matcher()
important_matcher = cssselect2.Matcher()
# Find stylesheets
# TODO: support contentStyleType on <svg>
stylesheets = []
for element in tree.etree_element.iter():
# https://www.w3.org/TR/SVG/styling.html#StyleElement
if (element.tag == '{http://www.w3.org/2000/svg}style' and
element.get('type', 'text/css') == 'text/css' and
element.text):
# TODO: pass href for relative URLs
# TODO: support media types
# TODO: what if <style> has children elements?
stylesheets.append(tinycss2.parse_stylesheet(
element.text, skip_comments=True, skip_whitespace=True))
# Parse rules and fill matchers
for stylesheet in stylesheets:
for rule in find_stylesheets_rules(tree, stylesheet, url):
normal_declarations, important_declarations = parse_declarations(
rule.content)
try:
selectors = cssselect2.compile_selector_list(rule.prelude)
except cssselect2.parser.SelectorError as exception:
LOGGER.warning(
'Failed to apply CSS rule in SVG rule: %s', exception)
break
for selector in selectors:
if (selector.pseudo_element is None and
not selector.never_matches):
if normal_declarations:
normal_matcher.add_selector(
selector, normal_declarations)
if important_declarations:
important_matcher.add_selector(
selector, important_declarations)
return normal_matcher, important_matcher

View File

@@ -0,0 +1,525 @@
"""Parse and draw definitions: gradients, patterns, masks, uses…"""
from itertools import cycle
from math import ceil, hypot
from ..matrix import Matrix
from .bounding_box import is_valid_bounding_box
from .utils import alpha_value, color, parse_url, size, transform
def use(svg, node, font_size):
"""Draw use tags."""
from . import NOT_INHERITED_ATTRIBUTES, SVG
x, y = svg.point(node.get('x'), node.get('y'), font_size)
for attribute in ('x', 'y', 'viewBox', 'mask'):
if attribute in node.attrib:
del node.attrib[attribute]
parsed_url = parse_url(node.get_href(svg.url))
svg_url = parse_url(svg.url)
if svg_url.scheme == 'data':
svg_url = parse_url('')
same_origin = (
parsed_url[:3] == ('', '', '') or
parsed_url[:3] == svg_url[:3])
if parsed_url.fragment and same_origin:
if parsed_url.fragment in svg.use_cache:
tree = svg.use_cache[parsed_url.fragment].copy()
else:
try:
tree = svg.tree.get_child(parsed_url.fragment).copy()
except Exception:
return
else:
svg.use_cache[parsed_url.fragment] = tree
else:
url = parsed_url.geturl()
try:
bytestring_svg = svg.url_fetcher(url)
use_svg = SVG(bytestring_svg, url)
except Exception:
return
else:
use_svg.get_intrinsic_size(font_size)
tree = use_svg.tree
if tree.tag in ('svg', 'symbol'):
# Explicitely specified
# https://www.w3.org/TR/SVG11/struct.html#UseElement
tree._etree_node.tag = 'svg'
if 'width' in node.attrib and 'height' in node.attrib:
tree.attrib['width'] = node.attrib['width']
tree.attrib['height'] = node.attrib['height']
# Cascade
for key, value in node.attrib.items():
if key not in NOT_INHERITED_ATTRIBUTES:
if key not in tree.attrib:
tree.attrib[key] = value
node.override_iter(iter((tree,)))
svg.stream.transform(e=x, f=y)
def draw_gradient_or_pattern(svg, node, name, font_size, opacity, stroke):
"""Draw given gradient or pattern."""
if name in svg.gradients:
return draw_gradient(
svg, node, svg.gradients[name], font_size, opacity, stroke)
elif name in svg.patterns:
return draw_pattern(
svg, node, svg.patterns[name], font_size, opacity, stroke)
def draw_gradient(svg, node, gradient, font_size, opacity, stroke):
"""Draw given gradient node."""
# TODO: merge with Gradient.draw
positions = []
colors = []
for child in gradient:
positions.append(max(
positions[-1] if positions else 0,
size(child.get('offset'), font_size, 1)))
stop_opacity = alpha_value(child.get('stop-opacity', 1)) * opacity
stop_color = color(child.get('stop-color', 'black'))
if stop_opacity < 1:
stop_color = tuple(
stop_color[:3] + (stop_color[3] * stop_opacity,))
colors.append(stop_color)
if not colors:
return False
elif len(colors) == 1:
red, green, blue, alpha = colors[0]
svg.stream.set_color_rgb(red, green, blue)
if alpha != 1:
svg.stream.set_alpha(alpha, stroke=stroke)
return True
bounding_box = svg.calculate_bounding_box(node, font_size, stroke)
if not is_valid_bounding_box(bounding_box):
return False
if gradient.get('gradientUnits') == 'userSpaceOnUse':
width, height = svg.inner_width, svg.inner_height
matrix = Matrix()
else:
width, height = 1, 1
e, f, a, d = bounding_box
matrix = Matrix(a=a, d=d, e=e, f=f)
spread = gradient.get('spreadMethod', 'pad')
if spread in ('repeat', 'reflect'):
if positions[0] > 0:
positions.insert(0, 0)
colors.insert(0, colors[0])
if positions[-1] < 1:
positions.append(1)
colors.append(colors[-1])
else:
# Add explicit colors at boundaries if needed, because PDF doesnt
# extend color stops that are not displayed
if positions[0] == positions[1]:
if gradient.tag == 'radialGradient':
# Avoid negative radius for radial gradients
positions.insert(0, 0)
else:
positions.insert(0, positions[0] - 1)
colors.insert(0, colors[0])
if positions[-2] == positions[-1]:
positions.append(positions[-1] + 1)
colors.append(colors[-1])
if 'gradientTransform' in gradient.attrib:
transform_matrix = transform(
gradient.get('gradientTransform'), font_size,
svg.normalized_diagonal)
matrix = transform_matrix @ matrix
if gradient.tag == 'linearGradient':
shading_type = 2
x1, y1 = (
size(gradient.get('x1', 0), font_size, width),
size(gradient.get('y1', 0), font_size, height))
x2, y2 = (
size(gradient.get('x2', '100%'), font_size, width),
size(gradient.get('y2', 0), font_size, height))
positions, colors, coords = spread_linear_gradient(
spread, positions, colors, x1, y1, x2, y2, bounding_box, matrix)
else:
assert gradient.tag == 'radialGradient'
shading_type = 3
cx, cy = (
size(gradient.get('cx', '50%'), font_size, width),
size(gradient.get('cy', '50%'), font_size, height))
r = size(gradient.get('r', '50%'), font_size, hypot(width, height))
fx, fy = (
size(gradient.get('fx', cx), font_size, width),
size(gradient.get('fy', cy), font_size, height))
fr = size(gradient.get('fr', 0), font_size, hypot(width, height))
positions, colors, coords = spread_radial_gradient(
spread, positions, colors, fx, fy, fr, cx, cy, r, width, height,
matrix)
alphas = [color[3] for color in colors]
alpha_couples = [
(alphas[i], alphas[i + 1])
for i in range(len(alphas) - 1)]
color_couples = [
[colors[i][:3], colors[i + 1][:3], 1]
for i in range(len(colors) - 1)]
# Premultiply colors
for i, alpha in enumerate(alphas):
if alpha == 0:
if i > 0:
color_couples[i - 1][1] = color_couples[i - 1][0]
if i < len(colors) - 1:
color_couples[i][0] = color_couples[i][1]
for i, (a0, a1) in enumerate(alpha_couples):
if 0 not in (a0, a1) and (a0, a1) != (1, 1):
color_couples[i][2] = a0 / a1
bx1, by1 = 0, 0
if 'gradientTransform' in gradient.attrib:
bx1, by1 = transform_matrix.invert.transform_point(bx1, by1)
bx2, by2 = transform_matrix.invert.transform_point(width, height)
width, height = bx2 - bx1, by2 - by1
# Ensure that width and height are positive to please some PDF readers
if bx1 > bx2:
width = -width
bx1, bx2 = bx2, bx1
if by1 > by2:
height = -height
by1, by2 = by2, by1
pattern = svg.stream.add_pattern(
bx1, by1, width, height, width, height, matrix @ svg.stream.ctm)
group = pattern.add_group(bx1, by1, width, height)
domain = (positions[0], positions[-1])
extend = spread not in ('repeat', 'reflect')
encode = (len(colors) - 1) * (0, 1)
bounds = positions[1:-1]
sub_functions = (
group.create_interpolation_function(domain, c0, c1, n)
for c0, c1, n in color_couples)
function = group.create_stitching_function(
domain, encode, bounds, sub_functions)
shading = group.add_shading(
shading_type, 'RGB', domain, coords, extend, function)
if any(alpha != 1 for alpha in alphas):
alpha_stream = group.set_alpha_state(bx1, by1, width, height)
domain = (positions[0], positions[-1])
extend = spread not in ('repeat', 'reflect')
encode = (len(colors) - 1) * (0, 1)
bounds = positions[1:-1]
sub_functions = (
group.create_interpolation_function((0, 1), [c0], [c1], 1)
for c0, c1 in alpha_couples)
function = group.create_stitching_function(
domain, encode, bounds, sub_functions)
alpha_shading = alpha_stream.add_shading(
shading_type, 'Gray', domain, coords, extend, function)
alpha_stream.stream = [f'/{alpha_shading.id} sh']
group.shading(shading.id)
pattern.set_alpha(1)
pattern.draw_x_object(group.id)
svg.stream.color_space('Pattern', stroke=stroke)
svg.stream.set_color_special(pattern.id, stroke=stroke)
return True
def spread_linear_gradient(spread, positions, colors, x1, y1, x2, y2,
bounding_box, matrix):
"""Repeat linear gradient."""
# TODO: merge with LinearGradient.layout
from ..images import gradient_average_color, normalize_stop_positions
first, last, positions = normalize_stop_positions(positions)
if spread in ('repeat', 'reflect'):
# Render as a solid color if the first and last positions are equal
# See https://drafts.csswg.org/css-images-3/#repeating-gradients
if first == last:
average_color = gradient_average_color(colors, positions)
return 1, 'solid', None, [], [average_color]
# Define defined gradient length and steps between positions
stop_length = last - first
position_steps = [
positions[i + 1] - positions[i]
for i in range(len(positions) - 1)]
# Create cycles used to add colors
if spread == 'repeat':
next_steps = cycle((0, *position_steps))
next_colors = cycle(colors)
previous_steps = cycle((0, *position_steps[::-1]))
previous_colors = cycle(colors[::-1])
else:
assert spread == 'reflect'
next_steps = cycle((0, *position_steps[::-1], 0, *position_steps))
next_colors = cycle(colors[::-1] + colors)
previous_steps = cycle((0, *position_steps, 0, *position_steps[::-1]))
previous_colors = cycle(colors + colors[::-1])
# Normalize bounding box
bx1, by1, bw, bh = bounding_box
bx1, bx2 = (bx1, bx1 + bw) if bw > 0 else (bx1 + bw, bx1)
by1, by2 = (by1, by1 + bh) if bh > 0 else (by1 + bh, by1)
# Transform gradient vector coordinates
tx1, ty1 = matrix.transform_point(x1, y1)
tx2, ty2 = matrix.transform_point(x2, y2)
# Find the extremities of the repeating vector, by projecting the
# bounding box corners on the gradient vector
xb, yb = tx1, ty1
xv, yv = tx2 - tx1, ty2 - ty1
xa1, xa2 = (bx1, bx2) if tx1 < tx2 else (bx2, bx1)
ya1, ya2 = (by1, by2) if ty1 < ty2 else (by2, by1)
min_vector = ((xa1 - xb) * xv + (ya1 - yb) * yv) / hypot(xv, yv) ** 2
max_vector = ((xa2 - xb) * xv + (ya2 - yb) * yv) / hypot(xv, yv) ** 2
# Add colors after last step
while last < max_vector:
step = next(next_steps)
colors.append(next(next_colors))
positions.append(positions[-1] + step)
last += step * stop_length
# Add colors before first step
while first > min_vector:
step = next(previous_steps)
colors.insert(0, next(previous_colors))
positions.insert(0, positions[0] - step)
first -= step * stop_length
x1, x2 = x1 + (x2 - x1) * first, x1 + (x2 - x1) * last
y1, y2 = y1 + (y2 - y1) * first, y1 + (y2 - y1) * last
coords = (x1, y1, x2, y2)
return positions, colors, coords
def spread_radial_gradient(spread, positions, colors, fx, fy, fr, cx, cy, r,
width, height, matrix):
"""Repeat radial gradient."""
# TODO: merge with RadialGradient._repeat
from ..images import gradient_average_color, normalize_stop_positions
first, last, positions = normalize_stop_positions(positions)
fr, r = fr + (r - fr) * first, fr + (r - fr) * last
if spread in ('repeat', 'reflect'):
# Keep original lists and values, theyre useful
original_colors = colors.copy()
original_positions = positions.copy()
# Get the maximum distance between the center and the corners, to find
# how many times we have to repeat the colors outside
tw, th = matrix.invert.transform_point(width, height)
max_distance = hypot(
max(abs(fx), abs(tw - fx)), max(abs(fy), abs(th - fy)))
gradient_length = r - fr
repeat_after = ceil((max_distance - r) / gradient_length)
if repeat_after > 0:
# Repeat colors and extrapolate positions
repeat = 1 + repeat_after
if spread == 'repeat':
colors *= repeat
else:
assert spread == 'reflect'
colors = []
for i in range(repeat):
colors += original_colors[::-1 if i % 2 else 1]
positions = [
i + position for i in range(repeat) for position in positions]
r += gradient_length * repeat_after
if fr == 0:
# Inner circle has 0 radius, no need to repeat inside, return
coords = (fx, fy, fr, cx, cy, r)
return positions, colors, coords
# Find how many times we have to repeat the colors inside
repeat_before = fr / gradient_length
# Set the inner circle size to 0
fr = 0
# Find how many times the whole gradient can be repeated
full_repeat = int(repeat_before)
if full_repeat:
# Repeat colors and extrapolate positions
if spread == 'repeat':
colors += original_colors * full_repeat
else:
assert spread == 'reflect'
for i in range(full_repeat):
colors += original_colors[
::-1 if (i + repeat_after) % 2 else 1]
positions = [
i - full_repeat + position for i in range(full_repeat)
for position in original_positions] + positions
# Find the ratio of gradient that must be added to reach the center
partial_repeat = repeat_before - full_repeat
if partial_repeat == 0:
# No partial repeat, return
coords = (fx, fy, fr, cx, cy, r)
return positions, colors, coords
# Iterate through positions in reverse order, from the outer
# circle to the original inner circle, to find positions from
# the inner circle (including full repeats) to the center
assert (original_positions[0], original_positions[-1]) == (0, 1)
assert 0 < partial_repeat < 1
reverse = original_positions[::-1]
ratio = 1 - partial_repeat
if spread == 'reflect':
original_colors = original_colors[::-1]
for i, position in enumerate(reverse, start=1):
if position == ratio:
# The center is a color of the gradient, truncate original
# colors and positions and prepend them
colors = original_colors[-i:] + colors
new_positions = [
position - full_repeat - 1
for position in original_positions[-i:]]
positions = new_positions + positions
break
if position < ratio:
# The center is between two colors of the gradient,
# define the center color as the average of these two
# gradient colors
color = original_colors[-i]
next_color = original_colors[-(i - 1)]
next_position = original_positions[-(i - 1)]
average_colors = [color, color, next_color, next_color]
average_positions = [position, ratio, ratio, next_position]
zero_color = gradient_average_color(
average_colors, average_positions)
colors = [zero_color] + original_colors[-(i - 1):] + colors
new_positions = [
position - 1 - full_repeat for position
in original_positions[-(i - 1):]]
positions = [ratio - 1 - full_repeat, *new_positions, *positions]
break
coords = (fx, fy, fr, cx, cy, r)
return positions, colors, coords
def draw_pattern(svg, node, pattern, font_size, opacity, stroke):
"""Draw given gradient node."""
from . import Pattern
pattern._etree_node.tag = 'svg'
bounding_box = svg.calculate_bounding_box(node, font_size, stroke)
if not is_valid_bounding_box(bounding_box):
return False
x, y = bounding_box[0], bounding_box[1]
matrix = Matrix(e=x, f=y)
if pattern.get('patternUnits') == 'userSpaceOnUse':
pattern_width = size(pattern.get('width', 0), font_size, 1)
pattern_height = size(pattern.get('height', 0), font_size, 1)
else:
width, height = bounding_box[2], bounding_box[3]
pattern_width = (
size(pattern.attrib.pop('width', '1'), font_size, 1) * width)
pattern_height = (
size(pattern.attrib.pop('height', '1'), font_size, 1) * height)
if 'viewBox' not in pattern:
pattern.attrib['width'] = pattern_width
pattern.attrib['height'] = pattern_height
if pattern.get('patternContentUnits') == 'objectBoundingBox':
pattern.attrib['transform'] = f'scale({width}, {height})'
# Fail if pattern has an invalid size
if pattern_width == 0 or pattern_height == 0:
return False
if 'patternTransform' in pattern.attrib:
transform_matrix = transform(
pattern.get('patternTransform'), font_size, svg.inner_diagonal)
matrix = transform_matrix @ matrix
matrix = matrix @ svg.stream.ctm
stream_pattern = svg.stream.add_pattern(
0, 0, pattern_width, pattern_height, pattern_width, pattern_height,
matrix)
stream_pattern.set_alpha(opacity)
group = stream_pattern.add_group(0, 0, pattern_width, pattern_height)
Pattern(pattern, svg).draw(
group, pattern_width, pattern_height, svg.base_url,
svg.url_fetcher, svg.context)
stream_pattern.draw_x_object(group.id)
svg.stream.color_space('Pattern', stroke=stroke)
svg.stream.set_color_special(stream_pattern.id, stroke=stroke)
return True
def apply_filters(svg, node, filter_node, font_size):
"""Apply filters defined in given filter node."""
for child in filter_node:
if child.tag == 'feOffset':
if filter_node.get('primitiveUnits') == 'objectBoundingBox':
bounding_box = svg.calculate_bounding_box(node, font_size)
if is_valid_bounding_box(bounding_box):
_, _, width, height = bounding_box
dx = size(child.get('dx', 0), font_size, 1) * width
dy = size(child.get('dy', 0), font_size, 1) * height
else:
dx = dy = 0
else:
dx, dy = svg.point(
child.get('dx', 0), child.get('dy', 0), font_size)
svg.stream.transform(e=dx, f=dy)
elif child.tag == 'feBlend':
mode = child.get('mode', 'normal')
mode = mode.replace('-', ' ').title().replace(' ', '')
svg.stream.set_blend_mode(mode)
def paint_mask(svg, node, mask, font_size):
"""Apply given mask node."""
mask._etree_node.tag = 'g'
if mask.get('maskUnits') == 'userSpaceOnUse':
width_ref, height_ref = svg.inner_width, svg.inner_height
else:
width_ref, height_ref = svg.point(
node.get('width'), node.get('height'), font_size)
mask.attrib['x'] = size(mask.get('x', '-10%'), font_size, width_ref)
mask.attrib['y'] = size(mask.get('y', '-10%'), font_size, height_ref)
mask.attrib['height'] = size(
mask.get('height', '120%'), font_size, height_ref)
mask.attrib['width'] = size(
mask.get('width', '120%'), font_size, width_ref)
if mask.get('maskUnits') == 'userSpaceOnUse':
x, y = mask.get('x'), mask.get('y')
width, height = mask.get('width'), mask.get('height')
mask.attrib['viewBox'] = f'{x} {y} {width} {height}'
else:
x, y = 0, 0
width, height = width_ref, height_ref
svg_stream = svg.stream
svg.stream = svg.stream.set_alpha_state(x, y, width, height)
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

View File

@@ -0,0 +1,62 @@
"""Draw image and svg tags."""
from .utils import preserve_ratio
def svg(svg, node, font_size):
"""Draw svg tags."""
x, y = svg.point(node.get('x'), node.get('y'), font_size)
svg.stream.transform(e=x, f=y)
if svg.tree == node:
width, height = svg.concrete_width, svg.concrete_height
else:
width, height = svg.point(
node.get('width'), node.get('height'), font_size)
scale_x, scale_y, translate_x, translate_y = preserve_ratio(
svg, node, font_size, width, height)
if svg.tree != node and node.get('overflow', 'hidden') == 'hidden':
svg.stream.rectangle(0, 0, width, height)
svg.stream.clip()
svg.stream.end()
svg.stream.transform(a=scale_x, d=scale_y, e=translate_x, f=translate_y)
def image(svg, node, font_size):
"""Draw image tags."""
x, y = svg.point(node.get('x'), node.get('y'), font_size)
svg.stream.transform(e=x, f=y)
base_url = node.get('{http://www.w3.org/XML/1998/namespace}base')
url = node.get_href(base_url or svg.url)
image = svg.context.get_image_from_uri(url=url, forced_mime_type='image/*')
if image is None:
return
width, height = svg.point(node.get('width'), node.get('height'), font_size)
intrinsic_width, intrinsic_height, intrinsic_ratio = (
image.get_intrinsic_size(1, font_size))
if intrinsic_width is None and intrinsic_height is None:
if intrinsic_ratio is None or (not width and not height):
intrinsic_width, intrinsic_height = 300, 150
elif not width:
intrinsic_width, intrinsic_height = (
intrinsic_ratio * height, height)
else:
intrinsic_width, intrinsic_height = width, width / intrinsic_ratio
elif intrinsic_width is None:
intrinsic_width = intrinsic_ratio * intrinsic_height
elif intrinsic_height is None:
intrinsic_height = intrinsic_width / intrinsic_ratio
width = width or intrinsic_width
height = height or intrinsic_height
scale_x, scale_y, translate_x, translate_y = preserve_ratio(
svg, node, font_size, width, height,
(0, 0, intrinsic_width, intrinsic_height))
svg.stream.rectangle(0, 0, width, height)
svg.stream.clip()
svg.stream.end()
svg.stream.push_state()
svg.stream.transform(a=scale_x, d=scale_y, e=translate_x, f=translate_y)
image.draw(
svg.stream, intrinsic_width, intrinsic_height, image_rendering='auto')
svg.stream.pop_state()

View File

@@ -0,0 +1,281 @@
"""Draw paths."""
from math import atan2, cos, isclose, pi, radians, sin, tan
from ..matrix import Matrix
from .utils import normalize, point
PATH_LETTERS = 'achlmqstvzACHLMQSTVZ'
def _rotate(x, y, angle):
"""Rotate (x, y) point of given angle around (0, 0)."""
return x * cos(angle) - y * sin(angle), y * cos(angle) + x * sin(angle)
def path(svg, node, font_size):
"""Draw path node."""
string = node.get('d', '')
for letter in PATH_LETTERS:
string = string.replace(letter, f' {letter} ')
string = normalize(string)
# TODO: get current point
current_point = 0, 0
svg.stream.move_to(*current_point)
last_letter = None
while string:
string = string.strip()
if string.split(' ', 1)[0] in PATH_LETTERS:
letter, string = (f'{string} ').split(' ', 1)
if last_letter in (None, 'z', 'Z') and letter not in 'mM':
node.vertices.append(current_point)
first_path_point = current_point
elif letter == 'M':
letter = 'L'
elif letter == 'm':
letter = 'l'
if last_letter in (None, 'm', 'M', 'z', 'Z'):
first_path_point = None
if letter not in (None, 'm', 'M', 'z', 'Z') and (
first_path_point is None):
first_path_point = current_point
if letter in 'aA':
# Elliptic curve
# Drawn as an approximation using Bézier curves
x1, y1 = current_point
rx, ry, string = point(svg, string, font_size)
rotation, string = string.split(' ', 1)
rotation = radians(float(rotation))
# The large and sweep values are not always separated from the
# following values. These flags can only be 0 or 1, so reading a
# single digit suffices.
large, string = string[0], string[1:].strip()
sweep, string = string[0], string[1:].strip()
# Retrieve end point and set remainder (before checking flags)
x3, y3, string = point(svg, string, font_size)
if letter == 'a':
x3 += x1
y3 += y1
# Only allow 0 or 1 for flags
large, sweep = int(large), int(sweep)
if large not in (0, 1) or sweep not in (0, 1):
continue
large, sweep = bool(large), bool(sweep)
# rx=0 or ry=0 means straight line
if not rx or not ry:
if string and string[0] not in PATH_LETTERS:
# As we replace the current operation by l, we must be sure
# that the next letter is set to the real current letter (a
# or A) in case its omitted
next_letter = f'{letter} '
else:
next_letter = ''
string = f'L {x3} {y3} {next_letter}{string}'
continue
# Cancel the rotation of the second point
xe, ye = _rotate(x3 - x1, y3 - y1, -rotation)
y_scale = ry / rx
ye /= y_scale
# Find the angle between the second point and the x axis
angle = atan2(ye, xe)
# Put the second point onto the x axis
xe = (xe ** 2 + ye ** 2) ** .5
ye = 0
# Update the x radius if it is too small
rx = max(rx, xe / 2)
# Find one circle centre
xc = xe / 2
yc = (rx ** 2 - xc ** 2) ** .5
# Choose between the two circles according to flags
if large == sweep:
yc = -yc
# Put the second point and the center back to their positions
xe, ye = _rotate(xe, ye, angle)
xc, yc = _rotate(xc, yc, angle)
# Find the drawing angles
angle1 = atan2(-yc, -xc)
angle2 = atan2(ye - yc, xe - xc)
while angle1 < 0 or angle2 < 0:
angle1 += 2 * pi
angle2 += 2 * pi
# Store the tangent angles
node.vertices.append((-angle1, -angle2))
# Fix angles to follow large arc flag
if isclose(abs(angle2 - angle1), pi):
if sweep and (angle2 < angle1):
angle1 -= 2 * pi
elif not sweep and (angle2 > angle1):
angle2 -= 2 * pi
elif large == (abs(angle2 - angle1) < pi):
if angle1 > angle2:
angle1 -= 2 * pi
else:
angle2 -= 2 * pi
# Split arc into 3 Bézier curves when larger than pi
if large:
step = (angle2 - angle1) / 3
angles = (
(angle1, angle1 + step),
(angle1 + step, angle1 + 2 * step),
(angle1 + 2 * step, angle2))
else:
angles = ((angle1, angle2),)
# Draw Bézier curves
matrix = Matrix(
cos(rotation), sin(rotation),
-sin(rotation) * y_scale, cos(rotation) * y_scale,
x1, y1)
h = 4 / 3 * tan((angles[0][1] - angles[0][0]) / 4)
for angle1, angle2 in angles:
point1 = matrix.transform_point(
xc + rx * cos(angle1) - h * rx * sin(angle1),
yc + rx * sin(angle1) + h * rx * cos(angle1))
point2 = matrix.transform_point(
xc + rx * cos(angle2) + h * rx * sin(angle2),
yc + rx * sin(angle2) - h * rx * cos(angle2))
point3 = matrix.transform_point(
xc + rx * cos(angle2),
yc + rx * sin(angle2))
svg.stream.curve_to(*point1, *point2, *point3)
current_point = x3, y3
elif letter in 'cC':
# Curve
x1, y1, string = point(svg, string, font_size)
x2, y2, string = point(svg, string, font_size)
x3, y3, string = point(svg, string, font_size)
if letter == 'c':
x, y = current_point
x1 += x
x2 += x
x3 += x
y1 += y
y2 += y
y3 += y
node.vertices.append((
atan2(y1 - y2, x1 - x2), atan2(y3 - y2, x3 - x2)))
svg.stream.curve_to(x1, y1, x2, y2, x3, y3)
current_point = x3, y3
elif letter in 'hH':
# Horizontal line
x, string = (f'{string} ').split(' ', 1)
old_x, old_y = current_point
x, _ = svg.point(x, 0, font_size)
if letter == 'h':
x += old_x
angle = 0 if x > old_x else pi
node.vertices.append((pi - angle, angle))
svg.stream.line_to(x, old_y)
current_point = x, old_y
elif letter in 'lL':
# Straight line
x, y, string = point(svg, string, font_size)
old_x, old_y = current_point
if letter == 'l':
x += old_x
y += old_y
angle = atan2(y - old_y, x - old_x)
node.vertices.append((pi - angle, angle))
svg.stream.line_to(x, y)
current_point = x, y
elif letter in 'mM':
# Current point move
x, y, string = point(svg, string, font_size)
if last_letter and last_letter not in 'zZ':
node.vertices.append(None)
if letter == 'm':
x += current_point[0]
y += current_point[1]
svg.stream.move_to(x, y)
current_point = x, y
elif letter in 'qQtT':
# Quadratic curve
x1, y1 = current_point
if letter in 'qQ':
x2, y2, string = point(svg, string, font_size)
else:
if last_letter not in 'QqTt':
x2, y2, x3, y3 = x, y, x, y
x2 = x1 + x3 - x2
y2 = y1 + y3 - y2
x3, y3, string = point(svg, string, font_size)
if letter == 'q':
x2 += x1
y2 += y1
if letter in 'qt':
x3 += x1
y3 += y1
xq1 = x2 * 2 / 3 + x1 / 3
yq1 = y2 * 2 / 3 + y1 / 3
xq2 = x2 * 2 / 3 + x3 / 3
yq2 = y2 * 2 / 3 + y3 / 3
svg.stream.curve_to(xq1, yq1, xq2, yq2, x3, y3)
node.vertices.append((0, 0))
current_point = x3, y3
elif letter in 'sS':
# Smooth curve
x, y = current_point
x1 = x3 + (x3 - x2) if last_letter in 'csCS' else x
y1 = y3 + (y3 - y2) if last_letter in 'csCS' else y
x2, y2, string = point(svg, string, font_size)
x3, y3, string = point(svg, string, font_size)
if letter == 's':
x2 += x
x3 += x
y2 += y
y3 += y
node.vertices.append((
atan2(y1 - y2, x1 - x2), atan2(y3 - y2, x3 - x2)))
svg.stream.curve_to(x1, y1, x2, y2, x3, y3)
current_point = x3, y3
elif letter in 'vV':
# Vertical line
y, string = (f'{string} ').split(' ', 1)
old_x, old_y = current_point
_, y = svg.point(0, y, font_size)
if letter == 'v':
y += old_y
angle = pi / 2 if y > old_y else -pi / 2
node.vertices.append((pi - angle, angle))
svg.stream.line_to(old_x, y)
current_point = old_x, y
elif letter in 'zZ' and first_path_point:
# End of path
node.vertices.append(None)
svg.stream.close()
current_point = first_path_point
if letter not in 'zZ':
node.vertices.append(current_point)
string = string.strip()
last_letter = letter

View File

@@ -0,0 +1,120 @@
"""Draw simple shapes."""
from math import atan2, pi, sqrt
from .utils import normalize, point
def circle(svg, node, font_size):
"""Draw circle tag."""
r = svg.length(node.get('r'), font_size)
if not r:
return
ratio = r / sqrt(pi)
cx, cy = svg.point(node.get('cx'), node.get('cy'), font_size)
svg.stream.move_to(cx + r, cy)
svg.stream.curve_to(cx + r, cy + ratio, cx + ratio, cy + r, cx, cy + r)
svg.stream.curve_to(cx - ratio, cy + r, cx - r, cy + ratio, cx - r, cy)
svg.stream.curve_to(cx - r, cy - ratio, cx - ratio, cy - r, cx, cy - r)
svg.stream.curve_to(cx + ratio, cy - r, cx + r, cy - ratio, cx + r, cy)
svg.stream.close()
def ellipse(svg, node, font_size):
"""Draw ellipse tag."""
rx, ry = svg.point(node.get('rx'), node.get('ry'), font_size)
if not rx or not ry:
return
ratio_x = rx / sqrt(pi)
ratio_y = ry / sqrt(pi)
cx, cy = svg.point(node.get('cx'), node.get('cy'), font_size)
svg.stream.move_to(cx + rx, cy)
svg.stream.curve_to(
cx + rx, cy + ratio_y, cx + ratio_x, cy + ry, cx, cy + ry)
svg.stream.curve_to(
cx - ratio_x, cy + ry, cx - rx, cy + ratio_y, cx - rx, cy)
svg.stream.curve_to(
cx - rx, cy - ratio_y, cx - ratio_x, cy - ry, cx, cy - ry)
svg.stream.curve_to(
cx + ratio_x, cy - ry, cx + rx, cy - ratio_y, cx + rx, cy)
svg.stream.close()
def rect(svg, node, font_size):
"""Draw rect tag."""
width, height = svg.point(node.get('width'), node.get('height'), font_size)
if width <= 0 or height <= 0:
return
x, y = svg.point(node.get('x'), node.get('y'), font_size)
rx = node.get('rx')
ry = node.get('ry')
if rx and ry is None:
ry = rx
elif ry and rx is None:
rx = ry
rx, ry = svg.point(rx, ry, font_size)
if rx == 0 or ry == 0:
svg.stream.rectangle(x, y, width, height)
return
if rx > width / 2:
rx = width / 2
if ry > height / 2:
ry = height / 2
# Inspired by Cairo Cookbook
# https://cairographics.org/cookbook/roundedrectangles/
arc_to_bezier = 4 * (2 ** .5 - 1) / 3
c1, c2 = arc_to_bezier * rx, arc_to_bezier * ry
svg.stream.move_to(x + rx, y)
svg.stream.line_to(x + width - rx, y)
svg.stream.curve_to(
x + width - rx + c1, y, x + width, y + c2, x + width, y + ry)
svg.stream.line_to(x + width, y + height - ry)
svg.stream.curve_to(
x + width, y + height - ry + c2, x + width + c1 - rx, y + height,
x + width - rx, y + height)
svg.stream.line_to(x + rx, y + height)
svg.stream.curve_to(
x + rx - c1, y + height, x, y + height - c2, x, y + height - ry)
svg.stream.line_to(x, y + ry)
svg.stream.curve_to(x, y + ry - c2, x + rx - c1, y, x + rx, y)
svg.stream.close()
def line(svg, node, font_size):
"""Draw line tag."""
x1, y1 = svg.point(node.get('x1'), node.get('y1'), font_size)
x2, y2 = svg.point(node.get('x2'), node.get('y2'), font_size)
svg.stream.move_to(x1, y1)
svg.stream.line_to(x2, y2)
angle = atan2(y2 - y1, x2 - x1)
node.vertices = [(x1, y1), (pi - angle, angle), (x2, y2)]
def polygon(svg, node, font_size):
"""Draw polygon tag."""
polyline(svg, node, font_size)
svg.stream.close()
def polyline(svg, node, font_size):
"""Draw polyline tag."""
points = normalize(node.get('points'))
if points:
x, y, points = point(svg, points, font_size)
svg.stream.move_to(x, y)
node.vertices = [(x, y)]
while points:
x_old, y_old = x, y
x, y, points = point(svg, points, font_size)
angle = atan2(x - x_old, y - y_old)
node.vertices.append((pi - angle, angle))
svg.stream.line_to(x, y)
node.vertices.append((x, y))

View File

@@ -0,0 +1,169 @@
"""Draw text."""
from math import cos, inf, radians, sin
from ..matrix import Matrix
from .bounding_box import extend_bounding_box
from .utils import normalize, size
class TextBox:
"""Dummy text box used to draw text."""
def __init__(self, pango_layout, style):
self.pango_layout = pango_layout
self.style = style
@property
def text(self):
return self.pango_layout.text
def text(svg, node, font_size):
"""Draw text node."""
from ..css.properties import INITIAL_VALUES
from ..draw import draw_emojis, draw_first_line
from ..text.line_break import split_first_line
# TODO: use real computed values
style = INITIAL_VALUES.copy()
style['font_family'] = [
font.strip('"\'') for font in
node.get('font-family', 'sans-serif').split(',')]
style['font_style'] = node.get('font-style', 'normal')
style['font_weight'] = node.get('font-weight', 400)
style['font_size'] = font_size
if style['font_weight'] == 'normal':
style['font_weight'] = 400
elif style['font_weight'] == 'bold':
style['font_weight'] = 700
else:
try:
style['font_weight'] = int(style['font_weight'])
except ValueError:
style['font_weight'] = 400
layout, _, _, width, height, _ = split_first_line(
node.text, style, svg.context, inf, 0)
# Get rotations and translations
x, y, dx, dy, rotate = [], [], [], [], [0]
if 'x' in node.attrib:
x = [size(i, font_size, svg.inner_width)
for i in normalize(node.attrib['x']).strip().split(' ')]
if 'y' in node.attrib:
y = [size(i, font_size, svg.inner_height)
for i in normalize(node.attrib['y']).strip().split(' ')]
if 'dx' in node.attrib:
dx = [size(i, font_size, svg.inner_width)
for i in normalize(node.attrib['dx']).strip().split(' ')]
if 'dy' in node.attrib:
dy = [size(i, font_size, svg.inner_height)
for i in normalize(node.attrib['dy']).strip().split(' ')]
if 'rotate' in node.attrib:
rotate = [radians(float(i)) if i else 0
for i in normalize(node.attrib['rotate']).strip().split(' ')]
last_r = rotate[-1]
letters_positions = [
([pl.pop(0) if pl else None for pl in (x, y, dx, dy, rotate)], char)
for char in node.text]
letter_spacing = svg.length(node.get('letter-spacing'), font_size)
text_length = svg.length(node.get('textLength'), font_size)
scale_x = 1
if text_length and node.text:
# calculate the number of spaces to be considered for the text
spaces_count = len(node.text) - 1
if normalize(node.attrib.get('lengthAdjust')) == 'spacingAndGlyphs':
# scale letter_spacing up/down to textLength
width_with_spacing = width + spaces_count * letter_spacing
letter_spacing *= text_length / width_with_spacing
# calculate the glyphs scaling factor by:
# - deducting the scaled letter_spacing from textLength
# - dividing the calculated value by the original width
spaceless_text_length = text_length - spaces_count * letter_spacing
scale_x = spaceless_text_length / width
elif spaces_count:
# adjust letter spacing to fit textLength
letter_spacing = (text_length - width) / spaces_count
width = text_length
# TODO: use real values
ascent, descent = font_size * .8, font_size * .2
# Align text box vertically
# TODO: This is a hack. Other baseline alignment tags are not supported.
# See https://www.w3.org/TR/SVG2/text.html#TextPropertiesSVG
y_align = 0
display_anchor = node.get('display-anchor')
alignment_baseline = node.get(
'dominant-baseline', node.get('alignment-baseline'))
if display_anchor == 'middle':
y_align = -height / 2
elif display_anchor == 'top':
pass
elif display_anchor == 'bottom':
y_align = -height
elif alignment_baseline in ('central', 'middle'):
# TODO: This is wrong, we use font top-to-bottom
y_align = (ascent + descent) / 2 - descent
elif alignment_baseline in (
'text-before-edge', 'before_edge', 'top', 'hanging', 'text-top'):
y_align = ascent
elif alignment_baseline in (
'text-after-edge', 'after_edge', 'bottom', 'text-bottom'):
y_align = -descent
# Return early when theres no text
if not node.text:
x = x[0] if x else svg.cursor_position[0]
y = y[0] if y else svg.cursor_position[1]
dx = dx[0] if dx else 0
dy = dy[0] if dy else 0
svg.cursor_position = (x + dx, y + dy)
return
svg.stream.push_state()
svg.stream.begin_text()
emoji_lines = []
# Draw letters
for i, ((x, y, dx, dy, r), letter) in enumerate(letters_positions):
if x:
svg.cursor_d_position[0] = 0
if y:
svg.cursor_d_position[1] = 0
svg.cursor_d_position[0] += dx or 0
svg.cursor_d_position[1] += dy or 0
layout, _, _, width, height, _ = split_first_line(
letter, style, svg.context, inf, 0)
x = svg.cursor_position[0] if x is None else x
y = svg.cursor_position[1] if y is None else y
width *= scale_x
if i:
x += letter_spacing
svg.cursor_position = x + width, y
x_position = x + svg.cursor_d_position[0]
y_position = y + svg.cursor_d_position[1] + y_align
angle = last_r if r is None else r
points = (
(x_position, y_position),
(x_position + width, y_position - height))
node.text_bounding_box = extend_bounding_box(
node.text_bounding_box, points)
layout.reactivate(style)
svg.fill_stroke(node, font_size, text=True)
matrix = Matrix(a=scale_x, d=-1, e=x_position, f=y_position)
if angle:
a, c = cos(angle), sin(angle)
matrix = Matrix(a, -c, c, a) @ matrix
emojis = draw_first_line(
svg.stream, TextBox(layout, style), 'none', 'none', matrix)
emoji_lines.append((font_size, x, y, emojis))
svg.stream.end_text()
svg.stream.pop_state()
for font_size, x, y, emojis in emoji_lines:
draw_emojis(svg.stream, font_size, x, y, emojis)

View File

@@ -0,0 +1,199 @@
"""Util functions for SVG rendering."""
import re
from contextlib import suppress
from math import cos, radians, sin, tan
from urllib.parse import urlparse
from tinycss2.color3 import parse_color
from ..matrix import Matrix
class PointError(Exception):
"""Exception raised when parsing a point fails."""
def normalize(string):
"""Give a canonical version of a given value string."""
string = (string or '').replace('E', 'e')
string = re.sub('(?<!e)-', ' -', string)
string = re.sub('[ \n\r\t,]+', ' ', string)
string = re.sub(r'(\.[0-9-]+)(?=\.)', r'\1 ', string)
return string.strip()
def size(string, font_size=None, percentage_reference=None):
"""Compute size from string, resolving units and percentages."""
from ..css.utils import LENGTHS_TO_PIXELS
if not string:
return 0
with suppress(ValueError):
return float(string)
# Not a float, try something else
string = normalize(string).split(' ', 1)[0]
if string.endswith('%'):
assert percentage_reference is not None
return float(string[:-1]) * percentage_reference / 100
elif string.endswith('rem'):
assert font_size is not None
return font_size * float(string[:-3])
elif string.endswith('em'):
assert font_size is not None
return font_size * float(string[:-2])
elif string.endswith('ex'):
# Assume that 1em == 2ex
assert font_size is not None
return font_size * float(string[:-2]) / 2
for unit, coefficient in LENGTHS_TO_PIXELS.items():
if string.endswith(unit):
return float(string[:-len(unit)]) * coefficient
# Unknown size
return 0
def alpha_value(value):
"""Return opacity between 0 and 1 from str, number or percentage."""
ratio = 1
if isinstance(value, str):
value = value.strip()
if value.endswith('%'):
ratio = 100
value = value[:-1].strip()
return min(1, max(0, float(value) / ratio))
def point(svg, string, font_size):
"""Pop first two size values from a string."""
match = re.match('(.*?) (.*?)(?: |$)', string)
if match:
x, y = match.group(1, 2)
string = string[match.end():]
return (*svg.point(x, y, font_size), string)
else:
raise PointError
def preserve_ratio(svg, node, font_size, width, height, viewbox=None):
"""Compute scale and translation needed to preserve ratio."""
viewbox = viewbox or node.get_viewbox()
if viewbox:
viewbox_width, viewbox_height = viewbox[2:]
elif svg.tree == node:
viewbox_width, viewbox_height = svg.get_intrinsic_size(font_size)
if None in (viewbox_width, viewbox_height):
return 1, 1, 0, 0
else:
return 1, 1, 0, 0
scale_x = width / viewbox_width if viewbox_width else 1
scale_y = height / viewbox_height if viewbox_height else 1
aspect_ratio = node.get('preserveAspectRatio', 'xMidYMid').split()
align = aspect_ratio[0]
if align == 'none':
x_position = 'min'
y_position = 'min'
else:
meet_or_slice = aspect_ratio[1] if len(aspect_ratio) > 1 else None
if meet_or_slice == 'slice':
scale_value = max(scale_x, scale_y)
else:
scale_value = min(scale_x, scale_y)
scale_x = scale_y = scale_value
x_position = align[1:4].lower()
y_position = align[5:].lower()
if node.tag == 'marker':
translate_x, translate_y = svg.point(
node.get('refX'), node.get('refY', '0'), font_size)
else:
translate_x = 0
if x_position == 'mid':
translate_x = (width - viewbox_width * scale_x) / 2
elif x_position == 'max':
translate_x = width - viewbox_width * scale_x
translate_y = 0
if y_position == 'mid':
translate_y += (height - viewbox_height * scale_y) / 2
elif y_position == 'max':
translate_y += height - viewbox_height * scale_y
if viewbox:
translate_x -= viewbox[0] * scale_x
translate_y -= viewbox[1] * scale_y
return scale_x, scale_y, translate_x, translate_y
def parse_url(url):
"""Parse a URL, possibly in a "url(…)" string."""
if url and url.startswith('url(') and url.endswith(')'):
url = url[4:-1]
if len(url) >= 2:
for quote in ("'", '"'):
if url[0] == url[-1] == quote:
url = url[1:-1]
break
return urlparse(url or '')
def color(string):
"""Safely parse a color string and return a RGBA tuple."""
return parse_color(string or '') or (0, 0, 0, 1)
def transform(transform_string, font_size, normalized_diagonal):
"""Get a matrix corresponding to the transform string."""
# TODO: merge with gather_anchors and css.validation.properties.transform
transformations = re.findall(
r'(\w+) ?\( ?(.*?) ?\)', normalize(transform_string))
matrix = Matrix()
for transformation_type, transformation in transformations:
values = [
size(value, font_size, normalized_diagonal)
for value in transformation.split(' ')]
if transformation_type == 'matrix':
matrix = Matrix(*values) @ matrix
elif transformation_type == 'rotate':
if len(values) == 3:
matrix = Matrix(e=values[1], f=values[2]) @ matrix
matrix = Matrix(
cos(radians(float(values[0]))),
sin(radians(float(values[0]))),
-sin(radians(float(values[0]))),
cos(radians(float(values[0])))) @ matrix
if len(values) == 3:
matrix = Matrix(e=-values[1], f=-values[2]) @ matrix
elif transformation_type.startswith('skew'):
if len(values) == 1:
values.append(0)
if transformation_type in ('skewX', 'skew'):
matrix = Matrix(
c=tan(radians(float(values.pop(0))))) @ matrix
if transformation_type in ('skewY', 'skew'):
matrix = Matrix(
b=tan(radians(float(values.pop(0))))) @ matrix
elif transformation_type.startswith('translate'):
if len(values) == 1:
values.append(0)
if transformation_type in ('translateX', 'translate'):
matrix = Matrix(e=values.pop(0)) @ matrix
if transformation_type in ('translateY', 'translate'):
matrix = Matrix(f=values.pop(0)) @ matrix
elif transformation_type.startswith('scale'):
if len(values) == 1:
values.append(values[0])
if transformation_type in ('scaleX', 'scale'):
matrix = Matrix(a=values.pop(0)) @ matrix
if transformation_type in ('scaleY', 'scale'):
matrix = Matrix(d=values.pop(0)) @ matrix
return matrix