Skip to content

Commit

Permalink
Added a Close command
Browse files Browse the repository at this point in the history
Added a Close command to simplify the handling of closepath commands and subpaths

Closes #37 and #45
  • Loading branch information
regebro committed Oct 29, 2019
1 parent 96c44ed commit 596de08
Show file tree
Hide file tree
Showing 6 changed files with 87 additions and 104 deletions.
7 changes: 6 additions & 1 deletion CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ Changelog
=========


3.2 (unreleased)
4.0 (unreleased)
----------------

- Moved all the information from setup.py into setup.cfg.

- Added a Close() command which is different from a Line() command in
no way at all, to simplify the handling of closepath commands and subpaths.

- Path()'s no longer have a `closed` attribute.


3.1 (2019-10-25)
----------------
Expand Down
62 changes: 19 additions & 43 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ collection of the path segment objects.

All coordinate values for these classes are given as ``complex`` values,
where the ``.real`` part represents the X coordinate, and the ``.imag`` part
representes the Y coordinate.
representes the Y coordinate::

>>> from svg.path import Path, Line, Arc, CubicBezier, QuadraticBezier
>>> from svg.path import Path, Line, Arc, CubicBezier, QuadraticBezier, Close

All of these objects have a ``.point()`` function which will return the
coordinates of a point on the path, where the point is given as a floating
Expand All @@ -26,7 +26,7 @@ You can calculate the length of a Path or it's segments with the
``.length()`` function. For CubicBezier and Arc segments this is done by
geometric approximation and for this reason **may be very slow**. You can
make it faster by passing in an ``error`` option to the method. If you
don't pass in error, it defaults to ``1e-12``.
don't pass in error, it defaults to ``1e-12``::

>>> CubicBezier(300+100j, 100+100j, 200+200j, 200+300j).length(error=1e-5)
297.2208145656899
Expand All @@ -44,11 +44,11 @@ methods, that check if the segment is a "smooth" segment compared to the
given segment.

There is also a ``parse_path()`` function that will take an SVG path definition
and return a ``Path`` object.
and return a ``Path`` object::

>>> from svg.path import parse_path
>>> parse_path('M 100 100 L 300 100')
Path(Move(to=(100+100j)), Line(start=(100+100j), end=(300+100j)), closed=False)
Path(Move(to=(100+100j)), Line(start=(100+100j), end=(300+100j)))


Classes
Expand All @@ -72,15 +72,15 @@ with a sequence of path segments:
* ``Path(*segments)``

The ``Path`` class is a mutable sequence, so it behaves like a list.
You can add to it and replace path segments etc.
You can add to it and replace path segments etc::

>>> path = Path(Line(100+100j,300+100j), Line(100+100j,300+100j))
>>> path.append(QuadraticBezier(300+100j, 200+200j, 200+300j))
>>> path[0] = Line(200+100j,300+100j)
>>> del path[1]

The path object also has a ``d()`` method that will return the
SVG representation of the Path segments.
SVG representation of the Path segments::

>>> path.d()
'M 200,100 L 300,100 Q 200,200 200,300'
Expand All @@ -89,66 +89,40 @@ SVG representation of the Path segments.
Examples
........

This SVG path example draws a triangle:
This SVG path example draws a triangle::


>>> path1 = parse_path('M 100 100 L 300 100 L 200 300 z')

You can format SVG paths in many different ways, all valid paths should be
accepted:
accepted::

>>> path2 = parse_path('M100,100L300,100L200,300z')

And these paths should be equal:
And these paths should be equal::

>>> path1 == path2
True

You can also build a path from objects:
You can also build a path from objects::

>>> path3 = Path(Line(100+100j,300+100j), Line(300+100j, 200+300j), Line(200+300j, 100+100j))

And it should again be equal to the first path:
And it should again be equal to the first path::

>>> path1 == path2
True

Paths are mutable sequences, you can slice and append:
Paths are mutable sequences, you can slice and append::

>>> path1.append(QuadraticBezier(300+100j, 200+200j, 200+300j))
>>> len(path1[2:]) == 3
True

Paths also have a ``closed`` property, which defines if the path should be
seen as a closed path or not.
Note that there is no protection against you creating paths that are invalid.
You can for example have a Close command that doesn't end at the path start::

>>> path = parse_path('M100,100L300,100L200,300z')
>>> path.closed
True

If you modify the path in such a way that it is no longer closeable, it will
not be closed.

>>> path[0].start = (100+105j)
>>> path[1].start = (100+105j)
>>> path.closed
False

However, a path previously set as closed will automatically close if it it
further modified to that it can be closed.

>>> path[-1].end = (300+100j)
>>> path.closed
True

Trying to set a Path to be closed if the end does not coincide with the start
of any segment will raise an error.

>>> path = parse_path('M100,100L300,100L200,300')
>>> path.closed = True
Traceback (most recent call last):
...
ValueError: End does not coincide with a segment start.
>>> wrong = Path(Line(100+100j,200+100j), Close(200+300j, 0))


