Commit 7862b208 authored by Martin Owens's avatar Martin Owens 🤖

Merge \!183 new feature: path interpolation with gradients by Matthias Meschede

parent 17dec7b4
Pipeline #137701548 passed with stages
in 3 minutes and 44 seconds
......@@ -23,6 +23,7 @@ Basic color controls
from .utils import PY3
from .tween import interpcoord
# All the names that get added to the inkex API itself.
__all__ = ('Color', 'ColorError', 'ColorIdError')
......@@ -297,7 +298,7 @@ class Color(list):
return 'rgb', None
if color.startswith('url('):
raise ColorIdError("Gradient other referenced element id.")
raise ColorIdError("Color references other element id, e.g. a gradient")
# Next handle short colors (css: #abc -> #aabbcc)
if color.startswith('#'):
......@@ -409,6 +410,13 @@ class Color(list):
return Color()
return Color(COLOR_SVG.get(str(self), str(self)))
def interpolate(self, other, fraction):
"""Iterpolate two colours by the given fraction"""
return Color(
[interpcoord(c1, c2, fraction)
for (c1, c2) in zip(self.to_floats(), other.to_floats())]
def rgb_to_hsl(red, green, blue):
"""RGB to HSL colour conversion"""
......@@ -10,9 +10,9 @@ from ._svg import SvgDocumentElement
from ._groups import Group, Layer, Anchor, Marker, ClipPath
from ._polygons import PathElement, Polyline, Polygon, Line, Rectangle, Circle, Ellipse
from ._text import FlowRegion, FlowRoot, FlowPara, FlowDiv, FlowSpan, TextElement, \
TextPath, Tspan, SVGfont, FontFace, Glyph, MissingGlyph
TextPath, Tspan, SVGfont, FontFace, Glyph, MissingGlyph
from ._use import Symbol, Use
from ._meta import Defs, StyleElement, Script, Desc, Title, NamedView, Guide, \
Metadata, ForeignObject, Switch, Grid
from ._filters import Filter, Pattern, Gradient, LinearGradient, RadialGradient, PathEffect
Metadata, ForeignObject, Switch, Grid
from ._filters import Filter, Pattern, Gradient, LinearGradient, RadialGradient, PathEffect, Stop
from ._image import Image
......@@ -333,6 +333,12 @@ class BaseElement(etree.ElementBase):
if self.getparent() is not None:
def remove_all(self, *types):
"""Remove all children or child types"""
for child in self:
if not types or isinstance(child, types):
def replace_with(self, elem):
"""Replace this element with the given element"""
......@@ -24,10 +24,13 @@ Element interface for patterns, filters, gradients and path effects.
from lxml import etree
from copy import deepcopy
from ..utils import addNS
from ..transforms import Transform
from ..tween import interpcoord, interp
from ..styles import Style
from ._base import BaseElement
class Filter(BaseElement):
......@@ -92,6 +95,24 @@ class Filter(BaseElement):
tag_name = 'feTurbulence'
class Stop(BaseElement):
tag_name = 'stop'
def offset(self):
return self.get('offset')
def offset(self, number):
self.set('offset', number)
def interpolate(self, other, fraction):
newstop = Stop() =, fraction)
newstop.offset = interpcoord(float(self.offset), float(other.offset), fraction)
return newstop
class Pattern(BaseElement):
"""Pattern element which is used in the def to control repeating fills"""
tag_name = 'pattern'
......@@ -102,14 +123,93 @@ class Gradient(BaseElement):
"""A gradient instruction usually in the defs"""
WRAPPED_ATTRS = BaseElement.WRAPPED_ATTRS + (('gradientTransform', Transform),)
orientation_attributes = ()
def stops(self): # type: () -> List[Stop]
"""Return an ordered list of own or linked stop nodes"""
gradcolor = self.href if isinstance(self.href, LinearGradient) else self
return sorted(gradcolor, key=lambda x: float(x.offset))
def stop_offsets(self): # type: () -> List[float]
"""Return a list of own or linked stop offsets"""
return [child.offset for child in self.stops]
def stop_styles(self): # type: () -> List[Style]
"""Return a list of own or linked offset styles"""
return [ for child in self.stops]
def remove_orientation(self):
"""Remove all orientation attributes from this element"""
for attr in self.orientation_attributes:
def interpolate(self, other, fraction): # type: (LinearGradient, float) -> LinearGradient
"""Interpolate with another gradient."""
if self.tag_name != other.tag_name:
return self
newgrad = self.copy()
# interpolate transforms
newtransform = self.gradientTransform.interpolate(other.gradientTransform, fraction)
newgrad.gradientTransform = newtransform
# interpolate orientation
for attr in self.orientation_attributes:
newattr = interpcoord(float(self.get(attr)), float(other.get(attr)), fraction)
newgrad.set(attr, newattr)
# interpolate stops
if self.href is not None and self.href is other.href:
# both gradients link to the same stops
# gradients might have different stops
newoffsets = sorted(self.stop_offsets + other.stop_offsets[1:-1])
sstops = interp(self.stop_offsets, self.stops, newoffsets)
ostops = interp(other.stop_offsets, other.stops, newoffsets)
newstops = [s1.interpolate(s2, fraction) for s1, s2 in zip(sstops, ostops)]
return newgrad
def stops_and_orientation(self):
"""Return a copy of all the stops in this gradient"""
stops = self.copy()
orientation = self.copy()
return stops, orientation
class LinearGradient(Gradient):
tag_name = 'linearGradient'
orientation_attributes = ('x1', 'y1', 'x2', 'y2')
def apply_transform(self): # type: () -> None
"""Apply transform to orientation points and set it to identity."""
trans = self.pop('gradientTransform')
p1 = (float(self.get('x1')), float(self.get('y1')))
p2 = (float(self.get('x2')), float(self.get('y2')))
p1t = trans.apply_to_point(p1)
p2t = trans.apply_to_point(p2)
self.update(x1=p1t[0], y1=p1t[1], x2=p2t[0], y2=p2t[1])
class RadialGradient(Gradient):
tag_name = 'radialGradient'
orientation_attributes = ('cx', 'cy', 'fx', 'fy', 'r')
def apply_transform(self): # type: () -> None
"""Apply transform to orientation points and set it to identity."""
trans = self.pop('gradientTransform')
p1 = (float(self.get('cx')), float(self.get('cy')))
p2 = (float(self.get('fx')), float(self.get('fy')))
p1t = trans.apply_to_point(p1)
p2t = trans.apply_to_point(p2)
self.update(cx=p1t[0], cy=p1t[1], fx=p2t[0], fy=p2t[1])
class PathEffect(BaseElement):
"""Inkscape LPE element"""
......@@ -25,7 +25,8 @@ import re
from collections import OrderedDict
from .utils import PY3
from .colors import Color
from .colors import Color, ColorIdError
from .tween import interpcoord, interpunit
if PY3:
unicode = str # pylint: disable=redefined-builtin,invalid-name
......@@ -72,7 +73,8 @@ class Classes(list):
class Style(OrderedDict):
"""A list of style directives"""
color_props = ('stroke', 'fill', 'stop-color', 'flood-color', 'lighting-color')
opacity_props = ('stroke-opacity', 'fill-opacity', 'opacity')
opacity_props = ('stroke-opacity', 'fill-opacity', 'opacity', 'stop-opacity')
unit_props = ('stroke-width')
def __init__(self, style=None, callback=None, **kw):
# This callback is set twice because this is 'pre-initial' data (no callback)
......@@ -170,6 +172,38 @@ class Style(OrderedDict):
if value == 'url(#{})'.format(old_id):
self[name] = 'url(#{})'.format(new_id)
def interpolate_prop(self, other, fraction, prop, svg=None):
"""Interpolate specific property."""
a1 = self[prop]
a2 = other.get(prop, None)
if a2 is None:
val = a1
if prop in self.color_props:
if isinstance(a1, Color):
val = a1.interpolate(Color(a2), fraction)
elif a1.startswith('url(') or a2.startswith('url('):
# gradient requires changes to the whole svg
# and needs to be handled externally
val = a1
val = Color(a1).interpolate(Color(a2), fraction)
elif prop in self.opacity_props:
val = interpcoord(float(a1), float(a2), fraction)
elif prop in self.unit_props:
val = interpunit(a1, a2, fraction)
val = a1
return val
def interpolate(self, other, fraction): # type: (Style, float, Optional[str], Optional[str]) -> Style
"""Interpolate all properties."""
style = Style()
for prop, value in self.items():
style[prop] = self.interpolate_prop(other, fraction, prop)
return style
class AttrFallbackStyle(object):
A container for a style and an element that may have competing styles
......@@ -35,9 +35,9 @@ For each of your extensions, you should create a file called
There are two types of tests:
1. Full-process Comparison tests - These are tests which envoke your
extension will various arguments and attempt to compare the
extension with various arguments and attempt to compare the
output to a known good state. These are useful for testing
that your extension would work, if it was used in Inkscape.
that your extension would work if it was used in Inkscape.
Good example of writing comparison tests can be found in the
inkscape core repository, each test which inherits from
......@@ -50,7 +50,7 @@ There are two types of tests:
can find the tests that test the inkex modules themsleves
to be the most instructive.
Your tests will hit a cetain amount of code, this is called it's **coverage**
Your tests will hit a certain amount of code, this is called it's **coverage**
and the higher the coverage, the better your tests are at stretching all
the options and varients your code has.
......@@ -30,6 +30,7 @@ import sys
from decimal import Decimal
from math import cos, radians, sin, sqrt, tan, fabs, atan2, hypot, pi, isnan
from .tween import interpcoord
from .utils import strargs, KeyDict, PY3
......@@ -456,6 +457,16 @@ class Transform(object):
tol = self.absolute_tolerance if not exactly else 0.0
return (fabs(self.a - self.d) <= tol) and (fabs(self.b + self.c) <= tol)
def interpolate(self, other, fraction):
"""Interpolate with another Transform."""
return Transform((
interpcoord(self.a, other.a, fraction),
interpcoord(self.b, other.b, fraction),
interpcoord(self.c, other.c, fraction),
interpcoord(self.d, other.d, fraction),
interpcoord(self.e, other.e, fraction),
interpcoord(self.f, other.f, fraction)))
class BoundingInterval(object): # pylint: disable=too-few-public-methods
"""A pair of numbers that represent the minimum and maximum values."""
......@@ -18,51 +18,54 @@
import math
from .utils import X, Y
def interpcoord(coord_a, coord_b, time):
"""Interpolate single coordinate by the amount of time"""
return coord_a + ((coord_b - coord_a) * time)
def interppoints(point1, point2, time):
"""Interpolate coordinate points by amount of time"""
return [interpcoord(point1[X], point2[X], time), interpcoord(point1[Y], point2[Y], time)]
from bisect import bisect_left
from .utils import X, Y
from .units import convert_unit, parse_unit, render_unit
def tweenstylefloat(prop, start, end, time):
sp = float(start[prop])
ep = float(end[prop])
return str(sp + (time * (ep - sp)))
from typing import Tuple, List, TypeVar, Callable
V = TypeVar('V')
except ImportError:
def tweenstyleunit(svg, prop, start, end, time): # moved here so we can call 'unittouu'
scale = svg.unittouu('1px')
sp = svg.unittouu(start.get(prop, '1px')) / scale
ep = svg.unittouu(end.get(prop, '1px')) / scale
return str(sp + (time * (ep - sp)))
def interpcoord(coord_a, coord_b, time): # type: (float, float, float) -> float
"""Interpolate single coordinate by the amount of time"""
return coord_a + ((coord_b - coord_a) * time)
def tweenstylecolor(prop, start, end, time):
sr, sg, sb = parsecolor(start[prop])
er, eg, eb = parsecolor(end[prop])
return '#%s%s%s' % (tweenhex(time, sr, er), tweenhex(time, sg, eg), tweenhex(time, sb, eb))
def interp(positions, values, newpositions, func=None): # type: (Callable[[V, V, float], V], List[float], List[V], List[float]) -> V
"""Interpolate list with arbitrary interpolation function."""
newvalues = []
positions = list(map(float, positions))
newpositions = list(map(float, newpositions))
for pos in newpositions:
idxl = max(0, bisect_left(positions, pos) - 1)
idxr = min(len(positions)-1, idxl + 1)
fraction = (pos - positions[idxl]) / (positions[idxr] - positions[idxl])
vall = values[idxl]
valr = values[idxr]
if func is not None:
newval = func(vall, valr, fraction)
if isinstance(vall, (float, int)):
newval = interpcoord(vall, valr, fraction)
elif hasattr(vall, 'interpolate'):
newval = vall.interpolate(valr, fraction)
raise Exception('Interpolated objects must be float/int or have an interpolate method if func is not passed as argument')
return newvalues
def tweenhex(time, s, e):
s = float(int(s, 16))
e = float(int(e, 16))
retval = hex(int(math.floor(s + (time * (e - s)))))[2:]
if len(retval) == 1:
retval = '0%s' % retval
return retval
def interppoints(point1, point2, time): # type: (Tuple[float, float], Tuple[float, float], float) -> Tuple[float, float]
"""Interpolate coordinate points by amount of time"""
return (interpcoord(point1[X], point2[X], time), interpcoord(point1[Y], point2[Y], time))
def parsecolor(c):
r, g, b = '0', '0', '0'
if c[:1] == '#':
if len(c) == 4:
r, g, b = c[1:2], c[2:3], c[3:4]
elif len(c) == 7:
r, g, b = c[1:3], c[3:5], c[5:7]
return r, g, b
def interpunit(start, end, fraction): # type: (SvgDocumentElement, str, str, str, float) -> str
"""Interpolate float attributes with unit."""
# moved here so we can call 'unittouu'
sp, unit = parse_unit(start)
ep = convert_unit(end, unit)
return render_unit(interpcoord(sp, ep, fraction), unit)
......@@ -19,11 +19,14 @@
import copy
from collections import namedtuple
from itertools import combinations
import inkex
from inkex.styles import Style
from inkex.utils import pairwise
from inkex.paths import CubicSuperPath
from inkex.tween import tweenstyleunit, tweenstylefloat, tweenstylecolor, interppoints
from inkex.tween import interppoints
from inkex.bezier import csplength, cspbezsplitatlength, cspbezsplit, bezlenapprx
class Interp(inkex.EffectExtension):
......@@ -67,7 +70,9 @@ class Interp(inkex.EffectExtension):
for node in objects:
for (elem1, elem2) in pairwise(objects, start=False):
objectpairs = pairwise(objects, start=False)
for (elem1, elem2) in objectpairs:
start = elem1.path.to_superpath()
end = elem2.path.to_superpath()
sst = copy.deepcopy(
......@@ -75,24 +80,39 @@ class Interp(inkex.EffectExtension):
basestyle = copy.deepcopy(sst)
if 'stroke-width' in basestyle:
basestyle['stroke-width'] = tweenstyleunit(self.svg, 'stroke-width', sst, est, 0)
basestyle['stroke-width'] = sst.interpolate_prop(est, 0, 'stroke-width')
# prepare for experimental style tweening
dostroke = True
dofill = True
styledefaults = inkex.Style(
styledefaults = Style(
{'opacity': 1.0,
'stroke-opacity': 1.0,
'fill-opacity': 1.0,
'stroke-width': 1.0,
'stroke': None,
'fill': None})
for key in styledefaults:
sst.setdefault(key, styledefaults[key])
est.setdefault(key, styledefaults[key])
isnotplain = lambda x: not (x == 'none' or x[:1] == '#')
if isnotplain(sst['stroke']) or isnotplain(est['stroke']) or (sst['stroke'] == 'none' and est['stroke'] == 'none'):
dostroke = False
if isnotplain(sst['fill']) or isnotplain(est['fill']) or (sst['fill'] == 'none' and est['fill'] == 'none'):
dofill = False
if dostroke:
isgradient = lambda x: x.startswith('url(#')
if isgradient(sst['stroke']) and isgradient(est['stroke']):
strokestyle = 'gradient'
elif isnotplain(sst['stroke']) or isnotplain(est['stroke']) or (sst['stroke'] == 'none' and est['stroke'] == 'none'):
strokestyle = 'notplain'
strokestyle = 'color'
if isgradient(sst['fill']) and isgradient(est['fill']):
fillstyle = 'gradient'
elif isnotplain(sst['fill']) or isnotplain(est['fill']) or (sst['fill'] == 'none' and est['fill'] == 'none'):
fillstyle = 'notplain'
fillstyle = 'color'
if strokestyle is 'color':
if sst['stroke'] == 'none':
sst['stroke-width'] = '0.0'
sst['stroke-opacity'] = '0.0'
......@@ -101,7 +121,8 @@ class Interp(inkex.EffectExtension):
est['stroke-width'] = '0.0'
est['stroke-opacity'] = '0.0'
est['stroke'] = sst['stroke']
if dofill:
if fillstyle is 'color':
if sst['fill'] == 'none':
sst['fill-opacity'] = '0.0'
sst['fill'] = est['fill']
......@@ -236,16 +257,22 @@ class Interp(inkex.EffectExtension):
if not interp[-1]:
del interp[-1]
# basic style tweening
# basic style interpolation
basestyle['opacity'] = tweenstylefloat('opacity', sst, est, time)
if dostroke:
basestyle['stroke-opacity'] = tweenstylefloat('stroke-opacity', sst, est, time)
basestyle['stroke-width'] = tweenstyleunit(self.svg, 'stroke-width', sst, est, time)
basestyle['stroke'] = tweenstylecolor('stroke', sst, est, time)
if dofill:
basestyle['fill-opacity'] = tweenstylefloat('fill-opacity', sst, est, time)
basestyle['fill'] = tweenstylecolor('fill', sst, est, time)
basestyle.update(sst.interpolate(est, time))
for prop in ['stroke', 'fill']:
if isgradient(sst[prop]) and isgradient(est[prop]):
gradid1 = sst[prop][4:-1]
gradid2 = est[prop][4:-1]
grad1 = self.svg.getElementById(gradid1)
grad2 = self.svg.getElementById(gradid2)
newgrad = grad1.interpolate(grad2, time)
stops, orientation = newgrad.stops_and_orientation()
basestyle[prop] = 'url(#{})'.format(orientation.get_id())
if len(stops):
self.svg.defs.add(stops, orientation)
orientation.set('xlink:href', '#{}'.format(stops.get_id()))
new = group.add(inkex.PathElement()) = basestyle
......@@ -23,6 +23,7 @@ Convert path to mesh gradient
import inkex
from inkex import BaseElement, Gradient
from inkex.paths import Line, Curve
from inkex.elements import Stop
class MeshGradient(Gradient):
"""Usable MeshGradient XML base class"""
......@@ -64,7 +65,7 @@ class MeshPatch(BaseElement):
if i < len(self):
stop = self[i]
stop = self.add(MeshStop())
stop = self.add(Stop())
# set edge path data
stop.set('path', str(edge))
......@@ -72,11 +73,6 @@ class MeshPatch(BaseElement):['stop-color'] = str(colors[i % 2])
class MeshStop(BaseElement):
"""Each stop color in a gradient"""
tag_name = 'stop'
class PathToMesh(inkex.EffectExtension):
"""Convert path data to mesh geometry."""
def add_arguments(self, pars):
This diff is collapsed.
This diff is collapsed.
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape ( -->
viewBox="0 0 412.29789 582.05017"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
style="stop-color:#008000;stop-opacity:1" />
id="stop879" />
style="stop-color:#0000ff;stop-opacity:1" />
id="stop841" />
id="stop843" />