diff --git a/Lib/fontTools/svgLib/path/parser.py b/Lib/fontTools/svgLib/path/parser.py index 4daefcae2..37675c3bf 100644 --- a/Lib/fontTools/svgLib/path/parser.py +++ b/Lib/fontTools/svgLib/path/parser.py @@ -10,6 +10,7 @@ from __future__ import ( print_function, division, absolute_import, unicode_literals) from fontTools.misc.py23 import * +from math import sqrt, cos, sin, acos, degrees, radians import re COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') @@ -18,6 +19,98 @@ UPPERCASE = set('MZLHVCSQTA') COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])") FLOAT_RE = re.compile("[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") +class Arc(object): + + def __init__(self, start, radius, rotation, arc, sweep, end): + """radius is complex, rotation is in degrees, + arc and sweep are 1 or 0 (True/False also work)""" + + self.start = start + self.radius = radius + self.rotation = rotation + self.arc = bool(arc) + self.sweep = bool(sweep) + self.end = end + + self._parameterize() + + def _parameterize(self): + # Conversion from endpoint to center parameterization + # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes + + cosr = cos(radians(self.rotation)) + sinr = sin(radians(self.rotation)) + dx = (self.start.real - self.end.real) / 2 + dy = (self.start.imag - self.end.imag) / 2 + x1prim = cosr * dx + sinr * dy + x1prim_sq = x1prim * x1prim + y1prim = -sinr * dx + cosr * dy + y1prim_sq = y1prim * y1prim + + rx = self.radius.real + rx_sq = rx * rx + ry = self.radius.imag + ry_sq = ry * ry + + # Correct out of range radii + radius_check = (x1prim_sq / rx_sq) + (y1prim_sq / ry_sq) + if radius_check > 1: + rx *= sqrt(radius_check) + ry *= sqrt(radius_check) + rx_sq = rx * rx + ry_sq = ry * ry + + t1 = rx_sq * y1prim_sq + t2 = ry_sq * x1prim_sq + c = sqrt(abs((rx_sq * ry_sq - t1 - t2) / (t1 + t2))) + + if self.arc == self.sweep: + c = -c + cxprim = c * rx * y1prim / ry + cyprim = -c * ry * x1prim / rx + + self.center = complex((cosr * cxprim - sinr * cyprim) + + ((self.start.real + self.end.real) / 2), + (sinr * cxprim + cosr * cyprim) + + ((self.start.imag + self.end.imag) / 2)) + + ux = (x1prim - cxprim) / rx + uy = (y1prim - cyprim) / ry + vx = (-x1prim - cxprim) / rx + vy = (-y1prim - cyprim) / ry + n = sqrt(ux * ux + uy * uy) + p = ux + theta = degrees(acos(p / n)) + if uy < 0: + theta = -theta + self.theta = theta % 360 + + n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) + p = ux * vx + uy * vy + d = p/n + # In certain cases the above calculation can through inaccuracies + # become just slightly out of range, f ex -1.0000000000000002. + if d > 1.0: + d = 1.0 + elif d < -1.0: + d = -1.0 + delta = degrees(acos(d)) + if (ux * vy - uy * vx) < 0: + delta = -delta + self.delta = delta % 360 + if not self.sweep: + self.delta -= 360 + + def point(self, pos): + angle = radians(self.theta + (self.delta * pos)) + cosr = cos(radians(self.rotation)) + sinr = sin(radians(self.rotation)) + + x = (cosr * cos(angle) * self.radius.real - sinr * sin(angle) * + self.radius.imag + self.center.real) + y = (sinr * cos(angle) * self.radius.real + cosr * sin(angle) * + self.radius.imag + self.center.imag) + return complex(x, y) def _tokenize_path(pathdef): for x in COMMAND_RE.split(pathdef): @@ -34,9 +127,6 @@ 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. """ # In the SVG specs, initial movetos are absolute, even if # specified as 'm'. This is the default behavior here as well. @@ -209,7 +299,29 @@ def parse_path(pathdef, pen, current_pos=(0, 0)): last_control = control elif command == 'A': - raise NotImplementedError('arcs are not supported') + # Arc + radius = float(elements.pop()) + float(elements.pop()) * 1j + rotation = float(elements.pop()) + arc = float(elements.pop()) + sweep = float(elements.pop()) + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + end += current_pos + + if end == current_pos: + # Guard against a situation where arc start and end being same. + # That results division by zero issues in Arc parameterization. + end += 0.00009 + svg_arc = Arc(current_pos, radius, rotation, arc, sweep, end) + arc_points = [] + for point in [0.2, 0.4, 0.6, 0.8, 1]: + # There are infinite points in an arc, but for our context, + # define the arc using 5 points. + arc_point = svg_arc.point(point) + arc_points.append((arc_point.real, arc_point.imag)) + pen.qCurveTo(*arc_points) + 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..254dd1ac5 100644 --- a/Tests/svgLib/path/parser_test.py +++ b/Tests/svgLib/path/parser_test.py @@ -226,14 +226,74 @@ import pytest ("closePath", ()), ] ), + # absolute A command, arc 1 + ( + "M 100 100 A 150 150 0 1 0 150 -150 z", + [ + ('moveTo', ((100.0, 100.0),)), + ('qCurveTo', ((217.17583, 139.78681), + (324.37829, 77.97418), + (348.64695, -43.36913), + (273.46493, -141.65865), + (150.0, -150.0))), + ('lineTo', ((100.0, 100.0),)), + ('closePath', ()), + ] + ), + # relative A command + ( + "M 100 100 a 150 150 0 1 0 150 -150", + [ + ('moveTo', ((100.0, 100.0),)), + ('qCurveTo', ((161.832212, 221.352549), + (296.3525491, 242.6584774), + (392.6584774, 146.35254915), + (371.3525491, 11.83221215), + (250.0, -50.0))), + ('endPath', ()) + ] + ), + # absolute A command, arc 1, sweap 1, rotation 30 + ( + "M 100 100 A 150 150 30 1 1 150 -150 z", + [ + ('moveTo', ((100.0, 100.0),)), + ('qCurveTo', ((-23.46493, 91.65865), + (-98.6469560, -6.63086811), + (-74.3782932, -127.97418174), + (32.8241612, -189.786813), + (150.0, -150.0))), + ('lineTo', ((100.0, 100.0),)), + ('closePath', ()), + ] + ), + # absolute A command, arc 1, sweap 1, rotation 30, end == start + ( + "M 100 100 A 150 150 30 1 1 100 100 z", + [ + ('moveTo', ((100.0, 100.0),)), + ('qCurveTo', ((-42.6584408, -3.64747653), + (11.832264448, -171.3525544), + (188.16782558, -171.352554), + (242.65853078, -3.647476), + (100.0, 100.0))), + ('lineTo', ((100.0, 100.0),)), + ('closePath', ()), + ] + ), ] ) + def test_parse_path(pathdef, expected): pen = RecordingPen() parse_path(pathdef, pen) - assert pen.value == expected - + assert len(pen.value) == len(expected) + for (instr, coords), (exp_instr, exp_coords) in zip(pen.value, expected): + assert instr == exp_instr + assert len(coords) == len(exp_coords) + for c, e in zip(coords, exp_coords): + assert c == pytest.approx(e) @pytest.mark.parametrize( "pathdef1, pathdef2", @@ -288,10 +348,3 @@ def test_invalid_implicit_command(): with pytest.raises(ValueError) as exc_info: parse_path("M 100 100 L 200 200 Z 100 200", RecordingPen()) 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")