diff --git a/Lib/fontTools/svgLib/path/arc.py b/Lib/fontTools/svgLib/path/arc.py new file mode 100644 index 000000000..38d1ea9c8 --- /dev/null +++ b/Lib/fontTools/svgLib/path/arc.py @@ -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), + ) diff --git a/Lib/fontTools/svgLib/path/parser.py b/Lib/fontTools/svgLib/path/parser.py index 4daefcae2..ae0aba396 100644 --- a/Lib/fontTools/svgLib/path/parser.py +++ b/Lib/fontTools/svgLib/path/parser.py @@ -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 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: diff --git a/Tests/svgLib/path/parser_test.py b/Tests/svgLib/path/parser_test.py index 786587004..eada14e70 100644 --- a/Tests/svgLib/path/parser_test.py +++ b/Tests/svgLib/path/parser_test.py @@ -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