Skip to content

Instantly share code, notes, and snippets.

@kieranb662
Created June 4, 2020 22:05
Show Gist options
  • Save kieranb662/7174402b4078e90deaf222c726b1fa9d to your computer and use it in GitHub Desktop.
Save kieranb662/7174402b4078e90deaf222c726b1fa9d to your computer and use it in GitHub Desktop.
SVG -> iOS Bezier Curves
import Foundation
import CoreGraphics
/// #SVGPath
/// A class that can take any svg path string and convert it to the
/// equivalent CGMutablePath
/// - Parameters
/// - string: The string of text representing an SVG path.
/// - Note
/// The string used should be the `d` attribute of the path element.
class SVGPath {
private let pathCommandRegex = "[MmLlHhVvCcSsQqTtAaZz][^MmLlHhVvCcSsQqTtAaZz]*"
private let commandNumbersRegex = "-?\\d*+(\\.\\d+)?"
var string: String
private var lastPoint: LastPoint?
var path: CGMutablePath
/// Helps keep track of the current position and if the last component
/// was a cubic or quadratic bezier to calculate inferred beziers.
private enum LastPoint {
case cubic(current: CGPoint, lastControl: CGPoint)
case quad(current: CGPoint, lastControl: CGPoint)
case other(current: CGPoint)
func getCurrent() -> CGPoint {
switch self {
case .cubic(let current, _):
return current
case .quad(let current, _):
return current
case .other(let current):
return current
}
}
func getControl() -> CGPoint? {
switch self {
case .cubic(_, let lastControl):
return lastControl
case .quad(_, let lastControl):
return lastControl
case .other(_):
break
}
return nil
}
}
init(path: String) {
self.string = path
self.path = CGMutablePath()
parse()
}
}
// MARK: Parsing Functions
extension SVGPath {
private func correctCommand(parsed command: String, amount: Int) -> [[CGFloat]] {
var nums = matches(for: commandNumbersRegex, in: command).filter({!$0.isEmpty})
var set = [[CGFloat]]()
while (nums.count%amount == 0) { nums.append("0.0") }
for i in 1...Int(nums.count/amount) {
var values = [CGFloat]()
for n in 1..<amount+1 {
values.append(CGFloat(Double(nums[(i-1)*amount+(n-1)])!))
}
set.append(values)
}
return set
}
private func correctLine(parsed command: String) -> [CGFloat] {
var nums = matches(for: commandNumbersRegex, in: command).filter({ !$0.isEmpty })
if nums.count == 0 {
nums.append("0.0")
}
return nums.map({ CGFloat(Double($0)!) })
}
private func parse() {
self.path = CGMutablePath()
let commands = matches(for: pathCommandRegex, in: string)
for command in commands {
if command.contains("M") {
let numbers = correctCommand(parsed: command, amount: 2)
moveTo(CGPoint(x: numbers[0][0], y: numbers[0][1]))
for i in 0...numbers.count-1 {
addLine(CGPoint(x: numbers[i][0], y: numbers[i][1]))
}
} else if command.contains("m") {
let numbers = correctCommand(parsed: command, amount: 2)
relMoveTo(CGPoint(x: numbers[0][0], y: numbers[0][1]))
for i in 0...numbers.count-1 {
addRelLine(CGPoint(x: numbers[i][0], y: numbers[i][1]))
}
} else if command.contains("L") {
let numbers = correctCommand(parsed: command, amount: 2)
for i in 0..<numbers.count {
addLine(CGPoint(x: numbers[i][0], y: numbers[i][1]))
}
} else if command.contains("l") {
let numbers = correctCommand(parsed: command, amount: 2)
for i in 0..<numbers.count {
addRelLine(CGPoint(x: numbers[i][0], y: numbers[i][1]))
}
} else if command.contains("H") {
let numbers = correctLine(parsed: command)
for num in numbers {
let last = lastPoint?.getCurrent()
addLine(CGPoint(x: num, y: last!.y))
}
} else if command.contains("h") {
let numbers = correctLine(parsed: command)
for num in numbers {
addRelLine(CGPoint(x: num, y: 0))
}
} else if command.contains("V") {
let numbers = correctLine(parsed: command)
for num in numbers {
let last = lastPoint?.getCurrent()
addLine(CGPoint(x: last!.x, y: num))
}
} else if command.contains("v") {
let numbers = correctLine(parsed: command)
for num in numbers {
addRelLine(CGPoint(x: 0, y: num))
}
} else if command.contains("C") {
let numbers = correctCommand(parsed: command, amount: 6)
for i in 0..<numbers.count {
let control1 = CGPoint(x: numbers[i][0], y: numbers[i][1])
let control2 = CGPoint(x: numbers[i][2], y: numbers[i][3])
let point = CGPoint(x: numbers[i][4], y: numbers[i][5])
addCubic(to: point, control1: control1, control2: control2)
}
} else if command.contains("c") {
let numbers = correctCommand(parsed: command, amount: 6)
for i in 0..<numbers.count {
let control1 = CGPoint(x: numbers[i][0], y: numbers[i][1])
let control2 = CGPoint(x: numbers[i][2], y: numbers[i][3])
let point = CGPoint(x: numbers[i][4], y: numbers[i][5])
addRelCubic(to: point, control1: control1, control2: control2)
}
} else if command.contains("S") {
let numbers = correctCommand(parsed: command, amount: 4)
for i in 0..<numbers.count {
let control = CGPoint(x: numbers[i][0], y: numbers[i][1])
let point = CGPoint(x: numbers[i][2], y: numbers[i][3])
addInfCubic(to: point, control: control)
}
} else if command.contains("s") {
let numbers = correctCommand(parsed: command, amount: 4)
for i in 0..<numbers.count {
let control = CGPoint(x: numbers[i][0], y: numbers[i][1])
let point = CGPoint(x: numbers[i][2], y: numbers[i][3])
addRelInfCubic(to: point, control: control)
}
} else if command.contains("Q") {
let numbers = correctCommand(parsed: command, amount: 4)
for i in 0..<numbers.count {
let control = CGPoint(x: numbers[i][0], y: numbers[i][1])
let point = CGPoint(x: numbers[i][2], y: numbers[i][3])
addQuad(to: point, control: control)
}
} else if command.contains("q") {
let numbers = correctCommand(parsed: command, amount: 4)
for i in 0..<numbers.count {
let control = CGPoint(x: numbers[i][0], y: numbers[i][1])
let point = CGPoint(x: numbers[i][2], y: numbers[i][3])
addRelQuad(to: point, control: control)
}
} else if command.contains("T") {
let numbers = correctCommand(parsed: command, amount: 2)
for i in 0..<numbers.count {
let point = CGPoint(x: numbers[i][0], y: numbers[i][1])
addInfQuad(to: point)
}
} else if command.contains("t") {
let numbers = correctCommand(parsed: command, amount: 2)
for i in 0..<numbers.count {
let point = CGPoint(x: numbers[i][0], y: numbers[i][1])
addRelInfQuad(to: point)
}
} else if command.contains("A") {
let numbers = correctCommand(parsed: command, amount: 7)
for i in 0..<numbers.count {
let point = CGPoint(x: numbers[i][5], y: numbers[i][6])
let radii = CGPoint(x: numbers[i][0], y: numbers[i][1])
let xAngle = numbers[i][2]
let flagA = numbers[i][3]
let flagS = numbers[i][4]
let last = lastPoint?.getCurrent()
checkAndAdjustRadii(startPoint: last!, endPoint: point, radii: radii, xAngle: xAngle, flagA: flagA, flagS: flagS)
lastPoint = LastPoint.other(current: point)
}
} else if command.contains("a") {
let numbers = correctCommand(parsed: command, amount: 7)
for i in 0..<numbers.count {
let point = CGPoint(x: numbers[i][5], y: numbers[i][6])
let radii = CGPoint(x: numbers[i][0], y: numbers[i][1])
let xAngle = numbers[i][2]
let flagA = numbers[i][3]
let flagS = numbers[i][4]
let last = lastPoint?.getCurrent()
checkAndAdjustRadii(startPoint: last!, endPoint: point+last!, radii: radii, xAngle: xAngle, flagA: flagA, flagS: flagS)
lastPoint = LastPoint.other(current: point+last!)
}
} else if command.contains("z") || command.contains("Z") {
path.closeSubpath()
}
}
}
}
// MARK: Building Functions
extension SVGPath {
private func moveTo(_ point: CGPoint) {
path.move(to: point)
lastPoint = LastPoint.other(current: point)
}
private func relMoveTo(_ point: CGPoint) {
if let last = lastPoint?.getCurrent() {
let new = last + point
path.move(to: new)
lastPoint = LastPoint.other(current: new)
} else {
path.move(to: point)
lastPoint = LastPoint.other(current: point)
}
}
private func addLine(_ point: CGPoint) {
path.addLine(to: point)
lastPoint = LastPoint.other(current: point)
}
private func addRelLine(_ point: CGPoint) {
let new = (lastPoint?.getCurrent())! + point
path.addLine(to: new)
lastPoint = LastPoint.other(current: new)
}
private func addCubic(to point: CGPoint, control1: CGPoint, control2: CGPoint) {
path.addCurve(to: point, control1: control1, control2: control2)
lastPoint = LastPoint.cubic(current: point, lastControl: control2)
}
private func addRelCubic(to point: CGPoint, control1: CGPoint, control2: CGPoint) {
if let last = lastPoint?.getCurrent() {
path.addCurve(to: point + last, control1: control1 + last, control2: control2 + last)
lastPoint = LastPoint.cubic(current: point+last, lastControl: control2+last)
}
}
/// I think this is wrong
private func addInfCubic(to point: CGPoint, control: CGPoint) {
switch lastPoint {
case .cubic(current: let last, lastControl: let lastControl):
let relativeReflection = last - lastControl
let reflected = last + relativeReflection
path.addCurve(to: point, control1: reflected, control2: control)
lastPoint = LastPoint.cubic(current: point, lastControl: control)
case .quad(current: let last, lastControl: _) :
path.addCurve(to: point, control1: last, control2: control)
lastPoint = LastPoint.cubic(current: point, lastControl: control)
case .other(current: let last):
path.addCurve(to: point, control1: last, control2: control)
lastPoint = LastPoint.cubic(current: point, lastControl: control)
default:
break
}
}
private func addRelInfCubic(to point: CGPoint, control: CGPoint) {
switch lastPoint {
case .cubic(current: let last, lastControl: let lastControl):
let relativeReflection = last - lastControl
let reflected = last + relativeReflection
path.addCurve(to: point + last, control1: reflected, control2: control + last)
lastPoint = LastPoint.cubic(current: point + last, lastControl: control + last)
case .quad(current: let last, lastControl: _) :
path.addCurve(to: point + last, control1: last, control2: control + last)
lastPoint = LastPoint.cubic(current: point + last, lastControl: control + last)
case .other(current: let last):
path.addCurve(to: point + last, control1: last, control2: control + last)
lastPoint = LastPoint.cubic(current: point + last, lastControl: control + last)
default:
break
}
}
private func addQuad(to point: CGPoint, control: CGPoint) {
path.addQuadCurve(to: point, control: control)
lastPoint = LastPoint.quad(current: point, lastControl: control)
}
private func addRelQuad(to point: CGPoint, control: CGPoint) {
if let last = lastPoint?.getCurrent() {
path.addQuadCurve(to: point + last, control: control + last)
}
}
private func addInfQuad(to point: CGPoint) {
switch lastPoint {
case .cubic(current: _, lastControl: _):
path.addQuadCurve(to: point, control: point)
lastPoint = LastPoint.quad(current: point, lastControl: point)
case .quad(current: let last, lastControl: let lastControl) :
let relative = last - lastControl
let reflection = last + relative
path.addQuadCurve(to: point, control: reflection)
lastPoint = LastPoint.quad(current: point, lastControl: reflection)
case .other(current: _):
path.addQuadCurve(to: point, control: point)
lastPoint = LastPoint.quad(current: point, lastControl: point)
default:
break
}
}
private func addRelInfQuad(to point: CGPoint) {
switch lastPoint {
case .cubic(current: let last, lastControl: _):
path.addQuadCurve(to: point + last, control: point + last)
lastPoint = LastPoint.quad(current: point + last, lastControl: point + last)
case .quad(current: let last, lastControl: let lastControl) :
let relative = last - lastControl
let reflection = last + relative
path.addQuadCurve(to: point + last, control: reflection + last)
lastPoint = LastPoint.quad(current: point + last, lastControl: reflection + last)
case .other(current: let last):
path.addQuadCurve(to: point + last, control: point + last)
lastPoint = LastPoint.quad(current: point + last, lastControl: point + last)
default:
break
}
}
}
// MARK: Arc Conversion Functions
extension SVGPath {
/* IMPORTANT the function that gets called amongst all of these is "checkAndAdjustRadii"
it will append a Bezier that is either a straight line or an elliptical arc that
has been subdivided into cubic Bezier curves. */
/// Function that converts a axially rotated elliptical arc in endpoint notation to center point notation.
private func convertEndPointToCenter(startPoint: CGPoint, endPoint: CGPoint, radii: CGPoint, xAngle: CGFloat, flagA: CGFloat, flagS: CGFloat) {
/* note in swift all angle values must be converted to radians */
// convert flags into bools representing the values 0 for false and 1 for true
let flagABool = convertFlagToBool(flag: flagA)
let flagSBool = convertFlagToBool(flag: flagS)
// Break down the points into individual components
let x1 = startPoint.x
let y1 = startPoint.y
let x2 = endPoint.x
let y2 = endPoint.y
// Calculate midPoints
let midX = (x1 - x2)/2
let midY = (y1 - y2)/2
// Break down radii into component values, must be variables because radii could be adjust for no solution values
let rX = radii.x
let rY = radii.y
let rXSquared = rX*rX
let rYSquared = rY*rY
// calculate the intermediate values xPrime and yPrime
let xPrime = midX*cos(xAngle) + midY*sin(xAngle)
let yPrime = -midX*sin(xAngle) + midY*cos(xAngle)
let xPrimeSquared = xPrime*xPrime
let yPrimeSquared = yPrime*yPrime
// Compute intermediate values of the center points cXPrime, cYPrime.
let tP1 = rXSquared*rYSquared
let tP2 = rXSquared*yPrimeSquared + rYSquared*xPrimeSquared
// this prevents errors resulting from imaginary values EX sqrt(-1) == error
var innerRoot = (tP1/tP2) - 1
if innerRoot < 0 {
innerRoot = 0
}
// This is an intermediate value required for calculation cXPrime and cYPrime
var root = CGFloat(sqrt(Double(innerRoot)))
if flagABool == flagSBool {
root = -root
}
// intermediate values for calculating the center point
let cXPrime = root*(rX*yPrime/rY)
let cYPrime = -root*(rY*xPrime/rX)
// average values of the point components
let xAve = (x1+x2)/2
let yAve = (y1+y2)/2
// values of the center points of the ellipse
let cX = cXPrime*cos(xAngle) - cYPrime*sin(xAngle) + xAve
let cY = cXPrime*sin(xAngle) + cYPrime*cos(xAngle) + yAve
// Calculate the psuedo angles theta and deltaTheta
let startTheta = angleBetweenVectors(uX: 1, uY: 0, vX: (xPrime-cXPrime)/rX, vY: (yPrime-cYPrime)/rY)
// DeltaTheta must be fixed on the range -2*pi<deltaTheta<2*pi
var deltaTheta = angleBetweenVectors(uX: (xPrime-cXPrime)/rX, uY: (yPrime-cYPrime)/rY, vX: (-xPrime-cXPrime)/rX, vY: (-yPrime-cYPrime)/rY)
// Accounts for the sweep values
if !flagSBool && deltaTheta>0 {
deltaTheta -= 2*CGFloat.pi
}else if flagSBool && deltaTheta<0 {
deltaTheta += 2*CGFloat.pi
}
makeArcFromBezier(cX: cX, cY: cY, rX: rX, rY: rY, xAngle: xAngle, theta: startTheta, deltaTheta: deltaTheta)
}
private func makeArcFromBezier(cX: CGFloat, cY: CGFloat, rX: CGFloat, rY: CGFloat, xAngle: CGFloat, theta: CGFloat, deltaTheta: CGFloat){
let incrementChoice = CGFloat.pi/6
// Semi-major axis of the ellipse
let a = rX
// Semi-minor axis of the ellipse
let b = rY
let startAngle = theta
let startPoint = findPointOnEllipse(a: a, b: b, cX: cX, cY: cY, xAngle: xAngle, eta: startAngle)
if abs(deltaTheta) <= incrementChoice {
let endAngle = theta + deltaTheta
let endPoint = findPointOnEllipse(a: a, b: b, cX: cX, cY: cY, xAngle: xAngle, eta: endAngle)
let startDerivative = findDerivativeAtAngle(a: a, b: b, xAngle: xAngle, eta: startAngle)
let endDerivative = findDerivativeAtAngle(a: a, b: b, xAngle: xAngle, eta: endAngle)
let controlPoints = calcControlPoints(startPoint: startPoint, endPoint: endPoint, startDerivative: startDerivative, endDerivative: endDerivative, startAngle: startAngle, endAngle: endAngle)
addCubic(to: endPoint, control1: controlPoints.0, control2: controlPoints.1)
} else if abs(deltaTheta) > incrementChoice {
/* If deltaTheta is greater than the allowable increment,
then delta theta is divided into equal parts that are less than or equal to the value of the allowable increment.
Those increments are then used to created new start and end angles for computing the values of the startPoint,
endPoints and ControlPoints. */
let rawNumberOfDivisions = deltaTheta/incrementChoice
let numberOfDivisions = abs(rawNumberOfDivisions.rounded(.awayFromZero))
let deltaIncrement = deltaTheta/numberOfDivisions
for i in 1...Int(numberOfDivisions) {
let startingAngle = startAngle + CGFloat(i-1)*deltaIncrement
let endAngle = startAngle + CGFloat(i)*deltaIncrement
let startingPoint = findPointOnEllipse(a: a, b: b, cX: cX, cY: cY, xAngle: xAngle, eta: startingAngle)
let endPoint = findPointOnEllipse(a: a, b: b, cX: cX, cY: cY, xAngle: xAngle, eta: endAngle)
let startDerivative = findDerivativeAtAngle(a: a, b: b, xAngle: xAngle, eta: startingAngle)
let endDerivative = findDerivativeAtAngle(a: a, b: b, xAngle: xAngle, eta: endAngle)
let controlPoints = calcControlPoints(startPoint: startingPoint, endPoint: endPoint, startDerivative: startDerivative, endDerivative: endDerivative, startAngle: startingAngle, endAngle: endAngle)
addCubic(to: endPoint, control1: controlPoints.0, control2: controlPoints.1)
}
}
}
/// calculated the point on the ellipse for an angle value eta
private func findPointOnEllipse(a: CGFloat, b: CGFloat, cX: CGFloat, cY: CGFloat, xAngle: CGFloat, eta: CGFloat) -> CGPoint {
let pX = (cX + a*cos(xAngle)*cos(eta) - b*sin(xAngle)*sin(eta)).rounded()
let pY = (cY + a*sin(xAngle)*cos(eta) + b*cos(xAngle)*sin(eta)).rounded()
return CGPoint(x: pX, y: pY)
}
/// calculates the derivative at on the ellipse at value eta
private func findDerivativeAtAngle(a: CGFloat, b: CGFloat, xAngle: CGFloat, eta: CGFloat) -> CGPoint {
let dX = -a*cos(xAngle)*sin(eta) - b*sin(xAngle)*cos(eta)
let dY = -a*sin(xAngle)*sin(eta) + b*cos(xAngle)*cos(eta)
return CGPoint(x: dX, y: dY)
}
private func calcControlPoints(startPoint: CGPoint, endPoint: CGPoint, startDerivative: CGPoint, endDerivative: CGPoint, startAngle: CGFloat, endAngle: CGFloat) -> (CGPoint, CGPoint) {
// calculate the intermediate values to get alpha
let tanSquared = tan((endAngle - startAngle)/2)*tan((endAngle - startAngle)/2)
let alpha = sin(endAngle - startAngle)*(CGFloat(sqrt(Double(4+3*tanSquared)))-1)/3
//calculates both control points
let control1 = startPoint + alpha*startDerivative
let control2 = endPoint - alpha*endDerivative
return (control1, control2)
}
/// Calculates the angle between two 2D vectors and changes the sign accordingly
private func angleBetweenVectors(uX: CGFloat, uY: CGFloat, vX: CGFloat, vY: CGFloat) -> CGFloat {
// Dot product of the two vectors
let dot = uX*vX + uY*vY
// length of vector U
let lenU = CGFloat(sqrt(Double(uX*uX + uY*uY)))
// length of vect V
let lenV = CGFloat(sqrt(Double(vX*vX + vY*vY)))
// Angle Between the vector, still needs to have the sign added. Range of acos is [0, pi]
var angle = acos(dot/(lenU*lenV))
// checks and adjusts the sign of the angle.
if uX*vY-uY*vX < 0 {
angle = -angle
}
return angle
}
// Ensure Radii values are proper and has a solution
private func checkAndAdjustRadii(startPoint: CGPoint, endPoint: CGPoint, radii: CGPoint, xAngle: CGFloat, flagA: CGFloat, flagS: CGFloat) {
/* note in swift all angle values must be converted to radians */
// Break down radii into component values, must be variables because radii could be adjust for no solution values
var rX = abs(radii.x)
var rY = abs(radii.y)
let rXSquared = rX*rX
let rYSquared = rY*rY
if rX == 0 || rY == 0 {
addLine(endPoint)
} else {
// Break down the points into individual components
let x1 = startPoint.x
let y1 = startPoint.y
let x2 = endPoint.x
let y2 = endPoint.y
// Calculate midPoints
let midX = (x1 - x2)/2
let midY = (y1 - y2)/2
// calculate the intermediate values xPrime and yPrime
let xPrime = midX*cos(xAngle) + midY*sin(xAngle)
let yPrime = -midX*sin(xAngle) + midY*cos(xAngle)
let xPrimeSquared = xPrime*xPrime
let yPrimeSquared = yPrime*yPrime
// Compare Value to this and if greater than 1 adjust each radii value
let lamda = xPrimeSquared/rXSquared + yPrimeSquared/rYSquared
// scale the radii up
if lamda > 1 {
rX = CGFloat(sqrt(Double(lamda)))*rX
rY = CGFloat(sqrt(Double(lamda)))*rY
}
//Convert xAngle to radians before the chain of events starts
let radiansXAngle = (xAngle/180)*CGFloat.pi
convertEndPointToCenter(startPoint: startPoint, endPoint: endPoint, radii: CGPoint(x: rX, y: rY), xAngle: radiansXAngle, flagA: flagA, flagS: flagS)
}
}
/// Function converts the numerical value of the flag into a boolean
private func convertFlagToBool(flag: CGFloat) -> Bool {
var flagBool: Bool
// as per SVG spec if the flag value is greater than 0 it is to be cosidered a 1
if flag > 0 {
flagBool = true
}else {
flagBool = false
}
return flagBool
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment