Merge pull request #1464 from anthrotype/svg-path-arc-to-cubic

svgLib: add support for converting elliptical arcs to cubic bezier curves
This commit is contained in:
Cosimo Lupo 2019-01-24 12:25:49 +01:00 committed by GitHub
commit 1bb55b5c7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 261 additions and 9 deletions

View File

@ -0,0 +1,157 @@
"""Convert SVG Path's elliptical arcs to Bezier curves.
The code is mostly adapted from Blink's SVGPathNormalizer::DecomposeArcToCubic
https://github.com/chromium/chromium/blob/93831f2/third_party/
blink/renderer/core/svg/svg_path_parser.cc#L169-L278
"""
from __future__ import print_function, division, absolute_import, unicode_literals
from fontTools.misc.py23 import *
from fontTools.misc.py23 import isfinite
from fontTools.misc.transform import Identity, Scale
from math import atan2, ceil, cos, fabs, pi, radians, sin, sqrt, tan
TWO_PI = 2 * pi
PI_OVER_TWO = 0.5 * pi
def _map_point(matrix, pt):
# apply Transform matrix to a point represented as a complex number
r = matrix.transformPoint((pt.real, pt.imag))
return r[0] + r[1] * 1j
class EllipticalArc(object):
def __init__(self, current_point, rx, ry, rotation, large, sweep, target_point):
self.current_point = current_point
self.rx = rx
self.ry = ry
self.rotation = rotation
self.large = large
self.sweep = sweep
self.target_point = target_point
# SVG arc's rotation angle is expressed in degrees, whereas Transform.rotate
# uses radians
self.angle = radians(rotation)
# these derived attributes are computed by the _parametrize method
self.center_point = self.theta1 = self.theta2 = self.theta_arc = None
def _parametrize(self):
# convert from endopoint to center parametrization:
# https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter
# If rx = 0 or ry = 0 then this arc is treated as a straight line segment (a
# "lineto") joining the endpoints.
# http://www.w3.org/TR/SVG/implnote.html#ArcOutOfRangeParameters
rx = fabs(self.rx)
ry = fabs(self.ry)
if not (rx and ry):
return False
# If the current point and target point for the arc are identical, it should
# be treated as a zero length path. This ensures continuity in animations.
if self.target_point == self.current_point:
return False
mid_point_distance = (self.current_point - self.target_point) * 0.5
point_transform = Identity.rotate(-self.angle)
transformed_mid_point = _map_point(point_transform, mid_point_distance)
square_rx = rx * rx
square_ry = ry * ry
square_x = transformed_mid_point.real * transformed_mid_point.real
square_y = transformed_mid_point.imag * transformed_mid_point.imag
# Check if the radii are big enough to draw the arc, scale radii if not.
# http://www.w3.org/TR/SVG/implnote.html#ArcCorrectionOutOfRangeRadii
radii_scale = square_x / square_rx + square_y / square_ry
if radii_scale > 1:
rx *= sqrt(radii_scale)
ry *= sqrt(radii_scale)
self.rx, self.ry = rx, ry
point_transform = Scale(1 / rx, 1 / ry).rotate(-self.angle)
point1 = _map_point(point_transform, self.current_point)
point2 = _map_point(point_transform, self.target_point)
delta = point2 - point1
d = delta.real * delta.real + delta.imag * delta.imag
scale_factor_squared = max(1 / d - 0.25, 0.0)
scale_factor = sqrt(scale_factor_squared)
if self.sweep == self.large:
scale_factor = -scale_factor
delta *= scale_factor
center_point = (point1 + point2) * 0.5
center_point += complex(-delta.imag, delta.real)
point1 -= center_point
point2 -= center_point
theta1 = atan2(point1.imag, point1.real)
theta2 = atan2(point2.imag, point2.real)
theta_arc = theta2 - theta1
if theta_arc < 0 and self.sweep:
theta_arc += TWO_PI
elif theta_arc > 0 and not self.sweep:
theta_arc -= TWO_PI
self.theta1 = theta1
self.theta2 = theta1 + theta_arc
self.theta_arc = theta_arc
self.center_point = center_point
return True
def _decompose_to_cubic_curves(self):
if self.center_point is None and not self._parametrize():
return
point_transform = Identity.rotate(self.angle).scale(self.rx, self.ry)
# Some results of atan2 on some platform implementations are not exact
# enough. So that we get more cubic curves than expected here. Adding 0.001f
# reduces the count of sgements to the correct count.
num_segments = int(ceil(fabs(self.theta_arc / (PI_OVER_TWO + 0.001))))
for i in range(num_segments):
start_theta = self.theta1 + i * self.theta_arc / num_segments
end_theta = self.theta1 + (i + 1) * self.theta_arc / num_segments
t = (4 / 3) * tan(0.25 * (end_theta - start_theta))
if not isfinite(t):
return
sin_start_theta = sin(start_theta)
cos_start_theta = cos(start_theta)
sin_end_theta = sin(end_theta)
cos_end_theta = cos(end_theta)
point1 = complex(
cos_start_theta - t * sin_start_theta,
sin_start_theta + t * cos_start_theta,
)
point1 += self.center_point
target_point = complex(cos_end_theta, sin_end_theta)
target_point += self.center_point
point2 = target_point
point2 += complex(t * sin_end_theta, -t * cos_end_theta)
point1 = _map_point(point_transform, point1)
point2 = _map_point(point_transform, point2)
target_point = _map_point(point_transform, target_point)
yield point1, point2, target_point
def draw(self, pen):
for point1, point2, target_point in self._decompose_to_cubic_curves():
pen.curveTo(
(point1.real, point1.imag),
(point2.real, point2.imag),
(target_point.real, target_point.imag),
)

