• Facebook
  • Twitter
  • Reddit
  • StumbleUpon
  • Digg
  • email

# The MIT License
#
# Copyright (c) 2010 Mikael Lind
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
 
"""An SVG loader for rapid game prototyping in Python.
 
Pinky tries to make it easy to use Inkscape as a game editor, without
getting in your way.
 
See: U{http://github.com/elemel/pinky}
"""
 
from itertools import chain
import math
import re
 
SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
SODIPODI_NAMESPACE = 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd'
 
def parse_style(arg):
    """Parse a CSS attribute list into a dictionary."""
    lines = (l.strip() for l in arg.split(';'))
    pairs = (l.split(':') for l in lines if l)
    return dict((k.strip(), v.strip()) for k, v in pairs)
 
def parse_shape(element):
    if element.namespaceURI == SVG_NAMESPACE:
        if element.localName == 'circle':
            return parse_circle_shape(element)
        elif element.localName == 'rect':
            return parse_rect_shape(element)
        elif element.localName == 'path':
            if element.getAttributeNS(SODIPODI_NAMESPACE, 'type') == 'arc':
                return parse_arc_shape(element)
            else:
                return parse_path_shape(element)
    return None
 
def parse_arc_shape(element):
    cx = float(element.getAttributeNS(SODIPODI_NAMESPACE, 'cx') or '0')
    cy = float(element.getAttributeNS(SODIPODI_NAMESPACE, 'cy') or '0')
    rx = float(element.getAttributeNS(SODIPODI_NAMESPACE, 'rx'))
    ry = float(element.getAttributeNS(SODIPODI_NAMESPACE, 'ry'))
    return Circle(cx, cy, (rx + ry) / 2.0)
 
def parse_circle_shape(element):
    cx = float(element.getAttribute('cx') or '0')
    cy = float(element.getAttribute('cy') or '0')
    r = float(element.getAttribute('r'))
    return Circle(cx, cy, r)
 
def parse_path_shape(element):
    d = element.getAttribute('d')
    return Path.from_string(d)
 
# TODO: Return a Rect instance, not a Polygon instance.
def parse_rect_shape(element):
    x = float(element.getAttribute('x') or '0')
    y = float(element.getAttribute('y') or '0')
    width = float(element.getAttribute('width'))
    height = float(element.getAttribute('height'))
    rx = float(element.getAttribute('rx') or '0')
    ry = float(element.getAttribute('ry') or '0')
    points = [(x, y), (x + width, y),
              (x + width, y + height), (x, y + height)]
    return Polygon(points)
 
