diff --git a/cairosvg/helpers.py b/cairosvg/helpers.py index c3fea7f8..5c332bae 100644 --- a/cairosvg/helpers.py +++ b/cairosvg/helpers.py @@ -153,6 +153,20 @@ def preserve_ratio(surface, node, width=None, height=None): return scale_x, scale_y, translate_x, translate_y +def bezier_angles(*points): + """Return the tangent angles of a Bezier curve of any degree.""" + if len(points) < 2: + # zero-length segment + return (0, 0) + # Control points that coincide with vertices can be removed + elif points[0] == points[1]: + return bezier_angles(*points[1:]) + elif points[-2] == points[-1]: + return bezier_angles(*points[:-1]) + else: + return (point_angle(*points[0], *points[1]), point_angle(*points[-2], *points[-1])) + + def clip_marker_box(surface, node, scale_x, scale_y): """Get the clip ``(x, y, width, height)`` of the marker box.""" width = size(surface, node.get('markerWidth', '3'), 'x') diff --git a/cairosvg/path.py b/cairosvg/path.py index fe9784fb..43136fa0 100644 --- a/cairosvg/path.py +++ b/cairosvg/path.py @@ -3,12 +3,12 @@ """ -from math import pi, radians +from math import pi, radians, cos, sin, atan2 from .bounding_box import calculate_bounding_box from .helpers import ( - PATH_LETTERS, clip_marker_box, node_format, normalize, point, point_angle, - preserve_ratio, quadratic_points, rotate, size) + PATH_LETTERS, bezier_angles, clip_marker_box, node_format, normalize, + point, point_angle, preserve_ratio, quadratic_points, rotate, size) from .url import parse_url @@ -26,96 +26,109 @@ def draw_markers(surface, node): else: markers[position] = common_marker - angle1, angle2 = None, None position = 'start' while node.vertices: - # Calculate position and angle - point = node.vertices.pop(0) - angles = node.vertices.pop(0) if node.vertices else None - if angles: - if position == 'start': - angle = pi - angles[0] - else: - angle = (angle2 + pi - angles[0]) / 2 - angle1, angle2 = angles - else: - angle = angle2 - position = 'end' - - # Draw marker (if a marker exists for 'position') - marker = markers[position] - if marker: - marker_node = surface.markers.get(marker) - - # Calculate scale based on current stroke (if requested) - if marker_node.get('markerUnits') == 'userSpaceOnUse': - scale = 1 - else: - scale = size( - surface, surface.parent_node.get('stroke-width', '1')) - - # Calculate position, (additional) scale and clipping based on - # marker properties - viewbox = node_format(surface, marker_node)[2] - if viewbox: - scale_x, scale_y, translate_x, translate_y = preserve_ratio( - surface, marker_node) - clip_box = clip_marker_box( - surface, marker_node, scale_x, scale_y) - else: - # Calculate sizes - marker_width = size(surface, - marker_node.get('markerWidth', '3'), 'x') - marker_height = size(surface, - marker_node.get('markerHeight', '3'), 'y') - bounding_box = calculate_bounding_box(surface, marker_node) - - # Calculate position and scale (preserve aspect ratio) - translate_x = -size(surface, marker_node.get('refX', '0'), 'x') - translate_y = -size(surface, marker_node.get('refY', '0'), 'y') - scale_x = scale_y = min( - marker_width / bounding_box[2], - marker_height / bounding_box[3]) - - # No clipping since viewbox is not present - clip_box = None - - # Add extra path for marker - temp_path = surface.context.copy_path() - surface.context.new_path() - - # Override angle (if requested) - node_angle = marker_node.get('orient', '0') - if node_angle not in ('auto', 'auto-start-reverse'): - angle = radians(float(node_angle)) - elif node_angle == 'auto-start-reverse' and position == 'start': - angle += radians(180) - - # Draw marker path - # See http://www.w3.org/TR/SVG/painting.html#MarkerAlgorithm - for child in marker_node.children: - surface.context.save() - surface.context.translate(*point) - surface.context.rotate(angle) - surface.context.scale(scale) - surface.context.scale(scale_x, scale_y) - surface.context.translate(translate_x, translate_y) - - # Add clipping (if present and requested) - overflow = marker_node.get('overflow', 'hidden') - if clip_box and overflow in ('hidden', 'scroll'): + subpath = node.vertices.pop(0) + angle1, angle2 = None, None + + if len(subpath) % 2 == 0: + # Closed subpath: assign final angle to average with initial angle & + # append copy of first vertex and angle at the end + angle2 = subpath[-1][1] + subpath += subpath[:2] + + while subpath: + # Calculate position and angle + point = subpath.pop(0) + angles = subpath.pop(0) if subpath else None + + if angles: + angle1 = angles[0] + if angle2 is None: + # Start of unclosed subpath + angle = angle1 + else: + # Two adjoining segments: bisect the angle difference + # by summing the corresponding unit vectors + angle = point_angle(0, 0, cos(angle1) + cos(angle2), sin(angle1) + sin(angle2)) + angle2 = angles[1] + else: # End of unclosed subpath + angle = angle2 + + if not node.vertices and not subpath: + # Last node of path + position = 'end' + + # Draw marker (if a marker exists for 'position') + marker = markers[position] + if marker: + marker_node = surface.markers.get(marker) + + # Calculate scale based on current stroke (if requested) + if marker_node.get('markerUnits') == 'userSpaceOnUse': + scale = 1 + else: + scale = size( + surface, surface.parent_node.get('stroke-width', '1')) + + # Calculate position, (additional) scale and clipping based on + # marker properties + viewbox = node_format(surface, marker_node)[2] + if viewbox: + scale_x, scale_y, translate_x, translate_y = preserve_ratio( + surface, marker_node) + clip_box = clip_marker_box( + surface, marker_node, scale_x, scale_y) + else: + # Calculate sizes and position + marker_width = size(surface, + marker_node.get('markerWidth', '3'), 'x') + marker_height = size(surface, + marker_node.get('markerHeight', '3'), 'y') + + translate_x = -size(surface, marker_node.get('refX', '0'), 'x') + translate_y = -size(surface, marker_node.get('refY', '0'), 'y') + + # No clipping or scaling since viewbox is not present + scale_x = scale_y = 1 + clip_box = None + + # Add extra path for marker + temp_path = surface.context.copy_path() + surface.context.new_path() + + # Override angle (if requested) + node_angle = marker_node.get('orient', '0') + if node_angle not in ('auto', 'auto-start-reverse'): + angle = radians(float(node_angle)) + elif node_angle == 'auto-start-reverse' and position == 'start': + angle += radians(180) + + # Draw marker path + # See http://www.w3.org/TR/SVG/painting.html#MarkerAlgorithm + for child in marker_node.children: surface.context.save() - surface.context.rectangle(*clip_box) + surface.context.translate(*point) + surface.context.rotate(angle) + surface.context.scale(scale) + surface.context.scale(scale_x, scale_y) + surface.context.translate(translate_x, translate_y) + + # Add clipping (if present and requested) + overflow = marker_node.get('overflow', 'hidden') + if clip_box and overflow in ('hidden', 'scroll'): + surface.context.save() + surface.context.rectangle(*clip_box) + surface.context.restore() + surface.context.clip() + + surface.draw(child) surface.context.restore() - surface.context.clip() - - surface.draw(child) - surface.context.restore() - surface.context.append_path(temp_path) + surface.context.append_path(temp_path) - position = 'mid' if angles else 'start' + position = 'mid' def path(surface, node): @@ -137,13 +150,17 @@ def path(surface, node): else: surface.context.move_to(0, 0) current_point = 0, 0 + if string[0] not in 'Mm': + # Avoid index error for invalid paths that do not start with + # a moveto; should this raise an error or skip the path? + node.vertices.append([]) while string: string = string.strip() if string.split(' ', 1)[0] in PATH_LETTERS: letter, string = (string + ' ').split(' ', 1) if last_letter in (None, 'z', 'Z') and letter not in 'mM': - node.vertices.append(current_point) + node.vertices[-1].append(current_point) first_path_point = current_point elif letter == 'M': letter = 'L' @@ -186,7 +203,14 @@ def path(surface, node): # rx=0 or ry=0 means straight line if not rx or not ry: - string = 'l {} {} {}'.format(x3, y3, string) + if string and string[0] not in PATH_LETTERS: + # As we replace the current operation by l, we must be sure + # that the next letter is set to the real current letter (a + # or A) in case it’s omitted + next_letter = '{} '.format(letter) + else: + next_letter = '' + string = 'l {} {} {}{}'.format(x3, y3, next_letter, string) continue radii_ratio = ry / rx @@ -226,7 +250,12 @@ def path(surface, node): angle2 = point_angle(xc, yc, xe, ye) # Store the tangent angles - node.vertices.append((-angle1, -angle2)) + tangent1 = angle1 + (pi/2 if sweep else -pi/2) + tangent2 = angle2 + (pi/2 if sweep else -pi/2) + if radii_ratio != 1: + tangent1 = atan2(radii_ratio*sin(tangent1), cos(tangent1)) + tangent2 = atan2(radii_ratio*sin(tangent2), cos(tangent2)) + node.vertices[-1].append((tangent1 + rotation, tangent2 + rotation)) # Draw the arc surface.context.save() @@ -243,8 +272,7 @@ def path(surface, node): x1, y1, string = point(surface, string) x2, y2, string = point(surface, string) x3, y3, string = point(surface, string) - node.vertices.append(( - point_angle(x2, y2, x1, y1), point_angle(x2, y2, x3, y3))) + node.vertices[-1].append(bezier_angles((0, 0), (x1, y1), (x2, y2), (x3, y3))) surface.context.rel_curve_to(x1, y1, x2, y2, x3, y3) current_point = current_point[0] + x3, current_point[1] + y3 @@ -258,31 +286,31 @@ def path(surface, node): elif letter == 'C': # Curve + x, y = current_point x1, y1, string = point(surface, string) x2, y2, string = point(surface, string) x3, y3, string = point(surface, string) - node.vertices.append(( - point_angle(x2, y2, x1, y1), point_angle(x2, y2, x3, y3))) + node.vertices[-1].append(bezier_angles((x, y), (x1, y1), (x2, y2), (x3, y3))) surface.context.curve_to(x1, y1, x2, y2, x3, y3) current_point = x3, y3 elif letter == 'h': # Relative horizontal line x, string = (string + ' ').split(' ', 1) - old_x, old_y = current_point - angle = 0 if size(surface, x, 'x') > 0 else pi - node.vertices.append((pi - angle, angle)) x = size(surface, x, 'x') + old_x, old_y = current_point + angle = 0 if x > 0 else pi + node.vertices[-1].append((angle, angle)) surface.context.rel_line_to(x, 0) current_point = current_point[0] + x, current_point[1] elif letter == 'H': # Horizontal line x, string = (string + ' ').split(' ', 1) - old_x, old_y = current_point - angle = 0 if size(surface, x, 'x') > old_x else pi - node.vertices.append((pi - angle, angle)) x = size(surface, x, 'x') + old_x, old_y = current_point + angle = 0 if x > old_x else pi + node.vertices[-1].append((angle, angle)) surface.context.line_to(x, old_y) current_point = x, current_point[1] @@ -290,7 +318,7 @@ def path(surface, node): # Relative straight line x, y, string = point(surface, string) angle = point_angle(0, 0, x, y) - node.vertices.append((pi - angle, angle)) + node.vertices[-1].append((angle, angle)) surface.context.rel_line_to(x, y) current_point = current_point[0] + x, current_point[1] + y @@ -299,59 +327,60 @@ def path(surface, node): x, y, string = point(surface, string) old_x, old_y = current_point angle = point_angle(old_x, old_y, x, y) - node.vertices.append((pi - angle, angle)) + node.vertices[-1].append((angle, angle)) surface.context.line_to(x, y) current_point = x, y elif letter == 'm': # Current point relative move x, y, string = point(surface, string) - if last_letter and last_letter not in 'zZ': - node.vertices.append(None) + node.vertices.append([]) surface.context.rel_move_to(x, y) current_point = current_point[0] + x, current_point[1] + y elif letter == 'M': # Current point move x, y, string = point(surface, string) - if last_letter and last_letter not in 'zZ': - node.vertices.append(None) + node.vertices.append([]) surface.context.move_to(x, y) current_point = x, y elif letter == 'q': # Relative quadratic curve - x1, y1 = 0, 0 + x, y = current_point + x1, y1, string = point(surface, string) x2, y2, string = point(surface, string) - x3, y3, string = point(surface, string) - xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points( - x1, y1, x2, y2, x3, y3) + xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points(0, 0, x1, y1, x2, y2) surface.context.rel_curve_to(xq1, yq1, xq2, yq2, xq3, yq3) - node.vertices.append((0, 0)) - current_point = current_point[0] + x3, current_point[1] + y3 + node.vertices[-1].append(bezier_angles((0, 0), (x1, y1), (x2, y2))) + current_point = x + x2, y + y2 + + # Save absolute values for x and y, useful if next letter is t or T + x1 += x + x2 += x + y1 += y + y2 += y elif letter == 'Q': # Quadratic curve - x1, y1 = current_point + x, y = current_point + x1, y1, string = point(surface, string) x2, y2, string = point(surface, string) - x3, y3, string = point(surface, string) - xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points( - x1, y1, x2, y2, x3, y3) + xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points(x, y, x1, y1, x2, y2) surface.context.curve_to(xq1, yq1, xq2, yq2, xq3, yq3) - node.vertices.append((0, 0)) - current_point = x3, y3 + node.vertices[-1].append(bezier_angles((x, y), (x1, y1), (x2, y2))) + current_point = x2, y2 elif letter == 's': # Relative smooth curve x, y = current_point - x1 = x3 - x2 if last_letter in 'csCS' else 0 - y1 = y3 - y2 if last_letter in 'csCS' else 0 + x1 = x - x2 if last_letter in 'csCS' else 0 + y1 = y - y2 if last_letter in 'csCS' else 0 x2, y2, string = point(surface, string) x3, y3, string = point(surface, string) - node.vertices.append(( - point_angle(x2, y2, x1, y1), point_angle(x2, y2, x3, y3))) + node.vertices[-1].append(bezier_angles((0, 0), (x1, y1), (x2, y2), (x3, y3))) surface.context.rel_curve_to(x1, y1, x2, y2, x3, y3) - current_point = current_point[0] + x3, current_point[1] + y3 + current_point = x + x3, y + y3 # Save absolute values for x and y, useful if next letter is s or S x1 += x @@ -364,82 +393,71 @@ def path(surface, node): elif letter == 'S': # Smooth curve x, y = current_point - x1 = x3 + (x3 - x2) if last_letter in 'csCS' else x - y1 = y3 + (y3 - y2) if last_letter in 'csCS' else y + x1 = x + (x - x2) if last_letter in 'csCS' else x + y1 = y + (y - y2) if last_letter in 'csCS' else y x2, y2, string = point(surface, string) x3, y3, string = point(surface, string) - node.vertices.append(( - point_angle(x2, y2, x1, y1), point_angle(x2, y2, x3, y3))) + node.vertices[-1].append(bezier_angles((x, y), (x1, y1), (x2, y2), (x3, y3))) surface.context.curve_to(x1, y1, x2, y2, x3, y3) current_point = x3, y3 elif letter == 't': - # Relative quadratic curve end - if last_letter not in 'QqTt': - x2, y2, x3, y3 = 0, 0, 0, 0 - elif last_letter in 'QT': - x2 -= x1 - y2 -= y1 - x3 -= x1 - y3 -= y1 - x2 = x3 - x2 - y2 = y3 - y2 - x1, y1 = 0, 0 - x3, y3, string = point(surface, string) - xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points( - x1, y1, x2, y2, x3, y3) - node.vertices.append((0, 0)) + # Relative quadratic smooth curve + x, y = current_point + x1 = x - x1 if last_letter in 'qtQT' else 0 + y1 = y - y1 if last_letter in 'qtQT' else 0 + x2, y2, string = point(surface, string) + xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points(0, 0, x1, y1, x2, y2) + node.vertices[-1].append(bezier_angles((0, 0), (x1, y1), (x2, y2))) surface.context.rel_curve_to(xq1, yq1, xq2, yq2, xq3, yq3) - current_point = current_point[0] + x3, current_point[1] + y3 + current_point = x + x2, y + y2 + + # Save absolute values for x and y, useful if next letter is t or T + x1 += x + x2 += x + y1 += y + y2 += y elif letter == 'T': # Quadratic curve end - abs_x, abs_y = current_point - if last_letter not in 'QqTt': - x2, y2, x3, y3 = abs_x, abs_y, abs_x, abs_y - elif last_letter in 'qt': - x2 += abs_x - y2 += abs_y - x3 += abs_x - y3 += abs_y - x2 = abs_x + (x3 - x2) - y2 = abs_y + (y3 - y2) - x1, y1 = abs_x, abs_y - x3, y3, string = point(surface, string) - xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points( - x1, y1, x2, y2, x3, y3) - node.vertices.append((0, 0)) + x, y = current_point + x1 = x + (x - x1) if last_letter in 'qtQT' else x + y1 = y + (y - y1) if last_letter in 'qtQT' else y + x2, y2, string = point(surface, string) + xq1, yq1, xq2, yq2, xq3, yq3 = quadratic_points(x, y, x1, y1, x2, y2) + node.vertices[-1].append(bezier_angles((x, y), (x1, y1), (x2, y2))) surface.context.curve_to(xq1, yq1, xq2, yq2, xq3, yq3) - current_point = x3, y3 + current_point = x2, y2 elif letter == 'v': # Relative vertical line y, string = (string + ' ').split(' ', 1) - old_x, old_y = current_point - angle = pi / 2 if size(surface, y, 'y') > 0 else -pi / 2 - node.vertices.append((-angle, angle)) y = size(surface, y, 'y') + old_x, old_y = current_point + angle = pi / 2 if y > 0 else -pi / 2 + node.vertices[-1].append((angle, angle)) surface.context.rel_line_to(0, y) current_point = current_point[0], current_point[1] + y elif letter == 'V': # Vertical line y, string = (string + ' ').split(' ', 1) - old_x, old_y = current_point - angle = pi / 2 if size(surface, y, 'y') > old_y else -pi / 2 - node.vertices.append((-angle, angle)) y = size(surface, y, 'y') + old_x, old_y = current_point + angle = pi / 2 if y > old_y else -pi / 2 + node.vertices[-1].append((angle, angle)) surface.context.line_to(old_x, y) current_point = current_point[0], y elif letter in 'zZ': # End of path - node.vertices.append(None) + angle = point_angle(*current_point, *first_path_point) + node.vertices[-1].append((angle, angle)) surface.context.close_path() current_point = first_path_point or (0, 0) if letter not in 'zZ': - node.vertices.append(current_point) + node.vertices[-1].append(current_point) string = string.strip() last_letter = letter diff --git a/cairosvg/shapes.py b/cairosvg/shapes.py index 8e01f22d..f89615e4 100644 --- a/cairosvg/shapes.py +++ b/cairosvg/shapes.py @@ -45,13 +45,15 @@ def line(surface, node): surface.context.move_to(x1, y1) surface.context.line_to(x2, y2) angle = point_angle(x1, y1, x2, y2) - node.vertices = [(x1, y1), (pi - angle, angle), (x2, y2)] + node.vertices = [[(x1, y1), (angle, angle), (x2, y2)]] def polygon(surface, node): """Draw a polygon ``node`` on ``surface``.""" polyline(surface, node) surface.context.close_path() + closing_angle = point_angle(*node.vertices[-1][-1], *node.vertices[-1][0]) + node.vertices[-1].append((closing_angle, closing_angle)) def polyline(surface, node): @@ -60,14 +62,14 @@ def polyline(surface, node): if points: x, y, points = point(surface, points) surface.context.move_to(x, y) - node.vertices = [(x, y)] + node.vertices = [[(x, y)]] while points: x_old, y_old = x, y x, y, points = point(surface, points) angle = point_angle(x_old, y_old, x, y) - node.vertices.append((pi - angle, angle)) + node.vertices[-1].append((angle, angle)) surface.context.line_to(x, y) - node.vertices.append((x, y)) + node.vertices[-1].append((x, y)) def rect(surface, node):