Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/SVGPath+CoreGraphics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ import Foundation
// MARK: SVGPath to CGPath

public extension CGPath {
static func from(svgPath: String) throws -> CGPath {
try from(svgPath: SVGPath(string: svgPath))
static func from(svgPath: String, with options: SVGPath.ParseOptions = .default) throws -> CGPath {
try from(svgPath: SVGPath(string: svgPath, with: options))
}

static func from(svgPath: SVGPath) -> CGPath {
Expand Down
74 changes: 55 additions & 19 deletions Sources/SVGPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,29 @@
import Foundation

public struct SVGPath: Hashable, Sendable {
public struct ParseOptions: Sendable {
public static let `default` = Self()

public var invertYAxis: Bool

public init(invertYAxis: Bool = true) {
self.invertYAxis = invertYAxis
}
}

public var commands: [SVGCommand]

public init(commands: [SVGCommand]) {
self.commands = commands
}

public init(string: String) throws {
public init(string: String, with options: ParseOptions = .default) throws {
var token: UnicodeScalar = " "
var commands = [SVGCommand]()
var numbers = ArraySlice<Double>()
var number = ""
var isRelative = false
let yAxisSign = options.invertYAxis ? -1.0 : 1.0

func assertArgs(_ count: Int) throws -> [Double] {
if numbers.count < count {
Expand All @@ -57,19 +68,19 @@ public struct SVGPath: Hashable, Sendable {

func moveTo() throws -> SVGCommand {
let numbers = try assertArgs(2)
return .moveTo(SVGPoint(x: numbers[0], y: -numbers[1]))
return .moveTo(SVGPoint(x: numbers[0], y: yAxisSign * numbers[1]))
}

func lineTo() throws -> SVGCommand {
let numbers = try assertArgs(2)
return .lineTo(SVGPoint(x: numbers[0], y: -numbers[1]))
return .lineTo(SVGPoint(x: numbers[0], y: yAxisSign * numbers[1]))
}

func lineToVertical() throws -> SVGCommand {
let numbers = try assertArgs(1)
return .lineTo(SVGPoint(
x: isRelative ? 0 : commands.lastPoint.x,
y: -numbers[0]
y: yAxisSign * numbers[0]
))
}

Expand All @@ -84,8 +95,8 @@ public struct SVGPath: Hashable, Sendable {
func quadCurve() throws -> SVGCommand {
let numbers = try assertArgs(4)
return .quadratic(
SVGPoint(x: numbers[0], y: -numbers[1]),
SVGPoint(x: numbers[2], y: -numbers[3])
SVGPoint(x: numbers[0], y: yAxisSign * numbers[1]),
SVGPoint(x: numbers[2], y: yAxisSign * numbers[3])
)
}

Expand All @@ -100,15 +111,15 @@ public struct SVGPath: Hashable, Sendable {
if !isRelative {
control += lastPoint
}
return .quadratic(control, SVGPoint(x: numbers[0], y: -numbers[1]))
return .quadratic(control, SVGPoint(x: numbers[0], y: yAxisSign * numbers[1]))
}

func cubicCurve() throws -> SVGCommand {
let numbers = try assertArgs(6)
return .cubic(
SVGPoint(x: numbers[0], y: -numbers[1]),
SVGPoint(x: numbers[2], y: -numbers[3]),
SVGPoint(x: numbers[4], y: -numbers[5])
SVGPoint(x: numbers[0], y: yAxisSign * numbers[1]),
SVGPoint(x: numbers[2], y: yAxisSign * numbers[3]),
SVGPoint(x: numbers[4], y: yAxisSign * numbers[5])
)
}

Expand All @@ -125,8 +136,8 @@ public struct SVGPath: Hashable, Sendable {
}
return .cubic(
control,
SVGPoint(x: numbers[0], y: -numbers[1]),
SVGPoint(x: numbers[2], y: -numbers[3])
SVGPoint(x: numbers[0], y: yAxisSign * numbers[1]),
SVGPoint(x: numbers[2], y: yAxisSign * numbers[3])
)
}

Expand All @@ -137,7 +148,7 @@ public struct SVGPath: Hashable, Sendable {
rotation: numbers[2] * .pi / 180,
largeArc: numbers[3] != 0,
sweep: numbers[4] != 0,
end: SVGPoint(x: numbers[5], y: -numbers[6])
end: SVGPoint(x: numbers[5], y: yAxisSign * numbers[6])
))
}

Expand Down Expand Up @@ -225,22 +236,29 @@ public extension SVGPath {
getPoints(&points, detail: detail)
return points
}

func length(withDetail detail: Int) -> Double {
return points(withDetail: detail).length
}

struct WriteOptions: Sendable {
public static let `default` = Self()

public var prettyPrinted: Bool
public var wrapWidth: Int
public var invertYAxis: Bool

public init(prettyPrinted: Bool = true, wrapWidth: Int = .max) {
public init(prettyPrinted: Bool = true, wrapWidth: Int = .max, invertYAxis: Bool = true) {
self.prettyPrinted = prettyPrinted
self.wrapWidth = wrapWidth
self.invertYAxis = invertYAxis
}
}

func string(with options: WriteOptions) -> String {
var output = ""
var width = 0
let yAxisSign = options.invertYAxis ? -1.0 : 1.0

func append(_ string: String) {
let spaced = width > 0 && (
Expand Down Expand Up @@ -269,19 +287,19 @@ public extension SVGPath {
for command in commands {
switch command {
case let .moveTo(point):
append("M", point.x, -point.y)
append("M", point.x, yAxisSign * point.y)
case let .lineTo(point):
append("L", point.x, -point.y)
append("L", point.x, yAxisSign * point.y)
case let .cubic(c1, c2, point):
append("C", c1.x, -c1.y, c2.x, -c2.y, point.x, -point.y)
append("C", c1.x, yAxisSign * c1.y, c2.x, yAxisSign * c2.y, point.x, yAxisSign * point.y)
case let .quadratic(control, point):
append("Q", control.x, -control.y, point.x, -point.y)
append("Q", control.x, yAxisSign * control.y, point.x, yAxisSign * point.y)
case let .arc(arc):
let rad = arc.radius, end = arc.end
let rot = arc.rotation / .pi * 180
let large = arc.largeArc ? 1.0 : 0
let sweep = arc.sweep ? 1.0 : 0
append("A", rad.x, rad.y, rot, large, sweep, end.x, -end.y)
append("A", rad.x, rad.y, rot, large, sweep, end.x, yAxisSign * end.y)
case .end:
append("Z")
}
Expand All @@ -290,6 +308,24 @@ public extension SVGPath {
}
}

public extension Collection where Element == SVGPoint {
var length: Double {
var it = makeIterator()
guard var prev = it.next() else { return 0 }

var total: Double = 0

while let p = it.next() {
let dx = Double(p.x - prev.x)
let dy = Double(p.y - prev.y)
total += (dx * dx + dy * dy).squareRoot()
prev = p
}

return total
}
}

private extension Character {
var isDigit: Bool {
isASCII && isWholeNumber
Expand Down
36 changes: 36 additions & 0 deletions Tests/SVGPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,42 @@ import SVGPath
import XCTest

class SVGPathTests: XCTestCase {
func testLineLength() throws {
let svgPath = try SVGPath(string: "M0 0 H100 V50")

let expected = 150.0
let measured = svgPath.length(withDetail: 32)

XCTAssertEqual(measured, expected, accuracy: 0.0)
}

func testArcLengthWithHalfCircle() throws {
let svgPath = try SVGPath(
string: "M100 0 A100 100 0 0 1 0 100 A100 100 0 0 1 -100 0"
)

let expected = Double.pi * 100
let measured = svgPath.length(withDetail: 32)
print("Measured: \(measured) Expected: \(expected)")

XCTAssertEqual(measured, expected, accuracy: 0.5)
}

func testTriangleWithoutInvertingYAxis() throws {
let parseOptions = SVGPath.ParseOptions(invertYAxis: false)
let svgPath = try SVGPath(string: "M150 0 L75 200 L225 200 Z", with: parseOptions)
let expected = SVGPath(commands: [
.moveTo(.init(x: 150, y: 0)),
.lineTo(.init(x: 75, y: 200)),
.lineTo(.init(x: 225, y: 200)),
.end,
])
XCTAssertEqual(svgPath, expected)

let writeOptions = SVGPath.WriteOptions(invertYAxis: false)
XCTAssertEqual(svgPath.string(with: writeOptions), "M150 0 L75 200 L225 200 Z")
}

func testTriangle() throws {
let svgPath = try SVGPath(string: "M150 0 L75 200 L225 200 Z")
let expected = SVGPath(commands: [
Expand Down