diff --git a/Lib/fontTools/svgLib/__init__.py b/Lib/fontTools/svgLib/__init__.py new file mode 100644 index 000000000..b301a3bab --- /dev/null +++ b/Lib/fontTools/svgLib/__init__.py @@ -0,0 +1,6 @@ +from __future__ import print_function, division, absolute_import +from fontTools.misc.py23 import * + +from .path import SVGPath, parse_path + +__all__ = ["SVGPath", "parse_path"] diff --git a/Lib/fontTools/svgLib/path/__init__.py b/Lib/fontTools/svgLib/path/__init__.py new file mode 100644 index 000000000..4f17e7662 --- /dev/null +++ b/Lib/fontTools/svgLib/path/__init__.py @@ -0,0 +1,58 @@ +from __future__ import ( + print_function, division, absolute_import, unicode_literals) +from fontTools.misc.py23 import * + +from fontTools.pens.transformPen import TransformPen +from .parser import parse_path + +try: + from xml.etree import cElementTree as ElementTree # python 2 +except ImportError: # pragma nocover + from xml.etree import ElementTree # python 3 + + +__all__ = [tostr(s) for s in ("SVGPath", "parse_path")] + + +class SVGPath(object): + """ Parse SVG ``path`` elements from a file or string, and draw them + onto a glyph object that supports the FontTools Pen protocol. + + For example, reading from an SVG file and drawing to a Defcon Glyph: + + import defcon + glyph = defcon.Glyph() + pen = glyph.getPen() + svg = SVGPath("path/to/a.svg") + svg.draw(pen) + + Or reading from a string containing SVG data, using the alternative + 'fromstring' (a class method): + + data = ' elements) + and call a 'pen' object's moveTo, lineTo, curveTo, qCurveTo and closePath + methods. + + 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. + # But if you pass in a current_pos variable, the initial moveto + # will be relative to that current_pos. This is useful. + current_pos = complex(*current_pos) + + elements = list(_tokenize_path(pathdef)) + # Reverse for easy use of .pop() + elements.reverse() + + start_pos = None + command = None + last_control = None + + while elements: + + if elements[-1] in COMMANDS: + # New command. + last_command = command # Used by S and T + command = elements.pop() + absolute = command in UPPERCASE + command = command.upper() + else: + # If this element starts with numbers, it is an implicit command + # and we don't change the command. Check that it's allowed: + if command is None: + raise ValueError("Unallowed implicit command in %s, position %s" % ( + pathdef, len(pathdef.split()) - len(elements))) + last_command = command # Used by S and T + + if command == 'M': + # Moveto command. + x = elements.pop() + y = elements.pop() + pos = float(x) + float(y) * 1j + if absolute: + current_pos = pos + else: + current_pos += pos + + # M is not preceded by Z; it's an open subpath + if start_pos is not None: + pen.endPath() + + pen.moveTo((current_pos.real, current_pos.imag)) + + # when M is called, reset start_pos + # This behavior of Z is defined in svg spec: + # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand + start_pos = current_pos + + # Implicit moveto commands are treated as lineto commands. + # So we set command to lineto here, in case there are + # further implicit commands after this moveto. + command = 'L' + + elif command == 'Z': + # Close path + if current_pos != start_pos: + pen.lineTo((start_pos.real, start_pos.imag)) + pen.closePath() + current_pos = start_pos + start_pos = None + command = None # You can't have implicit commands after closing. + + elif command == 'L': + x = elements.pop() + y = elements.pop() + pos = float(x) + float(y) * 1j + if not absolute: + pos += current_pos + pen.lineTo((pos.real, pos.imag)) + current_pos = pos + + elif command == 'H': + x = elements.pop() + pos = float(x) + current_pos.imag * 1j + if not absolute: + pos += current_pos.real + pen.lineTo((pos.real, pos.imag)) + current_pos = pos + + elif command == 'V': + y = elements.pop() + pos = current_pos.real + float(y) * 1j + if not absolute: + pos += current_pos.imag * 1j + pen.lineTo((pos.real, pos.imag)) + current_pos = pos + + elif command == 'C': + control1 = float(elements.pop()) + float(elements.pop()) * 1j + control2 = float(elements.pop()) + float(elements.pop()) * 1j + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + control1 += current_pos + control2 += current_pos + end += current_pos + + pen.curveTo((control1.real, control1.imag), + (control2.real, control2.imag), + (end.real, end.imag)) + current_pos = end + last_control = control2 + + elif command == 'S': + # Smooth curve. First control point is the "reflection" of + # the second control point in the previous path. + + if last_command not in 'CS': + # If there is no previous command or if the previous command + # was not an C, c, S or s, assume the first control point is + # coincident with the current point. + control1 = current_pos + else: + # The first control point is assumed to be the reflection of + # the second control point on the previous command relative + # to the current point. + control1 = current_pos + current_pos - last_control + + control2 = float(elements.pop()) + float(elements.pop()) * 1j + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + control2 += current_pos + end += current_pos + + pen.curveTo((control1.real, control1.imag), + (control2.real, control2.imag), + (end.real, end.imag)) + current_pos = end + last_control = control2 + + elif command == 'Q': + control = float(elements.pop()) + float(elements.pop()) * 1j + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + control += current_pos + end += current_pos + + pen.qCurveTo((control.real, control.imag), (end.real, end.imag)) + current_pos = end + last_control = control + + elif command == 'T': + # Smooth curve. Control point is the "reflection" of + # the second control point in the previous path. + + if last_command not in 'QT': + # If there is no previous command or if the previous command + # was not an Q, q, T or t, assume the first control point is + # coincident with the current point. + control = current_pos + else: + # The control point is assumed to be the reflection of + # the control point on the previous command relative + # to the current point. + control = current_pos + current_pos - last_control + + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + end += current_pos + + pen.qCurveTo((control.real, control.imag), (end.real, end.imag)) + current_pos = end + last_control = control + + elif command == 'A': + raise NotImplementedError('arcs are not supported') + + # no final Z command, it's an open path + if start_pos is not None: + pen.endPath() diff --git a/Tests/svgLib/path/__init__.py b/Tests/svgLib/path/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/Tests/svgLib/path/parser_test.py b/Tests/svgLib/path/parser_test.py new file mode 100644 index 000000000..786587004 --- /dev/null +++ b/Tests/svgLib/path/parser_test.py @@ -0,0 +1,297 @@ +from __future__ import print_function, absolute_import, division + +from fontTools.misc.py23 import * +from fontTools.pens.recordingPen import RecordingPen +from fontTools.svgLib import parse_path + +import pytest + + +@pytest.mark.parametrize( + "pathdef, expected", + [ + + # Examples from the SVG spec + + ( + "M 100 100 L 300 100 L 200 300 z", + [ + ("moveTo", ((100.0, 100.0),)), + ("lineTo", ((300.0, 100.0),)), + ("lineTo", ((200.0, 300.0),)), + ("lineTo", ((100.0, 100.0),)), + ("closePath", ()), + ] + ), + # for Z command behavior when there is multiple subpaths + ( + "M 0 0 L 50 20 M 100 100 L 300 100 L 200 300 z", + [ + ("moveTo", ((0.0, 0.0),)), + ("lineTo", ((50.0, 20.0),)), + ("endPath", ()), + ("moveTo", ((100.0, 100.0),)), + ("lineTo", ((300.0, 100.0),)), + ("lineTo", ((200.0, 300.0),)), + ("lineTo", ((100.0, 100.0),)), + ("closePath", ()), + ] + ), + ( + "M100,200 C100,100 250,100 250,200 S400,300 400,200", + [ + ("moveTo", ((100.0, 200.0),)), + ("curveTo", ((100.0, 100.0), + (250.0, 100.0), + (250.0, 200.0))), + ("curveTo", ((250.0, 300.0), + (400.0, 300.0), + (400.0, 200.0))), + ("endPath", ()), + ] + ), + ( + "M100,200 C100,100 400,100 400,200", + [ + ("moveTo", ((100.0, 200.0),)), + ("curveTo", ((100.0, 100.0), + (400.0, 100.0), + (400.0, 200.0))), + ("endPath", ()), + ] + ), + ( + "M100,500 C25,400 475,400 400,500", + [ + ("moveTo", ((100.0, 500.0),)), + ("curveTo", ((25.0, 400.0), + (475.0, 400.0), + (400.0, 500.0))), + ("endPath", ()), + ] + ), + ( + "M100,800 C175,700 325,700 400,800", + [ + ("moveTo", ((100.0, 800.0),)), + ("curveTo", ((175.0, 700.0), + (325.0, 700.0), + (400.0, 800.0))), + ("endPath", ()), + ] + ), + ( + "M600,200 C675,100 975,100 900,200", + [ + ("moveTo", ((600.0, 200.0),)), + ("curveTo", ((675.0, 100.0), + (975.0, 100.0), + (900.0, 200.0))), + ("endPath", ()), + ] + ), + ( + "M600,500 C600,350 900,650 900,500", + [ + ("moveTo", ((600.0, 500.0),)), + ("curveTo", ((600.0, 350.0), + (900.0, 650.0), + (900.0, 500.0))), + ("endPath", ()), + ] + ), + ( + "M600,800 C625,700 725,700 750,800 S875,900 900,800", + [ + ("moveTo", ((600.0, 800.0),)), + ("curveTo", ((625.0, 700.0), + (725.0, 700.0), + (750.0, 800.0))), + ("curveTo", ((775.0, 900.0), + (875.0, 900.0), + (900.0, 800.0))), + ("endPath", ()), + ] + ), + ( + "M200,300 Q400,50 600,300 T1000,300", + [ + ("moveTo", ((200.0, 300.0),)), + ("qCurveTo", ((400.0, 50.0), + (600.0, 300.0))), + ("qCurveTo", ((800.0, 550.0), + (1000.0, 300.0))), + ("endPath", ()), + ] + ), + # End examples from SVG spec + + # Relative moveto + ( + "M 0 0 L 50 20 m 50 80 L 300 100 L 200 300 z", + [ + ("moveTo", ((0.0, 0.0),)), + ("lineTo", ((50.0, 20.0),)), + ("endPath", ()), + ("moveTo", ((100.0, 100.0),)), + ("lineTo", ((300.0, 100.0),)), + ("lineTo", ((200.0, 300.0),)), + ("lineTo", ((100.0, 100.0),)), + ("closePath", ()), + ] + ), + # Initial smooth and relative curveTo + ( + "M100,200 s 150,-100 150,0", + [ + ("moveTo", ((100.0, 200.0),)), + ("curveTo", ((100.0, 200.0), + (250.0, 100.0), + (250.0, 200.0))), + ("endPath", ()), + ] + ), + # Initial smooth and relative qCurveTo + ( + "M100,200 t 150,0", + [ + ("moveTo", ((100.0, 200.0),)), + ("qCurveTo", ((100.0, 200.0), + (250.0, 200.0))), + ("endPath", ()), + ] + ), + # relative l command + ( + "M 100 100 L 300 100 l -100 200 z", + [ + ("moveTo", ((100.0, 100.0),)), + ("lineTo", ((300.0, 100.0),)), + ("lineTo", ((200.0, 300.0),)), + ("lineTo", ((100.0, 100.0),)), + ("closePath", ()), + ] + ), + # relative q command + ( + "M200,300 q200,-250 400,0", + [ + ("moveTo", ((200.0, 300.0),)), + ("qCurveTo", ((400.0, 50.0), + (600.0, 300.0))), + ("endPath", ()), + ] + ), + # absolute H command + ( + "M 100 100 H 300 L 200 300 z", + [ + ("moveTo", ((100.0, 100.0),)), + ("lineTo", ((300.0, 100.0),)), + ("lineTo", ((200.0, 300.0),)), + ("lineTo", ((100.0, 100.0),)), + ("closePath", ()), + ] + ), + # relative h command + ( + "M 100 100 h 200 L 200 300 z", + [ + ("moveTo", ((100.0, 100.0),)), + ("lineTo", ((300.0, 100.0),)), + ("lineTo", ((200.0, 300.0),)), + ("lineTo", ((100.0, 100.0),)), + ("closePath", ()), + ] + ), + # absolute V command + ( + "M 100 100 V 300 L 200 300 z", + [ + ("moveTo", ((100.0, 100.0),)), + ("lineTo", ((100.0, 300.0),)), + ("lineTo", ((200.0, 300.0),)), + ("lineTo", ((100.0, 100.0),)), + ("closePath", ()), + ] + ), + # relative v command + ( + "M 100 100 v 200 L 200 300 z", + [ + ("moveTo", ((100.0, 100.0),)), + ("lineTo", ((100.0, 300.0),)), + ("lineTo", ((200.0, 300.0),)), + ("lineTo", ((100.0, 100.0),)), + ("closePath", ()), + ] + ), + ] +) +def test_parse_path(pathdef, expected): + pen = RecordingPen() + parse_path(pathdef, pen) + + assert pen.value == expected + + +@pytest.mark.parametrize( + "pathdef1, pathdef2", + [ + # don't need spaces between numbers and commands + ( + "M 100 100 L 200 200", + "M100 100L200 200", + ), + # repeated implicit command + ( + "M 100 200 L 200 100 L -100 -200", + "M 100 200 L 200 100 -100 -200" + ), + # don't need spaces before a minus-sign + ( + "M100,200c10-5,20-10,30-20", + "M 100 200 c 10 -5 20 -10 30 -20" + ), + # closed paths have an implicit lineTo if they don't + # end on the same point as the initial moveTo + ( + "M 100 100 L 300 100 L 200 300 z", + "M 100 100 L 300 100 L 200 300 L 100 100 z" + ) + ] +) +def test_equivalent_paths(pathdef1, pathdef2): + pen1 = RecordingPen() + parse_path(pathdef1, pen1) + + pen2 = RecordingPen() + parse_path(pathdef2, pen2) + + assert pen1.value == pen2.value + + +def test_exponents(): + # It can be e or E, the plus is optional, and a minimum of +/-3.4e38 must be supported. + pen = RecordingPen() + parse_path("M-3.4e38 3.4E+38L-3.4E-38,3.4e-38", pen) + expected = [ + ("moveTo", ((-3.4e+38, 3.4e+38),)), + ("lineTo", ((-3.4e-38, 3.4e-38),)), + ("endPath", ()), + ] + + assert pen.value == expected + + +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") diff --git a/Tests/svgLib/path/path_test.py b/Tests/svgLib/path/path_test.py new file mode 100644 index 000000000..09b944793 --- /dev/null +++ b/Tests/svgLib/path/path_test.py @@ -0,0 +1,80 @@ +from __future__ import print_function, absolute_import, division + +from fontTools.misc.py23 import * +from fontTools.pens.recordingPen import RecordingPen +from fontTools.svgLib import SVGPath + +import os +from tempfile import NamedTemporaryFile + + +SVG_DATA = """\ + + + + + + +""" + +EXPECTED_PEN_COMMANDS = [ + ("moveTo", ((100.0, 100.0),)), + ("lineTo", ((300.0, 100.0),)), + ("lineTo", ((200.0, 300.0),)), + ("lineTo", ((100.0, 100.0),)), + ("closePath", ()), + ("moveTo", ((100.0, 200.0),)), + ("curveTo", ((100.0, 100.0), + (250.0, 100.0), + (250.0, 200.0))), + ("curveTo", ((250.0, 300.0), + (400.0, 300.0), + (400.0, 200.0))), + ("endPath", ()) +] + + +class SVGPathTest(object): + + def test_from_svg_file(self): + pen = RecordingPen() + with NamedTemporaryFile(delete=False) as tmp: + tmp.write(tobytes(SVG_DATA)) + try: + svg = SVGPath(tmp.name) + svg.draw(pen) + finally: + os.remove(tmp.name) + + assert pen.value == EXPECTED_PEN_COMMANDS + + def test_fromstring(self): + pen = RecordingPen() + svg = SVGPath.fromstring(SVG_DATA) + svg.draw(pen) + + assert pen.value == EXPECTED_PEN_COMMANDS + + def test_transform(self): + pen = RecordingPen() + svg = SVGPath.fromstring(SVG_DATA, + transform=(1.0, 0, 0, -1.0, 0, 1000)) + svg.draw(pen) + + assert pen.value == [ + ("moveTo", ((100.0, 900.0),)), + ("lineTo", ((300.0, 900.0),)), + ("lineTo", ((200.0, 700.0),)), + ("lineTo", ((100.0, 900.0),)), + ("closePath", ()), + ("moveTo", ((100.0, 800.0),)), + ("curveTo", ((100.0, 900.0), + (250.0, 900.0), + (250.0, 800.0))), + ("curveTo", ((250.0, 700.0), + (400.0, 700.0), + (400.0, 800.0))), + ("endPath", ()) + ]