svg2gcode/svg/svg.py

changeset 2
660ce16822a9
child 3
a519e3ac3849
equal deleted inserted replaced
1:0c9798d91427 2:660ce16822a9
1 # SVG parser in Python
2
3 # Copyright (C) 2013 -- CJlano < cjlano @ free.fr >
4
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18
19 from __future__ import absolute_import
20 import sys
21 import os
22 import copy
23 import re
24 import xml.etree.ElementTree as etree
25 import itertools
26 import operator
27 import json
28 from .geometry import *
29
30 svg_ns = '{http://www.w3.org/2000/svg}'
31
32 # Regex commonly used
33 number_re = r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?'
34 unit_re = r'em|ex|px|in|cm|mm|pt|pc|%'
35
36 # Unit converter
37 unit_convert = {
38 None: 1, # Default unit (same as pixel)
39 'px': 1, # px: pixel. Default SVG unit
40 'em': 10, # 1 em = 10 px FIXME
41 'ex': 5, # 1 ex = 5 px FIXME
42 'in': 96, # 1 in = 96 px
43 'cm': 96 / 2.54, # 1 cm = 1/2.54 in
44 'mm': 96 / 25.4, # 1 mm = 1/25.4 in
45 'pt': 96 / 72.0, # 1 pt = 1/72 in
46 'pc': 96 / 6.0, # 1 pc = 1/6 in
47 '%' : 1 / 100.0 # 1 percent
48 }
49
50 class Transformable:
51 '''Abstract class for objects that can be geometrically drawn & transformed'''
52 def __init__(self, elt=None):
53 # a 'Transformable' is represented as a list of Transformable items
54 self.items = []
55 self.id = hex(id(self))
56 # Unit transformation matrix on init
57 self.matrix = Matrix()
58 self.viewport = Point(800, 600) # default viewport is 800x600
59 if elt is not None:
60 self.id = elt.get('id', self.id)
61 # Parse transform attibute to update self.matrix
62 self.getTransformations(elt)
63
64 def bbox(self):
65 '''Bounding box'''
66 bboxes = [x.bbox() for x in self.items]
67 xmin = min([b[0].x for b in bboxes])
68 xmax = max([b[1].x for b in bboxes])
69 ymin = min([b[0].y for b in bboxes])
70 ymax = max([b[1].y for b in bboxes])
71
72 return (Point(xmin,ymin), Point(xmax,ymax))
73
74 # Parse transform field
75 def getTransformations(self, elt):
76 t = elt.get('transform')
77 if t is None: return
78
79 svg_transforms = [
80 'matrix', 'translate', 'scale', 'rotate', 'skewX', 'skewY']
81
82 # match any SVG transformation with its parameter (until final parenthese)
83 # [^)]* == anything but a closing parenthese
84 # '|'.join == OR-list of SVG transformations
85 transforms = re.findall(
86 '|'.join([x + '[^)]*\)' for x in svg_transforms]), t)
87
88 for t in transforms:
89 op, arg = t.split('(')
90 op = op.strip()
91 # Keep only numbers
92 arg = [float(x) for x in re.findall(number_re, arg)]
93 print('transform: ' + op + ' '+ str(arg))
94
95 if op == 'matrix':
96 self.matrix *= Matrix(arg)
97
98 if op == 'translate':
99 tx = arg[0]
100 if len(arg) == 1: ty = 0
101 else: ty = arg[1]
102 self.matrix *= Matrix([1, 0, 0, 1, tx, ty])
103
104 if op == 'scale':
105 sx = arg[0]
106 if len(arg) == 1: sy = sx
107 else: sy = arg[1]
108 self.matrix *= Matrix([sx, 0, 0, sy, 0, 0])
109
110 if op == 'rotate':
111 cosa = math.cos(math.radians(arg[0]))
112 sina = math.sin(math.radians(arg[0]))
113 if len(arg) != 1:
114 tx, ty = arg[1:3]
115 self.matrix *= Matrix([1, 0, 0, 1, tx, ty])
116 self.matrix *= Matrix([cosa, sina, -sina, cosa, 0, 0])
117 if len(arg) != 1:
118 self.matrix *= Matrix([1, 0, 0, 1, -tx, -ty])
119
120 if op == 'skewX':
121 tana = math.tan(math.radians(arg[0]))
122 self.matrix *= Matrix([1, 0, tana, 1, 0, 0])
123
124 if op == 'skewY':
125 tana = math.tan(math.radians(arg[0]))
126 self.matrix *= Matrix([1, tana, 0, 1, 0, 0])
127
128 def transform(self, matrix=None):
129 for x in self.items:
130 x.transform(self.matrix)
131
132 def length(self, v, mode='xy'):
133 # Handle empty (non-existing) length element
134 if v is None:
135 return 0
136
137 # Get length value
138 m = re.search(number_re, v)
139 if m: value = m.group(0)
140 else: raise TypeError(v + 'is not a valid length')
141
142 # Get length unit
143 m = re.search(unit_re, v)
144 if m: unit = m.group(0)
145 else: unit = None
146
147 if unit == '%':
148 if mode == 'x':
149 return float(value) * unit_convert[unit] * self.viewport.x
150 if mode == 'y':
151 return float(value) * unit_convert[unit] * self.viewport.y
152 if mode == 'xy':
153 return float(value) * unit_convert[unit] * self.viewport.x # FIXME
154
155 return float(value) * unit_convert[unit]
156
157 def xlength(self, x):
158 return self.length(x, 'x')
159 def ylength(self, y):
160 return self.length(y, 'y')
161
162 def flatten(self):
163 '''Flatten the SVG objects nested list into a flat (1-D) list,
164 removing Groups'''
165 # http://rightfootin.blogspot.fr/2006/09/more-on-python-flatten.html
166 # Assigning a slice a[i:i+1] with a list actually replaces the a[i]
167 # element with the content of the assigned list
168 i = 0
169 flat = copy.deepcopy(self.items)
170 while i < len(flat):
171 while isinstance(flat[i], Group):
172 flat[i:i+1] = flat[i].items
173 i += 1
174 return flat
175
176 def scale(self, ratio):
177 for x in self.items:
178 x.scale(ratio)
179 return self
180
181 def translate(self, offset):
182 for x in self.items:
183 x.translate(offset)
184 return self
185
186 def rotate(self, angle):
187 for x in self.items:
188 x.rotate(angle)
189 return self
190
191 class Svg(Transformable):
192 '''SVG class: use parse to parse a file'''
193 # class Svg handles the <svg> tag
194 # tag = 'svg'
195
196 def __init__(self, filename=None):
197 Transformable.__init__(self)
198 if filename:
199 self.parse(filename)
200
201 def parse(self, filename):
202 self.filename = filename
203 tree = etree.parse(filename)
204 self.root = tree.getroot()
205 if self.root.tag != svg_ns + 'svg':
206 raise TypeError('file %s does not seem to be a valid SVG file', filename)
207
208 # Create a top Group to group all other items (useful for viewBox elt)
209 top_group = Group()
210 self.items.append(top_group)
211
212 # SVG dimension
213 width = self.xlength(self.root.get('width'))
214 height = self.ylength(self.root.get('height'))
215 # update viewport
216 top_group.viewport = Point(width, height)
217
218 # viewBox
219 if self.root.get('viewBox') is not None:
220 viewBox = re.findall(number_re, self.root.get('viewBox'))
221 sx = width / float(viewBox[2])
222 sy = height / float(viewBox[3])
223 tx = -float(viewBox[0])
224 ty = -float(viewBox[1])
225 top_group.matrix = Matrix([sx, 0, 0, sy, tx, ty])
226
227 # Parse XML elements hierarchically with groups <g>
228 top_group.append(self.root)
229
230 self.transform()
231
232 def title(self):
233 t = self.root.find(svg_ns + 'title')
234 if t is not None:
235 return t
236 else:
237 return os.path.splitext(os.path.basename(self.filename))[0]
238
239 def json(self):
240 return self.items
241
242
243 class Group(Transformable):
244 '''Handle svg <g> elements'''
245 # class Group handles the <g> tag
246 tag = 'g'
247
248 def __init__(self, elt=None):
249 Transformable.__init__(self, elt)
250
251 def append(self, element):
252 for elt in element:
253 elt_class = svgClass.get(elt.tag, None)
254 if elt_class is None:
255 print('No handler for element %s' % elt.tag)
256 continue
257 # instanciate elt associated class (e.g. <path>: item = Path(elt)
258 item = elt_class(elt)
259 # Apply group matrix to the newly created object
260 item.matrix = self.matrix * item.matrix
261 item.viewport = self.viewport
262
263 self.items.append(item)
264 # Recursively append if elt is a <g> (group)
265 if elt.tag == svg_ns + 'g':
266 item.append(elt)
267
268 def __repr__(self):
269 return '<Group ' + self.id + '>: ' + repr(self.items)
270
271 def json(self):
272 return {'Group ' + self.id : self.items}
273
274 class Matrix:
275 ''' SVG transformation matrix and its operations
276 a SVG matrix is represented as a list of 6 values [a, b, c, d, e, f]
277 (named vect hereafter) which represent the 3x3 matrix
278 ((a, c, e)
279 (b, d, f)
280 (0, 0, 1))
281 see http://www.w3.org/TR/SVG/coords.html#EstablishingANewUserSpace '''
282
283 def __init__(self, vect=[1, 0, 0, 1, 0, 0]):
284 # Unit transformation vect by default
285 if len(vect) != 6:
286 raise ValueError("Bad vect size %d" % len(vect))
287 self.vect = list(vect)
288
289 def __mul__(self, other):
290 '''Matrix multiplication'''
291 if isinstance(other, Matrix):
292 a = self.vect[0] * other.vect[0] + self.vect[2] * other.vect[1]
293 b = self.vect[1] * other.vect[0] + self.vect[3] * other.vect[1]
294 c = self.vect[0] * other.vect[2] + self.vect[2] * other.vect[3]
295 d = self.vect[1] * other.vect[2] + self.vect[3] * other.vect[3]
296 e = self.vect[0] * other.vect[4] + self.vect[2] * other.vect[5] \
297 + self.vect[4]
298 f = self.vect[1] * other.vect[4] + self.vect[3] * other.vect[5] \
299 + self.vect[5]
300 return Matrix([a, b, c, d, e, f])
301
302 elif isinstance(other, Point):
303 x = other.x * self.vect[0] + other.y * self.vect[2] + self.vect[4]
304 y = other.x * self.vect[1] + other.y * self.vect[3] + self.vect[5]
305 return Point(x,y)
306
307 else:
308 return NotImplemented
309
310 def __str__(self):
311 return str(self.vect)
312
313 def xlength(self, x):
314 return x * self.vect[0]
315 def ylength(self, y):
316 return y * self.vect[3]
317
318
319 COMMANDS = 'MmZzLlHhVvCcSsQqTtAa'
320
321 class Path(Transformable):
322 '''SVG <path>'''
323 # class Path handles the <path> tag
324 tag = 'path'
325
326 def __init__(self, elt=None):
327 Transformable.__init__(self, elt)
328 if elt is not None:
329 self.style = elt.get('style')
330 self.parse(elt.get('d'))
331
332 def parse(self, pathstr):
333 """Parse path string and build elements list"""
334
335 pathlst = re.findall(number_re + r"|\ *[%s]\ *" % COMMANDS, pathstr)
336
337 pathlst.reverse()
338
339 command = None
340 current_pt = Point(0,0)
341 start_pt = None
342
343 while pathlst:
344 if pathlst[-1].strip() in COMMANDS:
345 last_command = command
346 command = pathlst.pop().strip()
347 absolute = (command == command.upper())
348 command = command.upper()
349 else:
350 if command is None:
351 raise ValueError("No command found at %d" % len(pathlst))
352
353 if command == 'M':
354 # MoveTo
355 x = pathlst.pop()
356 y = pathlst.pop()
357 pt = Point(x, y)
358 if absolute:
359 current_pt = pt
360 else:
361 current_pt += pt
362 start_pt = current_pt
363
364 self.items.append(MoveTo(current_pt))
365
366 # MoveTo with multiple coordinates means LineTo
367 command = 'L'
368
369 elif command == 'Z':
370 # Close Path
371 l = Segment(current_pt, start_pt)
372 self.items.append(l)
373
374
375 elif command in 'LHV':
376 # LineTo, Horizontal & Vertical line
377 # extra coord for H,V
378 if absolute:
379 x,y = current_pt.coord()
380 else:
381 x,y = (0,0)
382
383 if command in 'LH':
384 x = pathlst.pop()
385 if command in 'LV':
386 y = pathlst.pop()
387
388 pt = Point(x, y)
389 if not absolute:
390 pt += current_pt
391
392 self.items.append(Segment(current_pt, pt))
393 current_pt = pt
394
395 elif command in 'CQ':
396 dimension = {'Q':3, 'C':4}
397 bezier_pts = []
398 bezier_pts.append(current_pt)
399 for i in range(1,dimension[command]):
400 x = pathlst.pop()
401 y = pathlst.pop()
402 pt = Point(x, y)
403 if not absolute:
404 pt += current_pt
405 bezier_pts.append(pt)
406
407 self.items.append(Bezier(bezier_pts))
408 current_pt = pt
409
410 elif command in 'TS':
411 # number of points to read
412 nbpts = {'T':1, 'S':2}
413 # the control point, from previous Bezier to mirror
414 ctrlpt = {'T':1, 'S':2}
415 # last command control
416 last = {'T': 'QT', 'S':'CS'}
417
418 bezier_pts = []
419 bezier_pts.append(current_pt)
420
421 if last_command in last[command]:
422 pt0 = self.items[-1].control_point(ctrlpt[command])
423 else:
424 pt0 = current_pt
425 pt1 = current_pt
426 # Symetrical of pt1 against pt0
427 bezier_pts.append(pt1 + pt1 - pt0)
428
429 for i in range(0,nbpts[command]):
430 x = pathlst.pop()
431 y = pathlst.pop()
432 pt = Point(x, y)
433 if not absolute:
434 pt += current_pt
435 bezier_pts.append(pt)
436
437 self.items.append(Bezier(bezier_pts))
438 current_pt = pt
439
440 elif command == 'A':
441 rx = pathlst.pop()
442 ry = pathlst.pop()
443 xrot = pathlst.pop()
444 # Arc flags are not necesarily sepatated numbers
445 flags = pathlst.pop().strip()
446 large_arc_flag = flags[0]
447 if large_arc_flag not in '01':
448 print('Arc parsing failure')
449 break
450
451 if len(flags) > 1: flags = flags[1:].strip()
452 else: flags = pathlst.pop().strip()
453 sweep_flag = flags[0]
454 if sweep_flag not in '01':
455 print('Arc parsing failure')
456 break
457
458 if len(flags) > 1: x = flags[1:]
459 else: x = pathlst.pop()
460 y = pathlst.pop()
461 # TODO
462 print('ARC: ' +
463 ', '.join([rx, ry, xrot, large_arc_flag, sweep_flag, x, y]))
464 # self.items.append(
465 # Arc(rx, ry, xrot, large_arc_flag, sweep_flag, Point(x, y)))
466
467 else:
468 pathlst.pop()
469
470 def __str__(self):
471 return '\n'.join(str(x) for x in self.items)
472
473 def __repr__(self):
474 return '<Path ' + self.id + '>'
475
476 def segments(self, precision=0):
477 '''Return a list of segments, each segment is ended by a MoveTo.
478 A segment is a list of Points'''
479 ret = []
480 # group items separated by MoveTo
481 for moveTo, group in itertools.groupby(self.items,
482 lambda x: isinstance(x, MoveTo)):
483 # Use only non MoveTo item
484 if not moveTo:
485 # Generate segments for each relevant item
486 seg = [x.segments(precision) for x in group]
487 # Merge all segments into one
488 ret.append(list(itertools.chain.from_iterable(seg)))
489
490 return ret
491
492 def simplify(self, precision):
493 '''Simplify segment with precision:
494 Remove any point which are ~aligned'''
495 ret = []
496 for seg in self.segments(precision):
497 ret.append(simplify_segment(seg, precision))
498
499 return ret
500
501 class Ellipse(Transformable):
502 '''SVG <ellipse>'''
503 # class Ellipse handles the <ellipse> tag
504 tag = 'ellipse'
505
506 def __init__(self, elt=None):
507 Transformable.__init__(self, elt)
508 if elt is not None:
509 self.center = Point(self.xlength(elt.get('cx')),
510 self.ylength(elt.get('cy')))
511 self.rx = self.length(elt.get('rx'))
512 self.ry = self.length(elt.get('ry'))
513 self.style = elt.get('style')
514
515 def __repr__(self):
516 return '<Ellipse ' + self.id + '>'
517
518 def bbox(self):
519 '''Bounding box'''
520 pmin = self.center - Point(self.rx, self.ry)
521 pmax = self.center + Point(self.rx, self.ry)
522 return (pmin, pmax)
523
524 def transform(self, matrix):
525 self.center = self.matrix * self.center
526 self.rx = self.matrix.xlength(self.rx)
527 self.ry = self.matrix.ylength(self.ry)
528
529 def scale(self, ratio):
530 self.center *= ratio
531 self.rx *= ratio
532 self.ry *= ratio
533 def translate(self, offset):
534 self.center += offset
535 def rotate(self, angle):
536 self.center = self.center.rot(angle)
537
538 def P(self, t):
539 '''Return a Point on the Ellipse for t in [0..1]'''
540 x = self.center.x + self.rx * math.cos(2 * math.pi * t)
541 y = self.center.y + self.ry * math.sin(2 * math.pi * t)
542 return Point(x,y)
543
544 def segments(self, precision=0):
545 if max(self.rx, self.ry) < precision:
546 return [[self.center]]
547
548 p = [(0,self.P(0)), (1, self.P(1))]
549 d = 2 * max(self.rx, self.ry)
550
551 while d > precision:
552 for (t1,p1),(t2,p2) in zip(p[:-1],p[1:]):
553 t = t1 + (t2 - t1)/2.
554 d = Segment(p1, p2).pdistance(self.P(t))
555 p.append((t, self.P(t)))
556 p.sort(key=operator.itemgetter(0))
557
558 ret = [x for t,x in p]
559 return [ret]
560
561 def simplify(self, precision):
562 return self
563
564 # A circle is a special type of ellipse where rx = ry = radius
565 class Circle(Ellipse):
566 '''SVG <circle>'''
567 # class Circle handles the <circle> tag
568 tag = 'circle'
569
570 def __init__(self, elt=None):
571 if elt is not None:
572 elt.set('rx', elt.get('r'))
573 elt.set('ry', elt.get('r'))
574 Ellipse.__init__(self, elt)
575
576 def __repr__(self):
577 return '<Circle ' + self.id + '>'
578
579 class Rect(Transformable):
580 '''SVG <rect>'''
581 # class Rect handles the <rect> tag
582 tag = 'rect'
583
584 def __init__(self, elt=None):
585 Transformable.__init__(self, elt)
586 if elt is not None:
587 self.P1 = Point(self.xlength(elt.get('x')),
588 self.ylength(elt.get('y')))
589
590 self.P2 = Point(self.P1.x + self.xlength(elt.get('width')),
591 self.P1.y + self.ylength(elt.get('height')))
592
593 def __repr__(self):
594 return '<Rect ' + self.id + '>'
595
596 def bbox(self):
597 '''Bounding box'''
598 xmin = min([p.x for p in (self.P1, self.P2)])
599 xmax = max([p.x for p in (self.P1, self.P2)])
600 ymin = min([p.y for p in (self.P1, self.P2)])
601 ymax = max([p.y for p in (self.P1, self.P2)])
602
603 return (Point(xmin,ymin), Point(xmax,ymax))
604
605 def transform(self, matrix):
606 self.P1 = self.matrix * self.P1
607 self.P2 = self.matrix * self.P2
608
609 def segments(self, precision=0):
610 # A rectangle is built with a segment going thru 4 points
611 ret = []
612 Pa = Point(self.P1.x, self.P2.y)
613 Pb = Point(self.P2.x, self.P1.y)
614
615 ret.append([self.P1, Pa, self.P2, Pb, self.P1])
616 return ret
617
618 def simplify(self, precision):
619 return self.segments(precision)
620
621 class Line(Transformable):
622 '''SVG <line>'''
623 # class Line handles the <line> tag
624 tag = 'line'
625
626 def __init__(self, elt=None):
627 Transformable.__init__(self, elt)
628 if elt is not None:
629 self.P1 = Point(self.xlength(elt.get('x1')),
630 self.ylength(elt.get('y1')))
631 self.P2 = Point(self.xlength(elt.get('x2')),
632 self.ylength(elt.get('y2')))
633 self.segment = Segment(self.P1, self.P2)
634
635 def __repr__(self):
636 return '<Line ' + self.id + '>'
637
638 def bbox(self):
639 '''Bounding box'''
640 xmin = min([p.x for p in (self.P1, self.P2)])
641 xmax = max([p.x for p in (self.P1, self.P2)])
642 ymin = min([p.y for p in (self.P1, self.P2)])
643 ymax = max([p.y for p in (self.P1, self.P2)])
644
645 return (Point(xmin,ymin), Point(xmax,ymax))
646
647 def transform(self, matrix):
648 self.P1 = self.matrix * self.P1
649 self.P2 = self.matrix * self.P2
650 self.segment = Segment(self.P1, self.P2)
651
652 def segments(self, precision=0):
653 return [self.segment.segments()]
654
655 def simplify(self, precision):
656 return self.segments(precision)
657
658 # overwrite JSONEncoder for svg classes which have defined a .json() method
659 class JSONEncoder(json.JSONEncoder):
660 def default(self, obj):
661 if not isinstance(obj, tuple(svgClass.values() + [Svg])):
662 return json.JSONEncoder.default(self, obj)
663
664 if not hasattr(obj, 'json'):
665 return repr(obj)
666
667 return obj.json()
668
669 ## Code executed on module load ##
670
671 # SVG tag handler classes are initialized here
672 # (classes must be defined before)
673 import inspect
674 svgClass = {}
675 # Register all classes with attribute 'tag' in svgClass dict
676 for name, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass):
677 tag = getattr(cls, 'tag', None)
678 if tag:
679 svgClass[svg_ns + tag] = cls
680

mercurial