#!/usr/bin/env python
# encoding: utf-8
# api: inkscape
##type: effect
# category: animation
# title: Animate Yo
# description: Attach an ❮animate❯ tag to selected object
# id: org.include-once.inkscape.animate-yo
# license: MITL
# version: 0.5
# state: alpha
# depends: bin:inkscape (>= 1.2), python (>= 3.6)
# pylint: disable=line-too-long, missing-module-docstring, bad-whitespace
# config:
# { xml: <hbox><vbox> }
# { name: general, type: notebook, value: time, description: Timing and control settings }
# { class: timing, name: start, type: str, value: "0s", description: Start time, help: "Can be a numeric `1s` or an expression and reference events `click + 1s` or other animations `animXY.end - 1s`." }
# { class: timing, name: duration, type: str, value: "5s", description: Duration, help: "Static value or expression again." }
# { class: repeat, name: repeat_count, type: select, select: "indefinite|0|1|2|3|4|5|10|15|20|25|50|100", value: style, description: repeatCount, help: "How often this animation loops. (Ignored by gif_export, where animations are generally per-frame.)" }
# { class: repeat, name: repeat_fill, type: select, select: "freeze|remove", value: freeze, translatable: no, description: repeatFill, help: "Animation can either stay at last state, or reset to value before animation. (gif_export always reverts to previous slide state)" }
# { class: calc, name: calc_mode, type: select, select: "linear|discrete|paced|spline", value: "linear", description: "calcMode", help: "Defines if values in animation are traversed in a straight from-to progression (linear), jump from start to end (discrete), slowly speed up (paced), or use a list of values/keyTimes (spline)." }
# { class: calc, name: key_values, type: str, value: "", description: "(key)values", help: "Spline mode: Instead of a single to= attribute, can give a list of values `12px;20px;50px` or `#f33;#77cc99;#03F` for use with keyTimes/Splines." }
# { class: calc, name: key_times, type: str, value: "", description: "keyTimes", help: "Spline mode: Lists the time lapse positions `0.0; 0.3; 0.9; 1.0` when point in values= list should be approached." }
# { class: calc, name: key_splines, type: str, value: "", description: "keySplines", help: "Spline mode: defines bezier curves for use with keyTimes. (Ignored by gif_export)" }
# { class: id, name: set_id, type: str, value: "", description: "Assign #id", help: "Animation itself can have an #id for referencing." }
# { name: remove_child, type: bool, value: 0, description: "Strip previous animation", help: "Remove any existing ﹤animate*﹥ tags from selected object. (Else use the XML editor Ctrl+Shift+X.)" }
# { xml: </vbox><image width='220' height='220'>animate_yo.svg</image></hbox> }
# { name: mode, type: notebook, select: "style|scale|rotate|translate|skewxy|attrib|move", value: style, description: Animation mode, help: Animation }
# { class: style, name: style, type: select, select: "fill|stroke|fill-opacity|stroke-opacity|stroke-width", value: style, description: Attribute, help: "Can change either colors, opacity, or width.", translatable: no }
# { class: style, name: color, type: color, appearance: colorbutton, value: "#ff5555", description: Color, help: Target color, translatable: no }
# { class: style, name: style_val, type: str, value: "", description: Opacity/width, help: "Use a fraction 0.75 for opacity. Or an integer 25 for widths." }
# { class: scale, name: scale, type: str, value: "1.0 1.0", description: Scaling, help: "Scales both width and height. Specify fractions each. For example 0.0 will vanish the object, or 2.0 double it per dimension. Beware that this transform can 'scale' x= and y= positions alongside. Can also be a single value `2.5` to scale both width and height." }
# { class: rotate, name: rotate, type: int, value: 0, min: -360, max: 360, appearance: full, description: Rotation, help: "Just in degrees." }
# { class: translate, label: Arguments for translate(x y) or matrix(a b c d e f) }
# { class: translate, name: translate, type: str, value: "", description: Translate, help: "Should usually be a tuple like 20 30.", translatable: no }
# { class: skewxy, label: Either or, tab_desc: skewXY }
# { class: skewxy, name: skew_x, type: str, value: "", description: X direction, help: "numeric value, like -20 or 50" }
# { class: skewxy, name: skew_y, type: str, value: "", description: Y direction, help: "only used if _x is empty" }
# { class: attrib, name: attr_name, type: str, value: "x", description: Attribute name, help: "Could be any of x, cy, or height, or other SVG tag attributes." }
# { class: attrib, name: attr_from, type: str, value: "", description: Start value, help: "numeric or string" }
# { class: attrib, name: attr_to, type: str, value: "", description: Final value, help: "numeric or string" }
# { class: move, label: Select a path alongside the main object. }
# { class: move, label: It’ll be applied as transition path. }
# { class: move, name: move_relative, type: bool, value: 0, description: "Convert to relative path. (No displacement in export_gif).", help: "Usually the animation path is best drawn at page origin (top left, x=0 y=0). This option tries to make it relative (to top left corner or center of animated object). // Alternatively to a selected path, you can define a `M0,0 L20,20` in the [translate] tab." }
# architecture: all
# pack: animate_yo.svg, animate_yo.inx
# format: off
# author: mario#include-once:org
#
# Allows to add trivial <animate> tags to a SVG document. Embedded animations
# work for standalone SVG graphics. Some instructions are understood by the
# GIF interpolation in ★export_gif (which this was originally intended for).
#
# Usually requires selecting one object/shape/path, invoking the module in
# Extension➜Animation➜Animate-Yo, and activating one of the tabs to inject an
# <animate*> tag. The [move] option additionally requires a selected path
# (second argument) from transitioning the main object on.
#
# More timing attributes:
# · fill, end, min, max, restart, repeatDur,
# · calcMode, values, by
# · keyTimes, keyPoints, keySplines
# · additive, accumulate
import sys
import re
import random
import inkex
from inkex import AbortExtension
class AnimateYo(inkex.EffectExtension):
""" Add an <animate*> tag to selected object/path """
def __init__(self):
self.obj = None
self.path = None
self.bbox = None
self.center = ""
self.has_transform = ""
super().__init__()
def add_arguments(self, pars):
""" populate self.options from script args """
pars.add_argument("--general", type=str, dest="general", default="time", help="Timing and control settings")
pars.add_argument("--start", type=str, dest="start", default="0s", help="Start time")
pars.add_argument("--duration", type=str, dest="duration", default="5s", help="Duration")
pars.add_argument("--repeat_count", type=str, dest="repeat_count", default="style", help="repeatCount")
pars.add_argument("--repeat_fill", type=str, dest="repeat_fill", default="freeze", help="fill")
pars.add_argument("--calc_mode", type=str, dest="calc_mode", default="linear", help="calcMode")
pars.add_argument("--key_values", type=str, dest="key_values", default="", help="(key)values")
pars.add_argument("--key_times", type=str, dest="key_times", default="", help="keyTimes")
pars.add_argument("--key_splines", type=str, dest="key_splines", default="", help="keySplines")
pars.add_argument("--set_id", type=str, dest="set_id", default="", help="Assign #id")
pars.add_argument("--remove_child", type=inkex.Boolean, dest="remove_child", default=False, help="Strip previous animations")
pars.add_argument("--mode", type=str, dest="mode", default="style", help="Animation mode")
pars.add_argument("--style", type=str, dest="style", default="style", help="Attribute")
pars.add_argument("--color", type=inkex.Color, dest="color", default="#ff5555", help="Color")
pars.add_argument("--style_val", type=str, dest="style_val", default="", help="Opacity/width")
pars.add_argument("--scale", type=str, dest="scale", default="1.0 1.0", help="Scaling")
pars.add_argument("--rotate", type=int, dest="rotate", default=0, help="Rotation")
pars.add_argument("--translate", type=str, dest="translate", default="", help="Translate")
pars.add_argument("--skew_x", type=str, dest="skew_x", default="", help="X direction")
pars.add_argument("--skew_y", type=str, dest="skew_y", default="", help="Y direction")
pars.add_argument("--attr_name", type=str, dest="attr_name", default="x", help="Attribute name")
pars.add_argument("--attr_from", type=str, dest="attr_from", default="", help="Start value")
pars.add_argument("--attr_to", type=str, dest="attr_to", default="", help="Final value")
pars.add_argument("--move_relative", type=inkex.Boolean, dest="move_relative", default=False, help="Convert to relative path. (No displacement in export_gif).")
def effect(self):
""" attach something to tree """
# check params
if not self.svg.selection or len(self.svg.selection) < 1:
raise AbortExtension("Requires a selected object to work on.")
if len(self.svg.selection) > 2:
raise AbortExtension("Can only work with one or two objects selected.")
if len(self.svg.selection) == 2:
self.options.mode = "move"
obj, path = self.svg.selection
if obj.tag_name == "path" and path.tag_name != "path":
obj, path = path, obj
elif path.tag_name != "path":
raise AbortExtension(f"One of the two selections must be a path to move on. ({obj.tag_name}, {path.tag_name} given)")
else:
obj, path = self.svg.selection[0], None
# prepare more arguments
self.obj = obj
self.path = path
self.bbox = obj.bounding_box()
self.has_transform=str(obj.transform) or ""
ink_ns = "{" + inkex.NSS["inkscape"] + "}"
if obj.attrib.get(f"{ink_ns}transform-center-x"):
self.center = [
self.bbox.center.x + float(obj.attrib[f"{ink_ns}transform-center-x"]),
self.bbox.center.y + float(obj.attrib[f"{ink_ns}transform-center-y"])
]
else:
self.center = [self.bbox.center.x, self.bbox.center.y]
self.center = " ".join(str(s) for s in self.center)
# should generally abort?
#if obj.transform:
# raise Exception("Object already has a transformation. Animations will be wonky at best.")
# dispatch & apply
method = getattr(self, self.options.mode)
creator, args = method()
args.update(self.default_args())
self.remove_previous_tags(obj, creator.tag_name)
self.obj.append(creator(**args))
def remove_previous_tags(self, parent, tag_name):
""" strip any existing <animate*> tag in children """
if not self.options.remove_child:
return
for child in parent.getchildren():
if not re.match("^animate(Motion|Transform)?$", child.tag_name):
continue
if child.tag_name != tag_name: # only remove identically-named
continue
parent.remove(child)
def style(self):
""" <animate attributeName=color to=red> """
if self.options.style in ("fill", "stroke"):
val = str(self.options.color)
else:
val = self.options.style_val
return Animate, {
"attributeName": self.options.style,
"attributeType": "CSS",
#"from": "",
"to": val,
}
def scale(self):
""" <animateTransform attributeName=transform to=scale(2,5)> """
return AnimateTransform, {
"attributeName": "transform",
"attributeType": "XML",
"type": self.options.mode, # scale
"from": "1.0 1.0",
"to": self.options.scale,
}
def translate(self):
""" <animateTransform attributeName=transform to=translate(20 30 50)> """
nums = re.findall(r"(-?\d+(?:\.\d+)?)", self.options.translate)
if len(nums) == 6:
func = "matrix"
elif len(nums) == 2:
func = "translate"
else:
raise AbortExtension(f"Requires either two params for translate(x y) or six for matrix(a b c d e f) - received '{nums}'")
return AnimateTransform, {
"attributeName": "transform",
"attributeType": "XML",
"type": func, # translate / matrix
#"from": "0 0",
"to": " ".join(nums),
}
def rotate(self):
""" <animateTransform attributeName=transform to=rotate(360)> """
if re.search(r"rotate\(.*\)", self.has_transform):
raise AbortExtension("Can't realistically calculate center point with pre-existing rotation.")
# obj.transform-origin=center ?
return AnimateTransform, {
"attributeName": "transform",
"attributeType": "XML",
"type": self.options.mode, # rotate
"from": f"0 {self.center}",
"to": f"{self.options.rotate} {self.center}",
}
def skewxy(self):
""" <animateTransform attributeName=skewX to=-50> """
return AnimateTransform, {
"attributeName": "transform",
"attributeType": "XML",
"type": "skewX" if self.options.skew_x else "skewY",
#"from": "0",
"to": self.options.skew_x or self.options.skew_y,
}
def attrib(self):
""" <animate attributeName=width to=20px> """
return Animate, {
"attributeName": self.options.attr_name,
"attributeType": "auto", # XML
"from": self.options.attr_from,
"to": self.options.attr_to,
}
def move(self):
""" <animateMotion path=m0,0 c50,20,10,50,30,10 l100,200 z> """
if self.path is not None and self.path.attrib.get("d"):
data = self.path.attrib["d"]
else:
data = self.options.translate
if self.options.move_relative:
path = inkex.paths.Path(data).to_relative()
if isinstance(path[0], (inkex.paths.Move, inkex.paths.move)):
path[0] = inkex.paths.move(0, 0)
data = str(path)
return AnimateMotion, {
"path": data,
}
def default_args(self):
""" add some default attributes """
args = {
"id": re.sub(r"^#|\s+", "", self.options.set_id),
"begin": self.options.start,
"dur": self.options.duration,
"repeatCount": self.options.repeat_count,
"fill": self.options.repeat_fill,
"calcMode": self.options.calc_mode,
}
if self.options.calc_mode == "spline":
args.update({
"values": self.options.key_values,
"keyTimes": self.options.key_times,
"keySplines": self.options.key_splines,
})
while args["id"] and self.svg.getElementById(args["id"]):
args["id"] = "anim" + str(random.randint(100, 10000))
return {
key: val for key, val in args.items() if val
}
class Animate(inkex.BaseElement):
"""Title element"""
tag_name = "animate"
class AnimateTransform(inkex.BaseElement):
"""Title element"""
tag_name = "animateTransform"
class AnimateMotion(inkex.BaseElement):
"""Title element"""
tag_name = "animateMotion"
if __name__ == "__main__":
AnimateYo().run()
sys.exit(0)