Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
32 changes: 27 additions & 5 deletions Source/CoreGraphicsPolyfill.swift
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ import Foundation
}

public func translatedBy(x: CGFloat, y: CGFloat) -> CGAffineTransform {
return self.concatenating(CGAffineTransform(translationX: x, y: y))
return CGAffineTransform(translationX: x, y: y).concatenating(self)
}

public func concatenating(_ t: CGAffineTransform) -> CGAffineTransform {
Expand All @@ -326,11 +326,11 @@ import Foundation
}

public func scaledBy(x: CGFloat, y: CGFloat) -> CGAffineTransform {
return self.concatenating(CGAffineTransform(scaleX: x, y: y))
return CGAffineTransform(scaleX: x, y: y).concatenating(self)
}

public func rotated(by angle: CGFloat) -> CGAffineTransform {
return self.concatenating(CGAffineTransform(rotationAngle: angle))
return CGAffineTransform(rotationAngle: angle).concatenating(self)
}
}

Expand Down Expand Up @@ -451,17 +451,39 @@ import Foundation
let initialPoint = CGPoint(
x: center.x + radius * cos(currentAngle), y: center.y + radius * sin(currentAngle))

// `UIBezierPath.addArc(withCenter:…)` documents that it implicitly
// sets the current point to the arc's starting point before
// emitting any cubics. With a non-empty path whose previous
// segment ends elsewhere, that "set current point" is a `move`,
// i.e. it opens a NEW subpath at the arc's start — Apple does not
// stitch the previous subpath to the arc with an implicit line.
//
// The original polyfill emitted `addLine(to: initialPoint)` here,
// which fuses the previous subpath and the arc into a single big
// subpath. When the SVG fixture uses evenodd-rule fills built up
// from many `M…A…` subpaths (animal-music body outlines stitched
// from 15+ arcs), that fusion flips which regions the fill rule
// considers "inside", which is exactly the cream/white blob over
// the pelican beak and otter forelock on Web. Always `move` to
// match Apple's UIBezierPath semantics.
if path.elements.isEmpty || (path.elements.last?.isCloseSubpath ?? false) {
path.move(to: initialPoint)
} else if let lastElement = path.elements.last, let lastPoint = lastElement.lastPoint,
lastPoint != initialPoint
{
path.addLine(to: initialPoint)
path.move(to: initialPoint)
}

for _ in 0..<numSegments {
let nextAngle = currentAngle + segmentAngleSweep
let L = radius * (4.0 / 3.0) * tan(abs(segmentAngleSweep) / 4.0)
// L's sign must follow the sweep so the cubic handles point in
// the direction the arc is traversed. `abs(segmentAngleSweep)`
// here orients the handles as if the sweep were CW, which
// mirrors counter-clockwise arcs and yields self-intersecting
// cubic approximations. The downstream fill rule then flips
// inside/outside on those self-intersections, producing the
// "white blob" we see on pelican beak / otter forelock fills.
let L = radius * (4.0 / 3.0) * tan(segmentAngleSweep / 4.0)

let cp1_centerRelative = CGPoint(
x: radius * cos(currentAngle) - L * sin(currentAngle),
Expand Down
77 changes: 68 additions & 9 deletions Source/Parser/SVG/SVGPathReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -564,18 +564,21 @@ extension SVGPath {
if w == h && rotation == 0 {
bezierPath.addArc(withCenter: CGPoint(x: cx, y: cy), radius: CGFloat(w / 2), startAngle: extent, endAngle: end, clockwise: arcAngle >= 0)
} else {
let maxSize = CGFloat(max(w, h))
#if os(WASI) || os(Linux) || os(Android)
var path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0)
SVGPath.appendEllipticalArcAsPolyline(
to: bezierPath,
cx: cx, cy: cy, w: w, h: h,
rotation: rotation,
startAngle: extent, arcAngle: arcAngle
)
#else
let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0)
SVGPath.appendEllipticalArcViaTransform(
to: bezierPath,
cx: cx, cy: cy, w: w, h: h,
rotation: rotation,
startAngle: extent, arcAngle: arcAngle
)
#endif

var transform = CGAffineTransform(translationX: cx, y: cy)
transform = transform.rotated(by: CGFloat(rotation))
path.apply(transform.scaledBy(x: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize))

bezierPath.append(path)
}
}

Expand Down Expand Up @@ -695,5 +698,61 @@ extension SVGPath {
return bezierPath
}