View File

@ -10,8 +10,10 @@
from __future__ import (
print_function, division, absolute_import, unicode_literals)
from fontTools.misc.py23 import *
from .arc import EllipticalArc
import re
COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
UPPERCASE = set('MZLHVCSQTA')
@ -27,7 +29,7 @@ def _tokenize_path(pathdef):
yield token
def parse_path(pathdef, pen, current_pos=(0, 0)):
def parse_path(pathdef, pen, current_pos=(0, 0), arc_class=EllipticalArc):
""" Parse SVG path definition (i.e. "d" attribute of <path> elements)
and call a 'pen' object's moveTo, lineTo, curveTo, qCurveTo and closePath
methods.
@ -35,8 +37,13 @@ def parse_path(pathdef, pen, current_pos=(0, 0)):
If 'current_pos' (2-float tuple) is provided, the initial moveTo will
be relative to that instead being absolute.
Arc segments (commands "A" or "a") are not currently supported, and raise
NotImplementedError.
If the pen has an "arcTo" method, it is called with the original values
of the elliptical arc curve commands:
pen.arcTo(rx, ry, rotation, arc_large, arc_sweep, (x, y))
Otherwise, the arcs are approximated by series of cubic Bezier segments
("curveTo"), one every 90 degrees.
"""
# In the SVG specs, initial movetos are absolute, even if
# specified as 'm'. This is the default behavior here as well.
@ -52,6 +59,8 @@ def parse_path(pathdef, pen, current_pos=(0, 0)):
command = None
last_control = None
have_arcTo = hasattr(pen, "arcTo")
while elements:
if elements[-1] in COMMANDS:
@ -209,7 +218,34 @@ def parse_path(pathdef, pen, current_pos=(0, 0)):
last_control = control
elif command == 'A':
raise NotImplementedError('arcs are not supported')
rx = float(elements.pop())
ry = float(elements.pop())
rotation = float(elements.pop())
arc_large = bool(int(elements.pop()))
arc_sweep = bool(int(elements.pop()))
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
end += current_pos
# if the pen supports arcs, pass the values unchanged, otherwise
# approximate the arc with a series of cubic bezier curves
if have_arcTo:
pen.arcTo(
rx,
ry,
rotation,
arc_large,
arc_sweep,
(end.real, end.imag),
)
else:
arc = arc_class(
current_pos, rx, ry, rotation, arc_large, arc_sweep, end
)
arc.draw(pen)
current_pos = end
# no final Z command, it's an open path
if start_pos is not None:

View File

@ -290,8 +290,67 @@ def test_invalid_implicit_command():
assert exc_info.match("Unallowed implicit command")
def test_arc_not_implemented():
pathdef = "M300,200 h-150 a150,150 0 1,0 150,-150 z"
with pytest.raises(NotImplementedError) as exc_info:
parse_path(pathdef, RecordingPen())
assert exc_info.match("arcs are not supported")
def test_arc_to_cubic_bezier():
pen = RecordingPen()
parse_path("M300,200 h-150 a150,150 0 1,0 150,-150 z", pen)
expected = [
('moveTo', ((300.0, 200.0),)),
('lineTo', ((150.0, 200.0),)),
(
'curveTo',
(
(150.0, 282.842),
(217.157, 350.0),
(300.0, 350.0)
)
),
(
'curveTo',
(
(382.842, 350.0),
(450.0, 282.842),
(450.0, 200.0)
)
),
(
'curveTo',
(
(450.0, 117.157),
(382.842, 50.0),
(300.0, 50.0)
)
),
('lineTo', ((300.0, 200.0),)),
('closePath', ())
]
result = list(pen.value)
assert len(result) == len(expected)
for (cmd1, points1), (cmd2, points2) in zip(result, expected):
assert cmd1 == cmd2
assert len(points1) == len(points2)
for pt1, pt2 in zip(points1, points2):
assert pt1 == pytest.approx(pt2, rel=1e-5)
class ArcRecordingPen(RecordingPen):
def arcTo(self, rx, ry, rotation, arc_large, arc_sweep, end_point):
self.value.append(
("arcTo", (rx, ry, rotation, arc_large, arc_sweep, end_point))
)
def test_arc_pen_with_arcTo():
pen = ArcRecordingPen()
parse_path("M300,200 h-150 a150,150 0 1,0 150,-150 z", pen)
expected = [
('moveTo', ((300.0, 200.0),)),
('lineTo', ((150.0, 200.0),)),
('arcTo', (150.0, 150.0, 0.0, True, False, (300.0, 50.0))),
('lineTo', ((300.0, 200.0),)),
('closePath', ())
]
assert pen.value == expected