├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── run.py ├── setup.py ├── svgsort ├── __init__.py ├── paper_utils.py ├── sort_utils.py ├── svgpathtools │ ├── README.md │ ├── __init__.py │ ├── bezier.py │ ├── misctools.py │ ├── parser.py │ ├── path.py │ ├── paths2svg.py │ ├── polytools.py │ ├── smoothing.py │ └── svg2paths.py └── svgsort.py └── test ├── a-res-dim.svg ├── a-res-no-adjust.svg ├── a-res.svg ├── a.svg ├── b-res-dim.svg ├── b-res-no-adjust.svg ├── b-res.svg ├── b.svg ├── c-res-dim.svg ├── c-res-no-adjust.svg ├── c-res.svg ├── c.svg ├── linearx-sorted-moves.svg ├── linearx-sorted-repeat.svg ├── linearx-sorted-reverse.svg ├── linearx-sorted.svg ├── linearx.svg ├── paper4-l-res.svg ├── paper4-l.svg ├── paper4-p-res.svg ├── paper4-p.svg ├── parallel-res.svg ├── parallel.svg └── run.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | indent_style = space 12 | indent_size = 2 13 | trim_trailing_whitespace = true 14 | 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-* 2 | *.*~ 3 | *.pyc 4 | build 5 | dist 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | You are not allowed to sell the svg-files (linearx) in the test folder, but you 3 | are encouraged to plot them. 4 | 5 | 6 | Svgsort is written by Anders Hoff: 7 | 8 | 9 | The MIT License (MIT) 10 | 11 | Copyright (c) 2017-2019 Anders Hoff 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | 31 | 32 | 33 | The code in svgpathtools is from https://github.com/mathandy/svgpathtools: 34 | 35 | 36 | The MIT License (MIT) 37 | 38 | Copyright (c) 2015 Andrew Allan Port 39 | 40 | Permission is hereby granted, free of charge, to any person obtaining a copy 41 | of this software and associated documentation files (the "Software"), to deal 42 | in the Software without restriction, including without limitation the rights 43 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 44 | copies of the Software, and to permit persons to whom the Software is 45 | furnished to do so, subject to the following conditions: 46 | 47 | The above copyright notice and this permission notice shall be included in all 48 | copies or substantial portions of the Software. 49 | 50 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 51 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 52 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 53 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 54 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 55 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 56 | SOFTWARE. 57 | 58 | 59 | 60 | Which in turn is based on https://github.com/regebro/svg.path: 61 | 62 | 63 | The MIT License (MIT) 64 | 65 | Copyright (c) 2013-2014 Lennart Regebro 66 | 67 | Permission is hereby granted, free of charge, to any person obtaining a copy 68 | of this software and associated documentation files (the "Software"), to deal 69 | in the Software without restriction, including without limitation the rights 70 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 71 | copies of the Software, and to permit persons to whom the Software is 72 | furnished to do so, subject to the following conditions: 73 | 74 | The above copyright notice and this permission notice shall be included in all 75 | copies or substantial portions of the Software. 76 | 77 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 78 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 79 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 80 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 81 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 82 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 83 | SOFTWARE. 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svg Spatial Sort 2 | 3 | 4 | Reasonably efficient, greedy, path planning for plotting svg files. 5 | 6 | 7 | ## Install 8 | 9 | Install locally using: 10 | 11 | ./setup.py [install | develop] --user 12 | 13 | 14 | ## Use 15 | 16 | Use from the terminal like this: 17 | 18 | svgsort input.svg out.svg 19 | 20 | This will break down paths into continous sub paths before sorting. it will 21 | also allow travelling along paths (once) in both directions. 22 | 23 | The default behaviour is to fit the result into an A3 sheet of paper. It will 24 | automatically rotate the paper orientation for the best possible fit, as well 25 | as align the drawing to the center. You can override the paper size by using 26 | `--dim=A4` or, eg., `--dim=30x40`. To disable centering entirely use 27 | `--no-adjust`. 28 | 29 | To ensure every path is drawn twice (once in each direction), you can use 30 | `--repeat` 31 | 32 | You can disable splitting with `--no-split`. To see other options: 33 | 34 | svgsort --help 35 | 36 | 37 | ## Credits 38 | 39 | The code in `svgsort/svgpaththools` is from 40 | https://github.com/mathandy/svgpathtools. With only minor changes by me. I had 41 | a number of strange issues when installing it via `pip`, so I decided to 42 | include it here. See the LICENSE file. 43 | 44 | 45 | ## Todo 46 | 47 | Strip out larger parts of svgpathtools, and refactor? 48 | 49 | 50 | ## Contributing 51 | 52 | This code is a tool for my own use. I release it publicly in case people find 53 | it useful. It is not however intended as a collaboration/Open Source project. 54 | As such I am unlikely to accept PRs, reply to issues, or take requests. 55 | 56 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from svgsort import main 5 | 6 | if __name__ == '__main__': 7 | main() 8 | 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | from setuptools import find_packages 6 | 7 | 8 | setup(name='svgsort', 9 | version='3.0.0', 10 | description='svg spatial sort for plotting', 11 | url='https://github.com/inconvergent/svgsort', 12 | license='MIT License', 13 | author='Anders Hoff', 14 | author_email='inconvergent@gmail.com', 15 | install_requires=['docopt', 'svgwrite', 'numpy', 'scipy'], 16 | packages=find_packages(), 17 | entry_points={'console_scripts': ['svgsort=svgsort:main']}, 18 | zip_safe=True 19 | ) 20 | 21 | -------------------------------------------------------------------------------- /svgsort/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """svgsort 5 | 6 | Usage: 7 | svgsort [] [--no-split | --split-all] 8 | [--dim=] 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/paper_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | PAPER = { 5 | 'a4': {'short': 210, 'long': 297, 'r': 297.0/210.0, 'name': 'A4'}, 6 | 'a3':{'short': 297, 'long': 420, 'r': 420.0/297.0, 'name': 'A3'} 7 | } 8 | 9 | def make_paper(xy): 10 | short = min(*xy) 11 | long = max(*xy) 12 | return { 13 | 'short': short, 14 | 'long': long, 15 | 'r': long/short, 16 | 'name': '{:d} x {:d}'.format(long, short) 17 | } 18 | 19 | 20 | def get_bbox(paths): 21 | xmin, xmax, ymin, ymax = paths[0].bbox() 22 | for p in paths: 23 | xmi, xma, ymi, yma = p.bbox() 24 | xmin = min(xmin, xmi) 25 | xmax = max(xmax, xma) 26 | ymin = min(ymin, ymi) 27 | ymax = max(ymax, yma) 28 | return xmin, xmax, ymin, ymax 29 | 30 | 31 | def get_long_short(paths, pad, padAbs=False): 32 | xmin, xmax, ymin, ymax = get_bbox(paths) 33 | width = xmax-xmin 34 | height = ymax-ymin 35 | portrait = width < height 36 | 37 | if not padAbs: 38 | b = pad*min(width, height) 39 | else: 40 | b = pad 41 | 42 | if portrait: 43 | return { 44 | 'longDim': 'y', 45 | 'portrait': True, 46 | 'longmin': ymin-b, 47 | 'shortmin': xmin-b, 48 | 'long': height+2*b, 49 | 'short': width+2*b, 50 | 'r': height/width, 51 | } 52 | return { 53 | 'longDim': 'x', 54 | 'portrait': False, 55 | 'longmin': xmin-b, 56 | 'shortmin': ymin-b, 57 | 'long': width+2*b, 58 | 'short': height+2*b, 59 | 'r': width/height, 60 | } 61 | 62 | 63 | def vbox_paper(ls, p): 64 | lsnew = {k:v for k, v in ls.items()} 65 | 66 | if ls['r'] < p['r']: 67 | # resize limited by short 68 | lsnew['long'] = ls['short']*p['r'] 69 | diff = lsnew['long'] - ls['long'] 70 | lsnew['longmin'] -= diff*0.5 71 | else: 72 | # resize limted by long 73 | lsnew['short'] = ls['long']/p['r'] 74 | diff = lsnew['short'] - ls['short'] 75 | lsnew['shortmin'] -= diff*0.5 76 | 77 | lsnew['r'] = lsnew['long'] / lsnew['short'] 78 | 79 | # xmin, ymin, width, height 80 | if ls['longDim'] == 'x': 81 | res = lsnew['longmin'], lsnew['shortmin'], lsnew['long'], lsnew['short'] 82 | else: 83 | res = lsnew['shortmin'], lsnew['longmin'], lsnew['short'], lsnew['long'] 84 | 85 | size = { 86 | 'width': p['short'], 87 | 'height': p['long'] 88 | } if ls['portrait'] else { 89 | 'width': p['long'], 90 | 'height': p['short'] 91 | } 92 | return ls['portrait'], res, {k:str(v)+'mm' for k, v in size.items()} 93 | 94 | -------------------------------------------------------------------------------- /svgsort/sort_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from math import sqrt 4 | from numpy import zeros 5 | from numpy.linalg import norm 6 | from scipy.spatial import cKDTree as kdt 7 | 8 | from .svgpathtools.path import Line 9 | from .svgpathtools import Path 10 | 11 | 12 | def ct(c): 13 | return (c.real, c.imag) 14 | 15 | 16 | def build_pos_index(paths): 17 | num = len(paths) 18 | xs = zeros((2*num, 2), 'float') 19 | x_path = zeros(2*num, 'int') 20 | 21 | for i, (start, stop) in enumerate(paths): 22 | xs[i, :] = start 23 | xs[num+i, :] = stop 24 | x_path[i] = i 25 | x_path[num+i] = i 26 | 27 | tree = kdt(xs) 28 | unsorted = set(range(2*num)) 29 | return tree, xs, x_path, unsorted 30 | 31 | 32 | def spatial_sort(paths, init_pos, init_rad=0.01): 33 | tree, xs, x_path, unsorted = build_pos_index(paths) 34 | 35 | num = len(paths) 36 | pos = init_pos 37 | 38 | flip = [] 39 | order = [] 40 | count = 0 41 | while count < num: 42 | rad = init_rad 43 | while True: 44 | near = tree.query_ball_point(pos, rad) 45 | cands = list(set(near).intersection(unsorted)) 46 | if not cands: 47 | rad *= 2.0 48 | continue 49 | 50 | dst = norm(pos - xs[cands, :], axis=1) 51 | cp = dst.argmin() 52 | curr = cands[cp] 53 | break 54 | 55 | path_ind = x_path[curr] 56 | start, stop = paths[path_ind] 57 | 58 | if curr >= num: 59 | flip.append(True) 60 | pos = start 61 | unsorted.remove(curr) 62 | unsorted.remove(curr-num) 63 | else: 64 | flip.append(False) 65 | pos = stop 66 | unsorted.remove(curr) 67 | unsorted.remove(curr+num) 68 | 69 | order.append(path_ind) 70 | count += 1 71 | 72 | return order, flip 73 | 74 | 75 | def attempt_reverse(path): 76 | try: 77 | if path.iscontinuous(): 78 | rpath = path.reversed() 79 | if rpath.iscontinuous(): 80 | return rpath 81 | print('''WARNING: unable to reverse path segment; this might give unintended 82 | results. try without --reverse.''') 83 | return path 84 | except Exception: 85 | print('ERROR: when reversing path. try without --reverse.') 86 | 87 | 88 | def flip_reorder(l, order, flip): 89 | for i, f in zip(order, flip): 90 | li = l[i] 91 | if f: 92 | li = attempt_reverse(li) 93 | yield li 94 | 95 | 96 | def pen_moves(paths): 97 | if paths: 98 | curr = paths[0] 99 | for p in paths[1:]: 100 | yield Line(curr.end, p.start) 101 | curr = p 102 | 103 | 104 | def get_sort_order(paths, init_pos): 105 | coords = [] 106 | for p in paths: 107 | coords.append([ct(p.point(0)), ct(p.point(1))]) 108 | return spatial_sort(coords, init_pos=init_pos) 109 | 110 | 111 | def get_length(paths): 112 | pos = (0.0, 0.0) 113 | tot = 0.0 114 | pen = 0.0 115 | 116 | for p in get_cont_paths(paths): 117 | ox, oy = pos 118 | cx, cy = ct(p.point(0)) 119 | tmp = sqrt(pow(ox-cx, 2.0) + pow(oy-cy, 2.0)) 120 | pen += tmp 121 | tot += tmp 122 | 123 | try: 124 | tot += p.length() 125 | except ZeroDivisionError: 126 | print('WARN: /0 error in get_length. this is probably ok.') 127 | pos = ct(p.point(1)) 128 | 129 | return tot, pen 130 | 131 | 132 | def split_all(paths): 133 | for p in paths: 134 | for e in p: 135 | yield Path(e) 136 | 137 | 138 | def get_cont_paths(paths): 139 | for p in paths: 140 | if not p.iscontinuous(): 141 | for sp in p.continuous_subpaths(): 142 | yield sp 143 | else: 144 | yield p 145 | 146 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /svgsort/svgpathtools/__init__.py: -------------------------------------------------------------------------------- 1 | from .bezier import (bezier_point, bezier2polynomial, 2 | polynomial2bezier, split_bezier, 3 | bezier_bounding_box, bezier_intersections, 4 | bezier_by_line_intersections) 5 | from .path import (Path, Line, QuadraticBezier, CubicBezier, Arc, 6 | bezier_segment, is_bezier_segment, is_path_segment, 7 | is_bezier_path, concatpaths, poly2bez, bpoints2bezier, 8 | closest_point_in_path, farthest_point_in_path, 9 | path_encloses_pt, bbox2path, polygon, polyline) 10 | from .parser import parse_path 11 | from .paths2svg import disvg 12 | from .polytools import polyroots, polyroots01, rational_limit, real, imag 13 | from .misctools import hex2rgb, rgb2hex 14 | from .smoothing import smoothed_path, smoothed_joint, is_differentiable, kinks 15 | from .svg2paths import svg2paths 16 | 17 | -------------------------------------------------------------------------------- /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/misctools.py: -------------------------------------------------------------------------------- 1 | """This submodule contains miscellaneous tools that are used internally, but 2 | aren't specific to SVGs or related mathematical objects.""" 3 | 4 | # External dependencies: 5 | from __future__ import division, absolute_import, print_function 6 | 7 | 8 | # stackoverflow.com/questions/214359/converting-hex-color-to-rgb-and-vice-versa 9 | def hex2rgb(value): 10 | """Converts a hexadeximal color string to an RGB 3-tuple 11 | 12 | EXAMPLE 13 | ------- 14 | >>> hex2rgb('#0000FF') 15 | (0, 0, 255) 16 | """ 17 | value = value.lstrip('#') 18 | lv = len(value) 19 | return tuple(int(value[i:i+lv//3], 16) for i in range(0, lv, lv//3)) 20 | 21 | 22 | # stackoverflow.com/questions/214359/converting-hex-color-to-rgb-and-vice-versa 23 | def rgb2hex(rgb): 24 | """Converts an RGB 3-tuple to a hexadeximal color string. 25 | 26 | EXAMPLE 27 | ------- 28 | >>> rgb2hex((0,0,255)) 29 | '#0000FF' 30 | """ 31 | return ('#%02x%02x%02x' % tuple(rgb)).upper() 32 | 33 | 34 | def isclose(a, b, rtol=1e-5, atol=1e-8): 35 | """This is essentially np.isclose, but slightly faster.""" 36 | return abs(a - b) < (atol + rtol * abs(b)) 37 | 38 | -------------------------------------------------------------------------------- /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/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/polytools.py: -------------------------------------------------------------------------------- 1 | """This submodule contains tools for working with numpy.poly1d objects.""" 2 | 3 | # External Dependencies 4 | from __future__ import division, absolute_import 5 | from itertools import combinations 6 | import numpy as np 7 | 8 | # Internal Dependencies 9 | from .misctools import isclose 10 | 11 | 12 | def polyroots(p, realroots=False, condition=lambda r: True): 13 | """ 14 | Returns the roots of a polynomial with coefficients given in p. 15 | p[0] * x**n + p[1] * x**(n-1) + ... + p[n-1]*x + p[n] 16 | INPUT: 17 | p - Rank-1 array-like object of polynomial coefficients. 18 | realroots - a boolean. If true, only real roots will be returned and the 19 | condition function can be written assuming all roots are real. 20 | condition - a boolean-valued function. Only roots satisfying this will be 21 | returned. If realroots==True, these conditions should assume the roots 22 | are real. 23 | OUTPUT: 24 | A list containing the roots of the polynomial. 25 | NOTE: This uses np.isclose and np.roots""" 26 | roots = np.roots(p) 27 | if realroots: 28 | roots = [r.real for r in roots if isclose(r.imag, 0)] 29 | roots = [r for r in roots if condition(r)] 30 | 31 | duplicates = [] 32 | for idx, (r1, r2) in enumerate(combinations(roots, 2)): 33 | if isclose(r1, r2): 34 | duplicates.append(idx) 35 | return [r for idx, r in enumerate(roots) if idx not in duplicates] 36 | 37 | 38 | def polyroots01(p): 39 | """Returns the real roots between 0 and 1 of the polynomial with 40 | coefficients given in p, 41 | p[0] * x**n + p[1] * x**(n-1) + ... + p[n-1]*x + p[n] 42 | p can also be a np.poly1d object. See polyroots for more information.""" 43 | return polyroots(p, realroots=True, condition=lambda tval: 0 <= tval <= 1) 44 | 45 | 46 | def rational_limit(f, g, t0): 47 | """Computes the limit of the rational function (f/g)(t) 48 | as t approaches t0.""" 49 | assert isinstance(f, np.poly1d) and isinstance(g, np.poly1d) 50 | assert g != np.poly1d([0]) 51 | if g(t0) != 0: 52 | return f(t0)/g(t0) 53 | elif f(t0) == 0: 54 | return rational_limit(f.deriv(), g.deriv(), t0) 55 | else: 56 | raise ValueError("Limit does not exist.") 57 | 58 | 59 | def real(z): 60 | try: 61 | return np.poly1d(z.coeffs.real) 62 | except AttributeError: 63 | return z.real 64 | 65 | 66 | def imag(z): 67 | try: 68 | return np.poly1d(z.coeffs.imag) 69 | except AttributeError: 70 | return z.imag 71 | 72 | 73 | def poly_real_part(poly): 74 | """Deprecated.""" 75 | return np.poly1d(poly.coeffs.real) 76 | 77 | 78 | def poly_imag_part(poly): 79 | """Deprecated.""" 80 | return np.poly1d(poly.coeffs.imag) 81 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /test/a-res-dim.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/a-res-no-adjust.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/a-res.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/a.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 26 | 43 | 56 | 57 | -------------------------------------------------------------------------------- /test/b-res-dim.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/b-res-no-adjust.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/b-res.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/b.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 57 | 64 | 70 | 71 | -------------------------------------------------------------------------------- /test/c-res-dim.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/c-res-no-adjust.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/c-res.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/c.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 57 | 64 | 65 | -------------------------------------------------------------------------------- /test/paper4-l-res.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/paper4-l.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 29 | 49 | 56 | 63 | 69 | 76 | 77 | -------------------------------------------------------------------------------- /test/paper4-p-res.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/paper4-p.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 29 | 49 | 56 | 63 | 70 | 77 | 83 | 90 | 91 | -------------------------------------------------------------------------------- /test/parallel-res.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/parallel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 60 | 65 | 70 | 75 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /test/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | 6 | svgsort a.svg a-res.svg --no-split --dim=A4 7 | echo "" 8 | svgsort b.svg b-res.svg --no-split --dim=A4 9 | echo "" 10 | svgsort c.svg c-res.svg --no-split --dim=A4 11 | echo "" 12 | 13 | svgsort a.svg a-res-dim.svg --dim=300x100 14 | echo "" 15 | svgsort b.svg b-res-dim.svg --dim=432x33 16 | echo "" 17 | svgsort c.svg c-res-dim.svg --dim=44x100 18 | echo "" 19 | 20 | echo "" 21 | svgsort a.svg a-res-no-adjust.svg --no-adjust 22 | echo "" 23 | svgsort b.svg b-res-no-adjust.svg --no-adjust 24 | echo "" 25 | svgsort c.svg c-res-no-adjust.svg --no-adjust 26 | echo "" 27 | 28 | svgsort parallel.svg parallel-res.svg --pen-moves 29 | echo "" 30 | 31 | svgsort paper4-l.svg paper4-l-res.svg --dim=A4 32 | echo "" 33 | svgsort paper4-p.svg paper4-p-res.svg --dim=A3 34 | echo "" 35 | 36 | svgsort linearx.svg linearx-sorted.svg --no-split --dim=A4 --pad 0.1 37 | echo "" 38 | svgsort linearx.svg linearx-sorted-moves.svg --dim=A4 --pen-moves 39 | echo "" 40 | svgsort linearx.svg linearx-sorted-repeat.svg --repeat --dim=A4 41 | 42 | --------------------------------------------------------------------------------