/// Apple/CoreGraphics implementation: build a unit-arc-radius MBezierPath
/// at the origin and apply a translate · rotate · scale transform.
/// This is the path used on iOS/macOS/tvOS/watchOS where
/// `MBezierPath.apply` is `UIBezierPath.apply` / `NSBezierPath.transform`
/// and renders the arc identically to native UIKit/AppKit.
static func appendEllipticalArcViaTransform(
to bezierPath: MBezierPath,
cx: CGFloat, cy: CGFloat,
w: CGFloat, h: CGFloat,
rotation: CGFloat,
startAngle: CGFloat, arcAngle: CGFloat
) {
let maxSize = max(w, h)
let end = startAngle + arcAngle
let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: startAngle, endAngle: end, clockwise: arcAngle >= 0)
var transform = CGAffineTransform(translationX: cx, y: cy)
transform = transform.rotated(by: rotation)
path.apply(transform.scaledBy(x: w / maxSize, y: h / maxSize))
bezierPath.append(path)
}

/// Polyfill implementation: emit the elliptical arc as a 1°/segment
/// polyline in target coordinates. Used on WASI/Linux/Android because
/// the polyfill `MBezierPath.apply` does literal per-point math under
/// row-vector convention, which composes `T * R * S` incorrectly for
/// arcs whose centers are deep inside an SVG viewBox.
static func appendEllipticalArcAsPolyline(
to bezierPath: MBezierPath,
cx: CGFloat, cy: CGFloat,
w: CGFloat, h: CGFloat,
rotation: CGFloat,
startAngle: CGFloat, arcAngle: CGFloat
) {
let rx = w / 2
let ry = h / 2
let cosA = cos(rotation)
let sinA = sin(rotation)

func transformedPoint(_ a: CGFloat) -> CGPoint {
let xs = cos(a) * rx
let ys = sin(a) * ry
return CGPoint(x: cx + cosA * xs - sinA * ys, y: cy + sinA * xs + cosA * ys)
}

let absSweep = abs(arcAngle)
let stepRadians = CGFloat.pi / 180
let segmentCount = Swift.max(1, Int(ceil(absSweep / stepRadians)))
let perSegmentSweep = arcAngle / CGFloat(segmentCount)

var currentAngle = startAngle
bezierPath.move(to: transformedPoint(currentAngle))
for _ in 0 ..< segmentCount {
currentAngle += perSegmentSweep
bezierPath.addLine(to: transformedPoint(currentAngle))
}
}

}
43 changes: 43 additions & 0 deletions Tests/CoreGraphicsPolyfillTests/PolyfillTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,49 @@ final class PolyfillTests: XCTestCase {
XCTAssertNotEqual(transform.tx, 0)
XCTAssertNotEqual(transform.ty, 0)
}

func testTranslatedByMatchesCoreGraphicsPrependingSemantics() {
let transform = CGAffineTransform(scaleX: 2, y: 3).translatedBy(x: 5, y: 7)

XCTAssertEqual(transform.a, 2)
XCTAssertEqual(transform.d, 3)
XCTAssertEqual(transform.tx, 10)
XCTAssertEqual(transform.ty, 21)
}

func testScaledByMatchesCoreGraphicsPrependingSemantics() {
let transform = CGAffineTransform(translationX: 5, y: 7).scaledBy(x: 2, y: 3)

XCTAssertEqual(transform.a, 2)
XCTAssertEqual(transform.d, 3)
XCTAssertEqual(transform.tx, 5)
XCTAssertEqual(transform.ty, 7)
}

func testRotatedByMatchesCoreGraphicsPrependingSemantics() {
let transform = CGAffineTransform(translationX: 20, y: 125).rotated(by: -.pi / 2)

XCTAssertEqual(transform.a, cos(-.pi / 2), accuracy: 1e-10)
XCTAssertEqual(transform.b, sin(-.pi / 2), accuracy: 1e-10)
XCTAssertEqual(transform.c, -sin(-.pi / 2), accuracy: 1e-10)
XCTAssertEqual(transform.d, cos(-.pi / 2), accuracy: 1e-10)
XCTAssertEqual(transform.tx, 20, accuracy: 1e-10)
XCTAssertEqual(transform.ty, 125, accuracy: 1e-10)
}

func testRotateAroundPointKeepsPivotFixed() {
let pivot = CGPoint(x: 20, y: 125)
let transform = CGAffineTransform.identity
.translatedBy(x: pivot.x, y: pivot.y)
.rotated(by: -.pi / 2)
.translatedBy(x: -pivot.x, y: -pivot.y)

let transformedPivot = pivot.applying(transform)
XCTAssertEqual(transformedPivot.x, pivot.x, accuracy: 1e-10)
XCTAssertEqual(transformedPivot.y, pivot.y, accuracy: 1e-10)
XCTAssertEqual(transform.tx, -105, accuracy: 1e-10)
XCTAssertEqual(transform.ty, 145, accuracy: 1e-10)
}

func testComplexTransform() {
let point = CGPoint(x: 5, y: 5)
Expand Down
Loading
Loading