--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svg2gcode/svg/svg.py Sat Nov 07 13:33:12 2015 +0100 @@ -0,0 +1,680 @@ +# SVG parser in Python + +# Copyright (C) 2013 -- CJlano < cjlano @ free.fr > + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from __future__ import absolute_import +import sys +import os +import copy +import re +import xml.etree.ElementTree as etree +import itertools +import operator +import json +from .geometry import * + +svg_ns = '{http://www.w3.org/2000/svg}' + +# Regex commonly used +number_re = r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?' +unit_re = r'em|ex|px|in|cm|mm|pt|pc|%' + +# Unit converter +unit_convert = { + None: 1, # Default unit (same as pixel) + 'px': 1, # px: pixel. Default SVG unit + 'em': 10, # 1 em = 10 px FIXME + 'ex': 5, # 1 ex = 5 px FIXME + 'in': 96, # 1 in = 96 px + 'cm': 96 / 2.54, # 1 cm = 1/2.54 in + 'mm': 96 / 25.4, # 1 mm = 1/25.4 in + 'pt': 96 / 72.0, # 1 pt = 1/72 in + 'pc': 96 / 6.0, # 1 pc = 1/6 in + '%' : 1 / 100.0 # 1 percent + } + +class Transformable: + '''Abstract class for objects that can be geometrically drawn & transformed''' + def __init__(self, elt=None): + # a 'Transformable' is represented as a list of Transformable items + self.items = [] + self.id = hex(id(self)) + # Unit transformation matrix on init + self.matrix = Matrix() + self.viewport = Point(800, 600) # default viewport is 800x600 + if elt is not None: + self.id = elt.get('id', self.id) + # Parse transform attibute to update self.matrix + self.getTransformations(elt) + + def bbox(self): + '''Bounding box''' + bboxes = [x.bbox() for x in self.items] + xmin = min([b[0].x for b in bboxes]) + xmax = max([b[1].x for b in bboxes]) + ymin = min([b[0].y for b in bboxes]) + ymax = max([b[1].y for b in bboxes]) + + return (Point(xmin,ymin), Point(xmax,ymax)) + + # Parse transform field + def getTransformations(self, elt): + t = elt.get('transform') + if t is None: return + + svg_transforms = [ + 'matrix', 'translate', 'scale', 'rotate', 'skewX', 'skewY'] + + # match any SVG transformation with its parameter (until final parenthese) + # [^)]* == anything but a closing parenthese + # '|'.join == OR-list of SVG transformations + transforms = re.findall( + '|'.join([x + '[^)]*\)' for x in svg_transforms]), t) + + for t in transforms: + op, arg = t.split('(') + op = op.strip() + # Keep only numbers + arg = [float(x) for x in re.findall(number_re, arg)] + print('transform: ' + op + ' '+ str(arg)) + + if op == 'matrix': + self.matrix *= Matrix(arg) + + if op == 'translate': + tx = arg[0] + if len(arg) == 1: ty = 0 + else: ty = arg[1] + self.matrix *= Matrix([1, 0, 0, 1, tx, ty]) + + if op == 'scale': + sx = arg[0] + if len(arg) == 1: sy = sx + else: sy = arg[1] + self.matrix *= Matrix([sx, 0, 0, sy, 0, 0]) + + if op == 'rotate': + cosa = math.cos(math.radians(arg[0])) + sina = math.sin(math.radians(arg[0])) + if len(arg) != 1: + tx, ty = arg[1:3] + self.matrix *= Matrix([1, 0, 0, 1, tx, ty]) + self.matrix *= Matrix([cosa, sina, -sina, cosa, 0, 0]) + if len(arg) != 1: + self.matrix *= Matrix([1, 0, 0, 1, -tx, -ty]) + + if op == 'skewX': + tana = math.tan(math.radians(arg[0])) + self.matrix *= Matrix([1, 0, tana, 1, 0, 0]) + + if op == 'skewY': + tana = math.tan(math.radians(arg[0])) + self.matrix *= Matrix([1, tana, 0, 1, 0, 0]) + + def transform(self, matrix=None): + for x in self.items: + x.transform(self.matrix) + + def length(self, v, mode='xy'): + # Handle empty (non-existing) length element + if v is None: + return 0 + + # Get length value + m = re.search(number_re, v) + if m: value = m.group(0) + else: raise TypeError(v + 'is not a valid length') + + # Get length unit + m = re.search(unit_re, v) + if m: unit = m.group(0) + else: unit = None + + if unit == '%': + if mode == 'x': + return float(value) * unit_convert[unit] * self.viewport.x + if mode == 'y': + return float(value) * unit_convert[unit] * self.viewport.y + if mode == 'xy': + return float(value) * unit_convert[unit] * self.viewport.x # FIXME + + return float(value) * unit_convert[unit] + + def xlength(self, x): + return self.length(x, 'x') + def ylength(self, y): + return self.length(y, 'y') + + def flatten(self): + '''Flatten the SVG objects nested list into a flat (1-D) list, + removing Groups''' + # http://rightfootin.blogspot.fr/2006/09/more-on-python-flatten.html + # Assigning a slice a[i:i+1] with a list actually replaces the a[i] + # element with the content of the assigned list + i = 0 + flat = copy.deepcopy(self.items) + while i < len(flat): + while isinstance(flat[i], Group): + flat[i:i+1] = flat[i].items + i += 1 + return flat + + def scale(self, ratio): + for x in self.items: + x.scale(ratio) + return self + + def translate(self, offset): + for x in self.items: + x.translate(offset) + return self + + def rotate(self, angle): + for x in self.items: + x.rotate(angle) + return self + +class Svg(Transformable): + '''SVG class: use parse to parse a file''' + # class Svg handles the <svg> tag + # tag = 'svg' + + def __init__(self, filename=None): + Transformable.__init__(self) + if filename: + self.parse(filename) + + def parse(self, filename): + self.filename = filename + tree = etree.parse(filename) + self.root = tree.getroot() + if self.root.tag != svg_ns + 'svg': + raise TypeError('file %s does not seem to be a valid SVG file', filename) + + # Create a top Group to group all other items (useful for viewBox elt) + top_group = Group() + self.items.append(top_group) + + # SVG dimension + width = self.xlength(self.root.get('width')) + height = self.ylength(self.root.get('height')) + # update viewport + top_group.viewport = Point(width, height) + + # viewBox + if self.root.get('viewBox') is not None: + viewBox = re.findall(number_re, self.root.get('viewBox')) + sx = width / float(viewBox[2]) + sy = height / float(viewBox[3]) + tx = -float(viewBox[0]) + ty = -float(viewBox[1]) + top_group.matrix = Matrix([sx, 0, 0, sy, tx, ty]) + + # Parse XML elements hierarchically with groups <g> + top_group.append(self.root) + + self.transform() + + def title(self): + t = self.root.find(svg_ns + 'title') + if t is not None: + return t + else: + return os.path.splitext(os.path.basename(self.filename))[0] + + def json(self): + return self.items + + +class Group(Transformable): + '''Handle svg <g> elements''' + # class Group handles the <g> tag + tag = 'g' + + def __init__(self, elt=None): + Transformable.__init__(self, elt) + + def append(self, element): + for elt in element: + elt_class = svgClass.get(elt.tag, None) + if elt_class is None: + print('No handler for element %s' % elt.tag) + continue + # instanciate elt associated class (e.g. <path>: item = Path(elt) + item = elt_class(elt) + # Apply group matrix to the newly created object + item.matrix = self.matrix * item.matrix + item.viewport = self.viewport + + self.items.append(item) + # Recursively append if elt is a <g> (group) + if elt.tag == svg_ns + 'g': + item.append(elt) + + def __repr__(self): + return '<Group ' + self.id + '>: ' + repr(self.items) + + def json(self): + return {'Group ' + self.id : self.items} + +class Matrix: + ''' SVG transformation matrix and its operations + a SVG matrix is represented as a list of 6 values [a, b, c, d, e, f] + (named vect hereafter) which represent the 3x3 matrix + ((a, c, e) + (b, d, f) + (0, 0, 1)) + see http://www.w3.org/TR/SVG/coords.html#EstablishingANewUserSpace ''' + + def __init__(self, vect=[1, 0, 0, 1, 0, 0]): + # Unit transformation vect by default + if len(vect) != 6: + raise ValueError("Bad vect size %d" % len(vect)) + self.vect = list(vect) + + def __mul__(self, other): + '''Matrix multiplication''' + if isinstance(other, Matrix): + a = self.vect[0] * other.vect[0] + self.vect[2] * other.vect[1] + b = self.vect[1] * other.vect[0] + self.vect[3] * other.vect[1] + c = self.vect[0] * other.vect[2] + self.vect[2] * other.vect[3] + d = self.vect[1] * other.vect[2] + self.vect[3] * other.vect[3] + e = self.vect[0] * other.vect[4] + self.vect[2] * other.vect[5] \ + + self.vect[4] + f = self.vect[1] * other.vect[4] + self.vect[3] * other.vect[5] \ + + self.vect[5] + return Matrix([a, b, c, d, e, f]) + + elif isinstance(other, Point): + x = other.x * self.vect[0] + other.y * self.vect[2] + self.vect[4] + y = other.x * self.vect[1] + other.y * self.vect[3] + self.vect[5] + return Point(x,y) + + else: + return NotImplemented + + def __str__(self): + return str(self.vect) + + def xlength(self, x): + return x * self.vect[0] + def ylength(self, y): + return y * self.vect[3] + + +COMMANDS = 'MmZzLlHhVvCcSsQqTtAa' + +class Path(Transformable): + '''SVG <path>''' + # class Path handles the <path> tag + tag = 'path' + + def __init__(self, elt=None): + Transformable.__init__(self, elt) + if elt is not None: + self.style = elt.get('style') + self.parse(elt.get('d')) + + def parse(self, pathstr): + """Parse path string and build elements list""" + + pathlst = re.findall(number_re + r"|\ *[%s]\ *" % COMMANDS, pathstr) + + pathlst.reverse() + + command = None + current_pt = Point(0,0) + start_pt = None + + while pathlst: + if pathlst[-1].strip() in COMMANDS: + last_command = command + command = pathlst.pop().strip() + absolute = (command == command.upper()) + command = command.upper() + else: + if command is None: + raise ValueError("No command found at %d" % len(pathlst)) + + if command == 'M': + # MoveTo + x = pathlst.pop() + y = pathlst.pop() + pt = Point(x, y) + if absolute: + current_pt = pt + else: + current_pt += pt + start_pt = current_pt + + self.items.append(MoveTo(current_pt)) + + # MoveTo with multiple coordinates means LineTo + command = 'L' + + elif command == 'Z': + # Close Path + l = Segment(current_pt, start_pt) + self.items.append(l) + + + elif command in 'LHV': + # LineTo, Horizontal & Vertical line + # extra coord for H,V + if absolute: + x,y = current_pt.coord() + else: + x,y = (0,0) + + if command in 'LH': + x = pathlst.pop() + if command in 'LV': + y = pathlst.pop() + + pt = Point(x, y) + if not absolute: + pt += current_pt + + self.items.append(Segment(current_pt, pt)) + current_pt = pt + + elif command in 'CQ': + dimension = {'Q':3, 'C':4} + bezier_pts = [] + bezier_pts.append(current_pt) + for i in range(1,dimension[command]): + x = pathlst.pop() + y = pathlst.pop() + pt = Point(x, y) + if not absolute: + pt += current_pt + bezier_pts.append(pt) + + self.items.append(Bezier(bezier_pts)) + current_pt = pt + + elif command in 'TS': + # number of points to read + nbpts = {'T':1, 'S':2} + # the control point, from previous Bezier to mirror + ctrlpt = {'T':1, 'S':2} + # last command control + last = {'T': 'QT', 'S':'CS'} + + bezier_pts = [] + bezier_pts.append(current_pt) + + if last_command in last[command]: + pt0 = self.items[-1].control_point(ctrlpt[command]) + else: + pt0 = current_pt + pt1 = current_pt + # Symetrical of pt1 against pt0 + bezier_pts.append(pt1 + pt1 - pt0) + + for i in range(0,nbpts[command]): + x = pathlst.pop() + y = pathlst.pop() + pt = Point(x, y) + if not absolute: + pt += current_pt + bezier_pts.append(pt) + + self.items.append(Bezier(bezier_pts)) + current_pt = pt + + elif command == 'A': + rx = pathlst.pop() + ry = pathlst.pop() + xrot = pathlst.pop() + # Arc flags are not necesarily sepatated numbers + flags = pathlst.pop().strip() + large_arc_flag = flags[0] + if large_arc_flag not in '01': + print('Arc parsing failure') + break + + if len(flags) > 1: flags = flags[1:].strip() + else: flags = pathlst.pop().strip() + sweep_flag = flags[0] + if sweep_flag not in '01': + print('Arc parsing failure') + break + + if len(flags) > 1: x = flags[1:] + else: x = pathlst.pop() + y = pathlst.pop() + # TODO + print('ARC: ' + + ', '.join([rx, ry, xrot, large_arc_flag, sweep_flag, x, y])) +# self.items.append( +# Arc(rx, ry, xrot, large_arc_flag, sweep_flag, Point(x, y))) + + else: + pathlst.pop() + + def __str__(self): + return '\n'.join(str(x) for x in self.items) + + def __repr__(self): + return '<Path ' + self.id + '>' + + def segments(self, precision=0): + '''Return a list of segments, each segment is ended by a MoveTo. + A segment is a list of Points''' + ret = [] + # group items separated by MoveTo + for moveTo, group in itertools.groupby(self.items, + lambda x: isinstance(x, MoveTo)): + # Use only non MoveTo item + if not moveTo: + # Generate segments for each relevant item + seg = [x.segments(precision) for x in group] + # Merge all segments into one + ret.append(list(itertools.chain.from_iterable(seg))) + + return ret + + def simplify(self, precision): + '''Simplify segment with precision: + Remove any point which are ~aligned''' + ret = [] + for seg in self.segments(precision): + ret.append(simplify_segment(seg, precision)) + + return ret + +class Ellipse(Transformable): + '''SVG <ellipse>''' + # class Ellipse handles the <ellipse> tag + tag = 'ellipse' + + def __init__(self, elt=None): + Transformable.__init__(self, elt) + if elt is not None: + self.center = Point(self.xlength(elt.get('cx')), + self.ylength(elt.get('cy'))) + self.rx = self.length(elt.get('rx')) + self.ry = self.length(elt.get('ry')) + self.style = elt.get('style') + + def __repr__(self): + return '<Ellipse ' + self.id + '>' + + def bbox(self): + '''Bounding box''' + pmin = self.center - Point(self.rx, self.ry) + pmax = self.center + Point(self.rx, self.ry) + return (pmin, pmax) + + def transform(self, matrix): + self.center = self.matrix * self.center + self.rx = self.matrix.xlength(self.rx) + self.ry = self.matrix.ylength(self.ry) + + def scale(self, ratio): + self.center *= ratio + self.rx *= ratio + self.ry *= ratio + def translate(self, offset): + self.center += offset + def rotate(self, angle): + self.center = self.center.rot(angle) + + def P(self, t): + '''Return a Point on the Ellipse for t in [0..1]''' + x = self.center.x + self.rx * math.cos(2 * math.pi * t) + y = self.center.y + self.ry * math.sin(2 * math.pi * t) + return Point(x,y) + + def segments(self, precision=0): + if max(self.rx, self.ry) < precision: + return [[self.center]] + + p = [(0,self.P(0)), (1, self.P(1))] + d = 2 * max(self.rx, self.ry) + + while d > precision: + for (t1,p1),(t2,p2) in zip(p[:-1],p[1:]): + t = t1 + (t2 - t1)/2. + d = Segment(p1, p2).pdistance(self.P(t)) + p.append((t, self.P(t))) + p.sort(key=operator.itemgetter(0)) + + ret = [x for t,x in p] + return [ret] + + def simplify(self, precision): + return self + +# A circle is a special type of ellipse where rx = ry = radius +class Circle(Ellipse): + '''SVG <circle>''' + # class Circle handles the <circle> tag + tag = 'circle' + + def __init__(self, elt=None): + if elt is not None: + elt.set('rx', elt.get('r')) + elt.set('ry', elt.get('r')) + Ellipse.__init__(self, elt) + + def __repr__(self): + return '<Circle ' + self.id + '>' + +class Rect(Transformable): + '''SVG <rect>''' + # class Rect handles the <rect> tag + tag = 'rect' + + def __init__(self, elt=None): + Transformable.__init__(self, elt) + if elt is not None: + self.P1 = Point(self.xlength(elt.get('x')), + self.ylength(elt.get('y'))) + + self.P2 = Point(self.P1.x + self.xlength(elt.get('width')), + self.P1.y + self.ylength(elt.get('height'))) + + def __repr__(self): + return '<Rect ' + self.id + '>' + + def bbox(self): + '''Bounding box''' + xmin = min([p.x for p in (self.P1, self.P2)]) + xmax = max([p.x for p in (self.P1, self.P2)]) + ymin = min([p.y for p in (self.P1, self.P2)]) + ymax = max([p.y for p in (self.P1, self.P2)]) + + return (Point(xmin,ymin), Point(xmax,ymax)) + + def transform(self, matrix): + self.P1 = self.matrix * self.P1 + self.P2 = self.matrix * self.P2 + + def segments(self, precision=0): + # A rectangle is built with a segment going thru 4 points + ret = [] + Pa = Point(self.P1.x, self.P2.y) + Pb = Point(self.P2.x, self.P1.y) + + ret.append([self.P1, Pa, self.P2, Pb, self.P1]) + return ret + + def simplify(self, precision): + return self.segments(precision) + +class Line(Transformable): + '''SVG <line>''' + # class Line handles the <line> tag + tag = 'line' + + def __init__(self, elt=None): + Transformable.__init__(self, elt) + if elt is not None: + self.P1 = Point(self.xlength(elt.get('x1')), + self.ylength(elt.get('y1'))) + self.P2 = Point(self.xlength(elt.get('x2')), + self.ylength(elt.get('y2'))) + self.segment = Segment(self.P1, self.P2) + + def __repr__(self): + return '<Line ' + self.id + '>' + + def bbox(self): + '''Bounding box''' + xmin = min([p.x for p in (self.P1, self.P2)]) + xmax = max([p.x for p in (self.P1, self.P2)]) + ymin = min([p.y for p in (self.P1, self.P2)]) + ymax = max([p.y for p in (self.P1, self.P2)]) + + return (Point(xmin,ymin), Point(xmax,ymax)) + + def transform(self, matrix): + self.P1 = self.matrix * self.P1 + self.P2 = self.matrix * self.P2 + self.segment = Segment(self.P1, self.P2) + + def segments(self, precision=0): + return [self.segment.segments()] + + def simplify(self, precision): + return self.segments(precision) + +# overwrite JSONEncoder for svg classes which have defined a .json() method +class JSONEncoder(json.JSONEncoder): + def default(self, obj): + if not isinstance(obj, tuple(svgClass.values() + [Svg])): + return json.JSONEncoder.default(self, obj) + + if not hasattr(obj, 'json'): + return repr(obj) + + return obj.json() + +## Code executed on module load ## + +# SVG tag handler classes are initialized here +# (classes must be defined before) +import inspect +svgClass = {} +# Register all classes with attribute 'tag' in svgClass dict +for name, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): + tag = getattr(cls, 'tag', None) + if tag: + svgClass[svg_ns + tag] = cls +