Future features
Expand All @@ -159,8 +133,10 @@ Future features

* Mathematical transformations might make sense.

* Verifying that paths are correct, or protection against creating incorrect paths.


Licence
License
-------

This module is under a MIT License.
2 changes: 1 addition & 1 deletion src/svg/path/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
from .path import Path, Move, Line, Arc, CubicBezier, QuadraticBezier
from .path import Path, Move, Line, Arc, CubicBezier, QuadraticBezier, Close
from .parser import parse_path
4 changes: 1 addition & 3 deletions src/svg/path/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ 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
segments.append(path.Close(current_pos, start_pos))
current_pos = start_pos
start_pos = None
command = None # You can't have implicit commands after closing.
Expand Down
84 changes: 36 additions & 48 deletions src/svg/path/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,16 @@ def segment_length(curve, start, end, start_point, end_point, error, min_depth,
return length2


class Line(object):
class Linear(object):
"""A straight line
The base for Line() and Close().
"""

def __init__(self, start, end):
self.start = start
self.end = end

def __repr__(self):
return 'Line(start=%s, end=%s)' % (self.start, self.end)

def __eq__(self, other):
if not isinstance(other, Line):
return NotImplemented
return self.start == other.start and self.end == other.end

def __ne__(self, other):
if not isinstance(other, Line):
return NotImplemented
Expand All @@ -58,6 +54,16 @@ def length(self, error=None, min_depth=None):
return sqrt(distance.real ** 2 + distance.imag ** 2)


class Line(Linear):
def __repr__(self):
return 'Line(start=%s, end=%s)' % (self.start, self.end)

def __eq__(self, other):
if not isinstance(other, Line):
return NotImplemented
return self.start == other.start and self.end == other.end


class CubicBezier(object):
def __init__(self, start, control1, control2, end):
self.start = start
Expand Down Expand Up @@ -321,20 +327,27 @@ def length(self, error=ERROR, min_depth=MIN_DEPTH):
return 0


class Close(Linear):
"""Represents the closepath command"""

def __eq__(self, other):
if not isinstance(other, Close):
return NotImplemented
return self.start == other.start and self.end == other.end

def __repr__(self):
return 'Close(start=%s, end=%s)' % (self.start, self.end)


class Path(MutableSequence):
"""A Path is a sequence of path segments"""

# Put it here, so there is a default if unpickled.
_closed = False

def __init__(self, *segments, **kw):
def __init__(self, *segments):
self._segments = list(segments)
self._length = None
self._lengths = None
# Fractional distance from starting point through the end of each segment.
self._fractions = []
if 'closed' in kw:
self.closed = kw['closed']

def __getitem__(self, index):
return self._segments[index]
Expand All @@ -360,10 +373,11 @@ def __len__(self):
return len(self._segments)

def __repr__(self):
return 'Path(%s, closed=%s)' % (
', '.join(repr(x) for x in self._segments), self.closed)
return 'Path(%s)' % (
', '.join(repr(x) for x in self._segments))

def __eq__(self, other):

if not isinstance(other, Path):
return NotImplemented
if len(self) != len(other):
Expand Down Expand Up @@ -412,43 +426,20 @@ def length(self, error=ERROR, min_depth=MIN_DEPTH):
self._calc_lengths(error, min_depth)
return self._length

def _is_closable(self):
"""Returns true if the end is on the start of a segment"""
end = self[-1].end
for segment in self:
if segment.start == end:
return True
return False

@property
def closed(self):
"""Checks that the path is closed"""
return self._closed and self._is_closable()

@closed.setter
def closed(self, value):
value = bool(value)
if value and not self._is_closable():
raise ValueError("End does not coincide with a segment start.")
self._closed = value

def d(self):
if self.closed:
segments = self[:-1]
else:
segments = self[:]

current_pos = None
parts = []
previous_segment = None
end = self[-1].end

for segment in segments:
for segment in self:
start = segment.start
# If the start of this segment does not coincide with the end of
# the last segment or if this segment is actually the close point
# of a closed path, then we should start a new subpath here.
if isinstance(segment, Move) or (current_pos != start) or (
if isinstance(segment, Close):
parts.append('Z')
elif isinstance(segment, Move) or (current_pos != start) or (
start == end and not isinstance(previous_segment, Move)):
parts.append('M {0:G},{1:G}'.format(start.real, start.imag))

Expand Down Expand Up @@ -478,17 +469,14 @@ def d(self):
segment.control.real, segment.control.imag,
segment.end.real, segment.end.imag)
)

elif isinstance(segment, Arc):
parts.append('A {0:G},{1:G} {2:G} {3:d},{4:d} {5:G},{6:G}'.format(
segment.radius.real, segment.radius.imag, segment.rotation,
int(segment.arc), int(segment.sweep),
segment.end.real, segment.end.imag)
)

current_pos = segment.end
previous_segment = segment

if self.closed:
parts.append('Z')

return ' '.join(parts)
Loading

0 comments on commit 596de08

Please sign in to comment.