From 26c57c353c0d1506fbe1111ec1e82f48c4662adc Mon Sep 17 00:00:00 2001 From: Lee Hericks Date: Sun, 21 Dec 2025 23:19:10 +0900 Subject: [PATCH 1/3] Make inverting Y axis optional (with non-breaking default) (#9) --- Sources/SVGPath+CoreGraphics.swift | 4 +-- Sources/SVGPath.swift | 52 +++++++++++++++++++----------- Tests/SVGPathTests.swift | 15 +++++++++ 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/Sources/SVGPath+CoreGraphics.swift b/Sources/SVGPath+CoreGraphics.swift index ae1591f..3cac560 100644 --- a/Sources/SVGPath+CoreGraphics.swift +++ b/Sources/SVGPath+CoreGraphics.swift @@ -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 { diff --git a/Sources/SVGPath.swift b/Sources/SVGPath.swift index e3e4821..7b6f5ec 100644 --- a/Sources/SVGPath.swift +++ b/Sources/SVGPath.swift @@ -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() var number = "" var isRelative = false + let yAxisSign = options.invertYAxis ? -1.0 : 1.0 func assertArgs(_ count: Int) throws -> [Double] { if numbers.count < count { @@ -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] )) } @@ -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]) ) } @@ -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]) ) } @@ -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]) ) } @@ -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]) )) } @@ -231,16 +242,19 @@ public extension SVGPath { 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 && ( @@ -269,19 +283,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") } diff --git a/Tests/SVGPathTests.swift b/Tests/SVGPathTests.swift index 21ecd47..a994ff0 100644 --- a/Tests/SVGPathTests.swift +++ b/Tests/SVGPathTests.swift @@ -10,6 +10,21 @@ import SVGPath import XCTest class SVGPathTests: XCTestCase { + 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: [ From 3f8802dfc9b9c98f02ff8aa6c2aa963b849a0065 Mon Sep 17 00:00:00 2001 From: Lee Hericks Date: Mon, 22 Dec 2025 00:10:16 +0900 Subject: [PATCH 2/3] Add length approximation from sampled points. --- Sources/SVGPath.swift | 23 +++++++++++++++++++++++ Tests/SVGPathTests.swift | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/Sources/SVGPath.swift b/Sources/SVGPath.swift index 7b6f5ec..084d59e 100644 --- a/Sources/SVGPath.swift +++ b/Sources/SVGPath.swift @@ -236,6 +236,10 @@ 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() @@ -304,6 +308,25 @@ public extension SVGPath { } } +public extension Array { + var length: Double { + guard self.count > 1 else { return 0 } + + var length: Double = 0 + var prev = self[0] + + for i in 1.. Date: Mon, 22 Dec 2025 00:48:59 +0900 Subject: [PATCH 3/3] Change extension from Array to Collection where Element == SVGPoint --- Sources/SVGPath.swift | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/Sources/SVGPath.swift b/Sources/SVGPath.swift index 084d59e..7b58d40 100644 --- a/Sources/SVGPath.swift +++ b/Sources/SVGPath.swift @@ -308,22 +308,21 @@ public extension SVGPath { } } -public extension Array { +public extension Collection where Element == SVGPoint { var length: Double { - guard self.count > 1 else { return 0 } - - var length: Double = 0 - var prev = self[0] - - for i in 1..