svgLib: add support for converting elliptical arcs to cubic bezier curves
Fixes https://github.com/fonttools/fonttools/issues/1141 Uses code from Chromium Blink's SVG path parser, in SVGPathNormalizer::DecomposeArcToCubic https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/svg/svg_path_parser.cc#L169-L278 Each elliptical arc is approximated by series of cubic bezier curves, one cubic every 90-degree portion of an arc.
This commit is contained in:
parent
2150ef875f
commit
f0ab265000
157
Lib/fontTools/svgLib/path/arc.py
Normal file
157
Lib/fontTools/svgLib/path/arc.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""Convert SVG Path's elliptical arcs to Bezier curves.
|
||||||
|
|
||||||
|
The code is mostly adapted from Blink's SVGPathNormalizer::DecomposeArcToCubic
|
||||||
|
https://github.com/chromium/chromium/blob/93831f2/third_party/
|
||||||
|
blink/renderer/core/svg/svg_path_parser.cc#L169-L278
|
||||||
|
"""
|
||||||
|
from __future__ import print_function, division, absolute_import, unicode_literals
|
||||||
|
from fontTools.misc.py23 import *
|
||||||
|
from fontTools.misc.py23 import isfinite
|
||||||
|
from fontTools.misc.transform import Identity, Scale
|
||||||
|
from math import atan2, ceil, cos, fabs, pi, radians, sin, sqrt, tan
|
||||||
|
|
||||||
|
|
||||||
|
TWO_PI = 2 * pi
|
||||||
|
PI_OVER_TWO = 0.5 * pi
|
||||||
|
|
||||||
|
|
||||||
|
def _map_point(matrix, pt):
|
||||||
|
# apply Transform matrix to a point represented as a complex number
|
||||||
|
r = matrix.transformPoint((pt.real, pt.imag))
|
||||||
|
return r[0] + r[1] * 1j
|
||||||
|
|
||||||
|
|
||||||
|
class EllipticalArc(object):
|
||||||
|
|
||||||
|
def __init__(self, current_point, rx, ry, rotation, large, sweep, target_point):
|
||||||
|
self.current_point = current_point
|
||||||
|
self.rx = rx
|
||||||
|
self.ry = ry
|
||||||
|
self.rotation = rotation
|
||||||
|
self.large = large
|
||||||
|
self.sweep = sweep
|
||||||
|
self.target_point = target_point
|
||||||
|
|
||||||
|
# SVG arc's rotation angle is expressed in degrees, whereas Transform.rotate
|
||||||
|
# uses radians
|
||||||
|
self.angle = radians(rotation)
|
||||||
|
|
||||||
|
# these derived attributes are computed by the _parametrize method
|
||||||
|
self.center_point = self.theta1 = self.theta2 = self.theta_arc = None
|
||||||
|
|
||||||
|
def _parametrize(self):
|
||||||
|
# convert from endopoint to center parametrization:
|
||||||
|
# https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter
|
||||||
|
|
||||||
|
# If rx = 0 or ry = 0 then this arc is treated as a straight line segment (a
|
||||||
|
# "lineto") joining the endpoints.
|
||||||
|
# http://www.w3.org/TR/SVG/implnote.html#ArcOutOfRangeParameters
|
||||||
|
rx = fabs(self.rx)
|
||||||
|
ry = fabs(self.ry)
|
||||||
|
if not (rx and ry):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If the current point and target point for the arc are identical, it should
|
||||||
|
# be treated as a zero length path. This ensures continuity in animations.
|
||||||
|
if self.target_point == self.current_point:
|
||||||
|
return False
|
||||||
|
|
||||||
|
mid_point_distance = (self.current_point - self.target_point) * 0.5
|
||||||
|
|
||||||
|
point_transform = Identity.rotate(-self.angle)
|
||||||
|
|
||||||
|
transformed_mid_point = _map_point(point_transform, mid_point_distance)
|
||||||
|
square_rx = rx * rx
|
||||||
|
square_ry = ry * ry
|
||||||
|
square_x = transformed_mid_point.real * transformed_mid_point.real
|
||||||
|
square_y = transformed_mid_point.imag * transformed_mid_point.imag
|
||||||
|
|
||||||
|
# Check if the radii are big enough to draw the arc, scale radii if not.
|
||||||
|
# http://www.w3.org/TR/SVG/implnote.html#ArcCorrectionOutOfRangeRadii
|
||||||
|
radii_scale = square_x / square_rx + square_y / square_ry
|
||||||
|
if radii_scale > 1:
|
||||||
|
rx *= sqrt(radii_scale)
|
||||||
|
ry *= sqrt(radii_scale)
|
||||||
|
self.rx, self.ry = rx, ry
|
||||||
|
|
||||||
|
point_transform = Scale(1 / rx, 1 / ry).rotate(-self.angle)
|
||||||
|
|
||||||
|
point1 = _map_point(point_transform, self.current_point)
|
||||||
|
point2 = _map_point(point_transform, self.target_point)
|
||||||
|
delta = point2 - point1
|
||||||
|
|
||||||
|
d = delta.real * delta.real + delta.imag * delta.imag
|
||||||
|
scale_factor_squared = max(1 / d - 0.25, 0.0)
|
||||||
|
|
||||||
|
scale_factor = sqrt(scale_factor_squared)
|
||||||
|
if self.sweep == self.large:
|
||||||
|
scale_factor = -scale_factor
|
||||||
|
|
||||||
|
delta *= scale_factor
|
||||||
|
center_point = (point1 + point2) * 0.5
|
||||||
|
center_point += complex(-delta.imag, delta.real)
|
||||||
|
point1 -= center_point
|
||||||
|
point2 -= center_point
|
||||||
|
|
||||||
|
theta1 = atan2(point1.imag, point1.real)
|
||||||
|
theta2 = atan2(point2.imag, point2.real)
|
||||||
|
|
||||||
|
theta_arc = theta2 - theta1
|
||||||
|
if theta_arc < 0 and self.sweep:
|
||||||
|
theta_arc += TWO_PI
|
||||||
|
elif theta_arc > 0 and not self.sweep:
|
||||||
|
theta_arc -= TWO_PI
|
||||||
|
|
||||||
|
self.theta1 = theta1
|
||||||
|
self.theta2 = theta1 + theta_arc
|
||||||
|
self.theta_arc = theta_arc
|
||||||
|
self.center_point = center_point
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _decompose_to_cubic_curves(self):
|
||||||
|
if self.center_point is None and not self._parametrize():
|
||||||
|
return
|
||||||
|
|
||||||
|
point_transform = Identity.rotate(self.angle).scale(self.rx, self.ry)
|
||||||
|
|
||||||
|
# Some results of atan2 on some platform implementations are not exact
|
||||||
|
# enough. So that we get more cubic curves than expected here. Adding 0.001f
|
||||||
|
# reduces the count of sgements to the correct count.
|
||||||
|
num_segments = int(ceil(fabs(self.theta_arc / (PI_OVER_TWO + 0.001))))
|
||||||
|
for i in range(num_segments):
|
||||||
|
start_theta = self.theta1 + i * self.theta_arc / num_segments
|
||||||
|
end_theta = self.theta1 + (i + 1) * self.theta_arc / num_segments
|
||||||
|
|
||||||
|
t = (4 / 3) * tan(0.25 * (end_theta - start_theta))
|
||||||
|
if not isfinite(t):
|
||||||
|
return
|
||||||
|
|
||||||
|
sin_start_theta = sin(start_theta)
|
||||||
|
cos_start_theta = cos(start_theta)
|
||||||
|
sin_end_theta = sin(end_theta)
|
||||||
|
cos_end_theta = cos(end_theta)
|
||||||
|
|
||||||
|
point1 = complex(
|
||||||
|
cos_start_theta - t * sin_start_theta,
|
||||||
|
sin_start_theta + t * cos_start_theta,
|
||||||
|
)
|
||||||
|
point1 += self.center_point
|
||||||
|
target_point = complex(cos_end_theta, sin_end_theta)
|
||||||
|
target_point += self.center_point
|
||||||
|
point2 = target_point
|
||||||
|
point2 += complex(t * sin_end_theta, -t * cos_end_theta)
|
||||||
|
|
||||||
|
point1 = _map_point(point_transform, point1)
|
||||||
|
point2 = _map_point(point_transform, point2)
|
||||||
|
target_point = _map_point(point_transform, target_point)
|
||||||
|
|
||||||
|
yield point1, point2, target_point
|
||||||
|
|
||||||
|
def draw(self, pen):
|
||||||
|
for point1, point2, target_point in self._decompose_to_cubic_curves():
|
||||||
|
pen.curveTo(
|
||||||
|
(point1.real, point1.imag),
|
||||||
|
(point2.real, point2.imag),
|
||||||
|
(target_point.real, target_point.imag),
|
||||||
|
)
|
@ -10,8 +10,10 @@
|
|||||||
from __future__ import (
|
from __future__ import (
|
||||||
print_function, division, absolute_import, unicode_literals)
|
print_function, division, absolute_import, unicode_literals)
|
||||||
from fontTools.misc.py23 import *
|
from fontTools.misc.py23 import *
|
||||||
|
from .arc import EllipticalArc
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
|
COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
|
||||||
UPPERCASE = set('MZLHVCSQTA')
|
UPPERCASE = set('MZLHVCSQTA')
|
||||||
|
|
||||||
@ -27,7 +29,7 @@ def _tokenize_path(pathdef):
|
|||||||
yield token
|
yield token
|
||||||
|
|
||||||
|
|
||||||
def parse_path(pathdef, pen, current_pos=(0, 0)):
|
def parse_path(pathdef, pen, current_pos=(0, 0), arc_class=EllipticalArc):
|
||||||
""" Parse SVG path definition (i.e. "d" attribute of <path> elements)
|
""" Parse SVG path definition (i.e. "d" attribute of <path> elements)
|
||||||
and call a 'pen' object's moveTo, lineTo, curveTo, qCurveTo and closePath
|
and call a 'pen' object's moveTo, lineTo, curveTo, qCurveTo and closePath
|
||||||
methods.
|
methods.
|
||||||
@ -35,8 +37,13 @@ def parse_path(pathdef, pen, current_pos=(0, 0)):
|
|||||||
If 'current_pos' (2-float tuple) is provided, the initial moveTo will
|
If 'current_pos' (2-float tuple) is provided, the initial moveTo will
|
||||||
be relative to that instead being absolute.
|
be relative to that instead being absolute.
|
||||||
|
|
||||||
Arc segments (commands "A" or "a") are not currently supported, and raise
|
If the pen has an "arcTo" method, it is called with the original values
|
||||||
NotImplementedError.
|
of the elliptical arc curve commands:
|
||||||
|
|
||||||
|
pen.arcTo(rx, ry, rotation, arc_large, arc_sweep, (x, y))
|
||||||
|
|
||||||
|
Otherwise, the arcs are approximated by series of cubic Bezier segments
|
||||||
|
("curveTo"), one every 90 degrees.
|
||||||
"""
|
"""
|
||||||
# In the SVG specs, initial movetos are absolute, even if
|
# In the SVG specs, initial movetos are absolute, even if
|
||||||
# specified as 'm'. This is the default behavior here as well.
|
# specified as 'm'. This is the default behavior here as well.
|
||||||
@ -52,6 +59,8 @@ def parse_path(pathdef, pen, current_pos=(0, 0)):
|
|||||||
command = None
|
command = None
|
||||||
last_control = None
|
last_control = None
|
||||||
|
|
||||||
|
have_arcTo = hasattr(pen, "arcTo")
|
||||||
|
|
||||||
while elements:
|
while elements:
|
||||||
|
|
||||||
if elements[-1] in COMMANDS:
|
if elements[-1] in COMMANDS:
|
||||||
@ -209,7 +218,34 @@ def parse_path(pathdef, pen, current_pos=(0, 0)):
|
|||||||
last_control = control
|
last_control = control
|
||||||
|
|
||||||
elif command == 'A':
|
elif command == 'A':
|
||||||
raise NotImplementedError('arcs are not supported')
|
rx = float(elements.pop())
|
||||||
|
ry = float(elements.pop())
|
||||||
|
rotation = float(elements.pop())
|
||||||
|
arc_large = bool(int(elements.pop()))
|
||||||
|
arc_sweep = bool(int(elements.pop()))
|
||||||
|
end = float(elements.pop()) + float(elements.pop()) * 1j
|
||||||
|
|
||||||
|
if not absolute:
|
||||||
|
end += current_pos
|
||||||
|
|
||||||
|
# if the pen supports arcs, pass the values unchanged, otherwise
|
||||||
|
# approximate the arc with a series of cubic bezier curves
|
||||||
|
if have_arcTo:
|
||||||
|
pen.arcTo(
|
||||||
|
rx,
|
||||||
|
ry,
|
||||||
|
rotation,
|
||||||
|
arc_large,
|
||||||
|
arc_sweep,
|
||||||
|
(end.real, end.imag),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
arc = arc_class(
|
||||||
|
current_pos, rx, ry, rotation, arc_large, arc_sweep, end
|
||||||
|
)
|
||||||
|
arc.draw(pen)
|
||||||
|
|
||||||
|
current_pos = end
|
||||||
|
|
||||||
# no final Z command, it's an open path
|
# no final Z command, it's an open path
|
||||||
if start_pos is not None:
|
if start_pos is not None:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user