Initial Commit

This commit is contained in:
OmniLottie
2026-03-01 21:36:54 +08:00
commit a386c803e1
199 changed files with 42253 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__all__ = ["animation", "ellipse", "ik", "linediff", "restructure", "script", "stripper"]
try:
from . import font
__all__ += ["font"]
except ImportError:
pass
+544
View File
@@ -0,0 +1,544 @@
import random
import math
from ..nvector import NVector
from ..objects.shapes import Path
from .. import objects
from ..objects import easing
from ..objects import properties
def shake(position_prop, x_radius, y_radius, start_time, end_time, n_frames, interp=easing.Linear()):
if not isinstance(position_prop, list):
position_prop = [position_prop]
n_frames = int(round(n_frames))
frame_time = (end_time - start_time) / n_frames
startpoints = list(map(
lambda pp: pp.get_value(start_time),
position_prop
))
for i in range(n_frames):
x = (random.random() * 2 - 1) * x_radius
y = (random.random() * 2 - 1) * y_radius
for pp, start in zip(position_prop, startpoints):
px = start[0] + x
py = start[1] + y
pp.add_keyframe(start_time + i * frame_time, NVector(px, py), interp)
for pp, start in zip(position_prop, startpoints):
pp.add_keyframe(end_time, start, interp)
def rot_shake(rotation_prop, angles, start_time, end_time, n_frames):
frame_time = (end_time - start_time) / n_frames
start = rotation_prop.get_value(start_time)
for i in range(0, n_frames):
a = angles[i % len(angles)] * math.sin(i/n_frames * math.pi)
rotation_prop.add_keyframe(start_time + i * frame_time, start + a)
rotation_prop.add_keyframe(end_time, start)
def spring_pull(position_prop, point, start_time, end_time, falloff=15, oscillations=7):
start = position_prop.get_value(start_time)
d = start-point
delta = (end_time - start_time) / oscillations
for i in range(oscillations):
time_x = i / oscillations
factor = math.cos(time_x * math.pi * oscillations) * (1-time_x**(1/falloff))
p = point + d * factor
position_prop.add_keyframe(start_time + delta * i, p)
position_prop.add_keyframe(end_time, point)
def follow_path(position_prop, bezier, start_time, end_time, n_keyframes,
reverse=False, offset=NVector(0, 0), start_t=0, rotation_prop=None, rotation_offset=0):
delta = (end_time - start_time) / (n_keyframes-1)
fact = start_t
factd = 1 / (n_keyframes-1)
if rotation_prop:
start_rot = rotation_prop.get_value(start_time) if rotation_offset is None else rotation_offset
for i in range(n_keyframes):
time = start_time + i * delta
if fact > 1 + factd/2:
fact -= 1
if time != start_time:
easing.Jump()(position_prop.keyframes[-1])
if rotation_prop:
easing.Jump()(rotation_prop.keyframes[-1])
f = 1 - fact if reverse else fact
position_prop.add_keyframe(time, bezier.point_at(f)+offset)
if rotation_prop:
rotation_prop.add_keyframe(time, bezier.tangent_angle_at(f) / math.pi * 180 + start_rot)
fact += factd
def generate_path_appear(bezier, appear_start, appear_end, n_keyframes, reverse=False):
obj = Path()
beziers = []
maxp = 0
time_delta = (appear_end - appear_start) / n_keyframes
for i in range(n_keyframes+1):
time = appear_start + i * time_delta
t2 = (time - appear_start) / (appear_end - appear_start)
if reverse:
t2 = 1 - t2
segment = bezier.segment(t2, 1)
segment.reverse()
else:
segment = bezier.segment(0, t2)
beziers.append(segment)
if len(segment.vertices) > maxp:
maxp = len(segment.vertices)
obj.shape.add_keyframe(time, segment)
for segment in beziers:
deltap = maxp - len(segment.vertices)
if deltap > 0:
segment.vertices += [segment.vertices[-1]] * deltap
segment.in_tangents += [NVector(0, 0)] * deltap
segment.out_tangents += [NVector(0, 0)] * deltap
return obj
def generate_path_disappear(bezier, disappear_start, disappear_end, n_keyframes, reverse=False):
obj = Path()
beziers = []
maxp = 0
time_delta = (disappear_end - disappear_start) / n_keyframes
for i in range(n_keyframes+1):
time = disappear_start + i * time_delta
t1 = (time - disappear_start) / (disappear_end - disappear_start)
if reverse:
t1 = 1 - t1
segment = bezier.segment(0, t1)
else:
segment = bezier.segment(1, t1)
segment.reverse()
beziers.append(segment)
if len(segment.vertices) > maxp:
maxp = len(segment.vertices)
obj.shape.add_keyframe(time, segment)
for segment in beziers:
deltap = maxp - len(segment.vertices)
if deltap > 0:
segment.vertices += [segment.vertices[-1]] * deltap
segment.in_tangents += [NVector(0, 0)] * deltap
segment.out_tangents += [NVector(0, 0)] * deltap
return obj
def generate_path_segment(bezier, appear_start, appear_end, disappear_start, disappear_end, n_keyframes, reverse=False):
obj = Path()
beziers = []
maxp = 0
# HACK: For some reson reversed works better
if not reverse:
bezier.reverse()
time_delta = (appear_end - appear_start) / n_keyframes
for i in range(n_keyframes+1):
time = appear_start + i * time_delta
t1 = (time - disappear_start) / (disappear_end - disappear_start)
t2 = (time - appear_start) / (appear_end - appear_start)
t1 = max(0, min(1, t1))
t2 = max(0, min(1, t2))
#if reverse:
if True:
t1 = 1 - t1
t2 = 1 - t2
segment = bezier.segment(t2, t1)
segment.reverse()
#else:
#segment = bezier.segment(t1, t2)
#segment.reverse()
beziers.append(segment)
if len(segment.vertices) > maxp:
maxp = len(segment.vertices)
obj.shape.add_keyframe(time, segment)
for segment in beziers:
deltap = maxp - len(segment.vertices)
if deltap > 0:
segment.split_self_chunks(deltap+1)
# HACK: Restore
if not reverse:
bezier.reverse()
return obj
class PointDisplacer:
def __init__(self, time_start, time_end, n_frames):
"""!
@param time_start When the animation shall start
@param time_end When the animation shall end
@param n_frames Number of frames in the animation
"""
## When the animation shall start
self.time_start = time_start
## When the animation shall end
self.time_end = time_end
## Number of frames in the animation
self.n_frames = n_frames
## Length of a frame
self.time_delta = (time_end - time_start) / n_frames
def animate_point(self, prop):
startpos = prop.get_value(self.time_start)
for f in range(self.n_frames+1):
p = self._on_displace(startpos, f)
prop.add_keyframe(self.frame_time(f), startpos+p)
def _on_displace(self, startpos, f):
raise NotImplementedError()
def animate_bezier(self, prop):
initial = prop.get_value(self.time_start)
for f in range(self.n_frames+1):
bezier = objects.Bezier()
bezier.closed = initial.closed
for pi in range(len(initial.vertices)):
startpos = initial.vertices[pi]
dp = self._on_displace(startpos, f)
t1sp = initial.in_tangents[pi] + startpos
t1fin = initial.in_tangents[pi] + self._on_displace(t1sp, f) - dp
t2sp = initial.out_tangents[pi] + startpos
t2fin = initial.out_tangents[pi] + self._on_displace(t2sp, f) - dp
bezier.add_point(dp + startpos, t1fin, t2fin)
prop.add_keyframe(self.frame_time(f), bezier)
def frame_time(self, f):
return f * self.time_delta + self.time_start
def _init_lerp(self, val_from, val_to, easing):
self._kf = properties.OffsetKeyframe(0, NVector(val_from), NVector(val_to), easing)
def _lerp_get(self, offset):
return self._kf.interpolated_value(offset / self.n_frames)[0]
class SineDisplacer(PointDisplacer):
def __init__(
self,
wavelength,
amplitude,
time_start,
time_end,
n_frames,
speed=1,
axis=90,
):
"""!
Displaces points as if they were following a sine wave
@param wavelength Distance between consecutive peaks
@param amplitude Distance from a peak to the original position
@param time_start When the animation shall start
@param time_end When the animation shall end
@param n_frames Number of keyframes to add
@param speed Number of peaks a point will go through in the given time
If negative, it will go the other way
@param axis Wave peak direction
"""
super().__init__(time_start, time_end, n_frames)
self.wavelength = wavelength
self.amplitude = amplitude
self.speed_f = math.pi * 2 * speed
self.axis = axis / 180 * math.pi
def _on_displace(self, startpos, f):
off = -math.sin(startpos[0]/self.wavelength*math.pi*2-f*self.speed_f/self.n_frames) * self.amplitude
return NVector(off * math.cos(self.axis), off * math.sin(self.axis))
class MultiSineDisplacer(PointDisplacer):
def __init__(
self,
waves,
time_start,
time_end,
n_frames,
speed=1,
axis=90,
amplitude_scale=1,
):
"""!
Displaces points as if they were following a sine wave
@param waves List of tuples (wavelength, amplitude)
@param time_start When the animation shall start
@param time_end When the animation shall end
@param n_frames Number of keyframes to add
@param speed Number of peaks a point will go through in the given time
If negative, it will go the other way
@param axis Wave peak direction
@param amplitude_scale Multiplies the resulting amplitude by this factor
"""
super().__init__(time_start, time_end, n_frames)
self.waves = waves
self.speed_f = math.pi * 2 * speed
self.axis = axis / 180 * math.pi
self.amplitude_scale = amplitude_scale
def _on_displace(self, startpos, f):
off = 0
for wavelength, amplitude in self.waves:
off -= math.sin(startpos[0]/wavelength*math.pi*2-f*self.speed_f/self.n_frames) * amplitude
off *= self.amplitude_scale
return NVector(off * math.cos(self.axis), off * math.sin(self.axis))
class DepthRotationAxis:
def __init__(self, x, y, keep):
self.x = x / x.length
self.y = y / y.length
self.keep = keep / keep.length # should be the cross product
def rot_center(self, center, point):
return (
self.x * self.x.dot(center) +
self.y * self.y.dot(center) +
self.keep * self.keep.dot(point)
)
def extract_component(self, vector, axis):
return sum(vector.element_scaled(axis).components)
@classmethod
def from_points(cls, keep_point, center=NVector(0, 0, 0)):
keep = keep_point - center
keep /= keep.length
# Hughes-Moller to find x and y
if abs(keep.x) > abs(keep.z):
y = NVector(-keep.y, keep.x, 0)
else:
y = NVector(0, -keep.z, keep.y)
y /= y.length
x = y.cross(keep)
return cls(x, y, keep)
class DepthRotation:
axis_x = DepthRotationAxis(NVector(0, 0, 1), NVector(0, 1, 0), NVector(1, 0, 0))
axis_y = DepthRotationAxis(NVector(1, 0, 0), NVector(0, 0, 1), NVector(0, 1, 0))
axis_z = DepthRotationAxis(NVector(1, 0, 0), NVector(0, 1, 0), NVector(0, 0, 1))
def __init__(self, center):
self.center = center
def rotate3d_y(self, point, angle):
return self.rotate3d(point, angle, self.axis_y)
# Hard-coded version:
#c = NVector(self.center.x, point.y, self.center.z)
#rad = angle * math.pi / 180
#delta = point - c
#pol_l = delta.length
#pol_a = math.atan2(delta.z, delta.x)
#dest_a = pol_a + rad
#return NVector(
# c.x + pol_l * math.cos(dest_a),
# point.y,
# c.z + pol_l * math.sin(dest_a)
#)
def rotate3d_x(self, point, angle):
return self.rotate3d(point, angle, self.axis_x)
# Hard-coded version:
#c = NVector(point.x, self.center.y, self.center.z)
#rad = angle * math.pi / 180
#delta = point - c
#pol_l = delta.length
#pol_a = math.atan2(delta.y, delta.z)
#dest_a = pol_a + rad
#return NVector(
# point.x,
# c.y + pol_l * math.sin(dest_a),
# c.z + pol_l * math.cos(dest_a),
#)
def rotate3d_z(self, point, angle):
return self.rotate3d(point, angle, self.axis_z)
def rotate3d(self, point, angle, axis):
c = axis.rot_center(self.center, point)
rad = angle * math.pi / 180
delta = point - c
pol_l = delta.length
pol_a = math.atan2(
axis.extract_component(delta, axis.y),
axis.extract_component(delta, axis.x)
)
dest_a = pol_a + rad
return c + axis.x * pol_l * math.cos(dest_a) + axis.y * pol_l * math.sin(dest_a)
class DepthRotationDisplacer(PointDisplacer):
axis_x = DepthRotation.axis_x
axis_y = DepthRotation.axis_y
axis_z = DepthRotation.axis_z
def __init__(self, center, time_start, time_end, n_frames, axis,
depth=0, angle=360, anglestart=0, ease=easing.Linear()):
super().__init__(time_start, time_end, n_frames)
self.rotation = DepthRotation(center)
if isinstance(axis, NVector):
axis = DepthRotationAxis.from_points(axis)
self.axis = axis
self.depth = depth
self._angle = angle
self.anglestart = anglestart
self.ease = ease
self._init_lerp(0, angle, ease)
@property
def angle(self):
return self._angle
@angle.setter
def angle(self, value):
self._angle = value
self._init_lerp(0, value, self.ease)
def _on_displace(self, startpos, f):
angle = self.anglestart + self._lerp_get(f)
if len(startpos) < 3:
startpos = NVector(*(startpos.components + [self.depth]))
return self.rotation.rotate3d(startpos, angle, self.axis) - startpos
class EnvelopeDeformation(PointDisplacer):
def __init__(self, topleft, bottomright):
self.topleft = topleft
self.size = bottomright - topleft
self.keyframes = []
@property
def time_start(self):
return self.keyframes[0][0]
def add_reset_keyframe(self, time):
self.add_keyframe(
time,
self.topleft.clone(),
NVector(self.topleft.x + self.size.x, self.topleft.y),
NVector(self.topleft.x + self.size.x, self.topleft.y + self.size.y),
NVector(self.topleft.x, self.topleft.y + self.size.y),
)
def add_keyframe(self, time, tl, tr, br, bl):
self.keyframes.append([
time,
tl.clone(),
tr.clone(),
br.clone(),
bl.clone()
])
def _on_displace(self, startpos, f):
_, tl, tr, br, bl = self.keyframes[f]
relp = startpos - self.topleft
relp.x /= self.size.x
relp.y /= self.size.y
x1 = tl.lerp(tr, relp.x)
x2 = bl.lerp(br, relp.x)
#return x1.lerp(x2, relp.y)
return x1.lerp(x2, relp.y) - startpos
@property
def n_frames(self):
return len(self.keyframes)-1
def frame_time(self, f):
return self.keyframes[f][0]
class DisplacerDampener(PointDisplacer):
"""!
Given a displacer and a function that returns a factor for a point,
multiplies the effect of the displacer by the factor
"""
def __init__(self, displacer, dampener):
self.displacer = displacer
self.dampener = dampener
@property
def time_start(self):
return self.displacer.time_start
def _on_displace(self, startpos, f):
disp = self.displacer._on_displace(startpos, f)
damp = self.dampener(startpos)
return disp * damp
@property
def n_frames(self):
return self.displacer.n_frames
def frame_time(self, f):
return self.displacer.frame_time(f)
class FollowDisplacer(PointDisplacer):
def __init__(
self,
origin,
range,
offset_func,
time_start, time_end, n_frames,
falloff_exp=1,
):
"""!
@brief Uses a custom offset function, and applies a falloff to the displacement
@param origin Origin point for the falloff
@param range Radius after which the points will not move
@param offset_func Function returning an offset given a ratio of the time
@param time_start When the animation shall start
@param time_end When the animation shall end
@param n_frames Number of frames in the animation
@param falloff_exp Exponent for the falloff
"""
super().__init__(time_start, time_end, n_frames)
self.origin = origin
self.range = range
self.offset_func = offset_func
self.falloff_exp = falloff_exp
def _on_displace(self, startpos, f):
influence = 1 - min(1, (startpos - self.origin).length / self.range) ** self.falloff_exp
return self.offset_func(f / self.n_frames) * influence
+447
View File
@@ -0,0 +1,447 @@
import enum
import math
import colorsys
from ..nvector import NVector
def from_uint8(r, g, b, a=255):
return Color(r, g, b, a) / 255
class ColorMode(enum.Enum):
## sRGB, Components in [0, 1]
RGB = enum.auto()
## HSV, components in [0, 1]
HSV = enum.auto()
## HSL, components in [0, 1]
HSL = enum.auto()
## CIE XYZ with Illuminant D65. Components in [0, 1]
XYZ = enum.auto()
## CIE L*u*v*
LUV = enum.auto()
## CIE Lch(uv), polar version of LUV where C is the radius and H an angle in radians
LCH_uv = enum.auto()
## CIE L*a*b*
LAB = enum.auto()
## CIE LCh(ab), polar version of LAB where C is the radius and H an angle in radians
#LCH_ab = enum.auto()
def _clamp(x):
return max(0, min(1, x))
class Conversion:
_conv_paths = {
(ColorMode.RGB, ColorMode.RGB): [],
(ColorMode.RGB, ColorMode.HSV): [],
(ColorMode.RGB, ColorMode.HSL): [],
(ColorMode.RGB, ColorMode.XYZ): [],
(ColorMode.RGB, ColorMode.LUV): [ColorMode.XYZ],
(ColorMode.RGB, ColorMode.LAB): [ColorMode.XYZ],
(ColorMode.RGB, ColorMode.LCH_uv): [ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.RGB, ColorMode.LCH_ab): [ColorMode.XYZ, ColorMode.LAB],
(ColorMode.HSV, ColorMode.RGB): [],
(ColorMode.HSV, ColorMode.HSV): [],
(ColorMode.HSV, ColorMode.HSL): [],
(ColorMode.HSV, ColorMode.XYZ): [ColorMode.RGB],
(ColorMode.HSV, ColorMode.LUV): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSV, ColorMode.LAB): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSV, ColorMode.LCH_uv): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.HSV, ColorMode.LCH_ab): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LAB],
(ColorMode.HSL, ColorMode.RGB): [],
(ColorMode.HSL, ColorMode.HSV): [],
(ColorMode.HSL, ColorMode.HSL): [],
(ColorMode.HSL, ColorMode.XYZ): [ColorMode.RGB],
(ColorMode.HSL, ColorMode.LUV): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSL, ColorMode.LAB): [ColorMode.RGB, ColorMode.XYZ],
(ColorMode.HSL, ColorMode.LCH_uv): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.HSL, ColorMode.LCH_ab): [ColorMode.RGB, ColorMode.XYZ, ColorMode.LAB],
(ColorMode.XYZ, ColorMode.RGB): [],
(ColorMode.XYZ, ColorMode.HSV): [ColorMode.RGB],
(ColorMode.XYZ, ColorMode.HSL): [ColorMode.RGB],
(ColorMode.XYZ, ColorMode.XYZ): [],
(ColorMode.XYZ, ColorMode.LUV): [],
(ColorMode.XYZ, ColorMode.LAB): [],
(ColorMode.XYZ, ColorMode.LCH_uv): [ColorMode.LUV],
#(ColorMode.XYZ, ColorMode.LCH_ab): [ColorMode.LAB],
(ColorMode.LCH_uv, ColorMode.RGB): [ColorMode.LUV, ColorMode.XYZ],
(ColorMode.LCH_uv, ColorMode.HSV): [ColorMode.LUV, ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LCH_uv, ColorMode.HSL): [ColorMode.LUV, ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LCH_uv, ColorMode.XYZ): [ColorMode.LUV],
(ColorMode.LCH_uv, ColorMode.LUV): [],
(ColorMode.LCH_uv, ColorMode.LAB): [ColorMode.LUV, ColorMode.XYZ],
(ColorMode.LCH_uv, ColorMode.LCH_uv): [],
#(ColorMode.LCH_uv, ColorMode.LCH_ab): [ColorMode.LUV, ColorMode.XYZ, ColorMode.LAB],
(ColorMode.LUV, ColorMode.RGB): [ColorMode.XYZ],
(ColorMode.LUV, ColorMode.HSV): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LUV, ColorMode.HSL): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LUV, ColorMode.XYZ): [],
(ColorMode.LUV, ColorMode.LUV): [],
(ColorMode.LUV, ColorMode.LAB): [ColorMode.XYZ],
(ColorMode.LUV, ColorMode.LCH_uv): [],
#(ColorMode.LUV, ColorMode.LCH_ab): [ColorMode.XYZ, ColorMode.LAB],
(ColorMode.LAB, ColorMode.RGB): [ColorMode.XYZ],
(ColorMode.LAB, ColorMode.HSV): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LAB, ColorMode.HSL): [ColorMode.XYZ, ColorMode.RGB],
(ColorMode.LAB, ColorMode.XYZ): [],
(ColorMode.LAB, ColorMode.LUV): [ColorMode.XYZ],
(ColorMode.LAB, ColorMode.LAB): [],
(ColorMode.LAB, ColorMode.LCH_uv): [ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.LAB, ColorMode.LCH_ab): [],
#(ColorMode.LCH_ab, ColorMode.RGB): [ColorMode.LAB, ColorMode.XYZ],
#(ColorMode.LCH_ab, ColorMode.HSV): [ColorMode.LAB, ColorMode.XYZ, ColorMode.RGB],
#(ColorMode.LCH_ab, ColorMode.HSL): [ColorMode.LAB, ColorMode.XYZ, ColorMode.RGB],
#(ColorMode.LCH_ab, ColorMode.XYZ): [ColorMode.LAB],
#(ColorMode.LCH_ab, ColorMode.LUV): [ColorMode.LAB, ColorMode.XYZ],
#(ColorMode.LCH_ab, ColorMode.LAB): [],
#(ColorMode.LCH_ab, ColorMode.LCH_uv): [ColorMode.LAB, ColorMode.XYZ, ColorMode.LUV],
#(ColorMode.LCH_ab, ColorMode.LCH_ab): [],
}
@staticmethod
def rgb_to_hsv(r, g, b):
return colorsys.rgb_to_hsv(r, g, b)
@staticmethod
def hsv_to_rgb(r, g, b):
return colorsys.hsv_to_rgb(r, g, b)
@staticmethod
def hsl_to_hsv(h, s_hsl, l):
v = l + s_hsl * min(l, 1 - l)
s_hsv = 0 if v == 0 else 2 - 2 * l / v
return (h, s_hsv, v)
@staticmethod
def hsv_to_hsl(h, s_hsv, v):
l = v - v * s_hsv / 2
s_hsl = 0 if l in (0, 1) else (v - l) / min(l, 1 - l)
return (h, s_hsl, l)
@staticmethod
def rgb_to_hsl(r, g, b):
h, l, s = colorsys.rgb_to_hls(r, g, b)
return (h, s, l)
@staticmethod
def hsl_to_rgb(h, s, l):
return colorsys.hls_to_rgb(h, l, s)
# http://w3.uqo.ca/missaoui/Publications/TRColorSpace.zip
#@staticmethod
#def rgb_to_hcl(r, g, b, gamma=3, y0=100):
#maxc = max(r, g, b)
#minc = min(r, g, b)
#if maxc > 0:
#alpha = 1/y0 * minc / maxc
#else:
#alpha = 0
#q = math.e ** (alpha * gamma)
#h = math.atan2(g - b, r - g)
#if h < 0:
#h += 2*math.pi
#h /= 2*math.pi
#c = q / 3 * (abs(r-g) + abs(g-b) + abs(b-r))
#l = (q * maxc + (q-1) * minc) / 2
#return (h, c, l)
#@staticmethod
#def hcl_to_rgb(h, c, l, gamma=3, y0=100):
#h *= 2*math.pi
#q = math.e ** ((1 - 2*c / 4*l) * gamma / y0)
#minc = (4*l - 3*c) / (4*q - 2)
#maxc = minc + 3*c / 2*q
#if h <= math.pi * 1 / 3:
#tan = math.tan(3/2*h)
#r = maxc
#b = minc
#g = (r * tan + b) / (1 + tan)
#elif h <= math.pi * 2 / 3:
#tan = math.tan(3/4*(h-math.pi))
#g = maxc
#b = minc
#r = (g * (1+tan) - b) / tan
#elif h <= math.pi * 3 / 3:
#tan = math.tan(3/4*(h-math.pi))
#g = maxc
#r = minc
#b = g * (1+tan) - r * tan
#elif h <= math.pi * 4 / 3:
#tan = math.tan(3/2*(h+math.pi))
#b = maxc
#r = minc
#g = (r * tan + b) / (1 + tan)
#elif h <= math.pi * 5 / 3:
#tan = math.tan(3/4*h)
#b = maxc
#g = minc
#r = (g * (1+tan) - b) / tan
#else:
#tan = math.tan(3/4*h)
#r = maxc
#g = minc
#b = g * (1+tan) - r * tan
#return _clamp(r), _clamp(g), _clamp(b)
@staticmethod
def rgb_to_xyz(r, g, b):
def _gamma(v):
return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
rgb = (_gamma(r), _gamma(g), _gamma(b))
matrix = [
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
]
return tuple(
sum(rgb[i] * c for i, c in enumerate(row))
for row in matrix
)
@staticmethod
def xyz_to_rgb(x, y, z):
def _gamma1(v):
return _clamp(v * 12.92 if v <= 0.0031308 else v ** (1/2.4) * 1.055 - 0.055)
matrix = [
[+3.2404542, -1.5371385, -0.4985314],
[-0.9692660, +1.8760108, +0.0415560],
[+0.0556434, -0.2040259, +1.0572252],
]
xyz = (x, y, z)
return tuple(map(_gamma1, (
sum(xyz[i] * c for i, c in enumerate(row))
for row in matrix
)))
@staticmethod
def xyz_to_luv(x, y, z):
u1r = 0.2009
v1r = 0.4610
yr = 100
kap = (29/3)**3
eps = (6/29)**3
try:
u1 = 4*x / (x + 15*y + 3*z)
v1 = 9*y / (x + 15*y + 3*z)
except ZeroDivisionError:
return 0, 0, 0
y_r = y/yr
l = 166 * y_r ** (1/3) - 16 if y_r > eps else kap * y_r
u = 13 * l * (u1 - u1r)
v = 13 * l * (v1 - v1r)
return l, u, v
@staticmethod
def luv_to_xyz(l, u, v):
u1r = 0.2009
v1r = 0.4610
yr = 100
kap = (29/3)**3
if l == 0:
u1 = u1r
v1 = v1r
else:
u1 = u / (13 * l) + u1r
v1 = v / (13 * l) + v1r
y = yr * l / kap if l <= 8 else yr * ((l + 16) / 116) ** 3
x = y * 9*u1 / (4*v1)
z = y * (12 - 3*u1 - 20*v1) / (4*v1)
return x, y, z
@staticmethod
def luv_to_lch_uv(l, u, v):
c = math.hypot(u, v)
h = math.atan2(v, u)
if h < 0:
h += math.tau
return l, c, h
@staticmethod
def lch_uv_to_luv(l, c, h):
u = math.cos(h) * c
v = math.sin(h) * c
return l, u, v
@staticmethod
def xyz_to_lab(x, y, z):
# D65 Illuminant aka sRGB(1,1,1)
xn = 0.950489
yn = 1
zn = 108.8840
delta = 6 / 29
def f(t):
return t ** (1/3) if t > delta ** 3 else t / (3*delta**2) + 4/29
fy = f(y/yn)
l = 116 * fy - 16
a = 500 * (f(x/xn) - fy)
b = 200 * (fy - f(z/zn))
return l, a, b
@staticmethod
def lab_to_xyz(l, a, b):
# D65 Illuminant aka sRGB(1,1,1)
xn = 0.950489
yn = 1
zn = 108.8840
delta = 6 / 29
def f1(t):
return t**3 if t > delta else 3*delta**2*(t-4/29)
l1 = (l+16) / 116
x = xn * f1(l1+a/500)
y = yn * f1(l1)
z = zn * f1(l1-b/200)
return x, y, z
#@staticmethod
#def lab_to_lch_ab(l, a, b):
#c = math.hypot(a, b)
#h = math.atan2(b, a)
#if h < 0:
#h += math.tau
#return l, c, h
#@staticmethod
#def lch_ab_to_lab(l, c, h):
#a = math.cos(h) * c
#b = math.sin(h) * c
#return l, a, b
@staticmethod
def conv_func(mode_from, mode_to):
return getattr(Conversion, "%s_to_%s" % (mode_from.name.lower(), mode_to.name.lower()), None)
@staticmethod
def convert(tuple, mode_from, mode_to):
if mode_from == mode_to:
return tuple
if len(tuple) == 4:
alpha = tuple[3]
tuple = tuple[:3]
else:
alpha = None
func = Conversion.conv_func(mode_from, mode_to)
if func:
return func(*tuple)
if (mode_from, mode_to) in Conversion._conv_paths:
steps = Conversion._conv_paths[(mode_from, mode_to)] + [mode_to]
for step in steps:
func = Conversion.conv_func(mode_from, step)
if not func:
raise ValueError("Missing definition for conversion from %s to %s" % (mode_from, step))
tuple = func(*tuple)
mode_from = step
if alpha is not None:
tuple += (alpha,)
return tuple
raise ValueError("No conversion path from %s to %s" % (mode_from, mode_to))
class Color(NVector):
Mode = ColorMode
def __init__(self, c1=0, c2=0, c3=0, a=1, *, mode=ColorMode.RGB):
if isinstance(a, ColorMode):
raise TypeError("Please update the Color constructor")
super().__init__(c1, c2, c3, a)
self._mode = mode
@property
def mode(self):
return self._mode
def convert(self, v):
if v == self._mode:
return self
self.components = list(Conversion.convert(self.components, self._mode, v))
self._mode = v
return self
def clone(self):
return Color(*self.components, mode=self._mode)
def converted(self, mode):
return self.clone().convert(mode)
def to_rgb(self):
return self.converted(ColorMode.RGB)
def __repr__(self):
return "<%s %s [%.3f, %.3f, %.3f, %.3f]>" % (
(self.__class__.__name__, self.mode.name) + tuple(self.components)
)
def component_names(self):
comps = None
if self._mode == ColorMode.RGB:
comps = ({"r", "red"}, {"g", "green"}, {"b", "blue"})
elif self._mode == ColorMode.HSV:
comps = ({"h", "hue"}, {"s", "saturation"}, {"v", "value"})
elif self._mode == ColorMode.HSL:
comps = ({"h", "hue"}, {"s", "saturation"}, {"l", "lightness"})
elif self._mode == ColorMode.LCH_uv: # in (ColorMode.LCH_uv, ColorMode.LCH_ab):
comps = ({"l", "luma", "luminance"}, {"c", "choma"}, {"h", "hue"})
elif self._mode == ColorMode.XYZ:
comps = "xyz"
elif self._mode == ColorMode.LUV:
comps = "luv"
elif self._mode == ColorMode.LAB:
comps = "lab"
return comps
def _attrindex(self, name):
comps = self.component_names()
if comps:
for i, vals in enumerate(comps):
if name in vals:
return i
return None
def __getattr__(self, name):
if name not in vars(self) and name not in {"_mode", "components"}:
i = self._attrindex(name)
if i is not None:
return self.components[i]
raise AttributeError(name)
def __setattr__(self, name, value):
if name not in vars(self) and name not in {"_mode", "components"}:
i = self._attrindex(name)
if i is not None:
self.components[i] = value
return
return super().__setattr__(name, value)
+124
View File
@@ -0,0 +1,124 @@
import math
from ..nvector import NVector
from ..objects.bezier import BezierPoint
## @todo Just output a Bezier object
class Ellipse:
def __init__(self, center, radii, xrot):
"""
@param center 2D vector, center of the ellipse
@param radii 2D vector, x/y radius of the ellipse
@param xrot Angle between the main axis of the ellipse and the x axis (in radians)
"""
self.center = center
self.radii = radii
self.xrot = xrot
def point(self, t):
return NVector(
self.center[0]
+ self.radii[0] * math.cos(self.xrot) * math.cos(t)
- self.radii[1] * math.sin(self.xrot) * math.sin(t),
self.center[1]
+ self.radii[0] * math.sin(self.xrot) * math.cos(t)
+ self.radii[1] * math.cos(self.xrot) * math.sin(t)
)
def derivative(self, t):
return NVector(
- self.radii[0] * math.cos(self.xrot) * math.sin(t)
- self.radii[1] * math.sin(self.xrot) * math.cos(t),
- self.radii[0] * math.sin(self.xrot) * math.sin(t)
+ self.radii[1] * math.cos(self.xrot) * math.cos(t)
)
def to_bezier(self, anglestart, angle_delta):
points = []
angle1 = anglestart
angle_left = abs(angle_delta)
step = math.pi / 2
sign = -1 if anglestart+angle_delta < angle1 else 1
# We need to fix the first handle
firststep = min(angle_left, step) * sign
alpha = self._alpha(firststep)
q1 = self.derivative(angle1) * alpha
points.append(BezierPoint(self.point(angle1), NVector(0, 0), q1))
# Then we iterate until the angle has been completed
tolerance = step / 2
while angle_left > tolerance:
lstep = min(angle_left, step)
step_sign = lstep * sign
angle2 = angle1 + step_sign
angle_left -= abs(lstep)
alpha = self._alpha(step_sign)
p2 = self.point(angle2)
q2 = self.derivative(angle2) * alpha
points.append(BezierPoint(p2, -q2, q2))
angle1 = angle2
return points
def _alpha(self, step):
return math.sin(step) * (math.sqrt(4+3*math.tan(step/2)**2) - 1) / 3
@classmethod
def from_svg_arc(cls, start, rx, ry, xrot, large, sweep, dest):
rx = abs(rx)
ry = abs(ry)
x1 = start[0]
y1 = start[1]
x2 = dest[0]
y2 = dest[1]
phi = math.pi * xrot / 180
x1p, y1p = _matrix_mul(phi, (start-dest)/2, -1)
cr = x1p ** 2 / rx**2 + y1p**2 / ry**2
if cr > 1:
s = math.sqrt(cr)
rx *= s
ry *= s
dq = rx**2 * y1p**2 + ry**2 * x1p**2
pq = (rx**2 * ry**2 - dq) / dq
cpm = math.sqrt(max(0, pq))
if large == sweep:
cpm = -cpm
cp = NVector(cpm * rx * y1p / ry, -cpm * ry * x1p / rx)
c = _matrix_mul(phi, cp) + NVector((x1+x2)/2, (y1+y2)/2)
theta1 = _angle(NVector(1, 0), NVector((x1p - cp[0]) / rx, (y1p - cp[1]) / ry))
deltatheta = _angle(
NVector((x1p - cp[0]) / rx, (y1p - cp[1]) / ry),
NVector((-x1p - cp[0]) / rx, (-y1p - cp[1]) / ry)
) % (2*math.pi)
if not sweep and deltatheta > 0:
deltatheta -= 2*math.pi
elif sweep and deltatheta < 0:
deltatheta += 2*math.pi
return cls(c, NVector(rx, ry), phi), theta1, deltatheta
def _matrix_mul(phi, p, sin_mul=1):
c = math.cos(phi)
s = math.sin(phi) * sin_mul
xr = c * p.x - s * p.y
yr = s * p.x + c * p.y
return NVector(xr, yr)
def _angle(u, v):
arg = math.acos(max(-1, min(1, u.dot(v) / (u.length * v.length))))
if u[0] * v[1] - u[1] * v[0] < 0:
return -arg
return arg
+13
View File
@@ -0,0 +1,13 @@
from contextlib import contextmanager
@contextmanager
def open_file(file_or_name, mode="w"):
if isinstance(file_or_name, str):
obj = open(file_or_name, mode)
try:
yield obj
finally:
obj.close()
else:
yield file_or_name
+831
View File
@@ -0,0 +1,831 @@
import os
import sys
import subprocess
import fontTools.pens.basePen
import fontTools.ttLib
import fontTools.t1Lib
from fontTools.pens.boundsPen import ControlBoundsPen
import enum
import math
from xml.etree import ElementTree
from ..nvector import NVector
from ..objects.bezier import Bezier, BezierPoint
from ..objects.shapes import Path, Group, Fill, Stroke
from ..objects.text import TextJustify
from ..objects.base import LottieProp, CustomObject
from ..objects.layers import ShapeLayer
class BezierPen(fontTools.pens.basePen.BasePen):
def __init__(self, glyphSet, offset=NVector(0, 0)):
super().__init__(glyphSet)
self.beziers = []
self.current = Bezier()
self.offset = offset
def _point(self, pt):
return self.offset + NVector(pt[0], -pt[1])
def _moveTo(self, pt):
self._endPath()
def _endPath(self):
if len(self.current.points):
self.beziers.append(self.current)
self.current = Bezier()
def _closePath(self):
self.current.close()
self._endPath()
def _lineTo(self, pt):
if len(self.current.points) == 0:
self.current.points.append(self._point(self._getCurrentPoint()))
self.current.points.append(self._point(pt))
def _curveToOne(self, pt1, pt2, pt3):
if len(self.current.points) == 0:
cp = self._point(self._getCurrentPoint())
self.current.points.append(
BezierPoint(
cp,
None,
self._point(pt1) - cp
)
)
else:
self.current.points[-1].out_tangent = self._point(pt1) - self.current.points[-1].vertex
dest = self._point(pt3)
self.current.points.append(
BezierPoint(
dest,
self._point(pt2) - dest,
None,
)
)
class SystemFont:
def __init__(self, family):
self.family = family
self.files = {}
self.styles = set()
self._renderers = {}
def add_file(self, styles, file):
self.styles |= set(styles)
key = self._key(styles)
self.files.setdefault(key, file)
def filename(self, styles):
return self.files[self._key(styles)]
def _key(self, styles):
if isinstance(styles, str):
return (styles,)
return tuple(sorted(styles))
def __getitem__(self, styles):
key = self._key(styles)
if key in self._renderers:
return self._renderers[key]
fr = RawFontRenderer(self.files[key])
self._renderers[key] = fr
return fr
def __repr__(self):
return "<SystemFont %s>" % self.family
class FontQuery:
"""!
@see https://www.freedesktop.org/software/fontconfig/fontconfig-user.html#AEN21
https://manpages.ubuntu.com/manpages/cosmic/man1/fc-pattern.1.html
"""
def __init__(self, str=""):
self._query = {}
if isinstance(str, FontQuery):
self._query = str._query.copy()
elif str:
chunks = str.split(":")
family = chunks.pop(0)
self._query = dict(
chunk.split("=")
for chunk in chunks
if chunk
)
self.family(family)
def family(self, name):
self._query["family"] = name
return self
def weight(self, weight):
self._query["weight"] = weight
return self
def css_weight(self, weight):
"""!
Weight from CSS weight value.
Weight is different between CSS and fontconfig
This creates some interpolations to ensure known values are translated properly
@see https://www.freedesktop.org/software/fontconfig/fontconfig-user.html#AEN178
https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Common_weight_name_mapping
"""
if weight < 200:
v = max(0, weight - 100) / 100 * 40
elif weight < 500:
v = -weight**3 / 200000 + weight**2 * 11/2000 - weight * 17/10 + 200
elif weight < 700:
v = -weight**2 * 3/1000 + weight * 41/10 - 1200
else:
v = (weight - 700) / 200 * 10 + 200
return self.weight(int(round(v)))
def style(self, *styles):
self._query["style"] = " ".join(styles)
return self
def charset(self, *hex_ranges):
self._query["charset"] = " ".join(hex_ranges)
return self
def char(self, char):
return self.charset("%x" % ord(char))
def custom(self, property, value):
self._query[property] = value
return self
def clone(self):
return FontQuery(self)
def __getitem__(self, key):
return self._query.get(key, "")
def __contains__(self, item):
return item in self._query
def get(self, key, default=None):
return self._query.get(key, default)
def __str__(self):
return self._query.get("family", "") + ":" + ":".join(
"%s=%s" % (p, v)
for p, v in self._query.items()
if p != "family"
)
def __repr__(self):
return "<FontQuery %r>" % str(self)
def weight_to_css(self):
x = int(self["weight"])
if x < 40:
v = x / 40 * 100 + 100
elif x < 100:
v = x**3/300 - x**2 * 11/15 + x*167/3 - 3200/3
elif x < 200:
v = (2050 - 10 * math.sqrt(5) * math.sqrt(1205 - 6 * x)) / 3
else:
v = (x - 200) * 200 / 10 + 700
return int(round(v))
class _SystemFontList:
def __init__(self):
self.fonts = None
def _lazy_load(self):
if self.fonts is None:
self.load()
def load(self):
self.fonts = {}
self.load_fc_list()
def cmd(self, *a):
p = subprocess.Popen(a, stdout=subprocess.PIPE)
out, err = p.communicate()
out = out.decode("utf-8").strip()
return out, p.returncode
def load_fc_list(self):
out, returncode = self.cmd("fc-list", r'--format=%{file}\t%{family[0]}\t%{style[0]}\n')
if returncode == 0:
for line in out.splitlines():
file, family, styles = line.split("\t")
self._get(family).add_file(styles.split(" "), file)
def best(self, query):
"""!
Returns the renderer best matching the name
"""
out, returncode = self.cmd("fc-match", r"--format=%{family}\t%{style}", str(query))
if returncode == 0:
return self._font_from_match(out)
def _font_from_match(self, out):
fam, style = out.split("\t")
fam = fam.split(",")[0]
style = style.split(",")[0].split()
return self[fam][style]
def all(self, query):
"""!
Yields all the renderers matching a query
"""
out, returncode = self.cmd("fc-match", "-s", r"--format=%{family}\t%{style}\n", str(query))
if returncode == 0:
for line in out.splitlines():
try:
yield self._font_from_match(line)
except (fontTools.ttLib.TTLibError, fontTools.t1Lib.T1Error):
pass
def default(self):
"""!
Returns the default fornt renderer
"""
return self.best()
def _get(self, family):
self._lazy_load()
if family in self.fonts:
return self.fonts[family]
font = SystemFont(family)
self.fonts[family] = font
return font
def __getitem__(self, key):
self._lazy_load()
return self.fonts[key]
def __iter__(self):
self._lazy_load()
return iter(self.fonts.values())
def keys(self):
self._lazy_load()
return self.fonts.keys()
def __contains__(self, item):
self._lazy_load()
return item in self.fonts
## Dictionary of system fonts
fonts = _SystemFontList()
def collect_kerning_pairs(font):
if "GPOS" not in font:
return {}
gpos_table = font["GPOS"].table
unique_kern_lookups = set()
for item in gpos_table.FeatureList.FeatureRecord:
if item.FeatureTag == "kern":
feature = item.Feature
unique_kern_lookups |= set(feature.LookupListIndex)
kerning_pairs = {}
for kern_lookup_index in sorted(unique_kern_lookups):
lookup = gpos_table.LookupList.Lookup[kern_lookup_index]
if lookup.LookupType in {2, 9}:
for pairPos in lookup.SubTable:
if pairPos.LookupType == 9: # extension table
if pairPos.ExtensionLookupType == 8: # contextual
continue
elif pairPos.ExtensionLookupType == 2:
pairPos = pairPos.ExtSubTable
if pairPos.Format != 1:
continue
firstGlyphsList = pairPos.Coverage.glyphs
for ps_index, _ in enumerate(pairPos.PairSet):
for pairValueRecordItem in pairPos.PairSet[ps_index].PairValueRecord:
secondGlyph = pairValueRecordItem.SecondGlyph
valueFormat = pairPos.ValueFormat1
if valueFormat == 5: # RTL kerning
kernValue = "<%d 0 %d 0>" % (
pairValueRecordItem.Value1.XPlacement,
pairValueRecordItem.Value1.XAdvance)
elif valueFormat == 0: # RTL pair with value <0 0 0 0>
kernValue = "<0 0 0 0>"
elif valueFormat == 4: # LTR kerning
kernValue = pairValueRecordItem.Value1.XAdvance
else:
print(
"\tValueFormat1 = %d" % valueFormat,
file=sys.stdout)
continue # skip the rest
kerning_pairs[(firstGlyphsList[ps_index], secondGlyph)] = kernValue
return kerning_pairs
class GlyphMetrics:
def __init__(self, glyph, lsb, aw, xmin, xmax):
self.glyph = glyph
self.lsb = lsb
self.advance = aw
self.xmin = xmin
self.xmax = xmax
self.width = xmax - xmin
self.advance = xmax
def draw(self, pen):
return self.glyph.draw(pen)
class Font:
def __init__(self, wrapped):
self.wrapped = wrapped
if isinstance(self.wrapped, fontTools.ttLib.TTFont):
self.cmap = self.wrapped.getBestCmap() or {}
else:
self.cmap = {}
self.glyphset = self.wrapped.getGlyphSet()
@classmethod
def open(cls, filename):
try:
f = fontTools.ttLib.TTFont(filename)
except fontTools.ttLib.TTLibError:
f = fontTools.t1Lib.T1Font(filename)
f.parse()
return cls(f)
def getGlyphSet(self):
return self.wrapped.getGlyphSet()
def getBestCmap(self):
return {}
def glyph_name(self, codepoint):
if isinstance(codepoint, str):
if len(codepoint) != 1:
return ""
codepoint = ord(codepoint)
if codepoint in self.cmap:
return self.cmap[codepoint]
return self.calculated_glyph_name(codepoint)
@staticmethod
def calculated_glyph_name(codepoint):
from fontTools import agl # Adobe Glyph List
if codepoint in agl.UV2AGL:
return agl.UV2AGL[codepoint]
elif codepoint <= 0xFFFF:
return "uni%04X" % codepoint
else:
return "u%X" % codepoint
def scale(self):
if isinstance(self.wrapped, fontTools.ttLib.TTFont):
return 1 / self.wrapped["head"].unitsPerEm
elif isinstance(self.wrapped, fontTools.t1Lib.T1Font):
return self.wrapped["FontMatrix"][0]
def yMax(self):
if isinstance(self.wrapped, fontTools.ttLib.TTFont):
return self.wrapped["head"].yMax
elif isinstance(self.wrapped, fontTools.t1Lib.T1Font):
return self.wrapped["FontBBox"][3]
def glyph(self, glyph_name):
if isinstance(self.wrapped, fontTools.ttLib.TTFont):
glyph = self.glyphset[glyph_name]
xmin = getattr(glyph._glyph, "xMin", glyph.lsb)
xmax = getattr(glyph._glyph, "xMax", glyph.width)
return GlyphMetrics(glyph, glyph.lsb, glyph.width, xmin, xmax)
elif isinstance(self.wrapped, fontTools.t1Lib.T1Font):
glyph = self.glyphset[glyph_name]
bounds_pen = ControlBoundsPen(self.glyphset)
bounds = bounds_pen.bounds
glyph.draw(bounds_pen)
if not hasattr(glyph, "width"):
advance = bounds[2]
else:
advance = glyph.width
return GlyphMetrics(glyph, bounds[0], advance, bounds[0], bounds[2])
def __contains__(self, key):
if isinstance(self.wrapped, fontTools.t1Lib.T1Font):
return key in self.wrapped.font
return key in self.wrapped
def __getitem__(self, key):
return self.wrapped[key]
class FontRenderer:
tab_width = 4
@property
def font(self):
raise NotImplementedError
def get_query(self):
raise NotImplementedError
def kerning(self, c1, c2):
return 0
def text_to_chars(self, text):
return text
def _on_missing(self, char, size, pos, group):
"""!
- Character as string
- Font size
- [in, out] Character position
- Group shape
"""
def glyph_name(self, ch):
return self.font.glyph_name(ch)
def scale(self, size):
return size * self.font.scale()
def line_height(self, size):
return self.font.yMax() * self.scale(size)
def ex(self, size):
return self.font.glyph("x").advance * self.scale(size)
def glyph_beziers(self, glyph, offset=NVector(0, 0)):
pen = BezierPen(self.font.glyphset, offset)
glyph.draw(pen)
return pen.beziers
def glyph_shapes(self, glyph, offset=NVector(0, 0)):
beziers = self.glyph_beziers(glyph, offset)
return [
Path(bez)
for bez in beziers
]
def _on_character(self, ch, size, pos, scale, line, use_kerning, chars, i):
chname = self.glyph_name(ch)
if chname in self.font.glyphset:
glyphdata = self.font.glyph(chname)
#pos.x += glyphdata.lsb * scale
glyph_shapes = self.glyph_shapes(glyphdata, pos / scale)
if glyph_shapes:
if len(glyph_shapes) > 1:
glyph_shape_group = line.add_shape(Group())
glyph_shape = glyph_shape_group
else:
glyph_shape_group = line
glyph_shape = glyph_shapes[0]
for sh in glyph_shapes:
sh.shape.value.scale(scale)
glyph_shape_group.add_shape(sh)
glyph_shape.name = ch
kerning = 0
if use_kerning and i < len(chars) - 1:
nextcname = chars[i+1]
kerning = self.kerning(chname, nextcname)
pos.x += (glyphdata.advance + kerning) * scale
return True
return False
def render(self, text, size, pos=None, use_kerning=True):
"""!
Renders some text
@param text String to render
@param size Font size (in pizels)
@param[in,out] pos Text position
@param use_kerning Whether to honour kerning info from the font file
@returns a Group shape, augmented with some extra attributes:
- line_height Line height
- next_x X position of the next character
"""
scale = self.scale(size)
line_height = self.line_height(size)
group = Group()
group.name = text
if pos is None:
pos = NVector(0, 0)
start_x = pos.x
line = Group()
group.add_shape(line)
#group.transform.scale.value = NVector(100, 100) * scale
chars = self.text_to_chars(text)
for i, ch in enumerate(chars):
if ch == "\n":
line.next_x = pos.x
pos.x = start_x
pos.y += line_height
line = Group()
group.add_shape(line)
continue
elif ch == "\t":
chname = self.glyph_name(ch)
if chname in self.font.glyphset:
width = self.font.glyph(chname).advance
else:
width = self.ex(size)
pos.x += width * scale * self.tab_width
continue
self._on_character(ch, size, pos, scale, line, use_kerning, chars, i)
group.line_height = line_height
group.next_x = line.next_x = pos.x
return group
class RawFontRenderer(FontRenderer):
def __init__(self, filename):
self.filename = filename
self._font = Font.open(filename)
self._kerning = None
@property
def font(self):
return self._font
def kerning(self, c1, c2):
if self._kerning is None:
self._kerning = collect_kerning_pairs(self.font)
return self._kerning.get((c1, c2), 0)
def __repr__(self):
return "<FontRenderer %r>" % self.filename
def get_query(self):
return self.filename
class FallbackFontRenderer(FontRenderer):
def __init__(self, query, max_attempts=10):
self.query = FontQuery(query)
self._best = None
self._bq = None
self._fallback = {}
self.max_attempts = max_attempts
@property
def font(self):
return self.best.font
def get_query(self):
return self.query
def ex(self, size):
best = self.best
if "x" not in self.font.glyphset:
best = fonts.best(self.query.clone().char("x"))
return best.ex(size)
@property
def best(self):
cq = str(self.query)
if self._best is None or self._bq != cq:
self._best = fonts.best(self.query)
self._bq = cq
return self._best
def fallback_renderer(self, char):
if char in self._fallback:
return self._fallback[char]
if len(char) != 1:
return None
codepoint = ord(char)
name = Font.calculated_glyph_name(codepoint)
for i, font in enumerate(fonts.all(self.query.clone().char(char))):
# For some reason fontconfig sometimes returns a font that doesn't
# actually contain the glyph
if name in font.font.glyphset or codepoint in font.cmap:
self._fallback[char] = font
return font
if i > self.max_attempts:
self._fallback[char] = None
return None
def _on_character(self, char, size, pos, scale, group, use_kerning, chars, i):
if self.best._on_character(char, size, pos, scale, group, use_kerning, chars, i):
return True
font = self.fallback_renderer(char)
if not font:
return False
child = font.render(char, size, pos)
if len(child.shapes) == 2:
group.add_shape(child.shapes[0])
else:
group.add_shape(child)
def __repr__(self):
return "<FallbackFontRenderer %s>" % self.query
class EmojiRenderer(FontRenderer):
_split = None
def __init__(self, wrapped, emoji_dir):
if not os.path.isdir(emoji_dir):
raise Exception("Not a valid directory: %s" % emoji_dir)
self.wrapped = wrapped
self.emoji_dir = emoji_dir
self._svgs = {}
@property
def font(self):
return self.wrapped.font
def _get_svg(self, char):
from ..parsers.svg import parse_svg_file
if char in self._svgs:
return self._svgs[char]
basename = "-".join("%x" % ord(cp) for cp in char) + ".svg"
filename = os.path.join(self.emoji_dir, basename)
if not os.path.isfile(filename):
self._svgs[char] = None
return None
svga = parse_svg_file(filename)
svgshape = Group()
svgshape.name = basename
for layer in svga.layers:
if isinstance(layer, ShapeLayer):
for shape in layer.shapes:
svgshape.add_shape(shape)
self._svgs[char] = svgshape
svgshape._bbox = svgshape.bounding_box()
return svgshape
def _on_character(self, char, size, pos, scale, group, use_kerning, chars, i):
svgshape = self._get_svg(char)
if svgshape:
target_height = self.line_height(size)
scale = target_height / svgshape._bbox.height
shape_group = Group()
shape_group = svgshape.clone()
shape_group.transform.scale.value *= scale
offset = NVector(
-svgshape._bbox.x1 + svgshape._bbox.width * 0.075,
-svgshape._bbox.y2 + svgshape._bbox.height * 0.1
)
shape_group.transform.position.value = pos + offset * scale
group.add_shape(shape_group)
pos.x += svgshape._bbox.width * scale
return True
return self.wrapped._on_character(char, size, pos, scale, group, use_kerning, chars, i)
def get_query(self):
return self.wrapped.get_query()
@staticmethod
def _get_splitter():
if EmojiRenderer._split is None:
try:
import grapheme
EmojiRenderer._split = grapheme.graphemes
except ImportError:
sys.stderr.write("Install `grapheme` for better Emoji support\n")
EmojiRenderer._split = lambda x: x
return EmojiRenderer._split
@staticmethod
def emoji_split(string):
return EmojiRenderer._get_splitter()(string)
def text_to_chars(self, string):
return list(self.emoji_split(string))
class FontStyle:
def __init__(self, query, size, justify=TextJustify.Left, position=None, use_kerning=True, emoji_svg=None):
self.emoji_svg = emoji_svg
self._set_query(query)
self.size = size
self.justify = justify
self.position = position.clone() if position else NVector(0, 0)
self.use_kerning = use_kerning
def _set_query(self, query):
if isinstance(query, str) and os.path.isfile(query):
self._renderer = RawFontRenderer(query)
else:
self._renderer = FallbackFontRenderer(query)
if self.emoji_svg:
self._renderer = EmojiRenderer(self._renderer, self.emoji_svg)
@property
def query(self):
return self._renderer.get_query()
@query.setter
def query(self, value):
if str(value) != str(self.query):
self._set_query(value)
@property
def renderer(self):
return self._renderer
def render(self, text, pos=NVector(0, 0)):
group = self._renderer.render(text, self.size, self.position+pos, self.use_kerning)
for subg in group.shapes[:-1]:
width = subg.next_x - self.position.x - pos.x
if self.justify == TextJustify.Center:
subg.transform.position.value.x -= width / 2
elif self.justify == TextJustify.Right:
subg.transform.position.value.x -= width
return group
def clone(self):
return FontStyle(str(self._renderer.query), self.size, self.justify, NVector(*self.position), self.use_kerning)
@property
def ex(self):
return self._renderer.ex(self.size)
@property
def line_height(self):
return self._renderer.line_height(self.size)
def _propfac(a):
return property(lambda s: s._get(a), lambda s, v: s._set(a, v))
class FontShape(CustomObject):
_props = [
LottieProp("query_string", "_query", str),
LottieProp("size", "_size", float),
LottieProp("justify", "_justify", TextJustify),
LottieProp("text", "_text", str),
LottieProp("position", "_position", NVector),
]
wrapped_lottie = Group
def __init__(self, text="", query="", size=64, justify=TextJustify.Left):
CustomObject.__init__(self)
if isinstance(query, FontStyle):
self.style = query
else:
self.style = FontStyle(query, size, justify)
self.text = text
self.hidden = None
def _get(self, a):
return getattr(self.style, a)
def _set(self, a, v):
return setattr(self.style, a, v)
query = _propfac("query")
size = _propfac("size")
justify = _propfac("justify")
position = _propfac("position")
@property
def query_string(self):
return str(self.query)
@query_string.setter
def query_string(self, v):
self.query = v
def _build_wrapped(self):
g = self.style.render(self.text)
self.line_height = g.line_height
return g
def bounding_box(self, time=0):
return self.wrapped.bounding_box(time)
+97
View File
@@ -0,0 +1,97 @@
from..nvector import NVector
# FABRIK
class Chain:
def __init__(self, tail, fixed_tail=True, tolerance=0.5, max_iter=8):
self.joints = [tail.clone()]
self.fixed_tail = fixed_tail
self.lengths = []
self.total_length = 0
self.tolerance = tolerance
self.max_iter = max_iter
def add_joint(self, point):
length = (point - self.joints[-1]).length
self.lengths.append(length)
self.total_length += length
self.joints.append(point.clone())
def add_joints(self, head, n):
delta = head - self.joints[-1]
self.total_length += delta.length
segment = delta / n
seglen = segment.length
for i in range(n):
self.lengths.append(seglen)
self.joints.append(self.joints[-1] + segment)
def backward(self, target):
"""!
target -> -> start
"""
self.joints[-1] = target
for i in range(len(self.joints)-2, -1, -1):
r = self.joints[i+1] - self.joints[i]
l = self.lengths[i] / r.length
self.joints[i] = self.joints[i+1].lerp(self.joints[i], l)
def forward(self, target):
"""!
start -> -> tail
"""
self.joints[0] = target
for i in range(0, len(self.joints)-1):
r = self.joints[i+1] - self.joints[i]
l = self.lengths[i] / r.length
self.joints[i+1] = self.joints[i].lerp(self.joints[i+1], l)
def reach(self, target):
if not self.fixed_tail:
self.backward(target)
return
distance = (target - self.joints[0]).length
if distance >= self.total_length:
for i in range(len(self.joints)-1):
r = target - self.joints[i]
l = self.lengths[i] / r.length
self.joints[i+1] = self.joints[i].lerp(target, l)
return
base = self.joints[0]
distance = (target - self.joints[-1]).length
n_it = 0
while distance > self.tolerance and n_it < self.max_iter:
self.backward(target)
self.forward(base)
distance = (target - self.joints[-1]).length
n_it += 1
class Octopus:
def __init__(self, master):
self.chains = {"master": master}
self.master = master
@property
def base(self):
return self.master.joints[-1]
def add_chain(self, name):
ch = Chain(self.base)
self.chains[name] = ch
return ch
def reach(self, target_map):
centroid = NVector(0, 0)
for chain, target in target_map.items():
self.chains[chain].backward(target)
centroid += self.chains[chain].joints[0]
centroid /= len(target_map)
self.master.reach(centroid)
for chain in target_map.keys():
self.chains[chain].forward(self.base)
+38
View File
@@ -0,0 +1,38 @@
from io import StringIO
from difflib import SequenceMatcher
from ..exporters import prettyprint
def difflines_str(a, b, widtha=None, widthb=None):
lines_a = a.splitlines()
lines_b = b.splitlines()
ia = 0
ib = 0
if widtha is None:
widtha = max(map(len, lines_a))
if widthb is None:
widthb = max(map(len, lines_b))
for ja, jb, size in SequenceMatcher(None, lines_a, lines_b, False).get_matching_blocks():
sideprinter(lines_a[ia:ja], lines_b[ib:jb], widtha, widthb, "\x1b[31m>", "=", "<\x1b[m")
ia = ja+size
ib = jb+size
sideprinter(lines_a[ja:ia], lines_b[jb:ib], widtha, widthb, "\x1b[m ", "|", " \x1b[m")
def sideprinter(left, right, widtha=40, widthb=40, prefix="", infix=" | ", suffix=""):
if len(left) > len(right):
right += [""] * (len(left) - len(right))
else:
left += [""] * (len(right) - len(left))
for l, r in zip(left, right):
print("".join([prefix, l[:widtha].ljust(widtha), infix, r[:widthb].ljust(widthb), suffix]))
def difflines(a, b, widtha=None, widthb=None):
ioa = StringIO()
prettyprint(a, ioa)
iob = StringIO()
prettyprint(b, iob)
difflines_str(ioa.getvalue(), iob.getvalue(), widtha, widthb)
+229
View File
@@ -0,0 +1,229 @@
from .. import objects
class RestructuredLayer:
def __init__(self, lottie):
self.lottie = lottie
self.children_pre = []
self.children_post = []
self.structured = False
self.shapegroup = None
self.matte_target = False
self.matte_source = None
self.matte_id = None
def add(self, child):
c = self.children_pre if self.structured else self.children_post
c.insert(0, child)
class RestructuredShapeGroup:
def __init__(self, lottie):
self.lottie = lottie
self.children = []
self.fill = None
self.stroke = None
self.layer = False
self.paths = None
self.stroke_above = False
def empty(self):
return not self.children
def finalize(self, thresh=6):
for g in self.subgroups:
if g.layer:
self.layer = True
for gg in self.subgroups:
gg.layer = True
return
nchild = len(self.children)
self.layer = nchild > thresh and self.lottie.name
@property
def subgroups(self):
for g in self.children:
if isinstance(g, RestructuredShapeGroup):
yield g
def add(self, child):
self.children.insert(0, child)
class RestructuredModifier:
def __init__(self, lottie, child):
self.child = child
self.lottie = lottie
class RestructuredPathMerger:
def __init__(self):
self.paths = []
def append(self, path):
self.paths.append(path)
class RestructuredAnimation:
def __init__(self):
self.layers = []
self.precomp = {}
class AbstractBuilder:
merge_paths = False
def _on_animation(self, animation):
raise NotImplementedError()
def _on_shapegroup(self, shapegroup, out_parent):
raise NotImplementedError()
def _on_shape(self, shape, shapegroup, out_parent):
raise NotImplementedError()
def _on_merged_path(self, shape, shapegroup, out_parent):
raise NotImplementedError()
def _on_shape_modifier(self, shape, shapegroup, out_parent):
raise NotImplementedError()
def process(self, animation: objects.Animation):
out_parent = self._on_animation(animation)
restructured = self.restructure_animation(animation, self.merge_paths)
for id, layers in restructured.precomp.items():
self._on_precomp(id, out_parent, layers)
for asset in animation.assets or []:
self._on_asset(asset)
for layer_builder in restructured.layers:
self.process_layer(layer_builder, out_parent)
def _on_layer(self, layer_builder, out_parent):
raise NotImplementedError()
def _on_precomp(self, id, out_parent, layers):
raise NotImplementedError()
def _on_asset(self, asset):
pass
def process_layer(self, layer_builder, out_parent):
out_layer = self._on_layer(layer_builder, out_parent)
if out_layer is None:
return
for c in layer_builder.children_pre:
self.process_layer(c, out_layer)
shapegroup = getattr(layer_builder, "shapegroup", None)
if shapegroup:
self.shapegroup_process_children(shapegroup, out_layer)
for c in layer_builder.children_post:
self.process_layer(c, out_layer)
self._on_layer_end(out_layer)
def _on_layer_end(self, out_layer):
pass
def shapegroup_process_child(self, shape, shapegroup, out_parent):
if isinstance(shape, RestructuredShapeGroup):
return self._on_shapegroup(shape, out_parent)
elif isinstance(shape, RestructuredPathMerger):
return self._on_merged_path(shape, shapegroup, out_parent)
elif isinstance(shape, RestructuredModifier):
return self._on_shape_modifier(shape, shapegroup, out_parent)
else:
return self._on_shape(shape, shapegroup, out_parent)
def shapegroup_process_children(self, shapegroup, out_parent):
for shape in shapegroup.children:
self.shapegroup_process_child(shape, shapegroup, out_parent)
def restructure_animation(self, animation, merge_paths):
restr = RestructuredAnimation()
restr.layers = self.restructure_layer_list(animation.layers, merge_paths)
if animation.assets:
for asset in animation.assets:
if isinstance(asset, objects.Precomp):
restr.precomp[asset.id] = self.restructure_layer_list(asset.layers, merge_paths)
return restr
def restructure_layer_list(self, layer_list, merge_paths):
layers = {}
flat_layers = []
prev = None
for layer in layer_list:
laybuilder = RestructuredLayer(layer)
flat_layers.append(laybuilder)
if layer.index is not None:
layers[layer.index] = laybuilder
if isinstance(layer, objects.ShapeLayer):
laybuilder.shapegroup = RestructuredShapeGroup(layer)
laybuilder.layer = True
for shape in layer.shapes:
self.restructure_shapegroup(shape, laybuilder.shapegroup, merge_paths)
laybuilder.shapegroup.finalize()
if layer.matte_mode not in {None, objects.MatteMode.Normal}:
laybuilder.matte_source = prev
if prev:
prev.matte_target = laybuilder
prev = laybuilder
top_layers = []
for layer in flat_layers:
layer.structured = True
if layer.lottie.parent_index is not None:
layers[layer.lottie.parent_index].add(layer)
else:
top_layers.insert(0, layer)
return top_layers
def restructure_shapegroup(self, shape, shape_group, merge_paths):
if isinstance(shape, (objects.Fill, objects.GradientFill)):
if not shape_group.fill:
shape_group.fill = shape
elif isinstance(shape, objects.BaseStroke):
if not shape_group.stroke or shape_group.stroke.width.get_value(0) < shape.width.get_value(0):
shape_group.stroke = shape
if not shape_group.fill:
shape_group.stroke_above = True
elif isinstance(shape, (objects.Path)):
if merge_paths:
if not shape_group.paths:
shape_group.paths = RestructuredPathMerger()
shape_group.add(shape_group.paths)
shape_group.paths.append(shape)
else:
shape_group.add(shape)
elif isinstance(shape, (objects.Group)):
subgroup = RestructuredShapeGroup(shape)
shape_group.add(subgroup)
merge_paths = self.merge_paths and not any(isinstance(p, objects.Group) for p in shape.shapes)
for subshape in shape.shapes:
self.restructure_shapegroup(subshape, subgroup, merge_paths)
subgroup.finalize()
elif isinstance(shape, (objects.Modifier)):
if shape_group.children:
ch = shape_group.children.pop(0)
shape_group.add(RestructuredModifier(shape, ch))
elif isinstance(shape, (objects.ShapeElement)):
shape_group.add(shape)
elif isinstance(shape, (objects.base.CustomObject)):
if self._custom_object_supported(shape):
shape_group.add(shape)
else:
self.restructure_shapegroup(shape.wrapped, shape_group, self.merge_paths)
def _custom_object_supported(self, shape):
return False
+82
View File
@@ -0,0 +1,82 @@
import os
import sys
import argparse
import inspect
from ..exporters import exporters
from .stripper import float_strip
def _get_caller():
return inspect.getmodule(inspect.currentframe().f_back.f_back)
def _get_parser(caller, basename, path, formats, verbosity):
if basename is None:
basename = os.path.splitext(os.path.basename(caller.__file__))[0]
parser = argparse.ArgumentParser(
conflict_handler='resolve'
)
parser.add_argument(
"--name",
"-n",
default=basename,
help="Output basename",
)
parser.add_argument(
"--path",
default=path,
help="Output path",
)
parser.add_argument(
"--formats", "-f",
nargs="+",
choices=list(sum((e.extensions for e in exporters), [])),
default=formats,
help="Formates to render",
metavar="format"
)
parser.add_argument(
"--verbosity",
type=int,
default=int(verbosity)
)
from .. import __version__
parser.add_argument(
"--version", "-v",
action="version",
version="%(prog)s - python-lottie script " + __version__
)
exporters.set_options(parser)
return parser
def get_parser(basename=None, path="/tmp", formats=["html"], verbosity=1):
caller = _get_caller()
return _get_parser(caller, basename, path, formats, verbosity)
def run(animation, ns):
for fmt in ns.formats:
if ns.path == "" and ns.name == "-":
outfile = sys.stdout
else:
absname = os.path.abspath(os.path.join(ns.path, ns.name + "." + fmt))
if ns.verbosity:
sys.stderr.write("file://%s\n" % absname)
outfile = absname
exporter = exporters.get_from_extension(fmt)
exporter.process(animation, outfile, **exporter.argparse_options(ns))
def script_main(animation, basename=None, path="/tmp", formats=["html"], verbosity=1, strip=float_strip):
"""
Sets up a script to output an animation into various formats
"""
caller = _get_caller()
if caller and caller.__name__ == "__main__":
parser = _get_parser(caller, basename, path, formats, verbosity)
strip(animation)
run(animation, parser.parse_args())
+52
View File
@@ -0,0 +1,52 @@
from ..objects.base import LottieObject, ObjectVisitor
from ..objects.bezier import Bezier
from ..objects.helpers import Transform
from ..nvector import NVector
class Strip(ObjectVisitor):
def __init__(self, float_round, remove_attributes={}):
self.float_round = float_round
self.remove_attributes = remove_attributes
def round(self, fl):
return round(fl, self.float_round)
def nvector(self, value):
value.components = list(map(self.round, value.components))
return value
def visit_property(self, object, property, value):
if isinstance(value, Bezier):
for l in ["vertices", "in_tangents", "out_tangents"]:
try:
setattr(value, l, [self.nvector(NVector(p.x, p.y)) for p in getattr(value, l)])
except:
print("An exception occurred")
elif property.lottie in self.remove_attributes:
property.set(object, None)
elif isinstance(value, float):
property.set(object, round(value, 3))
elif isinstance(value, NVector):
self.nvector(value)
class TransformStip(Strip):
def visit(self, object):
if isinstance(object, Transform):
self.transform_unset(object, "anchor_point", NVector(0, 0))
self.transform_unset(object, "position", NVector(0, 0))
#self.transform_unset(object, "scale", NVector(100, 100))
self.transform_unset(object, "rotation", 0)
#self.transform_unset(object, "opacity", 100)
self.transform_unset(object, "skew", 0)
self.transform_unset(object, "skew_axis", 0)
def transform_unset(self, object, prop_name, value):
prop = getattr(object, prop_name)
if not prop.animated and prop.value == value:
setattr(object, prop_name, None)
heavy_strip = TransformStip(3, {"ind", "ix", "nm", "mn"})
float_strip = Strip(3)
+205
View File
@@ -0,0 +1,205 @@
import math
from ..nvector import NVector
def _sign(x):
if x < 0:
return -1
return 1
class TransformMatrix:
scalar = float
def __init__(self):
""" Creates an Identity matrix """
self.to_identity()
def clone(self):
m = TransformMatrix()
m._mat = list(self._mat)
return m
return self
def __getitem__(self, key):
row, col = key
return self._mat[row*4+col]
def __setitem__(self, key, value):
row, col = key
self._mat[row*4+col] = self.scalar(value)
@property
def a(self):
return self[0, 0]
@a.setter
def a(self, v):
self[0, 0] = self.scalar(v)
@property
def b(self):
return self[0, 1]
@b.setter
def b(self, v):
self[0, 1] = self.scalar(v)
@property
def c(self):
return self[1, 0]
@c.setter
def c(self, v):
self[1, 0] = self.scalar(v)
@property
def d(self):
return self[1, 1]
@d.setter
def d(self, v):
self[1, 1] = self.scalar(v)
@property
def tx(self):
return self[3, 0]
@tx.setter
def tx(self, v):
self[3, 0] = self.scalar(v)
@property
def ty(self):
return self[3, 1]
@ty.setter
def ty(self, v):
self[3, 1] = self.scalar(v)
def __str__(self):
return str(self._mat)
def scale(self, x, y=None):
if y is None:
y = x
m = TransformMatrix()
m.a = x
m.d = y
self *= m
return self
def translate(self, x, y=None):
if y is None:
x, y = x
m = TransformMatrix()
m.tx = x
m.ty = y
self *= m
return self
def skew(self, x_rad, y_rad):
m = TransformMatrix()
m.c = math.tan(x_rad)
m.b = math.tan(y_rad)
self *= m
return self
def skew_from_axis(self, skew, axis):
self.rotate(axis)
m = TransformMatrix()
m.c = math.tan(skew)
self *= m
self.rotate(-axis)
return self
def row(self, i):
return NVector(self[i, 0], self[i, 1], self[i, 2], self[i, 3])
def column(self, i):
return NVector(self[0, i], self[1, i], self[2, i], self[3, i])
def to_identity(self):
self._mat = [
1., 0., 0., 0.,
0., 1., 0., 0.,
0., 0., 1., 0.,
0., 0., 0., 1.,
]
def apply(self, vector):
vector3 = NVector(vector.x, vector.y, 0, 1)
return NVector(
self.column(0).dot(vector3),
self.column(1).dot(vector3),
)
@classmethod
def rotation(cls, radians):
m = cls()
m.a = math.cos(radians)
m.b = -math.sin(radians)
m.c = math.sin(radians)
m.d = math.cos(radians)
return m
def __mul__(self, other):
m = TransformMatrix()
for row in range(4):
for col in range(4):
m[row, col] = self.row(row).dot(other.column(col))
return m
def __imul__(self, other):
m = self * other
self._mat = m._mat
return self
def rotate(self, radians):
self *= TransformMatrix.rotation(radians)
return self
def extract_transform(self):
a = self.a
b = self.b
c = self.c
d = self.d
tx = self.tx
ty = self.ty
dest_trans = {
"translation": NVector(tx, ty),
"angle": 0,
"scale": NVector(1, 1),
"skew_axis": 0,
"skew_angle": 0,
}
delta = a * d - b * c
if a != 0 or b != 0:
r = math.hypot(a, b)
dest_trans["angle"] = - _sign(b) * math.acos(a/r)
sx = r
sy = delta / r
dest_trans["skew_axis"] = 0
else:
r = math.hypot(c, d)
dest_trans["angle"] = math.pi / 2 + _sign(d) * math.acos(c / r)
sx = delta / r
sy = r
dest_trans["skew_axis"] = math.pi / 2
dest_trans["scale"] = NVector(sx, sy)
skew = math.atan2((a * c + b * d), r * r)
dest_trans["skew_angle"] = skew
return dest_trans
def to_css_2d(self):
return "matrix(%s, %s, %s, %s, %s, %s)" % (
self.a, self.b, self.c, self.d, self.tx, self.ty
)