From e2bb8f3053e333be2ebfea2e12601e695f097144 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Sep 2017 17:36:55 +0100 Subject: [PATCH 01/13] [svgPath] add copy of parser module from svg.path package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream code comes from here: https://github.com/regebro/svg.path I prefer to 'vendor' the svg path parser instead of adding it as a requirement to fonttools, because this way we can patch it to call a pen object directly instead of returning a list of Path instances. Also I'm only interested in the parser module, the rest of svg.path is not useful here. The license of svg.path is MIT, so it's compatible with fonttools license (BSD 2-clause). I added the original code so it's clear from the subsequent commits what changes are being applied. --- Lib/fontTools/misc/svgPath.py | 194 ++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 Lib/fontTools/misc/svgPath.py diff --git a/Lib/fontTools/misc/svgPath.py b/Lib/fontTools/misc/svgPath.py new file mode 100644 index 000000000..042645b87 --- /dev/null +++ b/Lib/fontTools/misc/svgPath.py @@ -0,0 +1,194 @@ +# SVG Path specification parser. +# Source: +# https://github.com/regebro/svg.path/blob/4f9b6e3/src/svg/path/parser.py +# Copyright (c) 2013-2014 Lennart Regebro +# License: MIT + + +import re +from . import path + +COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') +UPPERCASE = set('MZLHVCSQTA') + +COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])") +FLOAT_RE = re.compile("[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") + + +def _tokenize_path(pathdef): + for x in COMMAND_RE.split(pathdef): + if x in COMMANDS: + yield x + for token in FLOAT_RE.findall(x): + yield token + + +def parse_path(pathdef, current_pos=0j): + # 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. + elements = list(_tokenize_path(pathdef)) + # Reverse for easy use of .pop() + elements.reverse() + + segments = path.Path() + start_pos = None + command = 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 + + # 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: + segments.append(path.Line(current_pos, start_pos)) + segments.closed = True + 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 + segments.append(path.Line(current_pos, pos)) + current_pos = pos + + elif command == 'H': + x = elements.pop() + pos = float(x) + current_pos.imag * 1j + if not absolute: + pos += current_pos.real + segments.append(path.Line(current_pos, pos)) + current_pos = pos + + elif command == 'V': + y = elements.pop() + pos = current_pos.real + float(y) * 1j + if not absolute: + pos += current_pos.imag * 1j + segments.append(path.Line(current_pos, pos)) + 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 + + segments.append(path.CubicBezier(current_pos, control1, control2, end)) + current_pos = end + + 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 - segments[-1].control2 + + 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 + + segments.append(path.CubicBezier(current_pos, control1, control2, end)) + current_pos = end + + 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 + + segments.append(path.QuadraticBezier(current_pos, control, end)) + current_pos = end + + 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 - segments[-1].control + + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + end += current_pos + + segments.append(path.QuadraticBezier(current_pos, control, end)) + current_pos = end + + elif command == 'A': + 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 + + segments.append(path.Arc(current_pos, radius, rotation, arc, sweep, end)) + current_pos = end + + return segments From 1ba2c3fe9cf05ff8f33b2cc65e846732486c09f4 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Sep 2017 17:38:32 +0100 Subject: [PATCH 02/13] [svgPath] modify parse_path so it calls pen object directly --- Lib/fontTools/misc/svgPath.py | 56 +++++++++++++++++------------------ 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/Lib/fontTools/misc/svgPath.py b/Lib/fontTools/misc/svgPath.py index 042645b87..b290da193 100644 --- a/Lib/fontTools/misc/svgPath.py +++ b/Lib/fontTools/misc/svgPath.py @@ -1,12 +1,13 @@ # SVG Path specification parser. -# Source: +# This is an adaptation from 'svg.path' by Lennart Regebro (@regebro), +# modified so that the parser takes a FontTools Pen object instead of +# returning a list of svg.path Path objects. +# The original code can be found at: # https://github.com/regebro/svg.path/blob/4f9b6e3/src/svg/path/parser.py # Copyright (c) 2013-2014 Lennart Regebro # License: MIT - import re -from . import path COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') UPPERCASE = set('MZLHVCSQTA') @@ -23,7 +24,7 @@ def _tokenize_path(pathdef): yield token -def parse_path(pathdef, current_pos=0j): +def parse_path(pathdef, pen, current_pos=0j): # 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 @@ -32,9 +33,9 @@ def parse_path(pathdef, current_pos=0j): # Reverse for easy use of .pop() elements.reverse() - segments = path.Path() start_pos = None command = None + last_control = None while elements: @@ -61,6 +62,7 @@ def parse_path(pathdef, current_pos=0j): current_pos = pos else: current_pos += pos + pen.moveTo((current_pos.real, current_pos.imag)) # when M is called, reset start_pos # This behavior of Z is defined in svg spec: @@ -75,8 +77,8 @@ def parse_path(pathdef, current_pos=0j): elif command == 'Z': # Close path if current_pos != start_pos: - segments.append(path.Line(current_pos, start_pos)) - segments.closed = True + 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. @@ -87,7 +89,7 @@ def parse_path(pathdef, current_pos=0j): pos = float(x) + float(y) * 1j if not absolute: pos += current_pos - segments.append(path.Line(current_pos, pos)) + pen.lineTo((pos.real, pos.imag)) current_pos = pos elif command == 'H': @@ -95,7 +97,7 @@ def parse_path(pathdef, current_pos=0j): pos = float(x) + current_pos.imag * 1j if not absolute: pos += current_pos.real - segments.append(path.Line(current_pos, pos)) + pen.lineTo((pos.real, pos.imag)) current_pos = pos elif command == 'V': @@ -103,7 +105,7 @@ def parse_path(pathdef, current_pos=0j): pos = current_pos.real + float(y) * 1j if not absolute: pos += current_pos.imag * 1j - segments.append(path.Line(current_pos, pos)) + pen.lineTo((pos.real, pos.imag)) current_pos = pos elif command == 'C': @@ -116,8 +118,11 @@ def parse_path(pathdef, current_pos=0j): control2 += current_pos end += current_pos - segments.append(path.CubicBezier(current_pos, control1, control2, end)) + 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 @@ -132,7 +137,7 @@ def parse_path(pathdef, current_pos=0j): # 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 - segments[-1].control2 + control1 = current_pos + current_pos - last_control control2 = float(elements.pop()) + float(elements.pop()) * 1j end = float(elements.pop()) + float(elements.pop()) * 1j @@ -141,8 +146,11 @@ def parse_path(pathdef, current_pos=0j): control2 += current_pos end += current_pos - segments.append(path.CubicBezier(current_pos, control1, control2, end)) + 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 @@ -152,8 +160,9 @@ def parse_path(pathdef, current_pos=0j): control += current_pos end += current_pos - segments.append(path.QuadraticBezier(current_pos, control, end)) + 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 @@ -168,27 +177,16 @@ def parse_path(pathdef, current_pos=0j): # 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 - segments[-1].control + control = current_pos + current_pos - last_control end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: end += current_pos - segments.append(path.QuadraticBezier(current_pos, control, end)) + pen.qCurveTo((control.real, control.imag), (end.real, end.imag)) current_pos = end + last_control = control2 elif command == 'A': - 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 - - segments.append(path.Arc(current_pos, radius, rotation, arc, sweep, end)) - current_pos = end - - return segments + raise NotImplementedError('arcs are not supported') From ed4cfcdf82e0847bef68810e84249f7e082a03c4 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Sep 2017 17:52:39 +0100 Subject: [PATCH 03/13] [svgPath] add docstring to parse_path; make 'current_pos' a tuple --- Lib/fontTools/misc/svgPath.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/svgPath.py b/Lib/fontTools/misc/svgPath.py index b290da193..cf8649da7 100644 --- a/Lib/fontTools/misc/svgPath.py +++ b/Lib/fontTools/misc/svgPath.py @@ -24,11 +24,23 @@ def _tokenize_path(pathdef): yield token -def parse_path(pathdef, pen, current_pos=0j): +def parse_path(pathdef, pen, current_pos=(0, 0)): + """ Parse SVG path definition (i.e. "d" attribute of 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() From e431bc2dc230494030a01e0cf2be592bc3597fca Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Sep 2017 18:06:58 +0100 Subject: [PATCH 04/13] make svgPath into a package; mv svgPath.py svgPath/parser.py --- Lib/fontTools/misc/svgPath/__init__.py | 0 Lib/fontTools/misc/{svgPath.py => svgPath/parser.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Lib/fontTools/misc/svgPath/__init__.py rename Lib/fontTools/misc/{svgPath.py => svgPath/parser.py} (100%) diff --git a/Lib/fontTools/misc/svgPath/__init__.py b/Lib/fontTools/misc/svgPath/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/Lib/fontTools/misc/svgPath.py b/Lib/fontTools/misc/svgPath/parser.py similarity index 100% rename from Lib/fontTools/misc/svgPath.py rename to Lib/fontTools/misc/svgPath/parser.py From cb91e3d742f234f8e531b46314c3ed63b5614138 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Sep 2017 18:17:08 +0100 Subject: [PATCH 05/13] [svgPath] export parse_path function from svgPath.parser submodule --- Lib/fontTools/misc/svgPath/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Lib/fontTools/misc/svgPath/__init__.py b/Lib/fontTools/misc/svgPath/__init__.py index e69de29bb..307579ee1 100644 --- a/Lib/fontTools/misc/svgPath/__init__.py +++ b/Lib/fontTools/misc/svgPath/__init__.py @@ -0,0 +1,7 @@ +from __future__ import ( + print_function, division, absolute_import, unicode_literals) +from fontTools.misc.py23 import * + +from .parser import parse_path + +__all__ = [tostr("parse_path")] From 433c7a4f91f7403b67ed2b8ca7b5fb56e97292ea Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Sep 2017 18:15:49 +0100 Subject: [PATCH 06/13] [svgPath.parser] add standard __future__ and py23 imports --- Lib/fontTools/misc/svgPath/parser.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/fontTools/misc/svgPath/parser.py b/Lib/fontTools/misc/svgPath/parser.py index cf8649da7..8df358b69 100644 --- a/Lib/fontTools/misc/svgPath/parser.py +++ b/Lib/fontTools/misc/svgPath/parser.py @@ -7,6 +7,9 @@ # Copyright (c) 2013-2014 Lennart Regebro # License: MIT +from __future__ import ( + print_function, division, absolute_import, unicode_literals) +from fontTools.misc.py23 import * import re COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') From d093eba098e87c902a210a97863690d611f03209 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 8 Sep 2017 19:26:57 +0100 Subject: [PATCH 07/13] [svgPath] add SVGPath class supporting the Pen protocol --- Lib/fontTools/misc/svgPath/__init__.py | 53 +++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/svgPath/__init__.py b/Lib/fontTools/misc/svgPath/__init__.py index 307579ee1..75ff3885a 100644 --- a/Lib/fontTools/misc/svgPath/__init__.py +++ b/Lib/fontTools/misc/svgPath/__init__.py @@ -2,6 +2,57 @@ 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 -__all__ = [tostr("parse_path")] +try: + from xml.etree import cElementTree as ElementTree # python 2 +except ImportError: + 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 = ' Date: Mon, 11 Sep 2017 17:31:58 +0100 Subject: [PATCH 08/13] [svgPath/parser] handle open paths with endPath() --- Lib/fontTools/misc/svgPath/parser.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Lib/fontTools/misc/svgPath/parser.py b/Lib/fontTools/misc/svgPath/parser.py index 8df358b69..36cf076b7 100644 --- a/Lib/fontTools/misc/svgPath/parser.py +++ b/Lib/fontTools/misc/svgPath/parser.py @@ -77,6 +77,11 @@ def parse_path(pathdef, pen, current_pos=(0, 0)): 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 @@ -205,3 +210,7 @@ def parse_path(pathdef, pen, current_pos=(0, 0)): 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() From cb72f9499c340327359299859e613e2cfd485aea Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 11 Sep 2017 17:34:04 +0100 Subject: [PATCH 09/13] [svgPath/parser] typo --- Lib/fontTools/misc/svgPath/parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/svgPath/parser.py b/Lib/fontTools/misc/svgPath/parser.py index 36cf076b7..4daefcae2 100644 --- a/Lib/fontTools/misc/svgPath/parser.py +++ b/Lib/fontTools/misc/svgPath/parser.py @@ -206,7 +206,7 @@ def parse_path(pathdef, pen, current_pos=(0, 0)): pen.qCurveTo((control.real, control.imag), (end.real, end.imag)) current_pos = end - last_control = control2 + last_control = control elif command == 'A': raise NotImplementedError('arcs are not supported') From ce530a51d3e4e45d5e10f8d915d382e5d6f433cc Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 11 Sep 2017 17:35:46 +0100 Subject: [PATCH 10/13] [svgPath] tag ImportError with '# pragma nocover' It appears that cElementTree is still present as a deprecated alias in python 3 We tell coverage to skip this branch since it is not (currently) being hit --- Lib/fontTools/misc/svgPath/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/svgPath/__init__.py b/Lib/fontTools/misc/svgPath/__init__.py index 75ff3885a..4f17e7662 100644 --- a/Lib/fontTools/misc/svgPath/__init__.py +++ b/Lib/fontTools/misc/svgPath/__init__.py @@ -7,7 +7,7 @@ from .parser import parse_path try: from xml.etree import cElementTree as ElementTree # python 2 -except ImportError: +except ImportError: # pragma nocover from xml.etree import ElementTree # python 3 From 69a75f5f3a5187ccac71f71a62590351d63b1f62 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 11 Sep 2017 17:38:33 +0100 Subject: [PATCH 11/13] [Tests/misc/svgPath] add tests for SVGPath and parse_path --- Tests/misc/svgPath/__init__.py | 0 Tests/misc/svgPath/parser_test.py | 297 +++++++++++++++++++++++++++++ Tests/misc/svgPath/svgPath_test.py | 80 ++++++++ 3 files changed, 377 insertions(+) create mode 100644 Tests/misc/svgPath/__init__.py create mode 100644 Tests/misc/svgPath/parser_test.py create mode 100644 Tests/misc/svgPath/svgPath_test.py diff --git a/Tests/misc/svgPath/__init__.py b/Tests/misc/svgPath/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/Tests/misc/svgPath/parser_test.py b/Tests/misc/svgPath/parser_test.py new file mode 100644 index 000000000..e333c70d0 --- /dev/null +++ b/Tests/misc/svgPath/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.misc.svgPath 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/misc/svgPath/svgPath_test.py b/Tests/misc/svgPath/svgPath_test.py new file mode 100644 index 000000000..29a1bb255 --- /dev/null +++ b/Tests/misc/svgPath/svgPath_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.misc.svgPath 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", ()) + ] From ecf781cfceb4102e402113edfe6fb3432aea6d76 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 12 Sep 2017 08:46:04 -0400 Subject: [PATCH 12/13] move fontTools.misc.svgPath to fontTools.svgLib --- Lib/fontTools/{misc/svgPath => svgLib}/__init__.py | 0 Lib/fontTools/{misc/svgPath => svgLib}/parser.py | 0 Tests/{misc/svgPath => svgLib}/__init__.py | 0 Tests/{misc/svgPath => svgLib}/parser_test.py | 2 +- Tests/{misc/svgPath/svgPath_test.py => svgLib/svgLib_test.py} | 2 +- 5 files changed, 2 insertions(+), 2 deletions(-) rename Lib/fontTools/{misc/svgPath => svgLib}/__init__.py (100%) rename Lib/fontTools/{misc/svgPath => svgLib}/parser.py (100%) rename Tests/{misc/svgPath => svgLib}/__init__.py (100%) rename Tests/{misc/svgPath => svgLib}/parser_test.py (99%) rename Tests/{misc/svgPath/svgPath_test.py => svgLib/svgLib_test.py} (98%) diff --git a/Lib/fontTools/misc/svgPath/__init__.py b/Lib/fontTools/svgLib/__init__.py similarity index 100% rename from Lib/fontTools/misc/svgPath/__init__.py rename to Lib/fontTools/svgLib/__init__.py diff --git a/Lib/fontTools/misc/svgPath/parser.py b/Lib/fontTools/svgLib/parser.py similarity index 100% rename from Lib/fontTools/misc/svgPath/parser.py rename to Lib/fontTools/svgLib/parser.py diff --git a/Tests/misc/svgPath/__init__.py b/Tests/svgLib/__init__.py similarity index 100% rename from Tests/misc/svgPath/__init__.py rename to Tests/svgLib/__init__.py diff --git a/Tests/misc/svgPath/parser_test.py b/Tests/svgLib/parser_test.py similarity index 99% rename from Tests/misc/svgPath/parser_test.py rename to Tests/svgLib/parser_test.py index e333c70d0..786587004 100644 --- a/Tests/misc/svgPath/parser_test.py +++ b/Tests/svgLib/parser_test.py @@ -2,7 +2,7 @@ from __future__ import print_function, absolute_import, division from fontTools.misc.py23 import * from fontTools.pens.recordingPen import RecordingPen -from fontTools.misc.svgPath import parse_path +from fontTools.svgLib import parse_path import pytest diff --git a/Tests/misc/svgPath/svgPath_test.py b/Tests/svgLib/svgLib_test.py similarity index 98% rename from Tests/misc/svgPath/svgPath_test.py rename to Tests/svgLib/svgLib_test.py index 29a1bb255..09b944793 100644 --- a/Tests/misc/svgPath/svgPath_test.py +++ b/Tests/svgLib/svgLib_test.py @@ -2,7 +2,7 @@ from __future__ import print_function, absolute_import, division from fontTools.misc.py23 import * from fontTools.pens.recordingPen import RecordingPen -from fontTools.misc.svgPath import SVGPath +from fontTools.svgLib import SVGPath import os from tempfile import NamedTemporaryFile From de59719db48b6aa0aefecc045a45be5bbc02500b Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 12 Sep 2017 22:21:20 -0400 Subject: [PATCH 13/13] move stuff to fontTools.svgLib.path sub-package in case later on we want to add things to svgLib which don't have to do with paths specifically --- Lib/fontTools/svgLib/__init__.py | 58 +------------------ Lib/fontTools/svgLib/path/__init__.py | 58 +++++++++++++++++++ Lib/fontTools/svgLib/{ => path}/parser.py | 0 Tests/svgLib/{ => path}/__init__.py | 0 Tests/svgLib/{ => path}/parser_test.py | 0 .../{svgLib_test.py => path/path_test.py} | 0 6 files changed, 61 insertions(+), 55 deletions(-) create mode 100644 Lib/fontTools/svgLib/path/__init__.py rename Lib/fontTools/svgLib/{ => path}/parser.py (100%) rename Tests/svgLib/{ => path}/__init__.py (100%) rename Tests/svgLib/{ => path}/parser_test.py (100%) rename Tests/svgLib/{svgLib_test.py => path/path_test.py} (100%) diff --git a/Lib/fontTools/svgLib/__init__.py b/Lib/fontTools/svgLib/__init__.py index 4f17e7662..b301a3bab 100644 --- a/Lib/fontTools/svgLib/__init__.py +++ b/Lib/fontTools/svgLib/__init__.py @@ -1,58 +1,6 @@ -from __future__ import ( - print_function, division, absolute_import, unicode_literals) +from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * -from fontTools.pens.transformPen import TransformPen -from .parser import parse_path +from .path import SVGPath, 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 = '