class Color(object):
    """An RGB color with integer components in the [0, 255] range."""
 
    # http://www.w3.org/TR/SVG/types.html#ColorKeywords
    _color_keywords = dict(
        aliceblue=(240, 248, 255),
        antiquewhite=(250, 235, 215),
        aqua=(0, 255, 255),
        aquamarine=(127, 255, 212),
        azure=(240, 255, 255),
        beige=(245, 245, 220),
        bisque=(255, 228, 196),
        black=(0, 0, 0),
        blanchedalmond=(255, 235, 205),
        blue=(0, 0, 255),
        blueviolet=(138, 43, 226),
        brown=(165, 42, 42),
        burlywood=(222, 184, 135),
        cadetblue=(95, 158, 160),
        chartreuse=(127, 255, 0),
        chocolate=(210, 105, 30),
        coral=(255, 127, 80),
        cornflowerblue=(100, 149, 237),
        cornsilk=(255, 248, 220),
        crimson=(220, 20, 60),
        cyan=(0, 255, 255),
        darkblue=(0, 0, 139),
        darkcyan=(0, 139, 139),
        darkgoldenrod=(184, 134, 11),
        darkgray=(169, 169, 169),
        darkgreen=(0, 100, 0),
        darkgrey=(169, 169, 169),
        darkkhaki=(189, 183, 107),
        darkmagenta=(139, 0, 139),
        darkolivegreen=(85, 107, 47),
        darkorange=(255, 140, 0),
        darkorchid=(153, 50, 204),
        darkred=(139, 0, 0),
        darksalmon=(233, 150, 122),
        darkseagreen=(143, 188, 143),
        darkslateblue=(72, 61, 139),
        darkslategray=(47, 79, 79),
        darkslategrey=(47, 79, 79),
        darkturquoise=(0, 206, 209),
        darkviolet=(148, 0, 211),
        deeppink=(255, 20, 147),
        deepskyblue=(0, 191, 255),
        dimgray=(105, 105, 105),
        dimgrey=(105, 105, 105),
        dodgerblue=(30, 144, 255),
        firebrick=(178, 34, 34),
        floralwhite=(255, 250, 240),
        forestgreen=(34, 139, 34),
        fuchsia=(255, 0, 255),
        gainsboro=(220, 220, 220),
        ghostwhite=(248, 248, 255),
        gold=(255, 215, 0),
        goldenrod=(218, 165, 32),
        gray=(128, 128, 128),
        grey=(128, 128, 128),
        green=(0, 128, 0),
        greenyellow=(173, 255, 47),
        honeydew=(240, 255, 240),
        hotpink=(255, 105, 180),
        indianred=(205, 92, 92),
        indigo=(75, 0, 130),
        ivory=(255, 255, 240),
        khaki=(240, 230, 140),
        lavender=(230, 230, 250),
        lavenderblush=(255, 240, 245),
        lawngreen=(124, 252, 0),
        lemonchiffon=(255, 250, 205),
        lightblue=(173, 216, 230),
        lightcoral=(240, 128, 128),
        lightcyan=(224, 255, 255),
        lightgoldenrodyellow=(250, 250, 210),
        lightgray=(211, 211, 211),
        lightgreen=(144, 238, 144),
        lightgrey=(211, 211, 211),
        lightpink=(255, 182, 193),
        lightsalmon=(255, 160, 122),
        lightseagreen=(32, 178, 170),
        lightskyblue=(135, 206, 250),
        lightslategray=(119, 136, 153),
        lightslategrey=(119, 136, 153),
        lightsteelblue=(176, 196, 222),
        lightyellow=(255, 255, 224),
        lime=(0, 255, 0),
        limegreen=(50, 205, 50),
        linen=(250, 240, 230),
        magenta=(255, 0, 255),
        maroon=(128, 0, 0),
        mediumaquamarine=(102, 205, 170),
        mediumblue=(0, 0, 205),
        mediumorchid=(186, 85, 211),
        mediumpurple=(147, 112, 219),
        mediumseagreen=(60, 179, 113),
        mediumslateblue=(123, 104, 238),
        mediumspringgreen=(0, 250, 154),
        mediumturquoise=(72, 209, 204),
        mediumvioletred=(199, 21, 133),
        midnightblue=(25, 25, 112),
        mintcream=(245, 255, 250),
        mistyrose=(255, 228, 225),
        moccasin=(255, 228, 181),
        navajowhite=(255, 222, 173),
        navy=(0, 0, 128),
        oldlace=(253, 245, 230),
        olive=(128, 128, 0),
        olivedrab=(107, 142, 35),
        orange=(255, 165, 0),
        orangered=(255, 69, 0),
        orchid=(218, 112, 214),
        palegoldenrod=(238, 232, 170),
        palegreen=(152, 251, 152),
        paleturquoise=(175, 238, 238),
        palevioletred=(219, 112, 147),
        papayawhip=(255, 239, 213),
        peachpuff=(255, 218, 185),
        peru=(205, 133, 63),
        pink=(255, 192, 203),
        plum=(221, 160, 221),
        powderblue=(176, 224, 230),
        purple=(128, 0, 128),
        red=(255, 0, 0),
        rosybrown=(188, 143, 143),
        royalblue=(65, 105, 225),
        saddlebrown=(139, 69, 19),
        salmon=(250, 128, 114),
        sandybrown=(244, 164, 96),
        seagreen=(46, 139, 87),
        seashell=(255, 245, 238),
        sienna=(160, 82, 45),
        silver=(192, 192, 192),
        skyblue=(135, 206, 235),
        slateblue=(106, 90, 205),
        slategray=(112, 128, 144),
        slategrey=(112, 128, 144),
        snow=(255, 250, 250),
        springgreen=(0, 255, 127),
        steelblue=(70, 130, 180),
        tan=(210, 180, 140),
        teal=(0, 128, 128),
        thistle=(216, 191, 216),
        tomato=(255, 99, 71),
        turquoise=(64, 224, 208),
        violet=(238, 130, 238),
        wheat=(245, 222, 179),
        white=(255, 255, 255),
        whitesmoke=(245, 245, 245),
        yellow=(255, 255, 0),
        yellowgreen=(154, 205, 50),
    )
 
    def __init__(self, red=0, green=0, blue=0):
        self.red = red
        self.green = green
        self.blue = blue
 
    def __iter__(self):
        yield self.red
        yield self.green
        yield self.blue
 
    def __str__(self):
        return '#%02x%02x%02x' % (self.red, self.green, self.blue)
 
    def __repr__(self):
        return 'Color(%i, %i, %i)' % (self.red, self.green, self.blue)
 
    @classmethod
    def from_string(cls, arg):
        lower_arg = arg.lower()
        if lower_arg == 'none':
            return None
        if lower_arg in cls._color_keywords:
            red, green, blue = cls._color_keywords[lower_arg]
        elif len(arg) == 4 and arg.startswith('#'):
            red = int(arg[1], 16) * 17
            green = int(arg[2], 16) * 17
            blue = int(arg[3], 16) * 17
        elif len(arg) == 7 and arg.startswith('#'):
            red = int(arg[1:3], 16)
            green = int(arg[3:5], 16)
            blue = int(arg[5:7], 16)
        else:
            raise ValueError('invalid color: ' + arg)
        return cls(red, green, blue)
 
    @property
    def red_as_float(self):
        return float(self.red) / 255.0
 
    @property
    def green_as_float(self):
        return float(self.green) / 255.0
 
    @property
    def blue_as_float(self):
        return float(self.blue) / 255.0
 
    @property
    def components_as_float(self):
        return self.red_as_float, self.green_as_float, self.blue_as_float
 
