feat: add comprehensive GitHub workflow and development tools
This commit is contained in:
819
app/.venv/Lib/site-packages/weasyprint/svg/__init__.py
Normal file
819
app/.venv/Lib/site-packages/weasyprint/svg/__init__.py
Normal 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 node’s 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 it’s
|
||||
# totally clipped when the clipping path is empty.
|
||||
self.stream.rectangle(0, 0, 0, 0)
|
||||
self.stream.clip()
|
||||
self.stream.end()
|
||||
new_ctm = self.stream.ctm
|
||||
if new_ctm.determinant:
|
||||
self.stream.transform(*(old_ctm @ new_ctm.invert).values)
|
||||
|
||||
# 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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
356
app/.venv/Lib/site-packages/weasyprint/svg/bounding_box.py
Normal file
356
app/.venv/Lib/site-packages/weasyprint/svg/bounding_box.py
Normal 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 doesn’t 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,
|
||||
}
|
||||
92
app/.venv/Lib/site-packages/weasyprint/svg/css.py
Normal file
92
app/.venv/Lib/site-packages/weasyprint/svg/css.py
Normal 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
|
||||
525
app/.venv/Lib/site-packages/weasyprint/svg/defs.py
Normal file
525
app/.venv/Lib/site-packages/weasyprint/svg/defs.py
Normal 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 doesn’t
|
||||
# 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, they’re 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
|
||||
62
app/.venv/Lib/site-packages/weasyprint/svg/images.py
Normal file
62
app/.venv/Lib/site-packages/weasyprint/svg/images.py
Normal 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()
|
||||
281
app/.venv/Lib/site-packages/weasyprint/svg/path.py
Normal file
281
app/.venv/Lib/site-packages/weasyprint/svg/path.py
Normal 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 it’s 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
|
||||
120
app/.venv/Lib/site-packages/weasyprint/svg/shapes.py
Normal file
120
app/.venv/Lib/site-packages/weasyprint/svg/shapes.py
Normal 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))
|
||||
169
app/.venv/Lib/site-packages/weasyprint/svg/text.py
Normal file
169
app/.venv/Lib/site-packages/weasyprint/svg/text.py
Normal 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 there’s 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)
|
||||
199
app/.venv/Lib/site-packages/weasyprint/svg/utils.py
Normal file
199
app/.venv/Lib/site-packages/weasyprint/svg/utils.py
Normal 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
|
||||
Reference in New Issue
Block a user