"""This submodule contains very stripped down bare bones version of svgpathtools module: https://github.com/mathandy/svgpathtools All external dependencies are removed. This code can parse path strings with segments and arcs, calculate bounding box and that's about it. This is all that is needed in ibom parsers at the moment. """ # External dependencies from __future__ import division, absolute_import, print_function import re from cmath import exp from math import sqrt, cos, sin, acos, degrees, radians, pi def clip(a, a_min, a_max): return min(a_max, max(a_min, a)) class Line(object): def __init__(self, start, end): self.start = start self.end = end def __repr__(self): return 'Line(start=%s, end=%s)' % (self.start, self.end) def __eq__(self, other): if not isinstance(other, Line): return NotImplemented return self.start == other.start and self.end == other.end def __ne__(self, other): if not isinstance(other, Line): return NotImplemented return not self == other def __len__(self): return 2 def bbox(self): """returns the bounding box for the segment in the form (xmin, xmax, ymin, ymax).""" xmin = min(self.start.real, self.end.real) xmax = max(self.start.real, self.end.real) ymin = min(self.start.imag, self.end.imag) ymax = max(self.start.imag, self.end.imag) return xmin, xmax, ymin, ymax class Arc(object): def __init__(self, start, radius, rotation, large_arc, sweep, end, autoscale_radius=True): """ This should be thought of as a part of an ellipse connecting two points on that ellipse, start and end. Parameters ---------- start : complex The start point of the curve. Note: `start` and `end` cannot be the same. To make a full ellipse or circle, use two `Arc` objects. radius : complex rx + 1j*ry, where rx and ry are the radii of the ellipse (also known as its semi-major and semi-minor axes, or vice-versa or if rx < ry). Note: If rx = 0 or ry = 0 then this arc is treated as a straight line segment joining the endpoints. Note: If rx or ry has a negative sign, the sign is dropped; the absolute value is used instead. Note: If no such ellipse exists, the radius will be scaled so that one does (unless autoscale_radius is set to False). rotation : float This is the CCW angle (in degrees) from the positive x-axis of the current coordinate system to the x-axis of the ellipse. large_arc : bool Given two points on an ellipse, there are two elliptical arcs connecting those points, the first going the short way around the ellipse, and the second going the long way around the ellipse. If `large_arc == False`, the shorter elliptical arc will be used. If `large_arc == True`, then longer elliptical will be used. In other words, `large_arc` should be 0 for arcs spanning less than or equal to 180 degrees and 1 for arcs spanning greater than 180 degrees. sweep : bool For any acceptable parameters `start`, `end`, `rotation`, and `radius`, there are two ellipses with the given major and minor axes (radii) which connect `start` and `end`. One which connects them in a CCW fashion and one which connected them in a CW fashion. If `sweep == True`, the CCW ellipse will be used. If `sweep == False`, the CW ellipse will be used. See note on curve orientation below. end : complex The end point of the curve. Note: `start` and `end` cannot be the same. To make a full ellipse or circle, use two `Arc` objects. autoscale_radius : bool If `autoscale_radius == True`, then will also scale `self.radius` in the case that no ellipse exists with the input parameters (see inline comments for further explanation). Derived Parameters/Attributes ----------------------------- self.theta : float This is the phase (in degrees) of self.u1transform(self.start). It is $\\theta_1$ in the official documentation and ranges from -180 to 180. self.delta : float This is the angular distance (in degrees) between the start and end of the arc after the arc has been sent to the unit circle through self.u1transform(). It is $\\Delta\\theta$ in the official documentation and ranges from -360 to 360; being positive when the arc travels CCW and negative otherwise (i.e. is positive/negative when sweep == True/False). self.center : complex This is the center of the arc's ellipse. self.phi : float The arc's rotation in radians, i.e. `radians(self.rotation)`. self.rot_matrix : complex Equal to `exp(1j * self.phi)` which is also equal to `cos(self.phi) + 1j*sin(self.phi)`. Note on curve orientation (CW vs CCW) ------------------------------------- The notions of clockwise (CW) and counter-clockwise (CCW) are reversed in some sense when viewing SVGs (as the y coordinate starts at the top of the image and increases towards the bottom). """ assert start != end assert radius.real != 0 and radius.imag != 0 self.start = start self.radius = abs(radius.real) + 1j * abs(radius.imag) self.rotation = rotation self.large_arc = bool(large_arc) self.sweep = bool(sweep) self.end = end self.autoscale_radius = autoscale_radius # Convenience parameters self.phi = radians(self.rotation) self.rot_matrix = exp(1j * self.phi) # Derive derived parameters self._parameterize() def __repr__(self): params = (self.start, self.radius, self.rotation, self.large_arc, self.sweep, self.end) return ("Arc(start={}, radius={}, rotation={}, " "large_arc={}, sweep={}, end={})".format(*params)) def __eq__(self, other): if not isinstance(other, Arc): return NotImplemented return self.start == other.start and self.end == other.end \ and self.radius == other.radius \ and self.rotation == other.rotation \ and self.large_arc == other.large_arc and self.sweep == other.sweep def __ne__(self, other): if not isinstance(other, Arc): return NotImplemented return not self == other def _parameterize(self): # See http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes # my notation roughly follows theirs rx = self.radius.real ry = self.radius.imag rx_sqd = rx * rx ry_sqd = ry * ry # Transform z-> z' = x' + 1j*y' # = self.rot_matrix**(-1)*(z - (end+start)/2) # coordinates. This translates the ellipse so that the midpoint # between self.end and self.start lies on the origin and rotates # the ellipse so that the its axes align with the xy-coordinate axes. # Note: This sends self.end to -self.start zp1 = (1 / self.rot_matrix) * (self.start - self.end) / 2 x1p, y1p = zp1.real, zp1.imag x1p_sqd = x1p * x1p y1p_sqd = y1p * y1p # Correct out of range radii # Note: an ellipse going through start and end with radius and phi # exists if and only if radius_check is true radius_check = (x1p_sqd / rx_sqd) + (y1p_sqd / ry_sqd) if radius_check > 1: if self.autoscale_radius: rx *= sqrt(radius_check) ry *= sqrt(radius_check) self.radius = rx + 1j * ry rx_sqd = rx * rx ry_sqd = ry * ry else: raise ValueError("No such elliptic arc exists.") # Compute c'=(c_x', c_y'), the center of the ellipse in (x', y') coords # Noting that, in our new coord system, (x_2', y_2') = (-x_1', -x_2') # and our ellipse is cut out by of the plane by the algebraic equation # (x'-c_x')**2 / r_x**2 + (y'-c_y')**2 / r_y**2 = 1, # we can find c' by solving the system of two quadratics given by # plugging our transformed endpoints (x_1', y_1') and (x_2', y_2') tmp = rx_sqd * y1p_sqd + ry_sqd * x1p_sqd radicand = (rx_sqd * ry_sqd - tmp) / tmp try: radical = sqrt(radicand) except ValueError: radical = 0 if self.large_arc == self.sweep: cp = -radical * (rx * y1p / ry - 1j * ry * x1p / rx) else: cp = radical * (rx * y1p / ry - 1j * ry * x1p / rx) # The center in (x,y) coordinates is easy to find knowing c' self.center = exp(1j * self.phi) * cp + (self.start + self.end) / 2 # Now we do a second transformation, from (x', y') to (u_x, u_y) # coordinates, which is a translation moving the center of the # ellipse to the origin and a dilation stretching the ellipse to be # the unit circle u1 = (x1p - cp.real) / rx + 1j * (y1p - cp.imag) / ry u2 = (-x1p - cp.real) / rx + 1j * (-y1p - cp.imag) / ry # clip in case of floating point error u1 = clip(u1.real, -1, 1) + 1j * clip(u1.imag, -1, 1) u2 = clip(u2.real, -1, 1) + 1j * clip(u2.imag, -1, 1) # Now compute theta and delta (we'll define them as we go) # delta is the angular distance of the arc (w.r.t the circle) # theta is the angle between the positive x'-axis and the start point # on the circle if u1.imag > 0: self.theta = degrees(acos(u1.real)) elif u1.imag < 0: self.theta = -degrees(acos(u1.real)) else: if u1.real > 0: # start is on pos u_x axis self.theta = 0 else: # start is on neg u_x axis # Note: This behavior disagrees with behavior documented in # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes # where theta is set to 0 in this case. self.theta = 180 det_uv = u1.real * u2.imag - u1.imag * u2.real acosand = u1.real * u2.real + u1.imag * u2.imag acosand = clip(acosand.real, -1, 1) + clip(acosand.imag, -1, 1) if det_uv > 0: self.delta = degrees(acos(acosand)) elif det_uv < 0: self.delta = -degrees(acos(acosand)) else: if u1.real * u2.real + u1.imag * u2.imag > 0: # u1 == u2 self.delta = 0 else: # u1 == -u2 # Note: This behavior disagrees with behavior documented in # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes # where delta is set to 0 in this case. self.delta = 180 if not self.sweep and self.delta >= 0: self.delta -= 360 elif self.large_arc and self.delta <= 0: self.delta += 360 def point(self, t): if t == 0: return self.start if t == 1: return self.end angle = radians(self.theta + t * self.delta) cosphi = self.rot_matrix.real sinphi = self.rot_matrix.imag rx = self.radius.real ry = self.radius.imag # z = self.rot_matrix*(rx*cos(angle) + 1j*ry*sin(angle)) + self.center x = rx * cosphi * cos(angle) - ry * sinphi * sin( angle) + self.center.real y = rx * sinphi * cos(angle) + ry * cosphi * sin( angle) + self.center.imag return complex(x, y) def bbox(self): """returns a bounding box for the segment in the form (xmin, xmax, ymin, ymax).""" # a(t) = radians(self.theta + self.delta*t) # = (2*pi/360)*(self.theta + self.delta*t) # x'=0: ~~~~~~~~~ # -rx*cos(phi)*sin(a(t)) = ry*sin(phi)*cos(a(t)) # -(rx/ry)*cot(phi)*tan(a(t)) = 1 # a(t) = arctan(-(ry/rx)tan(phi)) + pi*k === atan_x # y'=0: ~~~~~~~~~~ # rx*sin(phi)*sin(a(t)) = ry*cos(phi)*cos(a(t)) # (rx/ry)*tan(phi)*tan(a(t)) = 1 # a(t) = arctan((ry/rx)*cot(phi)) # atanres = arctan((ry/rx)*cot(phi)) === atan_y # ~~~~~~~~ # (2*pi/360)*(self.theta + self.delta*t) = atanres + pi*k # Therefore, for both x' and y', we have... # t = ((atan_{x/y} + pi*k)*(360/(2*pi)) - self.theta)/self.delta # for all k s.t. 0 < t < 1 from math import atan, tan if cos(self.phi) == 0: atan_x = pi / 2 atan_y = 0 elif sin(self.phi) == 0: atan_x = 0 atan_y = pi / 2 else: rx, ry = self.radius.real, self.radius.imag atan_x = atan(-(ry / rx) * tan(self.phi)) atan_y = atan((ry / rx) / tan(self.phi)) def angle_inv(ang, q): # inverse of angle from Arc.derivative() return ((ang + pi * q) * (360 / (2 * pi)) - self.theta) / self.delta xtrema = [self.start.real, self.end.real] ytrema = [self.start.imag, self.end.imag] for k in range(-4, 5): tx = angle_inv(atan_x, k) ty = angle_inv(atan_y, k) if 0 <= tx <= 1: xtrema.append(self.point(tx).real) if 0 <= ty <= 1: ytrema.append(self.point(ty).imag) return min(xtrema), max(xtrema), min(ytrema), max(ytrema) COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') UPPERCASE = set('MZLHVCSQTA') COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])") FLOAT_RE = re.compile(r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") def _tokenize_path(path_def): for x in COMMAND_RE.split(path_def): if x in COMMANDS: yield x for token in FLOAT_RE.findall(x): yield token def parse_path(pathdef, logger, current_pos=0j): # In the SVG specs, initial movetos are absolute, even if # specified as 'm'. This is the default behavior here as well. # But if you pass in a current_pos variable, the initial moveto # will be relative to that current_pos. This is useful. elements = list(_tokenize_path(pathdef)) # Reverse for easy use of .pop() elements.reverse() absolute = False segments = [] start_pos = None command = None while elements: if elements[-1] in COMMANDS: # New command. command = elements.pop() absolute = command in UPPERCASE command = command.upper() else: # If this element starts with numbers, it is an implicit command # and we don't change the command. Check that it's allowed: if command is None: raise ValueError( "Unallowed implicit command in %s, position %s" % ( pathdef, len(pathdef.split()) - len(elements))) if command == 'M': # Moveto command. x = elements.pop() y = elements.pop() pos = float(x) + float(y) * 1j if absolute: current_pos = pos else: current_pos += pos # when M is called, reset start_pos # This behavior of Z is defined in svg spec: # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand start_pos = current_pos # Implicit moveto commands are treated as lineto commands. # So we set command to lineto here, in case there are # further implicit commands after this moveto. command = 'L' elif command == 'Z': # Close path if not (current_pos == start_pos): segments.append(Line(current_pos, start_pos)) current_pos = start_pos command = None elif command == 'L': x = elements.pop() y = elements.pop() pos = float(x) + float(y) * 1j if not absolute: pos += current_pos segments.append(Line(current_pos, pos)) current_pos = pos elif command == 'H': x = elements.pop() pos = float(x) + current_pos.imag * 1j if not absolute: pos += current_pos.real segments.append(Line(current_pos, pos)) current_pos = pos elif command == 'V': y = elements.pop() pos = current_pos.real + float(y) * 1j if not absolute: pos += current_pos.imag * 1j segments.append(Line(current_pos, pos)) current_pos = pos elif command == 'C': logger.warn('Encountered Cubic Bezier segment. ' 'It is currently not supported and will be replaced ' 'by a line segment.') for i in range(4): # ignore control points elements.pop() end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: end += current_pos segments.append(Line(current_pos, end)) current_pos = end elif command == 'S': logger.warn('Encountered Quadratic Bezier segment. ' 'It is currently not supported and will be replaced ' 'by a line segment.') for i in range(2): # ignore control points elements.pop() end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: end += current_pos segments.append(Line(current_pos, end)) current_pos = end elif command == 'Q': logger.warn('Encountered Quadratic Bezier segment. ' 'It is currently not supported and will be replaced ' 'by a line segment.') for i in range(2): # ignore control points elements.pop() end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: end += current_pos segments.append(Line(current_pos, end)) current_pos = end elif command == 'T': logger.warn('Encountered Quadratic Bezier segment. ' 'It is currently not supported and will be replaced ' 'by a line segment.') end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: end += current_pos segments.append(Line(current_pos, end)) current_pos = end elif command == 'A': radius = float(elements.pop()) + float(elements.pop()) * 1j rotation = float(elements.pop()) arc = float(elements.pop()) sweep = float(elements.pop()) end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: end += current_pos segments.append( Arc(current_pos, radius, rotation, arc, sweep, end)) current_pos = end return segments def create_path(lines, circles=[]): """Returns a path d-string.""" def limit_digits(val): return format(val, '.6f').rstrip('0').replace(',', '.').rstrip('.') def different_points(a, b): return abs(a[0] - b[0]) > 1e-6 or abs(a[1] - b[1]) > 1e-6 parts = [] for i, line in enumerate(lines): if i == 0 or different_points(lines[i - 1][-1], line[0]): parts.append('M{},{}'.format(*map(limit_digits, line[0]))) for point in line[1:]: parts.append('L{},{}'.format(*map(limit_digits, point))) for circle in circles: cx, cy, r = circle[0][0], circle[0][1], circle[1] parts.append('M{},{}'.format(limit_digits(cx - r), limit_digits(cy))) parts.append('a {},{} 0 1,0 {},0'.format( *map(limit_digits, [r, r, r + r]))) parts.append('a {},{} 0 1,0 -{},0'.format( *map(limit_digits, [r, r, r + r]))) return ''.join(parts)