class Matrix(object):
    """A transformation matrix."""
 
    def __init__(self, a=1.0, b=0.0, c=0.0, d=1.0, e=0.0, f=0.0):
        """Initialize a matrix from the given components."""
        assert all(isinstance(x, float) for x in (a, b, c, d, e, f))
        self.abcdef = a, b, c, d, e, f
 
    @classmethod
    def from_string(cls, arg):
        matrix = Matrix()
        for part in arg.replace(',', ' ').split(')')[:-1]:
            name, args = part.strip().split('(')
            name = name.rstrip()
            args = map(float, args.split())
            if name == 'matrix':
                matrix *= cls(*args)
            elif name == 'translate':
                matrix *= cls.create_translate(*args)
            elif name == 'scale':
                matrix *= cls.create_scale(*args)
            elif name == 'rotate':
                matrix *= cls.create_rotate(*args)
            elif name == 'skewX':
                matrix *= cls.create_skew_x(*args)
            elif name == 'skewY':
                matrix *= cls.create_skew_y(*args)
            else:
                raise ValueError('invalid transform: ' + name)
        return matrix
 
    def __str__(self):
        """Get an SVG representation of the matrix."""
        return 'matrix(%g %g %g %g %g %g)' % self.abcdef
 
    def __repr__(self):
        """Get a Python representation of the matrix."""
        return 'Matrix(%r, %r, %r, %r, %r, %r)' % self.abcdef
 
    def __mul__(self, other):
        """Multiply with another matrix."""
        if isinstance(other, Matrix):
            a1, b1, c1, d1, e1, f1 = self.abcdef
            a2, b2, c2, d2, e2, f2 = other.abcdef
            a3 = a1 * a2 + c1 * b2
            b3 = b1 * a2 + d1 * b2
            c3 = a1 * c2 + c1 * d2
            d3 = b1 * c2 + d1 * d2
            e3 = a1 * e2 + c1 * f2 + e1
            f3 = b1 * e2 + d1 * f2 + f1
            return Matrix(a3, b3, c3, d3, e3, f3)
        else:
            return NotImplemented
 
    def transform_point(self, x, y):
        """Get a transformed copy of a point."""
        a, b, c, d, e, f = self.abcdef
        return a * x + c * y + e, b * x + d * y + f
 
    def transform_shape(self, shape):
        """Get a transformed copy of a shape."""
        return shape.transform(self)
 
    @classmethod
    def create_translate(cls, tx, ty=0.0):
        """Create a translation matrix."""
        return cls(1.0, 0.0, 0.0, 1.0, tx, ty)
 
    @classmethod
    def create_scale(cls, sx, sy=None):
        """Create a scale matrix."""
        if sy is None:
            sy = sx
        return cls(sx, 0.0, 0.0, sy, 0.0, 0.0)
 
    @classmethod
    def create_rotate(cls, angle, cx=None, cy=None):
        """Create a rotation matrix."""
        if cx is not None and cy is not None:
            return (cls.create_translate(cx, cy) * cls.create_rotate(angle) *
                    cls.create_translate(-cx, -cy))
        angle_rad = angle * math.pi / 180.0
        cos_angle = math.cos(angle_rad)
        sin_angle = math.sin(angle_rad)
        return cls(cos_angle, sin_angle, -sin_angle, cos_angle, 0.0, 0.0)
 
    @classmethod
    def create_skew_x(cls, angle):
        """Create a horizontal skew matrix."""
        angle_rad = angle * math.pi / 180.0
        return cls(1.0, 0.0, math.tan(angle_rad), 1.0, 0.0, 0.0)
 
    @classmethod
    def create_skew_y(cls, angle):
        """Create a vertical skew matrix."""
        angle_rad = angle * math.pi / 180.0
        return cls(1.0, math.tan(angle_rad), 0.0, 1.0, 0.0, 0.0)
 
    @classmethod
    def create_flip_x(cls):
        """Create a horizontal flip matrix."""
        return cls(-1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
 
    @classmethod
    def create_flip_y(cls):
        """Create a vertical flip matrix."""
        return cls(1.0, 0.0, 0.0, -1.0, 0.0, 0.0)
 
class Shape(object):
    """The base class for shapes."""
 
    @property
    def bounding_box(self):
        """The bounding box of the shape."""
        raise NotImplementedError()
 
    @property
    def perimeter(self):
        """The perimeter of the shape."""
        raise NotImplementedError()
 
    @property
    def area(self):
        """The area of the shape."""
        raise NotImplementedError()
 
    @property
    def centroid(self):
        """The mass center of the shape."""
        raise NotImplementedError()
 
    def transform(self, matrix):
        """Get a transformed copy of the shape."""
        raise NotImplementedError()
 
    def get_bounding_box(self, matrix):
        """Get the bounding box of the shape after applying the given
        transformation matrix."""
        return self.transform(matrix).bounding_box
 
class BoundingBox(Shape):
    """An axis-aligned rectangle for representing shape boundaries.
 
    See: U{http://www.w3.org/TR/SVG/coords.html#ObjectBoundingBox}
    """
 
    def __init__(self, min_x=float('inf'), min_y=float('inf'),
                 max_x=float('-inf'), max_y=float('-inf')):
        """Initialize a bounding box from the given minima and maxima."""
        self.min_x = min_x
        self.min_y = min_y
        self.max_x = max_x
        self.max_y = max_y
 
    def __nonzero__(self):
        """Is the bounding box non-empty?"""
        return self.min_x <= self.max_x and self.min_y <= self.max_y
 
    def __repr__(self):
        return ('BoundingBox(min_x=%r, min_y=%r, max_x=%r, max_y=%r)' %
                (self.min_x, self.min_y, self.max_x, self.max_y))
 
    def add_point(self, x, y, matrix=None):
        """Expand the bounding box to contain the given point."""
        if matrix is not None:
            x, y = matrix.transform_point(x, y)
        self.min_x = min(self.min_x, x)
        self.min_y = min(self.min_y, y)
        self.max_x = max(self.max_x, x)
        self.max_y = max(self.max_y, y)
 
    def add_shape(self, shape, matrix=None):
        """Expand the bounding box to contain the given shape."""
        if isinstance(shape, tuple):
            if matrix is None:
                x, y = shape
            else:
                x, y = matrix.transform_point(shape)
            self.min_x = min(self.min_x, x)
            self.min_y = min(self.min_y, y)
            self.max_x = max(self.max_x, x)
            self.max_y = max(self.max_y, y)
        else:
            if matrix is None:
                bounding_box = shape.bounding_box
            else:
                bounding_box = shape.get_bounding_box(matrix)
            self.min_x = min(self.min_x, bounding_box.min_x)
            self.min_y = min(self.min_y, bounding_box.min_y)
            self.max_x = max(self.max_x, bounding_box.max_x)
            self.max_y = max(self.max_y, bounding_box.max_y)
 
    def intersects(self, other):
        """Do the two bounding boxes intersect?"""
        return (self.min_x < other.max_x and other.min_x < self.max_x and
                self.min_y < other.max_y and other.min_y < self.max_y)
 
    @property
    def bounding_box(self):
        """The bounding box itself."""
        return self
 
    @property
    def width(self):
        """The width of the bounding box."""
        return self.max_x - self.min_x
 
    @property
    def height(self):
        """The height of the bounding box."""
        return self.max_y - self.min_y
 
    @property
    def area(self):
        return self.width * self.height
 
    @property
    def centroid(self):
        cx = 0.5 * (self.min_x + self.max_x)
        cy = 0.5 * (self.min_y + self.max_y)
        return cx, cy
 
    @classmethod
    def from_points(cls, points, matrix=None):
        bounding_box = cls()
        for x, y in points:
            bounding_box.add_point(x, y, matrix)
        return bounding_box
 
    @classmethod
    def from_shapes(cls, shapes, matrix=None):
        bounding_box = cls()
        for shape in shapes:
            bounding_box.add_shape(shape, matrix)
        return bounding_box
 
class Line(Shape):
    """A line."""
 
    def __init__(self, x1, y1, x2, y2):
        """Initialize a line from two points."""
        self.x1, self.y1 = x1, y1
        self.x2, self.y2 = x2, y2
 
    def __repr__(self):
        return ('Line(x1=%r, y1=%r, x2=%r, y2=%r)' %
                (self.x1, self.y1, self.x2, self.y2))
 
    def transform(self, matrix):
        x1, y1 = matrix.transform_point(self.x1, self.y1)
        x2, y2 = matrix.transform_point(self.x2, self.y2)
        return Line(x1, y1, x2, y2)
 
    @property
    def p1(self):
        """The first point."""
        return self.x1, self.y1
 
    @property
    def p2(self):
        """The second point."""
        return self.x2, self.y2
 
    @property
    def area(self):
        """The area of a line is always zero."""
        return 0.0
 
    @property
    def centroid(self):
        """The center point of the line."""
        cx = 0.5 * (self.x1 + self.x2)
        cy = 0.5 * (self.y1 + self.y2)
        return cx, cy
 
    @property
    def bounding_box(self):
        min_x = min(self.x1, self.x2)
        min_y = min(self.y1, self.y2)
        max_x = max(self.x1, self.x2)
        max_y = max(self.y1, self.y2)
        return BoundingBox(min_x, min_y, max_x, max_y)
 
    @property
    def path(self):
        commands = [Moveto(self.x1, self.y1), Lineto(self.x2, self.y2)]
        return Path([Subpath(commands)])
 
class Polyline(Shape):
    """A line strip."""
 
    def __init__(self, points):
        self.points = list(points)
 
    def __repr__(self):
        return 'Polyline(%r)' % self.points
 
    def transform(self, matrix):
        return Polyline(matrix.transform_point(x, y) for x, y in self.points)
 
    @property
    def area(self):
        return 0.0
 
    @property
    def bounding_box(self):
        xs, ys = zip(*self.points)
        return BoundingBox(min(xs), min(ys), max(xs), max(ys))
 
class Polygon(Shape):
    """A polygon."""
 
    def __init__(self, points):
        self.points = list(points)
 
    def __repr__(self):
        return 'Polygon(%r)' % self.points
 
    def transform(self, matrix):
        return Polygon(matrix.transform_point(x, y) for x, y in self.points)
 
    @property
    def area(self):
        """The area of the polygon.
 
        See: U{http://mathworld.wolfram.com/PolygonArea.html}
        """
        area = 0.0
        for i in xrange(len(self.points)):
            x1, y1 = self.points[i]
            x2, y2 = self.points[(i + 1) % len(self.points)]
            area += x1 * y2 - x2 * y1
        return area / 2.0
 
    @property
    def bounding_box(self):
        xs, ys = zip(*self.points)
        return BoundingBox(min(xs), min(ys), max(xs), max(ys))
 
    def repair(self, epsilon=0.0):
        def eq(p1, p2):
            x1, y1 = p1
            x2, y2 = p2
            squared_distance = (x2 - x1) ** 2 + (y2 - y1) ** 2
            return squared_distance <= epsilon ** 2
        if len(self.points) >= 2 and eq(self.points[0], self.points[-1]):
            self.points.pop()
        if self.area < 0.0:
            self.points.reverse()
 
class Circle(Shape):
    """A circle."""
 
    def __init__(self, cx, cy, r):
        """Initialize a circle from the given center point and radius."""
        self.cx, self.cy = cx, cy
        self.r = r
 
    def __repr__(self):
        return 'Circle(cx=%r, cy=%r, r=%r)' % (self.cx, self.cy, self.r)
 
    def transform(self, matrix):
        """Get a transformed copy of the circle.
 
        The given transform should only translate, scale, and rotate the
        circle. The scale should maintain aspect ratio.
        """
        cx, cy = matrix.transform_point(self.cx, self.cy)
        px, py = matrix.transform_point(self.cx + self.r, self.cy)
        r = math.sqrt((px - cx) ** 2 + (py - cy) ** 2)
        return Circle(cx, cy, r)
 
    @property
    def centroid(self):
        return self.cx, self.cy
 
    @property
    def bounding_box(self):
        return BoundingBox(self.cx - self.r, self.cy - self.r,
                           self.cx + self.r, self.cy + self.r)
 
class Rect(Shape):
    """An axis-aligned rectangle with rounded corners."""
 
    def __init__(self, x, y, width, height, rx, ry):
        """Initialize a rectangle from the given position, dimensions, and
        corner radii.
        """
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.rx = rx
        self.ry = ry
 
    def __repr__(self):
        return ('Rect(x=%r, y=%r, width=%r, height=%r, rx=%r, ry=%r)' %
                (self.x, self.y, self.width, self.height, self.rx, self.ry))
 
    @property
    def perimeter(self):
        if not self.rx and not self.ry:
            return 2.0 * (self.width + self.height)
        else:
            raise NotImplementedError()
 
    @property
    def area(self):
        if not self.rx and not self.ry:
            return self.width * self.height
        else:
            raise NotImplementedError()
 
    @property
    def centroid(self):
        return self.x + 0.5 * self.width, self.y + 0.5 * self.height
 
    @property
    def bounding_box(self):
        return BoundingBox(self.x, self.y, self.x + self.width,
                           self.y + self.height)
 
    @property
    def polygon(self):
        return Polygon([(self.x, self.y),
                        (self.x + self.width, self.y),
                        (self.x + self.width, self.y + self.height),
                        (self.x, self.y + self.height)])
 
class Command(object):
    """The base class for path commands."""
 
    __slots__ = ()
 
    def __init__(self, *args):
        """Initialize a command from the given arguments."""
        for name, value in zip(self.__slots__, args):
            setattr(self, name, value)
 
    def __str__(self):
        """Get an SVG representation of the command."""
        return '%s %s' % (self.letter,
                          ' '.join('%g' % getattr(self, s)
                                   for s in self.__slots__))
 
    def __repr__(self):
        """Get a Python representation of the command."""
        return '%s(%s)' % (self.__class__.__name__,
                           ', '.join('%s=%r' % (s, getattr(self, s))
                                     for s in self.__slots__))
 
    def transform(self, matrix):
        """Get a transformed copy of the command."""
        control_points = [matrix.transform_point(x, y)
                          for x, y in self.control_points]
        return self.__class__(*chain(*control_points))
 
    @property
    def endpoint(self):
        """The endpoint of the command."""
        return (self.x, self.y) if self.__slots__ else None
 
    @property
    def control_points(self):
        """The control points of the command."""
        args = [getattr(self, s) for s in self.__slots__]
        return zip(args[::2], args[1::2])
 
class Moveto(Command):
    """A moveto command."""
 
    letter = 'M'
    __slots__ = 'x', 'y'
 
class Closepath(Command):
    """A closepath command."""
 
    letter = 'Z'
    __slots__ = ()
 
class Lineto(Command):
    """A lineto command."""
 
    letter = 'L'
    __slots__ = 'x', 'y'
 
class Curveto(Command):
    """A curveto command."""
 
    letter = 'C'
    __slots__ = 'x1', 'y1', 'x2', 'y2', 'x', 'y'
 
class SmoothCurveto(Command):
    """A smooth curveto command."""
 
    letter = 'S'
    __slots__ = 'x2', 'y2', 'x', 'y'
 
class QuadraticBezierCurveto(Command):
    """A quadratic Bezier curveto command."""
 
    letter = 'Q'
    __slots__ = 'x1', 'y1', 'x', 'y'
 
class SmoothQuadraticBezierCurveto(Command):
    """A smooth quadratic Bezier curveto command."""
 
    letter = 'T'
    __slots__ = 'x', 'y'
 
class EllipticalArc(Command):
    """An elliptical arc command."""
 
    letter = 'A'
    __slots__ = 'rx', 'ry', 'rotation', 'large', 'sweep', 'x', 'y'
 
    # TODO: Proper implementation?
    def transform(self, matrix):
        rx = self.rx
        ry = self.ry
        rotation = self.rotation
        large = self.large
        sweep = self.sweep
        x, y = matrix.transform_point(self.x, self.y)
        return EllipticalArc(rx, ry, rotation, large, sweep, x, y)
 
    # TODO: Proper implementation?
    @property
    def control_points(self):
        return [(self.x, self.y)]
 
class Subpath(Shape):
    """A subpath."""
 
    def __init__(self, commands):
        self.commands = list(commands)
        assert all(isinstance(c, Command) for c in self.commands)
 
    def transform(self, matrix):
        return Subpath(c.transform(matrix) for c in self.commands)
 
    @property
    def basic_shape(self):
        """Convert the subpath to a basic shape."""
        if self.closed:
            return Polygon(c.endpoint for c in self.commands[:-1])
        elif len(self.commands) == 2:
            x1, y1 = self.commands[0].endpoint
            x2, y2 = self.commands[1].endpoint
            return Line(x1, y1, x2, y2)
        else:
            return Polyline(c.endpoint for c in self.commands)
 
    @property
    def closed(self):
        return self.commands and self.commands[-1].endpoint is None
 
class Path(Shape):
    """A path."""
 
    _scanner = re.Scanner([
        ('[MmZzLlHhVvCcSsQqTtAa]', (lambda s, t: t)),
        ('[-+0-9.Ee]+', (lambda s, t: float(t))),
        ('[, \t\r\n]+', None),
    ])
 
    _command_classes = dict(M=Moveto, Z=Closepath, L=Lineto, C=Curveto,
                            S=SmoothCurveto, Q=QuadraticBezierCurveto,
                            T=SmoothQuadraticBezierCurveto, A=EllipticalArc)
 
    def __init__(self, subpaths):
        self.subpaths = list(subpaths)
        assert all(isinstance(s, Subpath) for s in self.subpaths)
 
    def __str__(self):
        """Get an SVG representation of the path."""
        return ' '.join(str(c) for c in self.commands)
 
    def transform(self, matrix):
        return Path(s.transform(matrix) for s in self.subpaths)
 
    @property
    def bounding_box(self):
        control_points = (c.control_points for c in self.commands)
        return BoundingBox.from_points(chain(*control_points))
 
    @property
    def commands(self):
        commands = (s.commands for s in self.subpaths)
        return chain(*commands)
 
    @property
    def basic_shapes(self):
        """Convert the path to basic shapes."""
        return [s.basic_shape for s in self.subpaths]
 
    @classmethod
    def from_string(cls, arg):
        assert isinstance(arg, basestring)
        command_tuples = cls._parse_commands(arg)
        command_tuples = cls._split_polycommands(command_tuples)
        command_tuples = cls._to_absolute_commands(command_tuples)
        commands = []
        for command_tuple in command_tuples:
            name, args = command_tuple[0], command_tuple[1:]
            command_class = cls._command_classes[name]
            command = command_class(*args)
            commands.append(command)
        subpaths = cls._split_subpaths(commands)
        return Path(Subpath(s) for s in subpaths)
 
    @classmethod
    def _parse_commands(cls, path_str):
        tokens, remainder = cls._scanner.scan(path_str)
        if remainder:
            raise ValueError('could not tokenize path: ' + remainder)
        command = []
        for token in tokens:
            if isinstance(token, basestring):
                if command:
                    yield tuple(command)
                    del command[:]
            else:
                if not command:
                    raise ValueError('argument before first command')
                if command[0] in 'Aa':
                    arg_index = (len(command) - 1) % 7
                    if arg_index in (0, 1):
                        token = abs(token)
                    elif arg_index in (3, 4):
                        token = bool(token)
            command.append(token)
        if command:
            yield tuple(command)
 
    @classmethod
    def _split_polycommands(cls, commands):
        for command in commands:
            name = command[0]
            arg_count = len(cls._command_classes[name.upper()].__slots__)
            if len(command) - 1 > arg_count:
                for i in xrange(1, len(command), arg_count):
                    if name == 'M' and i > 1:
                        name = 'L'
                    if name == 'm' and i > 1:
                        name = 'l'
                    yield (name,) + command[i:i + arg_count]
            else:
                yield command
 
    @classmethod
    def _to_absolute_commands(cls, commands):
        mx, my = 0.0, 0.0
        cx, cy = 0.0, 0.0
        for command in commands:
            x, y = cx, cy
            name, args = command[0], command[1:]
            if name == 'M':
                x, y = args
                mx, my = x, y
            elif name == 'm':
                x, y = args
                x += cx
                y += cy
                mx, my = x, y
                command = 'M', x, y
            elif name == 'Z':
                x, y = mx, my
            elif name == 'z':
                x, y = mx, my
                command = 'Z',
            elif name == 'L':
                x, y = args
            elif name == 'l':
                x, y = args
                x += cx
                y += cy
                command = 'L', x, y
            elif name == 'H':
                x, = args
                command = 'L', x, y
            elif name == 'h':
                x, = args
                x += cx
                command = 'L', x, y
            elif name == 'V':
                y, = args
                command = 'L', x, y
            elif name == 'v':
                y, = args
                y += cy
                command = 'L', x, y
            elif name == 'C':
                _, _, _, _, x, y = args
            elif name == 'c':
                x1, y1, x2, y2, x, y = args
                x1 += cx
                y1 += cy
                x2 += cx
                y2 += cy
                x += cx
                y += cy
                command = 'C', x1, y1, x2, y2, x, y
            elif name == 'S':
                _, _, x, y = args
            elif name == 's':
                x2, y2, x, y = args
                x2 += cx
                y2 += cy
                x += cx
                y += cy
                command = 'S', x2, y2, x, y
            elif name == 'Q':
                _, _, x, y = args
            elif name == 'q':
                x1, y1, x, y = args
                x1 += cx
                y1 += cy
                x += cx
                y += cy
                command = 'Q', x1, y1, x, y
            elif name == 'T':
                x, y = args
            elif name == 't':
                x, y = args
                x += cx
                y += cy
                command = 'T', x, y
            elif name == 'A':
                _, _, _, _, _, x, y = args
            elif name == 'a':
                rx, ry, rotation, large, sweep, x, y = args
                x += cx
                y += cy
                command = 'A', rx, ry, rotation, large, sweep, x, y
            else:
                assert False
            cx, cy = x, y
            yield command
 
    @classmethod
    def _split_subpaths(self, commands):
        subpath = []
        for command in commands:
            if subpath and isinstance(command, Moveto):
                yield subpath
                subpath = []
            subpath.append(command)
        if subpath:
            yield subpath