Files
OmniLottie/lottie/parsers/svg/importer.py
T

1301 lines
45 KiB
Python
Raw Normal View History

2026-03-01 21:36:54 +08:00
import re
import math
import colorsys
from xml.etree import ElementTree
from ... import objects
from ...nvector import NVector
from .svgdata import color_table, css_atrrs
from .handler import SvgHandler, NameMode
from ...utils.ellipse import Ellipse
from ...utils.transform import TransformMatrix
from ...utils.color import Color
try:
from ...utils import font
has_font = True
except ImportError:
has_font = False
nocolor = {"none"}
class SvgGradientCoord:
def __init__(self, name, comp, value, percent):
self.name = name
self.comp = comp
self.value = value
self.percent = percent
def to_value(self, bbox, default=None):
if self.value is None:
return default
if not self.percent:
return self.value
if self.comp == "w":
return (bbox.x2 - bbox.x1) * self.value
if self.comp == "x":
return bbox.x1 + (bbox.x2 - bbox.x1) * self.value
return bbox.y1 + (bbox.y2 - bbox.y1) * self.value
def parse(self, attr, default_percent):
if attr is None:
return
if attr.endswith("%"):
self.percent = True
self.value = float(attr[:-1])/100
else:
self.percent = default_percent
self.value = float(attr)
class SvgGradient:
def __init__(self):
self.colors = []
self.coords = []
self.matrix = TransformMatrix()
def add_color(self, offset, color):
self.colors.append((offset, color[:4]))
def to_lottie(self, gradient_shape, shape, time=0):
"""!
@param gradient_shape Should be a GradientFill or GradientStroke
@param shape ShapeElement to apply the gradient to
@param time Time to fetch properties from @p shape
"""
for off, col in self.colors:
gradient_shape.colors.add_color(off, col)
def add_coord(self, value):
setattr(self, value.name, value)
self.coords.append(value)
def parse_attrs(self, attrib):
relunits = attrib.get("gradientUnits", "") != "userSpaceOnUse"
for c in self.coords:
c.parse(attrib.get(c.name, None), relunits)
class SvgLinearGradient(SvgGradient):
def __init__(self):
super().__init__()
self.add_coord(SvgGradientCoord("x1", "x", 0, True))
self.add_coord(SvgGradientCoord("y1", "y", 0, True))
self.add_coord(SvgGradientCoord("x2", "x", 1, True))
self.add_coord(SvgGradientCoord("y2", "y", 0, True))
def to_lottie(self, gradient_shape, shape, time=0):
bbox = shape.bounding_box(time)
gradient_shape.start_point.value = self.matrix.apply(NVector(
self.x1.to_value(bbox),
self.y1.to_value(bbox),
))
gradient_shape.end_point.value = self.matrix.apply(NVector(
self.x2.to_value(bbox),
self.y2.to_value(bbox),
))
gradient_shape.gradient_type = objects.GradientType.Linear
super().to_lottie(gradient_shape, shape, time)
class SvgRadialGradient(SvgGradient):
def __init__(self):
super().__init__()
self.add_coord(SvgGradientCoord("cx", "x", 0.5, True))
self.add_coord(SvgGradientCoord("cy", "y", 0.5, True))
self.add_coord(SvgGradientCoord("fx", "x", None, True))
self.add_coord(SvgGradientCoord("fy", "y", None, True))
self.add_coord(SvgGradientCoord("r", "w", 0.5, True))
def to_lottie(self, gradient_shape, shape, time=0):
bbox = shape.bounding_box(time)
cx = self.cx.to_value(bbox)
cy = self.cy.to_value(bbox)
gradient_shape.start_point.value = self.matrix.apply(NVector(cx, cy))
r = self.r.to_value(bbox)
gradient_shape.end_point.value = self.matrix.apply(NVector(cx+r, cy))
fx = self.fx.to_value(bbox, cx) - cx
fy = self.fy.to_value(bbox, cy) - cy
gradient_shape.highlight_angle.value = math.atan2(fy, fx) * 180 / math.pi
gradient_shape.highlight_length.value = math.hypot(fx, fy)
gradient_shape.gradient_type = objects.GradientType.Radial
super().to_lottie(gradient_shape, shape, time)
def parse_color(color, current_color=Color(0, 0, 0, 1)):
"""!
Parses CSS colors
@see https://www.w3.org/wiki/CSS/Properties/color
"""
# #fff
if re.match(r"^#[0-9a-fA-F]{6}$", color):
return Color(int(color[1:3], 16) / 0xff, int(color[3:5], 16) / 0xff, int(color[5:7], 16) / 0xff, 1)
# #112233
if re.match(r"^#[0-9a-fA-F]{3}$", color):
return Color(int(color[1], 16) / 0xf, int(color[2], 16) / 0xf, int(color[3], 16) / 0xf, 1)
# rgba(123, 123, 123, 0.7)
match = re.match(r"^rgba\s*\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9.eE]+)\s*\)$", color)
if match:
return Color(int(match[1])/255, int(match[2])/255, int(match[3])/255, float(match[4]))
# rgb(123, 123, 123)
match = re.match(r"^rgb\s*\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*\)$", color)
if match:
return Color(int(match[1])/255, int(match[2])/255, int(match[3])/255, 1)
# rgb(60%, 30%, 20%)
match = re.match(r"^rgb\s*\(\s*([0-9]+)%\s*,\s*([0-9]+)%\s*,\s*([0-9]+)%\s*\)$", color)
if match:
return Color(int(match[1])/100, int(match[2])/100, int(match[3])/100, 1)
# rgba(60%, 30%, 20%, 0.7)
match = re.match(r"^rgba\s*\(\s*([0-9]+)%\s*,\s*([0-9]+)%\s*,\s*([0-9]+)%\s*,\s*([0-9.eE]+)\s*\)$", color)
if match:
return Color(int(match[1])/100, int(match[2])/100, int(match[3])/100, float(match[4]))
# transparent
if color == "transparent":
return Color(0, 0, 0, 0)
# hsl(60, 30%, 20%)
match = re.match(r"^hsl\s*\(\s*([0-9]+)\s*,\s*([0-9]+)%\s*,\s*([0-9]+)%\s*\)$", color)
if match:
return Color(*(colorsys.hls_to_rgb(int(match[1])/360, int(match[3])/100, int(match[2])/100) + (1,)))
# hsla(60, 30%, 20%, 0.7)
match = re.match(r"^hsla\s*\(\s*([0-9]+)\s*,\s*([0-9]+)%\s*,\s*([0-9]+)%\s*,\s*([0-9.eE]+)\s*\)$", color)
if match:
return Color(*(colorsys.hls_to_rgb(int(match[1])/360, int(match[3])/100, int(match[2])/100) + (float(match[4]),)))
# currentColor
if color in {"currentColor", "inherit"}:
return current_color.clone()
# red
return Color(*color_table[color])
class SvgDefsParent:
def __init__(self):
self.items = {}
def insert(self, dummy, shape):
self.items[shape.name] = shape
def __getitem__(self, key):
return self.items[key]
def __setitem__(self, key, value):
self.items[key] = value
def __contains__(self, key):
return key in self.items
@property
def shapes(self):
return self
class SvgParser(SvgHandler):
def __init__(self, name_mode=NameMode.Inkscape):
self.init_etree()
self.name_mode = name_mode
self.current_color = Color(0, 0, 0, 1)
self.gradients = {}
self.max_time = 0
self.defs = SvgDefsParent()
self.dpi = 96
def _get_name(self, element, inkscapequal):
if self.name_mode == NameMode.Inkscape:
return element.attrib.get(inkscapequal, element.attrib.get("id"))
return self._get_id(element)
def _get_id(self, element):
if self.name_mode != NameMode.NoName:
return element.attrib.get("id")
return None
def parse_etree(self, etree, layer_frames=0, *args, **kwargs):
animation = objects.Animation(*args, **kwargs)
self.animation = animation
self.max_time = 0
self.document = etree
svg = etree.getroot()
self._get_dpi(svg)
if "width" in svg.attrib and "height" in svg.attrib:
animation.width = int(round(self._parse_unit(svg.attrib["width"])))
animation.height = int(round(self._parse_unit(svg.attrib["height"])))
else:
_, _, animation.width, animation.height = map(int, svg.attrib["viewBox"].split(" "))
animation.name = self._get_name(svg, self.qualified("sodipodi", "docname"))
if layer_frames:
for frame in svg:
if self.unqualified(frame.tag) == "g":
layer = objects.ShapeLayer()
layer.in_point = self.max_time
animation.add_layer(layer)
self._parseshape_g(frame, layer, {})
self.max_time += layer_frames
layer.out_point = self.max_time
else:
self._svg_to_layer(animation, svg)
if self.max_time:
animation.out_point = self.max_time
self._fix_viewbox(svg, (layer for layer in animation.layers if not layer.parent_index))
return animation
def etree_to_layer(self, animation, etree):
svg = etree.getroot()
self._get_dpi(svg)
layer = self._svg_to_layer(animation, svg)
self._fix_viewbox(svg, [layer])
return layer
def _get_dpi(self, svg):
self.dpi = float(svg.attrib.get(self.qualified("inkscape", "export-xdpi"), self.dpi))
def _svg_to_layer(self, animation, svg):
self.animation = animation
layer = objects.ShapeLayer()
animation.add_layer(layer)
self.parse_children(svg, layer, self.parse_style(svg, {}))
if self.max_time:
for sublayer in layer.find_all(objects.Layer):
sublayer.out_point = self.max_time
return layer
def _fix_viewbox(self, svg, layers):
if "viewBox" in svg.attrib:
vbx, vby, vbw, vbh = map(float, svg.attrib["viewBox"].split())
if vbx != 0 or vby != 0 or vbw != self.animation.width or vbh != self.animation.height:
for layer in layers:
layer.transform.position.value = -NVector(vbx, vby)
layer.transform.scale.value = NVector(self.animation.width / vbw, self.animation.height / vbh) * 100
def _parse_unit(self, value):
if not isinstance(value, str):
return value
mult = 1
cmin = 2.54
if value.endswith("px"):
value = value[:-2]
elif value.endswith("vw"):
value = value[:-2]
mult = self.animation.width * 0.01
elif value.endswith("vh"):
value = value[:-2]
mult = self.animation.height * 0.01
elif value.endswith("vmin"):
value = value[:-4]
mult = min(self.animation.width, self.animation.height) * 0.01
elif value.endswith("vmax"):
value = value[:-4]
mult = max(self.animation.width, self.animation.height) * 0.01
elif value.endswith("in"):
value = value[:-2]
mult = self.dpi
elif value.endswith("pc"):
value = value[:-2]
mult = self.dpi / 6
elif value.endswith("pt"):
value = value[:-2]
mult = self.dpi / 72
elif value.endswith("cm"):
value = value[:-2]
mult = self.dpi / cmin
elif value.endswith("mm"):
value = value[:-2]
mult = self.dpi / cmin / 10
elif value.endswith("Q"):
value = value[:-1]
mult = self.dpi / cmin / 40
return float(value) * mult
def parse_color(self, color):
return parse_color(color, self.current_color)
def parse_transform(self, element, group, dest_trans):
bb = group.bounding_box()
if not bb.isnull():
itcx = self.qualified("inkscape", "transform-center-x")
if itcx in element.attrib:
cx = float(element.attrib[itcx])
cy = float(element.attrib[self.qualified("inkscape", "transform-center-y")])
bbx, bby = bb.center()
cx += bbx
cy = bby - cy
dest_trans.anchor_point.value = NVector(cx, cy)
dest_trans.position.value = NVector(cx, cy)
#else:
#c = bb.center()
#dest_trans.anchor_point.value = c
#dest_trans.position.value = c.clone()
if "transform" not in element.attrib:
return
matrix = TransformMatrix()
read_matrix = False
for t in re.finditer(r"([a-zA-Z]+)\s*\(([^\)]*)\)", element.attrib["transform"]):
name = t[1]
params = list(map(float, t[2].strip().replace(",", " ").split()))
if name == "translate":
dest_trans.position.value += NVector(
params[0],
(params[1] if len(params) > 1 else 0),
)
elif name == "scale":
xfac = params[0]
dest_trans.scale.value[0] = (dest_trans.scale.value[0] / 100 * xfac) * 100
yfac = params[1] if len(params) > 1 else xfac
dest_trans.scale.value[1] = (dest_trans.scale.value[1] / 100 * yfac) * 100
elif name == "rotate":
ang = params[0]
x = y = 0
if len(params) > 2:
x = params[1]
y = params[2]
ap = NVector(x, y)
dap = ap - dest_trans.position.value
dest_trans.position.value += dap
dest_trans.anchor_point.value += dap
dest_trans.rotation.value = ang
else:
read_matrix = True
self._apply_transform_element_to_matrix(matrix, t)
if read_matrix:
dest_trans.position.value -= dest_trans.anchor_point.value
dest_trans.anchor_point.value = NVector(0, 0)
trans = matrix.extract_transform()
dest_trans.skew_axis.value = math.degrees(trans["skew_axis"])
dest_trans.skew.value = -math.degrees(trans["skew_angle"])
dest_trans.position.value += trans["translation"]
dest_trans.rotation.value -= math.degrees(trans["angle"])
dest_trans.scale.value *= trans["scale"]
def parse_style(self, element, parent_style):
style = parent_style.copy()
for att in css_atrrs & set(element.attrib.keys()):
if att in element.attrib:
style[att] = element.attrib[att]
if "style" in element.attrib:
style.update(**dict(map(
lambda x: map(lambda y: y.strip(), x.split(":")),
filter(bool, element.attrib["style"].split(";"))
)))
return style
def apply_common_style(self, style, transform):
opacity = float(style.get("opacity", 1))
transform.opacity.value = opacity * 100
def apply_visibility(self, style, object):
if style.get("display", "inline") == "none" or style.get("visibility", "visible") == "hidden":
object.hidden = True
def add_shapes(self, element, shapes, shape_parent, parent_style):
style = self.parse_style(element, parent_style)
group = objects.Group()
self.apply_common_style(style, group.transform)
self.apply_visibility(style, group)
group.name = self._get_name(element, self.qualified("inkscape", "label"))
shape_parent.shapes.insert(0, group)
for shape in shapes:
group.add_shape(shape)
self._add_style_shapes(style, group)
self.parse_transform(element, group, group.transform)
return group
def _add_style_shapes(self, style, group):
stroke_color = style.get("stroke", "none")
if stroke_color not in nocolor:
if stroke_color.startswith("url"):
stroke = self.get_color_url(stroke_color, objects.GradientStroke, group)
opacity = 1
else:
stroke = objects.Stroke()
color = self.parse_color(stroke_color)
stroke.color.value = color
opacity = color[3]
group.add_shape(stroke)
stroke.opacity.value = opacity * float(style.get("stroke-opacity", 1)) * 100
stroke.width.value = self._parse_unit(style.get("stroke-width", 1))
linecap = style.get("stroke-linecap")
if linecap == "round":
stroke.line_cap = objects.shapes.LineCap.Round
elif linecap == "butt":
stroke.line_cap = objects.shapes.LineCap.Butt
elif linecap == "square":
stroke.line_cap = objects.shapes.LineCap.Square
linejoin = style.get("stroke-linejoin")
if linejoin == "round":
stroke.line_join = objects.shapes.LineJoin.Round
elif linejoin == "bevel":
stroke.line_join = objects.shapes.LineJoin.Bevel
elif linejoin in {"miter", "arcs", "miter-clip"}:
stroke.line_join = objects.shapes.LineJoin.Miter
stroke.miter_limit = self._parse_unit(style.get("stroke-miterlimit", 0))
dash_array = style.get("stroke-dasharray")
if dash_array and dash_array != "none":
values = list(map(self._parse_unit, dash_array.replace(",", " ").split()))
if len(values) % 2:
values += values
stroke.dashes = []
for i in range(0, len(values), 2):
stroke.dashes.append(objects.StrokeDash(values[i], objects.StrokeDashType.Dash))
stroke.dashes.append(objects.StrokeDash(values[i+1], objects.StrokeDashType.Gap))
fill_color = style.get("fill", "inherit")
if fill_color not in nocolor:
if fill_color.startswith("url"):
fill = self.get_color_url(fill_color, objects.GradientFill, group)
opacity = 1
else:
color = self.parse_color(fill_color)
fill = objects.Fill(color)
opacity = color[3]
opacity *= float(style.get("fill-opacity", 1))
fill.opacity.value = opacity * 100
if style.get("fill-rule", "") == "evenodd":
fill.fill_rule = objects.FillRule.EvenOdd
group.add_shape(fill)
def _parseshape_use(self, element, shape_parent, parent_style):
link = element.attrib[self.qualified("xlink", "href")]
if link.startswith("#"):
id = link[1:]
base_element = self.document.find(".//*[@id='%s']" % id)
use_style = self.parse_style(element, parent_style)
used = objects.Group()
shape_parent.add_shape(used)
used.name = "use"
used.transform.position.value.x = float(element.attrib.get("x", 0))
used.transform.position.value.y = float(element.attrib.get("y", 0))
self.parse_transform(element, used, used.transform)
self.parse_shape(base_element, used, use_style)
return used
def _parseshape_g(self, element, shape_parent, parent_style):
group = objects.Group()
shape_parent.shapes.insert(0, group)
style = self.parse_style(element, parent_style)
self.apply_common_style(style, group.transform)
self.apply_visibility(style, group)
group.name = self._get_name(element, self.qualified("inkscape", "label"))
self.parse_children(element, group, style)
self.parse_transform(element, group, group.transform)
if group.hidden: # Lottie web doesn't seem to support .hd
group.transform.opacity.value = 0
return group
def _parseshape_ellipse(self, element, shape_parent, parent_style):
ellipse = objects.Ellipse()
ellipse.position.value = NVector(
self._parse_unit(element.attrib["cx"]),
self._parse_unit(element.attrib["cy"])
)
ellipse.size.value = NVector(
self._parse_unit(element.attrib["rx"]) * 2,
self._parse_unit(element.attrib["ry"]) * 2
)
self.add_shapes(element, [ellipse], shape_parent, parent_style)
return ellipse
def _parseshape_anim_ellipse(self, ellipse, element, animations):
self._merge_animations(element, animations, "cx", "cy", "position")
self._merge_animations(element, animations, "rx", "ry", "size", lambda x, y: NVector(x, y) * 2)
self._apply_animations(ellipse.position, "position", animations)
self._apply_animations(ellipse.size, "size", animations)
def _parseshape_circle(self, element, shape_parent, parent_style):
ellipse = objects.Ellipse()
ellipse.position.value = NVector(
self._parse_unit(element.attrib["cx"]),
self._parse_unit(element.attrib["cy"])
)
r = self._parse_unit(element.attrib["r"]) * 2
ellipse.size.value = NVector(r, r)
self.add_shapes(element, [ellipse], shape_parent, parent_style)
return ellipse
def _parseshape_anim_circle(self, ellipse, element, animations):
self._merge_animations(element, animations, "cx", "cy", "position")
self._apply_animations(ellipse.position, "position", animations)
self._apply_animations(ellipse.size, "r", animations, lambda r: NVector(r, r) * 2)
def _parseshape_rect(self, element, shape_parent, parent_style):
rect = objects.Rect()
w = self._parse_unit(element.attrib.get("width", 0))
h = self._parse_unit(element.attrib.get("height", 0))
rect.position.value = NVector(
self._parse_unit(element.attrib.get("x", 0)) + w / 2,
self._parse_unit(element.attrib.get("y", 0)) + h / 2
)
rect.size.value = NVector(w, h)
rx = self._parse_unit(element.attrib.get("rx", 0))
ry = self._parse_unit(element.attrib.get("ry", 0))
rect.rounded.value = (rx + ry) / 2
self.add_shapes(element, [rect], shape_parent, parent_style)
return rect
def _parseshape_anim_rect(self, rect, element, animations):
self._merge_animations(element, animations, "width", "height", "size", lambda x, y: NVector(x, y))
self._apply_animations(rect.size, "size", animations)
self._merge_animations(element, animations, "x", "y", "position")
self._merge_animations(element, animations, "position", "size", "position", lambda p, s: p + s / 2)
self._apply_animations(rect.position, "position", animations)
self._merge_animations(element, animations, "rx", "ry", "rounded", lambda x, y: (x + y) / 2)
self._apply_animations(rect.rounded, "rounded", animations)
def _parseshape_line(self, element, shape_parent, parent_style):
line = objects.Path()
line.shape.value.add_point(NVector(
self._parse_unit(element.attrib["x1"]),
self._parse_unit(element.attrib["y1"])
))
line.shape.value.add_point(NVector(
self._parse_unit(element.attrib["x2"]),
self._parse_unit(element.attrib["y2"])
))
return self.add_shapes(element, [line], shape_parent, parent_style)
def _parseshape_anim_line(self, group, element, animations):
line = group.shapes[0]
self._merge_animations(element, animations, "x1", "y1", "p1")
self._merge_animations(element, animations, "x2", "y2", "p2")
self._apply_animations(line.vertices[0], "p1", animations)
self._apply_animations(line.vertices[1], "p2", animations)
def _handle_poly(self, element):
line = objects.Path()
coords = list(map(float, element.attrib["points"].replace(",", " ").split()))
for i in range(0, len(coords), 2):
line.shape.value.add_point(coords[i:i+2])
return line
def _parseshape_polyline(self, element, shape_parent, parent_style):
line = self._handle_poly(element)
return self.add_shapes(element, [line], shape_parent, parent_style)
def _parseshape_polygon(self, element, shape_parent, parent_style):
line = self._handle_poly(element)
line.shape.value.close()
return self.add_shapes(element, [line], shape_parent, parent_style)
def _parseshape_path(self, element, shape_parent, parent_style):
d_parser = PathDParser(element.attrib.get("d", ""))
d_parser.parse()
paths = []
for path in d_parser.paths:
p = objects.Path()
p.shape.value = path
paths.append(p)
#if len(d_parser.paths) > 1:
#paths.append(objects.shapes.Merge())
return self.add_shapes(element, paths, shape_parent, parent_style)
def parse_children(self, element, shape_parent, parent_style):
for child in element:
tag = self.unqualified(child.tag)
if not self.parse_shape(child, shape_parent, parent_style):
handler = getattr(self, "_parse_" + tag, None)
if handler:
handler(child)
def parse_shape(self, element, shape_parent, parent_style):
handler = getattr(self, "_parseshape_" + self.unqualified(element.tag), None)
if handler:
out = handler(element, shape_parent, parent_style)
self.parse_animations(out, element)
if element.attrib.get("id"):
self.defs.items[element.attrib["id"]] = out
return out
return None
def _parse_defs(self, element):
self.parse_children(element, self.defs, {})
def _apply_transform_element_to_matrix(self, matrix, t):
name = t[1]
params = list(map(float, t[2].strip().replace(",", " ").split()))
if name == "translate":
matrix.translate(
params[0],
(params[1] if len(params) > 1 else 0),
)
elif name == "scale":
xfac = params[0]
yfac = params[1] if len(params) > 1 else xfac
matrix.scale(xfac, yfac)
elif name == "rotate":
ang = params[0]
x = y = 0
if len(params) > 2:
x = params[1]
y = params[2]
matrix.translate(-x, -y)
matrix.rotate(math.radians(ang))
matrix.translate(x, y)
else:
matrix.rotate(math.radians(ang))
elif name == "skewX":
matrix.skew(math.radians(params[0]), 0)
elif name == "skewY":
matrix.skew(0, math.radians(params[0]))
elif name == "matrix":
m = TransformMatrix()
m.a, m.b, m.c, m.d, m.tx, m.ty = params
matrix *= m
def _transform_to_matrix(self, transform):
matrix = TransformMatrix()
for t in re.finditer(r"([a-zA-Z]+)\s*\(([^\)]*)\)", transform):
self._apply_transform_element_to_matrix(matrix, t)
return matrix
def _gradient(self, element, grad):
grad.matrix = self._transform_to_matrix(element.attrib.get("gradientTransform", ""))
id = element.attrib["id"]
if id in self.gradients:
grad.colors = self.gradients[id].colors
grad.parse_attrs(element.attrib)
href = element.attrib.get(self.qualified("xlink", "href"))
if href:
srcid = href.strip("#")
if srcid in self.gradients:
src = self.gradients[srcid]
else:
src = grad.__class__()
self.gradients[srcid] = src
grad.colors = src.colors
for stop in element.findall("./%s" % self.qualified("svg", "stop")):
off = float(stop.attrib["offset"].strip("%"))
if stop.attrib["offset"].endswith("%"):
off /= 100
style = self.parse_style(stop, {})
color = self.parse_color(style["stop-color"])
if "stop-opacity" in style:
color[3] = float(style["stop-opacity"])
grad.add_color(off, color)
self.gradients[id] = grad
def _parse_linearGradient(self, element):
self._gradient(element, SvgLinearGradient())
def _parse_radialGradient(self, element):
self._gradient(element, SvgRadialGradient())
def get_color_url(self, color, gradientclass, shape):
match = re.match(r"""url\(['"]?#([^)'"]+)['"]?\)""", color)
if not match:
return None
id = match[1]
if id not in self.gradients:
return None
grad = self.gradients[id]
outgrad = gradientclass()
grad.to_lottie(outgrad, shape)
if self.name_mode != NameMode.NoName:
grad.name = id
return outgrad
## @todo Parse single font property, fallback family etc
def _parse_text_style(self, style, font_style=None):
if "font-family" in style:
font_style.query.family(style["font-family"].strip("'\""))
if "font-style" in style:
if style["font-style"] == "oblique":
font_style.query.custom("slant", 110)
elif style["font-style"] == "italic":
font_style.query.custom("slant", 100)
if "font-weight" in style:
if style["font-weight"] in {"bold", "bolder"}:
font_style.query.weight(200)
elif style["font-weight"] == "lighter":
font_style.query.weight(50)
elif style["font-weight"].isdigit():
font_style.query.css_weight(int(style["font-weight"]))
if "font-size" in style:
fz = style["font-size"]
fz_names = {
"xx-small": 8,
"x-small": 16,
"small": 32,
"medium": 64,
"large": 128,
"x-large": 256,
"xx-large": 512,
}
if fz in fz_names:
font_style.size = fz_names[fz]
elif fz == "smaller":
font_style.size /= 2
elif fz == "larger":
font_style.size *= 2
elif fz.endswith("px"):
font_style.size = float(fz[:-2])
elif fz.isnumeric():
font_style.size = float(fz)
if "text-align" in style:
ta = style["text-align"]
if ta in ("left", "start"):
font_style.justify = font.TextJustify.Left
elif ta == "center":
font_style.justify = font.TextJustify.Center
elif ta in ("right", "end"):
font_style.justify = font.TextJustify.Right
def _parse_text_elem(self, element, style, group, parent_style, font_style):
self._parse_text_style(style, font_style)
if "x" in element.attrib or "y" in element.attrib:
font_style.position = NVector(
float(element.attrib["x"]),
float(element.attrib["y"]),
)
childpos = NVector(0, font_style.position.y)
if element.text:
fs = font.FontShape(element.text, font_style)
fs.refresh()
group.add_shape(fs)
childpos.x = fs.wrapped.next_x
for child in element:
if child.tag == self.qualified("svg", "tspan"):
child_style = font_style.clone()
child_style.position = childpos.clone()
fs = self._parseshape_text(child, group, parent_style, child_style)
childpos.x = fs.next_x
if child.tail:
child_style = font_style.clone()
child_style.position = childpos.clone()
fs = font.FontShape(child.tail, child_style)
fs.refresh()
group.add_shape(fs)
childpos.x = fs.wrapped.next_x
group.next_x = childpos.x
def _parseshape_text(self, element, shape_parent, parent_style, font_style=None):
group = objects.Group()
style = self.parse_style(element, parent_style)
self.apply_common_style(style, group.transform)
self.apply_visibility(style, group)
group.name = self._get_id(element)
if has_font:
if font_style is None:
font_style = font.FontStyle("", 64)
self._parse_text_elem(element, style, group, style, font_style)
style.setdefault("fill", "none")
self._add_style_shapes(style, group)
## @todo text-anchor when it doesn't match text-align
#if element.tag == self.qualified("svg", "text"):
#dx = 0
#dy = 0
#ta = style.get("text-anchor", style.get("text-align", ""))
#if ta == "middle":
#dx -= group.bounding_box().width / 2
#elif ta == "end":
#dx -= group.bounding_box().width
#if dx or dy:
#ng = objects.Group()
#ng.add_shape(group)
#group.transform.position.value.x += dx
#group.transform.position.value.y += dy
#group = ng
shape_parent.shapes.insert(0, group)
self.parse_transform(element, group, group.transform)
return group
def parse_animations(self, lottie, element):
animations = {}
for child in element:
if self.unqualified(child.tag) == "animate":
att = child.attrib["attributeName"]
from_val = child.attrib["from"]
if att == "d":
## @todo
continue
else:
from_val = float(from_val)
if "to" in child.attrib:
to_val = float(child.attrib["to"])
elif "by" in child.attrib:
to_val = float(child.attrib["by"]) + from_val
begin = self.parse_animation_time(child.attrib.get("begin", 0)) or 0
if "dur" in child.attrib:
end = (self.parse_animation_time(child.attrib["dur"]) or 0) + begin
elif "end" in child.attrib:
end = self.parse_animation_time(child.attrib["dur"]) or 0
else:
continue
if att not in animations:
animations[att] = {}
animations[att][begin] = from_val
animations[att][end] = to_val
if self.max_time < end:
self.max_time = end
tag = self.unqualified(element.tag)
handler = getattr(self, "_parseshape_anim_" + tag, None)
if handler:
handler(lottie, element, animations)
def parse_animation_time(self, value):
"""!
@see https://developer.mozilla.org/en-US/docs/Web/SVG/Content_type#Clock-value
"""
if not value:
return None
try:
seconds = 0
if ":" in value:
mult = 1
for elem in reversed(value.split(":")):
seconds += float(elem) * mult
mult *= 60
elif value.endswith("s"):
seconds = float(value[:-1])
elif value.endswith("ms"):
seconds = float(value[:-2]) / 1000
elif value.endswith("min"):
seconds = float(value[:-3]) * 60
elif value.endswith("h"):
seconds = float(value[:-1]) * 60 * 60
else:
seconds = float(value)
return seconds * self.animation.frame_rate
except ValueError:
pass
return None
def _merge_animations(self, element, animations, val1, val2, dest, merge=NVector):
if val1 not in animations and val2 not in animations:
return
dict1 = list(sorted(animations.pop(val1, {}).items()))
dict2 = list(sorted(animations.pop(val2, {}).items()))
x = float(element.attrib[val1])
y = float(element.attrib[val2])
values = {}
while dict1 or dict2:
if not dict1 or (dict2 and dict1[0][0] > dict2[0][0]):
t, y = dict2.pop(0)
elif not dict2 or dict1[0][0] < dict2[0][0]:
t, x = dict1.pop(0)
else:
t, x = dict1.pop(0)
t, y = dict2.pop(0)
values[t] = merge(x, y)
animations[dest] = values
def _apply_animations(self, animatable, name, animations, transform=lambda v: v):
if name in animations:
for t, v in animations[name].items():
animatable.add_keyframe(t, transform(v))
class PathDParser:
_re = re.compile("|".join((
r"[a-zA-Z]",
r"[-+]?[0-9]*\.?[0-9]*[eE][-+]?[0-9]+",
r"[-+]?[0-9]*\.?[0-9]+",
)))
def __init__(self, d_string):
self.path = objects.properties.Bezier()
self.paths = []
self.p = NVector(0, 0)
self.la = None
self.la_type = None
self.tokens = list(map(self.d_subsplit, self._re.findall(d_string)))
self.add_p = True
self.implicit = "M"
def d_subsplit(self, tok):
if tok.isalpha():
return tok
return float(tok)
def next_token(self):
if self.tokens:
self.la = self.tokens.pop(0)
if isinstance(self.la, str):
self.la_type = 0
else:
self.la_type = 1
else:
self.la = None
return self.la
def next_vec(self):
x = self.next_token()
y = self.next_token()
return NVector(x, y)
def cur_vec(self):
x = self.la
y = self.next_token()
return NVector(x, y)
def parse(self):
self.next_token()
while self.la is not None:
if self.la_type == 0:
parser = "_parse_" + self.la
self.next_token()
getattr(self, parser)()
else:
parser = "_parse_" + self.implicit
getattr(self, parser)()
def _push_path(self):
self.path = objects.properties.Bezier()
self.add_p = True
def _parse_M(self):
if self.la_type != 1:
self.next_token()
return
self.p = self.cur_vec()
self.implicit = "L"
if not self.add_p:
self._push_path()
self.next_token()
def _parse_m(self):
if self.la_type != 1:
self.next_token()
return
self.p += self.cur_vec()
self.implicit = "l"
if not self.add_p:
self._push_path()
self.next_token()
def _rpoint(self, point, rel=None):
return (point - (rel or self.p)) if point is not None else NVector(0, 0)
def _do_add_p(self, outp=None):
if self.add_p:
self.paths.append(self.path)
self.path.add_point(self.p.clone(), NVector(0, 0), self._rpoint(outp))
self.add_p = False
elif outp:
rp = self.path.vertices[-1]
self.path.out_tangents[-1] = self._rpoint(outp, rp)
def _parse_L(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
self.p = self.cur_vec()
self.path.add_point(self.p.clone(), NVector(0, 0), NVector(0, 0))
self.implicit = "L"
self.next_token()
def _parse_l(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
self.p += self.cur_vec()
self.path.add_point(self.p.clone(), NVector(0, 0), NVector(0, 0))
self.implicit = "l"
self.next_token()
def _parse_H(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
self.p[0] = self.la
self.path.add_point(self.p.clone(), NVector(0, 0), NVector(0, 0))
self.implicit = "H"
self.next_token()
def _parse_h(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
self.p[0] += self.la
self.path.add_point(self.p.clone(), NVector(0, 0), NVector(0, 0))
self.implicit = "h"
self.next_token()
def _parse_V(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
self.p[1] = self.la
self.path.add_point(self.p.clone(), NVector(0, 0), NVector(0, 0))
self.implicit = "V"
self.next_token()
def _parse_v(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
self.p[1] += self.la
self.path.add_point(self.p.clone(), NVector(0, 0), NVector(0, 0))
self.implicit = "v"
self.next_token()
def _parse_C(self):
if self.la_type != 1:
self.next_token()
return
pout = self.cur_vec()
self._do_add_p(pout)
pin = self.next_vec()
self.p = self.next_vec()
self.path.add_point(
self.p.clone(),
(pin - self.p),
NVector(0, 0)
)
self.implicit = "C"
self.next_token()
def _parse_c(self):
if self.la_type != 1:
self.next_token()
return
pout = self.p + self.cur_vec()
self._do_add_p(pout)
pin = self.p + self.next_vec()
self.p += self.next_vec()
self.path.add_point(
self.p.clone(),
(pin - self.p),
NVector(0, 0)
)
self.implicit = "c"
self.next_token()
def _parse_S(self):
if self.la_type != 1:
self.next_token()
return
pin = self.cur_vec()
self._do_add_p()
handle = self.path.in_tangents[-1]
self.path.out_tangents[-1] = (-handle)
self.p = self.next_vec()
self.path.add_point(
self.p.clone(),
(pin - self.p),
NVector(0, 0)
)
self.implicit = "S"
self.next_token()
def _parse_s(self):
if self.la_type != 1:
self.next_token()
return
pin = self.cur_vec() + self.p
self._do_add_p()
handle = self.path.in_tangents[-1]
self.path.out_tangents[-1] = (-handle)
self.p += self.next_vec()
self.path.add_point(
self.p.clone(),
(pin - self.p),
NVector(0, 0)
)
self.implicit = "s"
self.next_token()
def _parse_Q(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
pin = self.cur_vec()
self.p = self.next_vec()
self.path.add_point(
self.p.clone(),
(pin - self.p),
NVector(0, 0)
)
self.implicit = "Q"
self.next_token()
def _parse_q(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
pin = self.p + self.cur_vec()
self.p += self.next_vec()
self.path.add_point(
self.p.clone(),
(pin - self.p),
NVector(0, 0)
)
self.implicit = "q"
self.next_token()
def _parse_T(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
handle = self.p - self.path.in_tangents[-1]
self.p = self.cur_vec()
self.path.add_point(
self.p.clone(),
(handle - self.p),
NVector(0, 0)
)
self.implicit = "T"
self.next_token()
def _parse_t(self):
if self.la_type != 1:
self.next_token()
return
self._do_add_p()
handle = -self.path.in_tangents[-1] + self.p
self.p += self.cur_vec()
self.path.add_point(
self.p.clone(),
(handle - self.p),
NVector(0, 0)
)
self.implicit = "t"
self.next_token()
def _parse_A(self):
if self.la_type != 1:
self.next_token()
return
r = self.cur_vec()
xrot = self.next_token()
large = self.next_token()
sweep = self.next_token()
dest = self.next_vec()
self._do_arc(r[0], r[1], xrot, large, sweep, dest)
self.implicit = "A"
self.next_token()
def _do_arc(self, rx, ry, xrot, large, sweep, dest):
self._do_add_p()
if self.p == dest:
return
if rx == 0 or ry == 0:
# Straight line
self.p = dest
self.path.add_point(
self.p.clone(),
NVector(0, 0),
NVector(0, 0)
)
return
ellipse, theta1, deltatheta = Ellipse.from_svg_arc(self.p, rx, ry, xrot, large, sweep, dest)
points = ellipse.to_bezier(theta1, deltatheta)
self._do_add_p()
self.path.out_tangents[-1] = points[0].out_tangent
for point in points[1:-1]:
self.path.add_point(
point.vertex,
point.in_tangent,
point.out_tangent,
)
self.path.add_point(
dest.clone(),
points[-1].in_tangent,
NVector(0, 0),
)
self.p = dest
def _parse_a(self):
if self.la_type != 1:
self.next_token()
return
r = self.cur_vec()
xrot = self.next_token()
large = self.next_token()
sweep = self.next_token()
dest = self.p + self.next_vec()
self._do_arc(r[0], r[1], xrot, large, sweep, dest)
self.implicit = "a"
self.next_token()
def _parse_Z(self):
if self.path.vertices:
self.p = self.path.vertices[0].clone()
self.path.close()
self._push_path()
def _parse_z(self):
self._parse_Z()
def parse_svg_etree(etree, layer_frames=0, *args, **kwargs):
parser = SvgParser()
return parser.parse_etree(etree, layer_frames, *args, **kwargs)
def parse_svg_file(file, layer_frames=0, *args, **kwargs):
return parse_svg_etree(ElementTree.parse(file), layer_frames, *args, **kwargs)