]
9 | [--pad-abs]
10 | [--pad=]
11 | [--pen-moves]
12 | [--sw=]
13 | [--rnd] [--repeat]
14 | svgsort [] --no-adjust [--no-split | --split-all]
15 | [--pen-moves]
16 | [--sw=]
17 | [--rnd] [--repeat]
18 | svgsort [] --no-sort [--dim=]
19 | [--pad-abs]
20 | [--pad=]
21 | [--pen-moves]
22 | [--sw=]
23 | [--rnd] [--repeat]
24 |
25 | Options:
26 | --no-split do not split paths into continous sub paths.
27 | (by default it will split.)
28 |
29 | --no-adjust do not change paper layout. experimental.
30 | --no-sort do not sort paths.
31 |
32 | --pen-moves draw pen moves in red. to see sort result.
33 |
34 | --dim= paper size. use A4, A3 (default), or eg. 100x200.
35 | in the latter case the unit is in millmeters [default: A3].
36 |
37 | --pad= padding in percentage of shortest side [default: 0.01].
38 | --pad-abs if this flag is used, the padding is an absolute value
39 | in the same units as the initial svg width/height properties.
40 |
41 | --repeat repeat every path, and draw it in the opposite direction.
42 |
43 | --sw= stroke width [default: 1.0].
44 |
45 | --split-all split all paths into primitives. (probably not what you want.)
46 |
47 | --rnd random initial position.
48 |
49 | -h --help show this screen.
50 | --version show version.
51 |
52 | Examples:
53 | svgsort input.svg
54 | svgsort input.svg out.svg
55 | svgsort input.svg out.svg --dim=30x40
56 | svgsort input.svg out.svg --dim=A4
57 | svgsort input.svg out.svg --repeat
58 | """
59 |
60 |
61 | __ALL__ = ['Svgsort']
62 |
63 | import sys
64 | import traceback
65 |
66 | from docopt import docopt
67 |
68 | from .svgsort import Svgsort
69 | from .paper_utils import PAPER
70 | from .paper_utils import make_paper
71 |
72 |
73 |
74 | def main():
75 | args = docopt(__doc__, version='svgsort 3.0.0')
76 | try:
77 | _in = args['']
78 | out = args[''] if args[''] else args['']+'-srt'
79 | adjust = not args['--no-adjust']
80 | penmoves = args['--pen-moves']
81 |
82 | svgs = Svgsort(sw=args['--sw']).load(_in)
83 |
84 | if args['--no-split']:
85 | pass
86 | elif args['--split-all']:
87 | svgs.eager_split()
88 | else:
89 | # default
90 | svgs.split()
91 |
92 | if args['--no-sort']:
93 | # do not sort
94 | pass
95 | else:
96 | svgs.sort(rnd=args['--rnd'])
97 |
98 | if args['--repeat']:
99 | svgs.repeat()
100 |
101 | if penmoves:
102 | svgs.make_pen_move_paths()
103 |
104 | dim = args['--dim'].strip().lower()
105 | paper = PAPER.get(dim, None)
106 | if paper is None:
107 | try:
108 | paper = make_paper(tuple([int(d) for d in args['--dim'].split('x')]))
109 | except Exception:
110 | raise ValueError('wrong dim/paper size')
111 |
112 | if adjust:
113 | svgs.save(out, paper=paper, pad=float(args['--pad']),
114 | padAbs=bool(args['--pad-abs']))
115 | else:
116 | svgs.save_no_adjust(out)
117 |
118 | except Exception:
119 | traceback.print_exc(file=sys.stdout)
120 | exit(1)
121 |
122 |
123 | if __name__ == '__main__':
124 | main()
125 |
126 |
--------------------------------------------------------------------------------
/svgsort/svgsort.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from os import getcwd
4 | from os.path import sep
5 |
6 | from copy import deepcopy
7 |
8 | from numpy.random import random
9 | from numpy import array
10 |
11 | from .svgpathtools import svg2paths
12 | from .svgpathtools import disvg
13 |
14 | from .sort_utils import attempt_reverse
15 | from .sort_utils import flip_reorder
16 | from .sort_utils import get_cont_paths
17 | from .sort_utils import get_length
18 | from .sort_utils import get_sort_order
19 | from .sort_utils import split_all
20 | from .sort_utils import pen_moves
21 |
22 | from .paper_utils import get_bbox
23 | from .paper_utils import get_long_short
24 | from .paper_utils import vbox_paper
25 |
26 |
27 | STROKE_WIDTH = 1.0
28 |
29 |
30 | def get_init_pos(bb, rnd):
31 | xmin, xmax, ymin, ymax = bb
32 | if rnd:
33 | return array([
34 | xmin + random()*(xmax-xmin),
35 | ymin + random()*(ymax-ymin)])
36 | return array([0, 0], 'float')
37 |
38 |
39 | class Svgsort():
40 | def __init__(self, sw=None):
41 | self.sw = sw if sw is not None else STROKE_WIDTH
42 | self.initial_length = -1
43 | self.svg_atr = {}
44 | self.attributes = None
45 | self.pen_move_paths = None
46 | self.bbox = None
47 | self.paths = None
48 |
49 | def _load_report(self, length, pen_length):
50 | print('initial:')
51 | print(' number of paths: {:d}'.format(len(self.paths)))
52 | print(' total path length: {:0.2f}\n pen move ratio: {:0.2f}'\
53 | .format(length, pen_length/length))
54 | print(' bbox', self.bbox)
55 |
56 | def _sort_report(self):
57 | length, pen_length = get_length(self.paths)
58 | print('sort:')
59 | print(' number of paths: {:d}'.format(len(self.paths)))
60 | print(' total path length: {:0.2f}\n pen move ratio: {:0.2f}'.format(
61 | length, pen_length/length))
62 |
63 | df = self.initial_length-length
64 | ratio = df/self.initial_length
65 |
66 | print(' bbox', get_bbox(self.paths))
67 | print(' improvement: {:0.2f}'.format(ratio))
68 |
69 | if ratio < 0.0:
70 | print('WARNING: the result is less efficient than the original.')
71 | elif ratio < 0.05:
72 | print('WARNING: there was very little improvement.')
73 |
74 | def _repeat_report(self):
75 | length, pen_length = get_length(self.paths)
76 | print('adding all primitives in reverse:')
77 | print(' number of paths: {:d}'.format(len(self.paths)))
78 | print(' total path length: {:0.2f}\n pen move ratio: {:0.2f}'\
79 | .format(length, pen_length/length))
80 |
81 | def _save_report(self, paper, pad, padAbs, portrait):
82 | print('centering on paper: {:s}:'.format(paper['name']))
83 | print(' pad: {:0.5f} ({:s})'.format(pad, 'abs' if padAbs else 'rel'))
84 | print(' format: {:s}'.format('portrait' if portrait else 'landscape'))
85 |
86 | def load(self, fn):
87 | paths, _, svg_atr = svg2paths(getcwd() + sep + fn,
88 | return_svg_attributes=True)
89 | self.paths = paths
90 | self.svg_atr = svg_atr
91 |
92 | length, pen_length = get_length(paths)
93 | self.initial_length = length
94 | self.bbox = get_bbox(paths)
95 | self._load_report(length, pen_length)
96 | return self
97 |
98 | def split(self):
99 | print('splitting paths:')
100 | self.paths = list(get_cont_paths(self.paths))
101 | print(' new number of paths: {:d}'.format(len(self.paths)))
102 | return self
103 |
104 | def eager_split(self):
105 | print('splitting into primitives:')
106 | self.paths = list(split_all(list(get_cont_paths(self.paths))))
107 | print(' new number of paths (primitives): {:d}'.format(len(self.paths)))
108 | return self
109 |
110 | def make_pen_move_paths(self):
111 | self.pen_move_paths = list(pen_moves(self.paths))
112 | return self
113 |
114 | def sort(self, rnd=False):
115 | order, flip = get_sort_order(self.paths, get_init_pos(self.bbox, rnd))
116 | self.paths = list(flip_reorder(self.paths, order, flip))
117 | self._sort_report()
118 | return self
119 |
120 | def repeat(self):
121 | self.paths.extend([attempt_reverse(deepcopy(p))
122 | for p in reversed(self.paths)])
123 | self._repeat_report()
124 | return self
125 |
126 | def _path_attr(self):
127 | sw = self.sw
128 | atr = {'stroke': 'black', 'stroke-width': sw, 'fill': 'none'}
129 | move_atr = {'stroke': 'red', 'stroke-width': sw, 'fill': 'none'}
130 |
131 | paths = self.paths
132 | attributes = [atr]*len(self.paths)
133 | if self.pen_move_paths is not None:
134 | paths = paths + self.pen_move_paths
135 | attributes = attributes + [move_atr]*len(self.pen_move_paths)
136 | return paths, attributes
137 |
138 | def save_no_adjust(self, fn):
139 | paths, attributes = self._path_attr()
140 | keys = ['width', 'height', 'viewBox']
141 | disvg(paths=paths,
142 | filename=fn,
143 | attributes=attributes,
144 | svg_attributes=dict({k:self.svg_atr[k] for k in keys
145 | if k in self.svg_atr}))
146 | print('wrote:', fn)
147 | return self
148 |
149 | def save(self, fn, paper, pad=None, padAbs=False):
150 | ls = get_long_short(self.paths, pad, padAbs)
151 | portrait, vb, size = vbox_paper(ls, paper)
152 | self._save_report(paper, pad, padAbs, portrait)
153 |
154 | paths, attributes = self._path_attr()
155 | disvg(paths=paths,
156 | filename=fn,
157 | attributes=attributes,
158 | dimensions=(size['width'], size['height']),
159 | svg_attributes={'viewBox': ' '.join([str(s) for s in vb])})
160 | print('wrote:', fn)
161 | return self
162 |
163 |
--------------------------------------------------------------------------------
/svgsort/svgpathtools/smoothing.py:
--------------------------------------------------------------------------------
1 | """This submodule contains functions related to smoothing paths of Bezier
2 | curves."""
3 |
4 | # External Dependencies
5 | from __future__ import division, absolute_import, print_function
6 |
7 | # Internal Dependencies
8 | from .path import Path, CubicBezier, Line
9 | from .misctools import isclose
10 | from .paths2svg import disvg
11 |
12 |
13 | def is_differentiable(path, tol=1e-8):
14 | for idx in range(len(path)):
15 | u = path[(idx-1) % len(path)].unit_tangent(1)
16 | v = path[idx].unit_tangent(0)
17 | u_dot_v = u.real*v.real + u.imag*v.imag
18 | if abs(u_dot_v - 1) > tol:
19 | return False
20 | return True
21 |
22 |
23 | def kinks(path, tol=1e-8):
24 | """returns indices of segments that start on a non-differentiable joint."""
25 | kink_list = []
26 | for idx in range(len(path)):
27 | if idx == 0 and not path.isclosed():
28 | continue
29 | try:
30 | u = path[(idx - 1) % len(path)].unit_tangent(1)
31 | v = path[idx].unit_tangent(0)
32 | u_dot_v = u.real*v.real + u.imag*v.imag
33 | flag = False
34 | except ValueError:
35 | flag = True
36 |
37 | if flag or abs(u_dot_v - 1) > tol:
38 | kink_list.append(idx)
39 | return kink_list
40 |
41 |
42 | def _report_unfixable_kinks(_path, _kink_list):
43 | mes = ("\n%s kinks have been detected at that cannot be smoothed.\n"
44 | "To ignore these kinks and fix all others, run this function "
45 | "again with the second argument 'ignore_unfixable_kinks=True' "
46 | "The locations of the unfixable kinks are at the beginnings of "
47 | "segments: %s" % (len(_kink_list), _kink_list))
48 | disvg(_path, nodes=[_path[idx].start for idx in _kink_list])
49 | raise Exception(mes)
50 |
51 |
52 | def smoothed_joint(seg0, seg1, maxjointsize=3, tightness=1.99):
53 | """ See Andy's notes on
54 | Smoothing Bezier Paths for an explanation of the method.
55 | Input: two segments seg0, seg1 such that seg0.end==seg1.start, and
56 | jointsize, a positive number
57 |
58 | Output: seg0_trimmed, elbow, seg1_trimmed, where elbow is a cubic bezier
59 | object that smoothly connects seg0_trimmed and seg1_trimmed.
60 |
61 | """
62 | assert seg0.end == seg1.start
63 | assert 0 < maxjointsize
64 | assert 0 < tightness < 2
65 | # sgn = lambda x:x/abs(x)
66 | q = seg0.end
67 |
68 | try: v = seg0.unit_tangent(1)
69 | except: v = seg0.unit_tangent(1 - 1e-4)
70 | try: w = seg1.unit_tangent(0)
71 | except: w = seg1.unit_tangent(1e-4)
72 |
73 | max_a = maxjointsize / 2
74 | a = min(max_a, min(seg1.length(), seg0.length()) / 20)
75 | if isinstance(seg0, Line) and isinstance(seg1, Line):
76 | '''
77 | Note: Letting
78 | c(t) = elbow.point(t), v= the unit tangent of seg0 at 1, w = the
79 | unit tangent vector of seg1 at 0,
80 | Q = seg0.point(1) = seg1.point(0), and a,b>0 some constants.
81 | The elbow will be the unique CubicBezier, c, such that
82 | c(0)= Q-av, c(1)=Q+aw, c'(0) = bv, and c'(1) = bw
83 | where a and b are derived above/below from tightness and
84 | maxjointsize.
85 | '''
86 | # det = v.imag*w.real-v.real*w.imag
87 | # Note:
88 | # If det is negative, the curvature of elbow is negative for all
89 | # real t if and only if b/a > 6
90 | # If det is positive, the curvature of elbow is negative for all
91 | # real t if and only if b/a < 2
92 |
93 | # if det < 0:
94 | # b = (6+tightness)*a
95 | # elif det > 0:
96 | # b = (2-tightness)*a
97 | # else:
98 | # raise Exception("seg0 and seg1 are parallel lines.")
99 | b = (2 - tightness)*a
100 | elbow = CubicBezier(q - a*v, q - (a - b/3)*v, q + (a - b/3)*w, q + a*w)
101 | seg0_trimmed = Line(seg0.start, elbow.start)
102 | seg1_trimmed = Line(elbow.end, seg1.end)
103 | return seg0_trimmed, [elbow], seg1_trimmed
104 | elif isinstance(seg0, Line):
105 | '''
106 | Note: Letting
107 | c(t) = elbow.point(t), v= the unit tangent of seg0 at 1,
108 | w = the unit tangent vector of seg1 at 0,
109 | Q = seg0.point(1) = seg1.point(0), and a,b>0 some constants.
110 | The elbow will be the unique CubicBezier, c, such that
111 | c(0)= Q-av, c(1)=Q, c'(0) = bv, and c'(1) = bw
112 | where a and b are derived above/below from tightness and
113 | maxjointsize.
114 | '''
115 | # det = v.imag*w.real-v.real*w.imag
116 | # Note: If g has the same sign as det, then the curvature of elbow is
117 | # negative for all real t if and only if b/a < 4
118 | b = (4 - tightness)*a
119 | # g = sgn(det)*b
120 | elbow = CubicBezier(q - a*v, q + (b/3 - a)*v, q - b/3*w, q)
121 | seg0_trimmed = Line(seg0.start, elbow.start)
122 | return seg0_trimmed, [elbow], seg1
123 | elif isinstance(seg1, Line):
124 | args = (seg1.reversed(), seg0.reversed(), maxjointsize, tightness)
125 | rseg1_trimmed, relbow, rseg0 = smoothed_joint(*args)
126 | elbow = relbow[0].reversed()
127 | return seg0, [elbow], rseg1_trimmed.reversed()
128 | else:
129 | # find a point on each seg that is about a/2 away from joint. Make
130 | # line between them.
131 | t0 = seg0.ilength(seg0.length() - a/2)
132 | t1 = seg1.ilength(a/2)
133 | seg0_trimmed = seg0.cropped(0, t0)
134 | seg1_trimmed = seg1.cropped(t1, 1)
135 | seg0_line = Line(seg0_trimmed.end, q)
136 | seg1_line = Line(q, seg1_trimmed.start)
137 |
138 | args = (seg0_trimmed, seg0_line, maxjointsize, tightness)
139 | dummy, elbow0, seg0_line_trimmed = smoothed_joint(*args)
140 |
141 | args = (seg1_line, seg1_trimmed, maxjointsize, tightness)
142 | seg1_line_trimmed, elbow1, dummy = smoothed_joint(*args)
143 |
144 | args = (seg0_line_trimmed, seg1_line_trimmed, maxjointsize, tightness)
145 | seg0_line_trimmed, elbowq, seg1_line_trimmed = smoothed_joint(*args)
146 |
147 | elbow = elbow0 + [seg0_line_trimmed] + elbowq + [seg1_line_trimmed] + elbow1
148 | return seg0_trimmed, elbow, seg1_trimmed
149 |
150 |
151 | def smoothed_path(path, maxjointsize=3, tightness=1.99, ignore_unfixable_kinks=False):
152 | """returns a path with no non-differentiable joints."""
153 | if len(path) == 1:
154 | return path
155 |
156 | assert path.iscontinuous()
157 |
158 | sharp_kinks = []
159 | new_path = [path[0]]
160 | for idx in range(len(path)):
161 | if idx == len(path)-1:
162 | if not path.isclosed():
163 | continue
164 | else:
165 | seg1 = new_path[0]
166 | else:
167 | seg1 = path[idx + 1]
168 | seg0 = new_path[-1]
169 |
170 | try:
171 | unit_tangent0 = seg0.unit_tangent(1)
172 | unit_tangent1 = seg1.unit_tangent(0)
173 | flag = False
174 | except ValueError:
175 | flag = True # unit tangent not well-defined
176 |
177 | if not flag and isclose(unit_tangent0, unit_tangent1): # joint is already smooth
178 | if idx != len(path)-1:
179 | new_path.append(seg1)
180 | continue
181 | else:
182 | kink_idx = (idx + 1) % len(path) # kink at start of this seg
183 | if not flag and isclose(-unit_tangent0, unit_tangent1):
184 | # joint is sharp 180 deg (must be fixed manually)
185 | new_path.append(seg1)
186 | sharp_kinks.append(kink_idx)
187 | else: # joint is not smooth, let's smooth it.
188 | args = (seg0, seg1, maxjointsize, tightness)
189 | new_seg0, elbow_segs, new_seg1 = smoothed_joint(*args)
190 | new_path[-1] = new_seg0
191 | new_path += elbow_segs
192 | if idx == len(path) - 1:
193 | new_path[0] = new_seg1
194 | else:
195 | new_path.append(new_seg1)
196 |
197 | # If unfixable kinks were found, let the user know
198 | if sharp_kinks and not ignore_unfixable_kinks:
199 | _report_unfixable_kinks(path, sharp_kinks)
200 |
201 | return Path(*new_path)
202 |
--------------------------------------------------------------------------------
/svgsort/svgpathtools/svg2paths.py:
--------------------------------------------------------------------------------
1 | """This submodule contains tools for creating path objects from SVG files.
2 | The main tool being the svg2paths() function."""
3 |
4 | # External dependencies
5 | from __future__ import division, absolute_import, print_function
6 | from xml.dom.minidom import parse
7 | from os import path as os_path, getcwd
8 | import re
9 |
10 | # Internal dependencies
11 | from .parser import parse_path
12 |
13 |
14 | COORD_PAIR_TMPLT = re.compile(
15 | r'([\+-]?\d*[\.\d]\d*[eE][\+-]?\d+|[\+-]?\d*[\.\d]\d*)' +
16 | r'(?:\s*,\s*|\s+|(?=-))' +
17 | r'([\+-]?\d*[\.\d]\d*[eE][\+-]?\d+|[\+-]?\d*[\.\d]\d*)')
18 |
19 | def path2pathd(path):
20 | return path.get('d', '')
21 |
22 | def ellipse2pathd(ellipse):
23 | """converts the parameters from an ellipse or a circle to a string for a
24 | Path object d-attribute"""
25 |
26 | cx = ellipse.get('cx', 0)
27 | cy = ellipse.get('cy', 0)
28 | rx = ellipse.get('rx', None)
29 | ry = ellipse.get('ry', None)
30 | r = ellipse.get('r', None)
31 |
32 | if r is not None:
33 | rx = ry = float(r)
34 | else:
35 | rx = float(rx)
36 | ry = float(ry)
37 |
38 | cx = float(cx)
39 | cy = float(cy)
40 |
41 | d = ''
42 | d += 'M' + str(cx - rx) + ',' + str(cy)
43 | d += 'a' + str(rx) + ',' + str(ry) + ' 0 1,0 ' + str(2 * rx) + ',0'
44 | d += 'a' + str(rx) + ',' + str(ry) + ' 0 1,0 ' + str(-2 * rx) + ',0'
45 |
46 | return d
47 |
48 |
49 | def polyline2pathd(polyline_d, is_polygon=False):
50 | """converts the string from a polyline points-attribute to a string for a
51 | Path object d-attribute"""
52 | points = COORD_PAIR_TMPLT.findall(polyline_d)
53 | closed = (float(points[0][0]) == float(points[-1][0]) and
54 | float(points[0][1]) == float(points[-1][1]))
55 |
56 | # The `parse_path` call ignores redundant 'z' (closure) commands
57 | # e.g. `parse_path('M0 0L100 100Z') == parse_path('M0 0L100 100L0 0Z')`
58 | # This check ensures that an n-point polygon is converted to an n-Line path.
59 | if is_polygon and closed:
60 | points.append(points[0])
61 |
62 | d = 'M' + 'L'.join('{0} {1}'.format(x,y) for x,y in points)
63 | if is_polygon or closed:
64 | d += 'z'
65 | return d
66 |
67 |
68 | def polygon2pathd(polyline_d):
69 | """converts the string from a polygon points-attribute to a string
70 | for a Path object d-attribute.
71 | Note: For a polygon made from n points, the resulting path will be
72 | composed of n lines (even if some of these lines have length zero).
73 | """
74 | return polyline2pathd(polyline_d, True)
75 |
76 |
77 | def rect2pathd(rect):
78 | """Converts an SVG-rect element to a Path d-string.
79 |
80 | The rectangle will start at the (x,y) coordinate specified by the
81 | rectangle object and proceed counter-clockwise."""
82 | x0, y0 = float(rect.get('x', 0)), float(rect.get('y', 0))
83 | w, h = float(rect.get('width', 0)), float(rect.get('height', 0))
84 | x1, y1 = x0 + w, y0
85 | x2, y2 = x0 + w, y0 + h
86 | x3, y3 = x0, y0 + h
87 |
88 | d = ("M{} {} L {} {} L {} {} L {} {} z"
89 | "".format(x0, y0, x1, y1, x2, y2, x3, y3))
90 | return d
91 |
92 | def line2pathd(l):
93 | return 'M' + l['x1'] + ' ' + l['y1'] + 'L' + l['x2'] + ' ' + l['y2']
94 |
95 | def svg2paths(svg_file_location,
96 | return_svg_attributes=False,
97 | convert_circles_to_paths=True,
98 | convert_ellipses_to_paths=True,
99 | convert_lines_to_paths=True,
100 | convert_polylines_to_paths=True,
101 | convert_polygons_to_paths=True,
102 | convert_rectangles_to_paths=True):
103 | """Converts an SVG into a list of Path objects and attribute dictionaries.
104 |
105 | Converts an SVG file into a list of Path objects and a list of
106 | dictionaries containing their attributes. This currently supports
107 | SVG Path, Line, Polyline, Polygon, Circle, and Ellipse elements.
108 |
109 | Args:
110 | svg_file_location (string): the location of the svg file
111 | return_svg_attributes (bool): Set to True and a dictionary of
112 | svg-attributes will be extracted and returned.
113 | convert_circles_to_paths: Set to False to exclude SVG-Circle
114 | elements (converted to Paths). By default circles are included as
115 | paths of two `Arc` objects.
116 | convert_ellipses_to_paths (bool): Set to False to exclude SVG-Ellipse
117 | elements (converted to Paths). By default ellipses are included as
118 | paths of two `Arc` objects.
119 | convert_lines_to_paths (bool): Set to False to exclude SVG-Line elements
120 | (converted to Paths)
121 | convert_polylines_to_paths (bool): Set to False to exclude SVG-Polyline
122 | elements (converted to Paths)
123 | convert_polygons_to_paths (bool): Set to False to exclude SVG-Polygon
124 | elements (converted to Paths)
125 | convert_rectangles_to_paths (bool): Set to False to exclude SVG-Rect
126 | elements (converted to Paths).
127 |
128 | Returns:
129 | list: The list of Path objects.
130 | list: The list of corresponding path attribute dictionaries.
131 | dict (optional): A dictionary of svg-attributes.
132 | """
133 | if os_path.dirname(svg_file_location) == '':
134 | svg_file_location = os_path.join(getcwd(), svg_file_location)
135 |
136 | doc = parse(svg_file_location)
137 |
138 | def dom2dict(element):
139 | """Converts DOM elements to dictionaries of attributes."""
140 | keys = list(element.attributes.keys())
141 | values = [val.value for val in list(element.attributes.values())]
142 | return dict(list(zip(keys, values)))
143 |
144 | # Use minidom to extract path strings from input SVG
145 | paths = [dom2dict(el) for el in doc.getElementsByTagName('path')]
146 | d_strings = [el['d'] for el in paths]
147 | attribute_dictionary_list = paths
148 |
149 | # Use minidom to extract polyline strings from input SVG, convert to
150 | # path strings, add to list
151 | if convert_polylines_to_paths:
152 | plins = [dom2dict(el) for el in doc.getElementsByTagName('polyline')]
153 | d_strings += [polyline2pathd(pl['points']) for pl in plins]
154 | attribute_dictionary_list += plins
155 |
156 | # Use minidom to extract polygon strings from input SVG, convert to
157 | # path strings, add to list
158 | if convert_polygons_to_paths:
159 | pgons = [dom2dict(el) for el in doc.getElementsByTagName('polygon')]
160 | d_strings += [polygon2pathd(pg['points']) for pg in pgons]
161 | attribute_dictionary_list += pgons
162 |
163 | if convert_lines_to_paths:
164 | lines = [dom2dict(el) for el in doc.getElementsByTagName('line')]
165 | d_strings += [('M' + l['x1'] + ' ' + l['y1'] +
166 | 'L' + l['x2'] + ' ' + l['y2']) for l in lines]
167 | attribute_dictionary_list += lines
168 |
169 | if convert_ellipses_to_paths:
170 | ellipses = [dom2dict(el) for el in doc.getElementsByTagName('ellipse')]
171 | d_strings += [ellipse2pathd(e) for e in ellipses]
172 | attribute_dictionary_list += ellipses
173 |
174 | if convert_circles_to_paths:
175 | circles = [dom2dict(el) for el in doc.getElementsByTagName('circle')]
176 | d_strings += [ellipse2pathd(c) for c in circles]
177 | attribute_dictionary_list += circles
178 |
179 | if convert_rectangles_to_paths:
180 | rectangles = [dom2dict(el) for el in doc.getElementsByTagName('rect')]
181 | d_strings += [rect2pathd(r) for r in rectangles]
182 | attribute_dictionary_list += rectangles
183 |
184 | if return_svg_attributes:
185 | svg_attributes = dom2dict(doc.getElementsByTagName('svg')[0])
186 | doc.unlink()
187 | path_list = [parse_path(d) for d in d_strings]
188 | return path_list, attribute_dictionary_list, svg_attributes
189 | else:
190 | doc.unlink()
191 | path_list = [parse_path(d) for d in d_strings]
192 | return path_list, attribute_dictionary_list
193 |
194 |
--------------------------------------------------------------------------------
/svgsort/svgpathtools/parser.py:
--------------------------------------------------------------------------------
1 | """This submodule contains the path_parse() function used to convert SVG path
2 | element d-strings into svgpathtools Path objects.
3 | Note: This file was taken (nearly) as is from the svg.path module (v 2.0)."""
4 |
5 | # External dependencies
6 | from __future__ import division, absolute_import, print_function
7 | import re
8 | import numpy as np
9 | import warnings
10 |
11 | # Internal dependencies
12 | from .path import Path, Line, QuadraticBezier, CubicBezier, Arc
13 |
14 | # To maintain forward/backward compatibility
15 | try:
16 | str = basestring
17 | except NameError:
18 | pass
19 |
20 | COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
21 | UPPERCASE = set('MZLHVCSQTA')
22 |
23 | COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])")
24 | FLOAT_RE = re.compile("[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?")
25 |
26 |
27 | def _tokenize_path(pathdef):
28 | for x in COMMAND_RE.split(pathdef):
29 | if x in COMMANDS:
30 | yield x
31 | for token in FLOAT_RE.findall(x):
32 | yield token
33 |
34 |
35 | def parse_path(pathdef, current_pos=0j, tree_element=None):
36 | # In the SVG specs, initial movetos are absolute, even if
37 | # specified as 'm'. This is the default behavior here as well.
38 | # But if you pass in a current_pos variable, the initial moveto
39 | # will be relative to that current_pos. This is useful.
40 | elements = list(_tokenize_path(pathdef))
41 | # Reverse for easy use of .pop()
42 | elements.reverse()
43 |
44 | if tree_element is None:
45 | segments = Path()
46 | else:
47 | segments = Path(tree_element=tree_element)
48 |
49 | start_pos = None
50 | command = None
51 |
52 | while elements:
53 |
54 | if elements[-1] in COMMANDS:
55 | # New command.
56 | last_command = command # Used by S and T
57 | command = elements.pop()
58 | absolute = command in UPPERCASE
59 | command = command.upper()
60 | else:
61 | # If this element starts with numbers, it is an implicit command
62 | # and we don't change the command. Check that it's allowed:
63 | if command is None:
64 | raise ValueError("Unallowed implicit command in %s, position %s" % (
65 | pathdef, len(pathdef.split()) - len(elements)))
66 |
67 | if command == 'M':
68 | # Moveto command.
69 | x = elements.pop()
70 | y = elements.pop()
71 | pos = float(x) + float(y) * 1j
72 | if absolute:
73 | current_pos = pos
74 | else:
75 | current_pos += pos
76 |
77 | # when M is called, reset start_pos
78 | # This behavior of Z is defined in svg spec:
79 | # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand
80 | start_pos = current_pos
81 |
82 | # Implicit moveto commands are treated as lineto commands.
83 | # So we set command to lineto here, in case there are
84 | # further implicit commands after this moveto.
85 | command = 'L'
86 |
87 | elif command == 'Z':
88 | # Close path
89 | if not (current_pos == start_pos):
90 | segments.append(Line(current_pos, start_pos))
91 | segments.closed = True
92 | current_pos = start_pos
93 | command = None
94 |
95 | elif command == 'L':
96 | x = elements.pop()
97 | y = elements.pop()
98 | pos = float(x) + float(y) * 1j
99 | if not absolute:
100 | pos += current_pos
101 | segments.append(Line(current_pos, pos))
102 | current_pos = pos
103 |
104 | elif command == 'H':
105 | x = elements.pop()
106 | pos = float(x) + current_pos.imag * 1j
107 | if not absolute:
108 | pos += current_pos.real
109 | segments.append(Line(current_pos, pos))
110 | current_pos = pos
111 |
112 | elif command == 'V':
113 | y = elements.pop()
114 | pos = current_pos.real + float(y) * 1j
115 | if not absolute:
116 | pos += current_pos.imag * 1j
117 | segments.append(Line(current_pos, pos))
118 | current_pos = pos
119 |
120 | elif command == 'C':
121 | control1 = float(elements.pop()) + float(elements.pop()) * 1j
122 | control2 = float(elements.pop()) + float(elements.pop()) * 1j
123 | end = float(elements.pop()) + float(elements.pop()) * 1j
124 |
125 | if not absolute:
126 | control1 += current_pos
127 | control2 += current_pos
128 | end += current_pos
129 |
130 | segments.append(CubicBezier(current_pos, control1, control2, end))
131 | current_pos = end
132 |
133 | elif command == 'S':
134 | # Smooth curve. First control point is the "reflection" of
135 | # the second control point in the previous path.
136 |
137 | if last_command not in 'CS':
138 | # If there is no previous command or if the previous command
139 | # was not an C, c, S or s, assume the first control point is
140 | # coincident with the current point.
141 | control1 = current_pos
142 | else:
143 | # The first control point is assumed to be the reflection of
144 | # the second control point on the previous command relative
145 | # to the current point.
146 | control1 = current_pos + current_pos - segments[-1].control2
147 |
148 | control2 = float(elements.pop()) + float(elements.pop()) * 1j
149 | end = float(elements.pop()) + float(elements.pop()) * 1j
150 |
151 | if not absolute:
152 | control2 += current_pos
153 | end += current_pos
154 |
155 | segments.append(CubicBezier(current_pos, control1, control2, end))
156 | current_pos = end
157 |
158 | elif command == 'Q':
159 | control = float(elements.pop()) + float(elements.pop()) * 1j
160 | end = float(elements.pop()) + float(elements.pop()) * 1j
161 |
162 | if not absolute:
163 | control += current_pos
164 | end += current_pos
165 |
166 | segments.append(QuadraticBezier(current_pos, control, end))
167 | current_pos = end
168 |
169 | elif command == 'T':
170 | # Smooth curve. Control point is the "reflection" of
171 | # the second control point in the previous path.
172 |
173 | if last_command not in 'QT':
174 | # If there is no previous command or if the previous command
175 | # was not an Q, q, T or t, assume the first control point is
176 | # coincident with the current point.
177 | control = current_pos
178 | else:
179 | # The control point is assumed to be the reflection of
180 | # the control point on the previous command relative
181 | # to the current point.
182 | control = current_pos + current_pos - segments[-1].control
183 |
184 | end = float(elements.pop()) + float(elements.pop()) * 1j
185 |
186 | if not absolute:
187 | end += current_pos
188 |
189 | segments.append(QuadraticBezier(current_pos, control, end))
190 | current_pos = end
191 |
192 | elif command == 'A':
193 | radius = float(elements.pop()) + float(elements.pop()) * 1j
194 | rotation = float(elements.pop())
195 | arc = float(elements.pop())
196 | sweep = float(elements.pop())
197 | end = float(elements.pop()) + float(elements.pop()) * 1j
198 |
199 | if not absolute:
200 | end += current_pos
201 |
202 | segments.append(Arc(current_pos, radius, rotation, arc, sweep, end))
203 | current_pos = end
204 |
205 | return segments
206 |
207 |
208 | def _check_num_parsed_values(values, allowed):
209 | if not any(num == len(values) for num in allowed):
210 | if len(allowed) > 1:
211 | warnings.warn('Expected one of the following number of values {0}, but found {1} values instead: {2}'
212 | .format(allowed, len(values), values))
213 | elif allowed[0] != 1:
214 | warnings.warn('Expected {0} values, found {1}: {2}'.format(allowed[0], len(values), values))
215 | else:
216 | warnings.warn('Expected 1 value, found {0}: {1}'.format(len(values), values))
217 | return False
218 | return True
219 |
220 |
221 | def _parse_transform_substr(transform_substr):
222 |
223 | type_str, value_str = transform_substr.split('(')
224 | value_str = value_str.replace(',', ' ')
225 | values = list(map(float, filter(None, value_str.split(' '))))
226 |
227 | transform = np.identity(3)
228 | if 'matrix' in type_str:
229 | if not _check_num_parsed_values(values, [6]):
230 | return transform
231 |
232 | transform[0:2, 0:3] = np.array([values[0:6:2], values[1:6:2]])
233 |
234 | elif 'translate' in transform_substr:
235 | if not _check_num_parsed_values(values, [1, 2]):
236 | return transform
237 |
238 | transform[0, 2] = values[0]
239 | if len(values) > 1:
240 | transform[1, 2] = values[1]
241 |
242 | elif 'scale' in transform_substr:
243 | if not _check_num_parsed_values(values, [1, 2]):
244 | return transform
245 |
246 | x_scale = values[0]
247 | y_scale = values[1] if (len(values) > 1) else x_scale
248 | transform[0, 0] = x_scale
249 | transform[1, 1] = y_scale
250 |
251 | elif 'rotate' in transform_substr:
252 | if not _check_num_parsed_values(values, [1, 3]):
253 | return transform
254 |
255 | angle = values[0] * np.pi / 180.0
256 | if len(values) == 3:
257 | offset = values[1:3]
258 | else:
259 | offset = (0, 0)
260 | tf_offset = np.identity(3)
261 | tf_offset[0:2, 2:3] = np.array([[offset[0]], [offset[1]]])
262 | tf_rotate = np.identity(3)
263 | tf_rotate[0:2, 0:2] = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]])
264 | tf_offset_neg = np.identity(3)
265 | tf_offset_neg[0:2, 2:3] = np.array([[-offset[0]], [-offset[1]]])
266 |
267 | transform = tf_offset.dot(tf_rotate).dot(tf_offset_neg)
268 |
269 | elif 'skewX' in transform_substr:
270 | if not _check_num_parsed_values(values, [1]):
271 | return transform
272 |
273 | transform[0, 1] = np.tan(values[0] * np.pi / 180.0)
274 |
275 | elif 'skewY' in transform_substr:
276 | if not _check_num_parsed_values(values, [1]):
277 | return transform
278 |
279 | transform[1, 0] = np.tan(values[0] * np.pi / 180.0)
280 | else:
281 | # Return an identity matrix if the type of transform is unknown, and warn the user
282 | warnings.warn('Unknown SVG transform type: {0}'.format(type_str))
283 |
284 | return transform
285 |
286 |
287 | def parse_transform(transform_str):
288 | """Converts a valid SVG transformation string into a 3x3 matrix.
289 | If the string is empty or null, this returns a 3x3 identity matrix"""
290 | if not transform_str:
291 | return np.identity(3)
292 | elif not isinstance(transform_str, str):
293 | raise TypeError('Must provide a string to parse')
294 |
295 | total_transform = np.identity(3)
296 | transform_substrs = transform_str.split(')')[:-1] # Skip the last element, because it should be empty
297 | for substr in transform_substrs:
298 | total_transform = total_transform.dot(_parse_transform_substr(substr))
299 |
300 | return total_transform
301 |
--------------------------------------------------------------------------------
/svgsort/svgpathtools/bezier.py:
--------------------------------------------------------------------------------
1 | """This submodule contains tools that deal with generic, degree n, Bezier
2 | curves.
3 | Note: Bezier curves here are always represented by the tuple of their control
4 | points given by their standard representation."""
5 |
6 | # External dependencies:
7 | from __future__ import division, absolute_import, print_function
8 | from math import factorial as fac, ceil, log, sqrt
9 | from numpy import poly1d
10 |
11 | # Internal dependencies
12 | from .polytools import real, imag, polyroots, polyroots01
13 |
14 |
15 | # Evaluation ##################################################################
16 |
17 | def n_choose_k(n, k):
18 | return fac(n)//fac(k)//fac(n-k)
19 |
20 |
21 | def bernstein(n, t):
22 | """returns a list of the Bernstein basis polynomials b_{i, n} evaluated at
23 | t, for i =0...n"""
24 | t1 = 1-t
25 | return [n_choose_k(n, k) * t1**(n-k) * t**k for k in range(n+1)]
26 |
27 |
28 | def bezier_point(p, t):
29 | """Evaluates the Bezier curve given by it's control points, p, at t.
30 | Note: Uses Horner's rule for cubic and lower order Bezier curves.
31 | Warning: Be concerned about numerical stability when using this function
32 | with high order curves."""
33 |
34 | # begin arc support block ########################
35 | try:
36 | p.large_arc
37 | return p.point(t)
38 | except:
39 | pass
40 | # end arc support block ##########################
41 |
42 | deg = len(p) - 1
43 | if deg == 3:
44 | return p[0] + t*(
45 | 3*(p[1] - p[0]) + t*(
46 | 3*(p[0] + p[2]) - 6*p[1] + t*(
47 | -p[0] + 3*(p[1] - p[2]) + p[3])))
48 | elif deg == 2:
49 | return p[0] + t*(
50 | 2*(p[1] - p[0]) + t*(
51 | p[0] - 2*p[1] + p[2]))
52 | elif deg == 1:
53 | return p[0] + t*(p[1] - p[0])
54 | elif deg == 0:
55 | return p[0]
56 | else:
57 | bern = bernstein(deg, t)
58 | return sum(bern[k]*p[k] for k in range(deg+1))
59 |
60 |
61 | # Conversion ##################################################################
62 |
63 | def bezier2polynomial(p, numpy_ordering=True, return_poly1d=False):
64 | """Converts a tuple of Bezier control points to a tuple of coefficients
65 | of the expanded polynomial.
66 | return_poly1d : returns a numpy.poly1d object. This makes computations
67 | of derivatives/anti-derivatives and many other operations quite quick.
68 | numpy_ordering : By default (to accommodate numpy) the coefficients will
69 | be output in reverse standard order."""
70 | if len(p) == 4:
71 | coeffs = (-p[0] + 3*(p[1] - p[2]) + p[3],
72 | 3*(p[0] - 2*p[1] + p[2]),
73 | 3*(p[1]-p[0]),
74 | p[0])
75 | elif len(p) == 3:
76 | coeffs = (p[0] - 2*p[1] + p[2],
77 | 2*(p[1] - p[0]),
78 | p[0])
79 | elif len(p) == 2:
80 | coeffs = (p[1]-p[0],
81 | p[0])
82 | elif len(p) == 1:
83 | coeffs = p
84 | else:
85 | # https://en.wikipedia.org/wiki/Bezier_curve#Polynomial_form
86 | n = len(p) - 1
87 | coeffs = [fac(n)//fac(n-j) * sum(
88 | (-1)**(i+j) * p[i] / (fac(i) * fac(j-i)) for i in range(j+1))
89 | for j in range(n+1)]
90 | coeffs.reverse()
91 | if not numpy_ordering:
92 | coeffs = coeffs[::-1] # can't use .reverse() as might be tuple
93 | if return_poly1d:
94 | return poly1d(coeffs)
95 | return coeffs
96 |
97 |
98 | def polynomial2bezier(poly):
99 | """Converts a cubic or lower order Polynomial object (or a sequence of
100 | coefficients) to a CubicBezier, QuadraticBezier, or Line object as
101 | appropriate."""
102 | if isinstance(poly, poly1d):
103 | c = poly.coeffs
104 | else:
105 | c = poly
106 | order = len(c)-1
107 | if order == 3:
108 | bpoints = (c[3], c[2]/3 + c[3], (c[1] + 2*c[2])/3 + c[3],
109 | c[0] + c[1] + c[2] + c[3])
110 | elif order == 2:
111 | bpoints = (c[2], c[1]/2 + c[2], c[0] + c[1] + c[2])
112 | elif order == 1:
113 | bpoints = (c[1], c[0] + c[1])
114 | else:
115 | raise AssertionError("This function is only implemented for linear, "
116 | "quadratic, and cubic polynomials.")
117 | return bpoints
118 |
119 |
120 | # Curve Splitting #############################################################
121 |
122 | def split_bezier(bpoints, t):
123 | """Uses deCasteljau's recursion to split the Bezier curve at t into two
124 | Bezier curves of the same order."""
125 | def split_bezier_recursion(bpoints_left_, bpoints_right_, bpoints_, t_):
126 | if len(bpoints_) == 1:
127 | bpoints_left_.append(bpoints_[0])
128 | bpoints_right_.append(bpoints_[0])
129 | else:
130 | new_points = [None]*(len(bpoints_) - 1)
131 | bpoints_left_.append(bpoints_[0])
132 | bpoints_right_.append(bpoints_[-1])
133 | for i in range(len(bpoints_) - 1):
134 | new_points[i] = (1 - t_)*bpoints_[i] + t_*bpoints_[i + 1]
135 | bpoints_left_, bpoints_right_ = split_bezier_recursion(
136 | bpoints_left_, bpoints_right_, new_points, t_)
137 | return bpoints_left_, bpoints_right_
138 |
139 | bpoints_left = []
140 | bpoints_right = []
141 | bpoints_left, bpoints_right = \
142 | split_bezier_recursion(bpoints_left, bpoints_right, bpoints, t)
143 | bpoints_right.reverse()
144 | return bpoints_left, bpoints_right
145 |
146 |
147 | def halve_bezier(p):
148 |
149 | # begin arc support block ########################
150 | try:
151 | p.large_arc
152 | return p.split(0.5)
153 | except:
154 | pass
155 | # end arc support block ##########################
156 |
157 | if len(p) == 4:
158 | return ([p[0], (p[0] + p[1])/2, (p[0] + 2*p[1] + p[2])/4,
159 | (p[0] + 3*p[1] + 3*p[2] + p[3])/8],
160 | [(p[0] + 3*p[1] + 3*p[2] + p[3])/8,
161 | (p[1] + 2*p[2] + p[3])/4, (p[2] + p[3])/2, p[3]])
162 | else:
163 | return split_bezier(p, 0.5)
164 |
165 |
166 | # Bounding Boxes ##############################################################
167 |
168 | def bezier_real_minmax(p):
169 | """returns the minimum and maximum for any real cubic bezier"""
170 | local_extremizers = [0, 1]
171 | if len(p) == 4: # cubic case
172 | a = [p.real for p in p]
173 | denom = a[0] - 3*a[1] + 3*a[2] - a[3]
174 | if denom != 0:
175 | delta = a[1]**2 - (a[0] + a[1])*a[2] + a[2]**2 + (a[0] - a[1])*a[3]
176 | if delta >= 0: # otherwise no local extrema
177 | sqdelta = sqrt(delta)
178 | tau = a[0] - 2*a[1] + a[2]
179 | r1 = (tau + sqdelta)/denom
180 | r2 = (tau - sqdelta)/denom
181 | if 0 < r1 < 1:
182 | local_extremizers.append(r1)
183 | if 0 < r2 < 1:
184 | local_extremizers.append(r2)
185 | local_extrema = [bezier_point(a, t) for t in local_extremizers]
186 | return min(local_extrema), max(local_extrema)
187 |
188 | # find reverse standard coefficients of the derivative
189 | dcoeffs = bezier2polynomial(a, return_poly1d=True).deriv().coeffs
190 |
191 | # find real roots, r, such that 0 <= r <= 1
192 | local_extremizers += polyroots01(dcoeffs)
193 | local_extrema = [bezier_point(a, t) for t in local_extremizers]
194 | return min(local_extrema), max(local_extrema)
195 |
196 |
197 | def bezier_bounding_box(bez):
198 | """returns the bounding box for the segment in the form
199 | (xmin, xmax, ymin, ymax).
200 | Warning: For the non-cubic case this is not particularly efficient."""
201 |
202 | # begin arc support block ########################
203 | try:
204 | bla = bez.large_arc
205 | return bez.bbox() # added to support Arc objects
206 | except:
207 | pass
208 | # end arc support block ##########################
209 |
210 | if len(bez) == 4:
211 | xmin, xmax = bezier_real_minmax([p.real for p in bez])
212 | ymin, ymax = bezier_real_minmax([p.imag for p in bez])
213 | return xmin, xmax, ymin, ymax
214 | poly = bezier2polynomial(bez, return_poly1d=True)
215 | x = real(poly)
216 | y = imag(poly)
217 | dx = x.deriv()
218 | dy = y.deriv()
219 | x_extremizers = [0, 1] + polyroots(dx, realroots=True,
220 | condition=lambda r: 0 < r < 1)
221 | y_extremizers = [0, 1] + polyroots(dy, realroots=True,
222 | condition=lambda r: 0 < r < 1)
223 | x_extrema = [x(t) for t in x_extremizers]
224 | y_extrema = [y(t) for t in y_extremizers]
225 | return min(x_extrema), max(x_extrema), min(y_extrema), max(y_extrema)
226 |
227 |
228 | def box_area(xmin, xmax, ymin, ymax):
229 | """
230 | INPUT: 2-tuple of cubics (given by control points)
231 | OUTPUT: boolean
232 | """
233 | return (xmax - xmin)*(ymax - ymin)
234 |
235 |
236 | def interval_intersection_width(a, b, c, d):
237 | """returns the width of the intersection of intervals [a,b] and [c,d]
238 | (thinking of these as intervals on the real number line)"""
239 | return max(0, min(b, d) - max(a, c))
240 |
241 |
242 | def boxes_intersect(box1, box2):
243 | """Determines if two rectangles, each input as a tuple
244 | (xmin, xmax, ymin, ymax), intersect."""
245 | xmin1, xmax1, ymin1, ymax1 = box1
246 | xmin2, xmax2, ymin2, ymax2 = box2
247 | if interval_intersection_width(xmin1, xmax1, xmin2, xmax2) and \
248 | interval_intersection_width(ymin1, ymax1, ymin2, ymax2):
249 | return True
250 | else:
251 | return False
252 |
253 |
254 | # Intersections ###############################################################
255 |
256 | class ApproxSolutionSet(list):
257 | """A class that behaves like a set but treats two elements , x and y, as
258 | equivalent if abs(x-y) < self.tol"""
259 | def __init__(self, tol):
260 | self.tol = tol
261 |
262 | def __contains__(self, x):
263 | for y in self:
264 | if abs(x - y) < self.tol:
265 | return True
266 | return False
267 |
268 | def appadd(self, pt):
269 | if pt not in self:
270 | self.append(pt)
271 |
272 |
273 | class BPair(object):
274 | def __init__(self, bez1, bez2, t1, t2):
275 | self.bez1 = bez1
276 | self.bez2 = bez2
277 | self.t1 = t1 # t value to get the mid point of this curve from cub1
278 | self.t2 = t2 # t value to get the mid point of this curve from cub2
279 |
280 |
281 | def bezier_intersections(bez1, bez2, longer_length, tol=1e-8, tol_deC=1e-8):
282 | """INPUT:
283 | bez1, bez2 = [P0,P1,P2,...PN], [Q0,Q1,Q2,...,PN] defining the two
284 | Bezier curves to check for intersections between.
285 | longer_length - the length (or an upper bound) on the longer of the two
286 | Bezier curves. Determines the maximum iterations needed together with tol.
287 | tol - is the smallest distance that two solutions can differ by and still
288 | be considered distinct solutions.
289 | OUTPUT: a list of tuples (t,s) in [0,1]x[0,1] such that
290 | abs(bezier_point(bez1[0],t) - bezier_point(bez2[1],s)) < tol_deC
291 | Note: This will return exactly one such tuple for each intersection
292 | (assuming tol_deC is small enough)."""
293 | maxits = int(ceil(1-log(tol_deC/longer_length)/log(2)))
294 | pair_list = [BPair(bez1, bez2, 0.5, 0.5)]
295 | intersection_list = []
296 | k = 0
297 | approx_point_set = ApproxSolutionSet(tol)
298 | while pair_list and k < maxits:
299 | new_pairs = []
300 | delta = 0.5**(k + 2)
301 | for pair in pair_list:
302 | bbox1 = bezier_bounding_box(pair.bez1)
303 | bbox2 = bezier_bounding_box(pair.bez2)
304 | if boxes_intersect(bbox1, bbox2):
305 | if box_area(*bbox1) < tol_deC and box_area(*bbox2) < tol_deC:
306 | point = bezier_point(bez1, pair.t1)
307 | if point not in approx_point_set:
308 | approx_point_set.append(point)
309 | # this is the point in the middle of the pair
310 | intersection_list.append((pair.t1, pair.t2))
311 |
312 | # this prevents the output of redundant intersection points
313 | for otherPair in pair_list:
314 | if pair.bez1 == otherPair.bez1 or \
315 | pair.bez2 == otherPair.bez2 or \
316 | pair.bez1 == otherPair.bez2 or \
317 | pair.bez2 == otherPair.bez1:
318 | pair_list.remove(otherPair)
319 | else:
320 | (c11, c12) = halve_bezier(pair.bez1)
321 | (t11, t12) = (pair.t1 - delta, pair.t1 + delta)
322 | (c21, c22) = halve_bezier(pair.bez2)
323 | (t21, t22) = (pair.t2 - delta, pair.t2 + delta)
324 | new_pairs += [BPair(c11, c21, t11, t21),
325 | BPair(c11, c22, t11, t22),
326 | BPair(c12, c21, t12, t21),
327 | BPair(c12, c22, t12, t22)]
328 | pair_list = new_pairs
329 | k += 1
330 | if k >= maxits:
331 | raise Exception("bezier_intersections has reached maximum "
332 | "iterations without terminating... "
333 | "either there's a problem/bug or you can fix by "
334 | "raising the max iterations or lowering tol_deC")
335 | return intersection_list
336 |
337 |
338 | def bezier_by_line_intersections(bezier, line):
339 | """Returns tuples (t1,t2) such that bezier.point(t1) ~= line.point(t2)."""
340 | # The method here is to translate (shift) then rotate the complex plane so
341 | # that line starts at the origin and proceeds along the positive real axis.
342 | # After this transformation, the intersection points are the real roots of
343 | # the imaginary component of the bezier for which the real component is
344 | # between 0 and abs(line[1]-line[0])].
345 | assert len(line[:]) == 2
346 | assert line[0] != line[1]
347 | if not any(p != bezier[0] for p in bezier):
348 | raise ValueError("bezier is nodal, use "
349 | "bezier_by_line_intersection(bezier[0], line) "
350 | "instead for a bool to be returned.")
351 |
352 | # First let's shift the complex plane so that line starts at the origin
353 | shifted_bezier = [z - line[0] for z in bezier]
354 | shifted_line_end = line[1] - line[0]
355 | line_length = abs(shifted_line_end)
356 |
357 | # Now let's rotate the complex plane so that line falls on the x-axis
358 | rotation_matrix = line_length/shifted_line_end
359 | transformed_bezier = [rotation_matrix*z for z in shifted_bezier]
360 |
361 | # Now all intersections should be roots of the imaginary component of
362 | # the transformed bezier
363 | transformed_bezier_imag = [p.imag for p in transformed_bezier]
364 | coeffs_y = bezier2polynomial(transformed_bezier_imag)
365 | roots_y = list(polyroots01(coeffs_y)) # returns real roots 0 <= r <= 1
366 |
367 | transformed_bezier_real = [p.real for p in transformed_bezier]
368 | intersection_list = []
369 | for bez_t in set(roots_y):
370 | xval = bezier_point(transformed_bezier_real, bez_t)
371 | if 0 <= xval <= line_length:
372 | line_t = xval/line_length
373 | intersection_list.append((bez_t, line_t))
374 | return intersection_list
375 |
376 |
--------------------------------------------------------------------------------
/svgsort/svgpathtools/paths2svg.py:
--------------------------------------------------------------------------------
1 | """This submodule contains tools for creating svg files from paths and path
2 | segments."""
3 |
4 | # External dependencies:
5 | from __future__ import division, absolute_import, print_function
6 | from math import ceil
7 | from os import getcwd, path as os_path, makedirs
8 | from time import time
9 | from warnings import warn
10 | import re
11 |
12 | from svgwrite import Drawing, text as txt
13 |
14 | # Internal dependencies
15 | from .path import Path, Line, is_path_segment
16 |
17 | # Used to convert a string colors (identified by single chars) to a list.
18 | color_dict = {'a': 'aqua',
19 | 'b': 'blue',
20 | 'c': 'cyan',
21 | 'd': 'darkblue',
22 | 'e': '',
23 | 'f': '',
24 | 'g': 'green',
25 | 'h': '',
26 | 'i': '',
27 | 'j': '',
28 | 'k': 'black',
29 | 'l': 'lime',
30 | 'm': 'magenta',
31 | 'n': 'brown',
32 | 'o': 'orange',
33 | 'p': 'pink',
34 | 'q': 'turquoise',
35 | 'r': 'red',
36 | 's': 'salmon',
37 | 't': 'tan',
38 | 'u': 'purple',
39 | 'v': 'violet',
40 | 'w': 'white',
41 | 'x': '',
42 | 'y': 'yellow',
43 | 'z': 'azure'}
44 |
45 |
46 | def str2colorlist(s, default_color=None):
47 | color_list = [color_dict[ch] for ch in s]
48 | if default_color:
49 | for idx, c in enumerate(color_list):
50 | if not c:
51 | color_list[idx] = default_color
52 | return color_list
53 |
54 |
55 | def is3tuple(c):
56 | return isinstance(c, tuple) and len(c) == 3
57 |
58 |
59 | def big_bounding_box(paths_n_stuff):
60 | """Finds a BB containing a collection of paths, Bezier path segments, and
61 | points (given as complex numbers)."""
62 | bbs = []
63 | for thing in paths_n_stuff:
64 | if is_path_segment(thing) or isinstance(thing, Path):
65 | bbs.append(thing.bbox())
66 | elif isinstance(thing, complex):
67 | bbs.append((thing.real, thing.real, thing.imag, thing.imag))
68 | else:
69 | try:
70 | complexthing = complex(thing)
71 | bbs.append((complexthing.real, complexthing.real,
72 | complexthing.imag, complexthing.imag))
73 | except ValueError:
74 | raise TypeError(
75 | "paths_n_stuff can only contains Path, CubicBezier, "
76 | "QuadraticBezier, Line, and complex objects.")
77 | xmins, xmaxs, ymins, ymaxs = list(zip(*bbs))
78 | xmin = min(xmins)
79 | xmax = max(xmaxs)
80 | ymin = min(ymins)
81 | ymax = max(ymaxs)
82 | return xmin, xmax, ymin, ymax
83 |
84 |
85 | def disvg(paths=None, colors=None,
86 | filename=os_path.join(getcwd(), 'disvg_output.svg'),
87 | stroke_widths=None, nodes=None, node_colors=None, node_radii=None,
88 | timestamp=False, margin_size=0.1, mindim=600, dimensions=None,
89 | viewbox=None, text=None, text_path=None, font_size=None,
90 | attributes=None, svg_attributes=None, svgwrite_debug=False,
91 | paths2Drawing=False):
92 | """Takes in a list of paths and creates an SVG file containing said paths.
93 | REQUIRED INPUTS:
94 | :param paths - a list of paths
95 |
96 | OPTIONAL INPUT:
97 | :param colors - specifies the path stroke color. By default all paths
98 | will be black (#000000). This paramater can be input in a few ways
99 | 1) a list of strings that will be input into the path elements stroke
100 | attribute (so anything that is understood by the svg viewer).
101 | 2) a string of single character colors -- e.g. setting colors='rrr' is
102 | equivalent to setting colors=['red', 'red', 'red'] (see the
103 | 'color_dict' dictionary above for a list of possibilities).
104 | 3) a list of rgb 3-tuples -- e.g. colors = [(255, 0, 0), ...].
105 |
106 | :param filename - the desired location/filename of the SVG file
107 | created (by default the SVG will be stored in the current working
108 | directory and named 'disvg_output.svg').
109 |
110 | :param stroke_widths - a list of stroke_widths to use for paths
111 | (default is 0.5% of the SVG's width or length)
112 |
113 | :param nodes - a list of points to draw as filled-in circles
114 |
115 | :param node_colors - a list of colors to use for the nodes (by default
116 | nodes will be red)
117 |
118 | :param node_radii - a list of radii to use for the nodes (by default
119 | nodes will be radius will be 1 percent of the svg's width/length)
120 |
121 | :param text - string or list of strings to be displayed
122 |
123 | :param text_path - if text is a list, then this should be a list of
124 | path (or path segments of the same length. Note: the path must be
125 | long enough to display the text or the text will be cropped by the svg
126 | viewer.
127 |
128 | :param font_size - a single float of list of floats.
129 |
130 | :param margin_size - The min margin (empty area framing the collection
131 | of paths) size used for creating the canvas and background of the SVG.
132 |
133 | :param mindim - The minimum dimension (height or width) of the output
134 | SVG (default is 600).
135 |
136 | :param dimensions - The (x,y) display dimensions of the output SVG.
137 | I.e. this specifies the `width` and `height` SVG attributes. Note that
138 | these also can be used to specify units other than pixels. Using this
139 | will override the `mindim` parameter.
140 |
141 | :param viewbox - This specifies the coordinated system used in the svg.
142 | The SVG `viewBox` attribute works together with the the `height` and
143 | `width` attrinutes. Using these three attributes allows for shifting
144 | and scaling of the SVG canvas without changing the any values other
145 | than those in `viewBox`, `height`, and `width`. `viewbox` should be
146 | input as a 4-tuple, (min_x, min_y, width, height), or a string
147 | "min_x min_y width height". Using this will override the `mindim`
148 | parameter.
149 |
150 | :param attributes - a list of dictionaries of attributes for the input
151 | paths. Note: This will override any other conflicting settings.
152 |
153 | :param svg_attributes - a dictionary of attributes for output svg.
154 |
155 | :param svgwrite_debug - This parameter turns on/off `svgwrite`'s
156 | debugging mode. By default svgwrite_debug=False. This increases
157 | speed and also prevents `svgwrite` from raising of an error when not
158 | all `svg_attributes` key-value pairs are understood.
159 |
160 | :param paths2Drawing - If true, an `svgwrite.Drawing` object is
161 | returned and no file is written. This `Drawing` can later be saved
162 | using the `svgwrite.Drawing.save()` method.
163 |
164 | NOTES:
165 | * The `svg_attributes` parameter will override any other conflicting
166 | settings.
167 |
168 | * Any `extra` parameters that `svgwrite.Drawing()` accepts can be
169 | controlled by passing them in through `svg_attributes`.
170 |
171 | * The unit of length here is assumed to be pixels in all variables.
172 |
173 | * If this function is used multiple times in quick succession to
174 | display multiple SVGs (all using the default filename), the
175 | svgviewer/browser will likely fail to load some of the SVGs in time.
176 | To fix this, use the timestamp attribute, or give the files unique
177 | names, or use a pause command (e.g. time.sleep(1)) between uses.
178 | """
179 |
180 |
181 | _default_relative_node_radius = 5e-3
182 | _default_relative_stroke_width = 1e-3
183 | _default_path_color = '#000000' # black
184 | _default_node_color = '#ff0000' # red
185 | _default_font_size = 12
186 |
187 |
188 | # append directory to filename (if not included)
189 | if os_path.dirname(filename) == '':
190 | filename = os_path.join(getcwd(), filename)
191 |
192 | # append time stamp to filename
193 | if timestamp:
194 | fbname, fext = os_path.splitext(filename)
195 | dirname = os_path.dirname(filename)
196 | tstamp = str(time()).replace('.', '')
197 | stfilename = os_path.split(fbname)[1] + '_' + tstamp + fext
198 | filename = os_path.join(dirname, stfilename)
199 |
200 | # check paths and colors are set
201 | if isinstance(paths, Path) or is_path_segment(paths):
202 | paths = [paths]
203 | if paths:
204 | if not colors:
205 | colors = [_default_path_color] * len(paths)
206 | else:
207 | assert len(colors) == len(paths)
208 | if isinstance(colors, str):
209 | colors = str2colorlist(colors,
210 | default_color=_default_path_color)
211 | elif isinstance(colors, list):
212 | for idx, c in enumerate(colors):
213 | if is3tuple(c):
214 | colors[idx] = "rgb" + str(c)
215 |
216 | # check nodes and nodes_colors are set (node_radii are set later)
217 | if nodes:
218 | if not node_colors:
219 | node_colors = [_default_node_color] * len(nodes)
220 | else:
221 | assert len(node_colors) == len(nodes)
222 | if isinstance(node_colors, str):
223 | node_colors = str2colorlist(node_colors,
224 | default_color=_default_node_color)
225 | elif isinstance(node_colors, list):
226 | for idx, c in enumerate(node_colors):
227 | if is3tuple(c):
228 | node_colors[idx] = "rgb" + str(c)
229 |
230 | # set up the viewBox and display dimensions of the output SVG
231 | # along the way, set stroke_widths and node_radii if not provided
232 | assert paths or nodes
233 | stuff2bound = []
234 | if viewbox:
235 | if not isinstance(viewbox, str):
236 | viewbox = '%s %s %s %s' % viewbox
237 | if dimensions is None:
238 | dimensions = viewbox.split(' ')[2:4]
239 | elif dimensions:
240 | dimensions = tuple(map(str, dimensions))
241 | def strip_units(s):
242 | return re.search(r'\d*\.?\d*', s.strip()).group()
243 | viewbox = '0 0 %s %s' % tuple(map(strip_units, dimensions))
244 | else:
245 | if paths:
246 | stuff2bound += paths
247 | if nodes:
248 | stuff2bound += nodes
249 | if text_path:
250 | stuff2bound += text_path
251 | xmin, xmax, ymin, ymax = big_bounding_box(stuff2bound)
252 | dx = xmax - xmin
253 | dy = ymax - ymin
254 |
255 | if dx == 0:
256 | dx = 1
257 | if dy == 0:
258 | dy = 1
259 |
260 | # determine stroke_widths to use (if not provided) and max_stroke_width
261 | if paths:
262 | if not stroke_widths:
263 | sw = max(dx, dy) * _default_relative_stroke_width
264 | stroke_widths = [sw]*len(paths)
265 | max_stroke_width = sw
266 | else:
267 | assert len(paths) == len(stroke_widths)
268 | max_stroke_width = max(stroke_widths)
269 | else:
270 | max_stroke_width = 0
271 |
272 | # determine node_radii to use (if not provided) and max_node_diameter
273 | if nodes:
274 | if not node_radii:
275 | r = max(dx, dy) * _default_relative_node_radius
276 | node_radii = [r]*len(nodes)
277 | max_node_diameter = 2*r
278 | else:
279 | assert len(nodes) == len(node_radii)
280 | max_node_diameter = 2*max(node_radii)
281 | else:
282 | max_node_diameter = 0
283 |
284 | extra_space_for_style = max(max_stroke_width, max_node_diameter)
285 | xmin -= margin_size*dx + extra_space_for_style/2
286 | ymin -= margin_size*dy + extra_space_for_style/2
287 | dx += 2*margin_size*dx + extra_space_for_style
288 | dy += 2*margin_size*dy + extra_space_for_style
289 | viewbox = "%s %s %s %s" % (xmin, ymin, dx, dy)
290 |
291 | if dx > dy:
292 | szx = str(mindim) + 'px'
293 | szy = str(int(ceil(mindim * dy / dx))) + 'px'
294 | else:
295 | szx = str(int(ceil(mindim * dx / dy))) + 'px'
296 | szy = str(mindim) + 'px'
297 | dimensions = szx, szy
298 |
299 | # Create an SVG file
300 | if svg_attributes is not None:
301 | dimensions = (svg_attributes.get("width", dimensions[0]),
302 | svg_attributes.get("height", dimensions[1]))
303 | debug = svg_attributes.get("debug", svgwrite_debug)
304 | dwg = Drawing(filename=filename, size=dimensions, debug=debug, **svg_attributes)
305 | else:
306 | dwg = Drawing(filename=filename, size=dimensions, debug=svgwrite_debug, viewBox=viewbox)
307 |
308 | # add paths
309 | if paths:
310 | for i, p in enumerate(paths):
311 | if isinstance(p, Path):
312 | ps = p.d()
313 | elif is_path_segment(p):
314 | ps = Path(p).d()
315 | else: # assume this path, p, was input as a Path d-string
316 | ps = p
317 |
318 | if attributes:
319 | good_attribs = {'d': ps}
320 | for key in attributes[i]:
321 | val = attributes[i][key]
322 | if key != 'd':
323 | try:
324 | dwg.path(ps, **{key: val})
325 | good_attribs.update({key: val})
326 | except Exception as e:
327 | warn(str(e))
328 |
329 | dwg.add(dwg.path(**good_attribs))
330 | else:
331 | dwg.add(dwg.path(ps, stroke=colors[i],
332 | stroke_width=str(stroke_widths[i]),
333 | fill='none'))
334 |
335 | # add nodes (filled in circles)
336 | if nodes:
337 | for i_pt, pt in enumerate([(z.real, z.imag) for z in nodes]):
338 | dwg.add(dwg.circle(pt, node_radii[i_pt], fill=node_colors[i_pt]))
339 |
340 | # add texts
341 | if text:
342 | assert isinstance(text, str) or (isinstance(text, list) and
343 | isinstance(text_path, list) and
344 | len(text_path) == len(text))
345 | if isinstance(text, str):
346 | text = [text]
347 | if not font_size:
348 | font_size = [_default_font_size]
349 | if not text_path:
350 | pos = complex(xmin + margin_size*dx, ymin + margin_size*dy)
351 | text_path = [Line(pos, pos + 1).d()]
352 | else:
353 | if font_size:
354 | if isinstance(font_size, list):
355 | assert len(font_size) == len(text)
356 | else:
357 | font_size = [font_size] * len(text)
358 | else:
359 | font_size = [_default_font_size] * len(text)
360 | for idx, s in enumerate(text):
361 | p = text_path[idx]
362 | if isinstance(p, Path):
363 | ps = p.d()
364 | elif is_path_segment(p):
365 | ps = Path(p).d()
366 | else: # assume this path, p, was input as a Path d-string
367 | ps = p
368 |
369 | # paragraph = dwg.add(dwg.g(font_size=font_size[idx]))
370 | # paragraph.add(dwg.textPath(ps, s))
371 | pathid = 'tp' + str(idx)
372 | dwg.defs.add(dwg.path(d=ps, id=pathid))
373 | txter = dwg.add(dwg.text('', font_size=font_size[idx]))
374 | txter.add(txt.TextPath('#'+pathid, s))
375 |
376 | if paths2Drawing:
377 | return dwg
378 |
379 | # save svg
380 | if not os_path.exists(os_path.dirname(filename)):
381 | makedirs(os_path.dirname(filename))
382 | dwg.save(pretty=True)
383 |
384 |
--------------------------------------------------------------------------------
/svgsort/svgpathtools/README.md:
--------------------------------------------------------------------------------
1 |
2 | svgpathtools
3 | ============
4 |
5 | svgpathtools is a collection of tools for manipulating and analyzing SVG
6 | Path objects and Bézier curves.
7 |
8 | Features
9 | --------
10 |
11 | svgpathtools contains functions designed to **easily read, write and
12 | display SVG files** as well as *a large selection of
13 | geometrically-oriented tools* to **transform and analyze path
14 | elements**.
15 |
16 | Additionally, the submodule *bezier.py* contains tools for for working
17 | with general **nth order Bezier curves stored as n-tuples**.
18 |
19 | Some included tools:
20 |
21 | - **read**, **write**, and **display** SVG files containing Path (and
22 | other) SVG elements
23 | - convert Bézier path segments to **numpy.poly1d** (polynomial) objects
24 | - convert polynomials (in standard form) to their Bézier form
25 | - compute **tangent vectors** and (right-hand rule) **normal vectors**
26 | - compute **curvature**
27 | - break discontinuous paths into their **continuous subpaths**.
28 | - efficiently compute **intersections** between paths and/or segments
29 | - find a **bounding box** for a path or segment
30 | - **reverse** segment/path orientation
31 | - **crop** and **split** paths and segments
32 | - **smooth** paths (i.e. smooth away kinks to make paths
33 | differentiable)
34 | - **transition maps** from path domain to segment domain and back (T2t
35 | and t2T)
36 | - compute **area** enclosed by a closed path
37 | - compute **arc length**
38 | - compute **inverse arc length**
39 | - convert RGB color tuples to hexadecimal color strings and back
40 |
41 | Prerequisites
42 | -------------
43 |
44 | - **numpy**
45 | - **svgwrite**
46 |
47 | Setup
48 | -----
49 |
50 | If not already installed, you can **install the prerequisites** using
51 | pip.
52 |
53 | .. code:: bash
54 |
55 | $ pip install numpy
56 |
57 | .. code:: bash
58 |
59 | $ pip install svgwrite
60 |
61 | Then **install svgpathtools**:
62 |
63 | .. code:: bash
64 |
65 | $ pip install svgpathtools
66 |
67 | Alternative Setup
68 | ~~~~~~~~~~~~~~~~~
69 |
70 | You can download the source from Github and install by using the command
71 | (from inside the folder containing setup.py):
72 |
73 | .. code:: bash
74 |
75 | $ python setup.py install
76 |
77 | Credit where credit's due
78 | -------------------------
79 |
80 | Much of the core of this module was taken from `the svg.path (v2.0)
81 | module `__. Interested svg.path
82 | users should see the compatibility notes at bottom of this readme.
83 |
84 | Basic Usage
85 | -----------
86 |
87 | Classes
88 | ~~~~~~~
89 |
90 | The svgpathtools module is primarily structured around four path segment
91 | classes: ``Line``, ``QuadraticBezier``, ``CubicBezier``, and ``Arc``.
92 | There is also a fifth class, ``Path``, whose objects are sequences of
93 | (connected or disconnected\ `1 <#f1>`__\ ) path segment objects.
94 |
95 | - ``Line(start, end)``
96 |
97 | - ``Arc(start, radius, rotation, large_arc, sweep, end)`` Note: See
98 | docstring for a detailed explanation of these parameters
99 |
100 | - ``QuadraticBezier(start, control, end)``
101 |
102 | - ``CubicBezier(start, control1, control2, end)``
103 |
104 | - ``Path(*segments)``
105 |
106 | See the relevant docstrings in *path.py* or the `official SVG
107 | specifications `__ for more
108 | information on what each parameter means.
109 |
110 | 1 Warning: Some of the functionality in this library has not been tested
111 | on discontinuous Path objects. A simple workaround is provided, however,
112 | by the ``Path.continuous_subpaths()`` method. `↩ <#a1>`__
113 |
114 | .. code:: ipython2
115 |
116 | from __future__ import division, print_function
117 |
118 | .. code:: ipython2
119 |
120 | # Coordinates are given as points in the complex plane
121 | from svgpathtools import Path, Line, QuadraticBezier, CubicBezier, Arc
122 | seg1 = CubicBezier(300+100j, 100+100j, 200+200j, 200+300j) # A cubic beginning at (300, 100) and ending at (200, 300)
123 | seg2 = Line(200+300j, 250+350j) # A line beginning at (200, 300) and ending at (250, 350)
124 | path = Path(seg1, seg2) # A path traversing the cubic and then the line
125 |
126 | # We could alternatively created this Path object using a d-string
127 | from svgpathtools import parse_path
128 | path_alt = parse_path('M 300 100 C 100 100 200 200 200 300 L 250 350')
129 |
130 | # Let's check that these two methods are equivalent
131 | print(path)
132 | print(path_alt)
133 | print(path == path_alt)
134 |
135 | # On a related note, the Path.d() method returns a Path object's d-string
136 | print(path.d())
137 | print(parse_path(path.d()) == path)
138 |
139 |
140 | .. parsed-literal::
141 |
142 | Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)),
143 | Line(start=(200+300j), end=(250+350j)))
144 | Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)),
145 | Line(start=(200+300j), end=(250+350j)))
146 | True
147 | M 300.0,100.0 C 100.0,100.0 200.0,200.0 200.0,300.0 L 250.0,350.0
148 | True
149 |
150 |
151 | The ``Path`` class is a mutable sequence, so it behaves much like a
152 | list. So segments can **append**\ ed, **insert**\ ed, set by index,
153 | **del**\ eted, **enumerate**\ d, **slice**\ d out, etc.
154 |
155 | .. code:: ipython2
156 |
157 | # Let's append another to the end of it
158 | path.append(CubicBezier(250+350j, 275+350j, 250+225j, 200+100j))
159 | print(path)
160 |
161 | # Let's replace the first segment with a Line object
162 | path[0] = Line(200+100j, 200+300j)
163 | print(path)
164 |
165 | # You may have noticed that this path is connected and now is also closed (i.e. path.start == path.end)
166 | print("path is continuous? ", path.iscontinuous())
167 | print("path is closed? ", path.isclosed())
168 |
169 | # The curve the path follows is not, however, smooth (differentiable)
170 | from svgpathtools import kinks, smoothed_path
171 | print("path contains non-differentiable points? ", len(kinks(path)) > 0)
172 |
173 | # If we want, we can smooth these out (Experimental and only for line/cubic paths)
174 | # Note: smoothing will always works (except on 180 degree turns), but you may want
175 | # to play with the maxjointsize and tightness parameters to get pleasing results
176 | # Note also: smoothing will increase the number of segments in a path
177 | spath = smoothed_path(path)
178 | print("spath contains non-differentiable points? ", len(kinks(spath)) > 0)
179 | print(spath)
180 |
181 | # Let's take a quick look at the path and its smoothed relative
182 | # The following commands will open two browser windows to display path and spaths
183 | from svgpathtools import disvg
184 | from time import sleep
185 | disvg(path)
186 | sleep(1) # needed when not giving the SVGs unique names (or not using timestamp)
187 | disvg(spath)
188 | print("Notice that path contains {} segments and spath contains {} segments."
189 | "".format(len(path), len(spath)))
190 |
191 |
192 | .. parsed-literal::
193 |
194 | Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)),
195 | Line(start=(200+300j), end=(250+350j)),
196 | CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j)))
197 | Path(Line(start=(200+100j), end=(200+300j)),
198 | Line(start=(200+300j), end=(250+350j)),
199 | CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j)))
200 | path is continuous? True
201 | path is closed? True
202 | path contains non-differentiable points? True
203 | spath contains non-differentiable points? False
204 | Path(Line(start=(200+101.5j), end=(200+298.5j)),
205 | CubicBezier(start=(200+298.5j), control1=(200+298.505j), control2=(201.057124638+301.057124638j), end=(201.060660172+301.060660172j)),
206 | Line(start=(201.060660172+301.060660172j), end=(248.939339828+348.939339828j)),
207 | CubicBezier(start=(248.939339828+348.939339828j), control1=(249.649982143+349.649982143j), control2=(248.995+350j), end=(250+350j)),
208 | CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j)),
209 | CubicBezier(start=(200+100j), control1=(199.62675237+99.0668809257j), control2=(200+100.495j), end=(200+101.5j)))
210 | Notice that path contains 3 segments and spath contains 6 segments.
211 |
212 |
213 | Reading SVGSs
214 | ~~~~~~~~~~~~~
215 |
216 | | The **svg2paths()** function converts an svgfile to a list of Path
217 | objects and a separate list of dictionaries containing the attributes
218 | of each said path.
219 | | Note: Line, Polyline, Polygon, and Path SVG elements can all be
220 | converted to Path objects using this function.
221 |
222 | .. code:: ipython2
223 |
224 | # Read SVG into a list of path objects and list of dictionaries of attributes
225 | from svgpathtools import svg2paths, wsvg
226 | paths, attributes = svg2paths('test.svg')
227 |
228 | # Update: You can now also extract the svg-attributes by setting
229 | # return_svg_attributes=True,
230 |
231 | # Let's print out the first path object and the color it was in the SVG
232 | # We'll see it is composed of two CubicBezier objects and, in the SVG file it
233 | # came from, it was red
234 | redpath = paths[0]
235 | redpath_attribs = attributes[0]
236 | print(redpath)
237 | print(redpath_attribs['stroke'])
238 |
239 |
240 | .. parsed-literal::
241 |
242 | Path(CubicBezier(start=(10.5+80j), control1=(40+10j), control2=(65+10j), end=(95+80j)),
243 | CubicBezier(start=(95+80j), control1=(125+150j), control2=(150+150j), end=(180+80j)))
244 | red
245 |
246 |
247 | Writing SVGSs (and some geometric functions and methods)
248 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
249 |
250 | The **wsvg()** function creates an SVG file from a list of path. This
251 | function can do many things (see docstring in *paths2svg.py* for more
252 | information) and is meant to be quick and easy to use. Note: Use the
253 | convenience function **disvg()** (or set 'openinbrowser=True') to
254 | automatically attempt to open the created svg file in your default SVG
255 | viewer.
256 |
257 | .. code:: ipython2
258 |
259 | # Let's make a new SVG that's identical to the first
260 | wsvg(paths, attributes=attributes, svg_attributes=svg_attributes, filename='output1.svg')
261 |
262 | .. figure:: https://cdn.rawgit.com/mathandy/svgpathtools/master/output1.svg
263 | :alt: output1.svg
264 |
265 | output1.svg
266 |
267 | There will be many more examples of writing and displaying path data
268 | below.
269 |
270 | The .point() method and transitioning between path and path segment parameterizations
271 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
272 |
273 | SVG Path elements and their segments have official parameterizations.
274 | These parameterizations can be accessed using the ``Path.point()``,
275 | ``Line.point()``, ``QuadraticBezier.point()``, ``CubicBezier.point()``,
276 | and ``Arc.point()`` methods. All these parameterizations are defined
277 | over the domain 0 <= t <= 1.
278 |
279 | | **Note:** In this document and in inline documentation and doctrings,
280 | I use a capital ``T`` when referring to the parameterization of a Path
281 | object and a lower case ``t`` when referring speaking about path
282 | segment objects (i.e. Line, QaudraticBezier, CubicBezier, and Arc
283 | objects).
284 | | Given a ``T`` value, the ``Path.T2t()`` method can be used to find the
285 | corresponding segment index, ``k``, and segment parameter, ``t``, such
286 | that ``path.point(T)=path[k].point(t)``.
287 | | There is also a ``Path.t2T()`` method to solve the inverse problem.
288 |
289 | .. code:: ipython2
290 |
291 | # Example:
292 |
293 | # Let's check that the first segment of redpath starts
294 | # at the same point as redpath
295 | firstseg = redpath[0]
296 | print(redpath.point(0) == firstseg.point(0) == redpath.start == firstseg.start)
297 |
298 | # Let's check that the last segment of redpath ends on the same point as redpath
299 | lastseg = redpath[-1]
300 | print(redpath.point(1) == lastseg.point(1) == redpath.end == lastseg.end)
301 |
302 | # This next boolean should return False as redpath is composed multiple segments
303 | print(redpath.point(0.5) == firstseg.point(0.5))
304 |
305 | # If we want to figure out which segment of redpoint the
306 | # point redpath.point(0.5) lands on, we can use the path.T2t() method
307 | k, t = redpath.T2t(0.5)
308 | print(redpath[k].point(t) == redpath.point(0.5))
309 |
310 |
311 | .. parsed-literal::
312 |
313 | True
314 | True
315 | False
316 | True
317 |
318 |
319 | Bezier curves as NumPy polynomial objects
320 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
321 |
322 | | Another great way to work with the parameterizations for ``Line``,
323 | ``QuadraticBezier``, and ``CubicBezier`` objects is to convert them to
324 | ``numpy.poly1d`` objects. This is done easily using the
325 | ``Line.poly()``, ``QuadraticBezier.poly()`` and ``CubicBezier.poly()``
326 | methods.
327 | | There's also a ``polynomial2bezier()`` function in the pathtools.py
328 | submodule to convert polynomials back to Bezier curves.
329 |
330 | **Note:** cubic Bezier curves are parameterized as
331 |
332 | .. math:: \mathcal{B}(t) = P_0(1-t)^3 + 3P_1(1-t)^2t + 3P_2(1-t)t^2 + P_3t^3
333 |
334 | where :math:`P_0`, :math:`P_1`, :math:`P_2`, and :math:`P_3` are the
335 | control points ``start``, ``control1``, ``control2``, and ``end``,
336 | respectively, that svgpathtools uses to define a CubicBezier object. The
337 | ``CubicBezier.poly()`` method expands this polynomial to its standard
338 | form
339 |
340 | .. math:: \mathcal{B}(t) = c_0t^3 + c_1t^2 +c_2t+c3
341 |
342 | where
343 |
344 | .. math::
345 |
346 | \begin{bmatrix}c_0\\c_1\\c_2\\c_3\end{bmatrix} =
347 | \begin{bmatrix}
348 | -1 & 3 & -3 & 1\\
349 | 3 & -6 & -3 & 0\\
350 | -3 & 3 & 0 & 0\\
351 | 1 & 0 & 0 & 0\\
352 | \end{bmatrix}
353 | \begin{bmatrix}P_0\\P_1\\P_2\\P_3\end{bmatrix}
354 |
355 | ``QuadraticBezier.poly()`` and ``Line.poly()`` are `defined
356 | similarly `__.
357 |
358 | .. code:: ipython2
359 |
360 | # Example:
361 | b = CubicBezier(300+100j, 100+100j, 200+200j, 200+300j)
362 | p = b.poly()
363 |
364 | # p(t) == b.point(t)
365 | print(p(0.235) == b.point(0.235))
366 |
367 | # What is p(t)? It's just the cubic b written in standard form.
368 | bpretty = "{}*(1-t)^3 + 3*{}*(1-t)^2*t + 3*{}*(1-t)*t^2 + {}*t^3".format(*b.bpoints())
369 | print("The CubicBezier, b.point(x) = \n\n" +
370 | bpretty + "\n\n" +
371 | "can be rewritten in standard form as \n\n" +
372 | str(p).replace('x','t'))
373 |
374 |
375 | .. parsed-literal::
376 |
377 | True
378 | The CubicBezier, b.point(x) =
379 |
380 | (300+100j)*(1-t)^3 + 3*(100+100j)*(1-t)^2*t + 3*(200+200j)*(1-t)*t^2 + (200+300j)*t^3
381 |
382 | can be rewritten in standard form as
383 |
384 | 3 2
385 | (-400 + -100j) t + (900 + 300j) t - 600 t + (300 + 100j)
386 |
387 |
388 | The ability to convert between Bezier objects to NumPy polynomial
389 | objects is very useful. For starters, we can take turn a list of Bézier
390 | segments into a NumPy array
391 |
392 | Numpy Array operations on Bézier path segments
393 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
394 |
395 | `Example available
396 | here `__
397 |
398 | To further illustrate the power of being able to convert our Bezier
399 | curve objects to numpy.poly1d objects and back, lets compute the unit
400 | tangent vector of the above CubicBezier object, b, at t=0.5 in four
401 | different ways.
402 |
403 | Tangent vectors (and more on NumPy polynomials)
404 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
405 |
406 | .. code:: ipython2
407 |
408 | t = 0.5
409 | ### Method 1: the easy way
410 | u1 = b.unit_tangent(t)
411 |
412 | ### Method 2: another easy way
413 | # Note: This way will fail if it encounters a removable singularity.
414 | u2 = b.derivative(t)/abs(b.derivative(t))
415 |
416 | ### Method 2: a third easy way
417 | # Note: This way will also fail if it encounters a removable singularity.
418 | dp = p.deriv()
419 | u3 = dp(t)/abs(dp(t))
420 |
421 | ### Method 4: the removable-singularity-proof numpy.poly1d way
422 | # Note: This is roughly how Method 1 works
423 | from svgpathtools import real, imag, rational_limit
424 | dx, dy = real(dp), imag(dp) # dp == dx + 1j*dy
425 | p_mag2 = dx**2 + dy**2 # p_mag2(t) = |p(t)|**2
426 | # Note: abs(dp) isn't a polynomial, but abs(dp)**2 is, and,
427 | # the limit_{t->t0}[f(t) / abs(f(t))] ==
428 | # sqrt(limit_{t->t0}[f(t)**2 / abs(f(t))**2])
429 | from cmath import sqrt
430 | u4 = sqrt(rational_limit(dp**2, p_mag2, t))
431 |
432 | print("unit tangent check:", u1 == u2 == u3 == u4)
433 |
434 | # Let's do a visual check
435 | mag = b.length()/4 # so it's not hard to see the tangent line
436 | tangent_line = Line(b.point(t), b.point(t) + mag*u1)
437 | disvg([b, tangent_line], 'bg', nodes=[b.point(t)])
438 |
439 |
440 | .. parsed-literal::
441 |
442 | unit tangent check: True
443 |
444 |
445 | Translations (shifts), reversing orientation, and normal vectors
446 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
447 |
448 | .. code:: ipython2
449 |
450 | # Speaking of tangents, let's add a normal vector to the picture
451 | n = b.normal(t)
452 | normal_line = Line(b.point(t), b.point(t) + mag*n)
453 | disvg([b, tangent_line, normal_line], 'bgp', nodes=[b.point(t)])
454 |
455 | # and let's reverse the orientation of b!
456 | # the tangent and normal lines should be sent to their opposites
457 | br = b.reversed()
458 |
459 | # Let's also shift b_r over a bit to the right so we can view it next to b
460 | # The simplest way to do this is br = br.translated(3*mag), but let's use
461 | # the .bpoints() instead, which returns a Bezier's control points
462 | br.start, br.control1, br.control2, br.end = [3*mag + bpt for bpt in br.bpoints()] #
463 |
464 | tangent_line_r = Line(br.point(t), br.point(t) + mag*br.unit_tangent(t))
465 | normal_line_r = Line(br.point(t), br.point(t) + mag*br.normal(t))
466 | wsvg([b, tangent_line, normal_line, br, tangent_line_r, normal_line_r],
467 | 'bgpkgp', nodes=[b.point(t), br.point(t)], filename='vectorframes.svg',
468 | text=["b's tangent", "br's tangent"], text_path=[tangent_line, tangent_line_r])
469 |
470 | .. figure:: https://cdn.rawgit.com/mathandy/svgpathtools/master/vectorframes.svg
471 | :alt: vectorframes.svg
472 |
473 | vectorframes.svg
474 |
475 | Rotations and Translations
476 | ~~~~~~~~~~~~~~~~~~~~~~~~~~
477 |
478 | .. code:: ipython2
479 |
480 | # Let's take a Line and an Arc and make some pictures
481 | top_half = Arc(start=-1, radius=1+2j, rotation=0, large_arc=1, sweep=1, end=1)
482 | midline = Line(-1.5, 1.5)
483 |
484 | # First let's make our ellipse whole
485 | bottom_half = top_half.rotated(180)
486 | decorated_ellipse = Path(top_half, bottom_half)
487 |
488 | # Now let's add the decorations
489 | for k in range(12):
490 | decorated_ellipse.append(midline.rotated(30*k))
491 |
492 | # Let's move it over so we can see the original Line and Arc object next
493 | # to the final product
494 | decorated_ellipse = decorated_ellipse.translated(4+0j)
495 | wsvg([top_half, midline, decorated_ellipse], filename='decorated_ellipse.svg')
496 |
497 | .. figure:: https://cdn.rawgit.com/mathandy/svgpathtools/master/decorated_ellipse.svg
498 | :alt: decorated\_ellipse.svg
499 |
500 | decorated\_ellipse.svg
501 |
502 | arc length and inverse arc length
503 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
504 |
505 | Here we'll create an SVG that shows off the parametric and geometric
506 | midpoints of the paths from ``test.svg``. We'll need to compute use the
507 | ``Path.length()``, ``Line.length()``, ``QuadraticBezier.length()``,
508 | ``CubicBezier.length()``, and ``Arc.length()`` methods, as well as the
509 | related inverse arc length methods ``.ilength()`` function to do this.
510 |
511 | .. code:: ipython2
512 |
513 | # First we'll load the path data from the file test.svg
514 | paths, attributes = svg2paths('test.svg')
515 |
516 | # Let's mark the parametric midpoint of each segment
517 | # I say "parametric" midpoint because Bezier curves aren't
518 | # parameterized by arclength
519 | # If they're also the geometric midpoint, let's mark them
520 | # purple and otherwise we'll mark the geometric midpoint green
521 | min_depth = 5
522 | error = 1e-4
523 | dots = []
524 | ncols = []
525 | nradii = []
526 | for path in paths:
527 | for seg in path:
528 | parametric_mid = seg.point(0.5)
529 | seg_length = seg.length()
530 | if seg.length(0.5)/seg.length() == 1/2:
531 | dots += [parametric_mid]
532 | ncols += ['purple']
533 | nradii += [5]
534 | else:
535 | t_mid = seg.ilength(seg_length/2)
536 | geo_mid = seg.point(t_mid)
537 | dots += [parametric_mid, geo_mid]
538 | ncols += ['red', 'green']
539 | nradii += [5] * 2
540 |
541 | # In 'output2.svg' the paths will retain their original attributes
542 | wsvg(paths, nodes=dots, node_colors=ncols, node_radii=nradii,
543 | attributes=attributes, filename='output2.svg')
544 |
545 | .. figure:: https://cdn.rawgit.com/mathandy/svgpathtools/master/output2.svg
546 | :alt: output2.svg
547 |
548 | output2.svg
549 |
550 | Intersections between Bezier curves
551 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
552 |
553 | .. code:: ipython2
554 |
555 | # Let's find all intersections between redpath and the other
556 | redpath = paths[0]
557 | redpath_attribs = attributes[0]
558 | intersections = []
559 | for path in paths[1:]:
560 | for (T1, seg1, t1), (T2, seg2, t2) in redpath.intersect(path):
561 | intersections.append(redpath.point(T1))
562 |
563 | disvg(paths, filename='output_intersections.svg', attributes=attributes,
564 | nodes = intersections, node_radii = [5]*len(intersections))
565 |
566 | .. figure:: https://cdn.rawgit.com/mathandy/svgpathtools/master/output_intersections.svg
567 | :alt: output\_intersections.svg
568 |
569 | output\_intersections.svg
570 |
571 | An Advanced Application: Offsetting Paths
572 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
573 |
574 | Here we'll find the `offset
575 | curve `__ for a few paths.
576 |
577 | .. code:: ipython2
578 |
579 | from svgpathtools import parse_path, Line, Path, wsvg
580 | def offset_curve(path, offset_distance, steps=1000):
581 | """Takes in a Path object, `path`, and a distance,
582 | `offset_distance`, and outputs an piecewise-linear approximation
583 | of the 'parallel' offset curve."""
584 | nls = []
585 | for seg in path:
586 | for k in range(steps):
587 | t = k / float(steps)
588 | offset_vector = offset_distance * seg.normal(t)
589 | nl = Line(seg.point(t), seg.point(t) + offset_vector)
590 | nls.append(nl)
591 | connect_the_dots = [Line(nls[k].end, nls[k+1].end) for k in range(len(nls)-1)]
592 | if path.isclosed():
593 | connect_the_dots.append(Line(nls[-1].end, nls[0].end))
594 | offset_path = Path(*connect_the_dots)
595 | return offset_path
596 |
597 | # Examples:
598 | path1 = parse_path("m 288,600 c -52,-28 -42,-61 0,-97 ")
599 | path2 = parse_path("M 151,395 C 407,485 726.17662,160 634,339").translated(300)
600 | path3 = parse_path("m 117,695 c 237,-7 -103,-146 457,0").translated(500+400j)
601 | paths = [path1, path2, path3]
602 |
603 | offset_distances = [10*k for k in range(1,51)]
604 | offset_paths = []
605 | for path in paths:
606 | for distances in offset_distances:
607 | offset_paths.append(offset_curve(path, distances))
608 |
609 | # Note: This will take a few moments
610 | wsvg(paths + offset_paths, 'g'*len(paths) + 'r'*len(offset_paths), filename='offset_curves.svg')
611 |
612 | .. figure:: https://cdn.rawgit.com/mathandy/svgpathtools/master/offset_curves.svg
613 | :alt: offset\_curves.svg
614 |
615 | offset\_curves.svg
616 |
617 | Compatibility Notes for users of svg.path (v2.0)
618 | ------------------------------------------------
619 |
620 | - renamed Arc.arc attribute as Arc.large\_arc
621 |
622 | - Path.d() : For behavior similar\ `2 <#f2>`__\ to svg.path (v2.0),
623 | set both useSandT and use\_closed\_attrib to be True.
624 |
625 | 2 The behavior would be identical, but the string formatting used in
626 | this method has been changed to use default format (instead of the
627 | General format, {:G}), for inceased precision. `↩ <#a2>`__
628 |
629 | Licence
630 | -------
631 |
632 | This module is under a MIT License.
633 |
634 |
--------------------------------------------------------------------------------