Skip to content
Merged
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
6 changes: 6 additions & 0 deletions DOM/Sources/DOM.SVG.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,13 @@ package extension DOM {

struct ClipPath: ContainerElement {
package var id: String
package var clipPathUnits: Units?
package var childElements = [GraphicsElement]()

package enum Units: String {
case userSpaceOnUse
case objectBoundingBox
}
}

final class Mask: GraphicsElement, ContainerElement {
Expand Down
5 changes: 4 additions & 1 deletion DOM/Sources/Parser.XML.SVG.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,12 @@ package extension XMLParser {

let att = try parseAttributes(e)
let id: String = try att.parseString("id")
let units: DOM.ClipPath.Units? = try att.parseRaw("clipPathUnits")

let children = try parseGraphicsElements(e.children)
return DOM.ClipPath(id: id, childElements: children)
var clip = DOM.ClipPath(id: id, childElements: children)
clip.clipPathUnits = units
return clip
}

func parseMasks(_ e: XML.Element) throws -> [DOM.Mask] {
Expand Down
11 changes: 11 additions & 0 deletions SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ extension LayerTree {
l.transform = Builder.createTransforms(from: attributes.transform ?? [])
l.clip = makeClipShapes(for: element)
l.clipRule = attributes.clipRule
l.clipUnits = makeClipUnits(for: element)
l.mask = createMaskLayer(for: element)
l.opacity = state.opacity
l.filters = makeFilters(for: state)
Expand Down Expand Up @@ -173,6 +174,16 @@ extension LayerTree {
return clip.childElements.compactMap(makeClipShape)
}

func makeClipUnits(for element: DOM.GraphicsElement) -> ClipUnits {
let attributes = DOM.presentationAttributes(for: element, styles: svg.styles)
guard let clipID = attributes.clipPath?.fragmentID,
let clip = svg.defs.clipPaths.first(where: { $0.id == clipID }) else { return .userSpaceOnUse }
switch clip.clipPathUnits {
case .objectBoundingBox: return .objectBoundingBox
case .userSpaceOnUse, nil: return .userSpaceOnUse
}
}

func makeClipShape(for element: DOM.GraphicsElement) -> ClipShape? {
guard let shape = Builder.makeShape(from: element) else {
return nil
Expand Down
3 changes: 3 additions & 0 deletions SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ extension LayerTree {
var transform: [Transform] = []
var clip: [ClipShape] = []
var clipRule: FillRule?
var clipUnits: ClipUnits = .userSpaceOnUse
var mask: Layer?
var filters: [Filter] = []

Expand Down Expand Up @@ -79,6 +80,7 @@ extension LayerTree {
opacity.hash(into: &hasher)
transform.hash(into: &hasher)
clip.hash(into: &hasher)
clipUnits.hash(into: &hasher)
mask.hash(into: &hasher)
filters.hash(into: &hasher)
}
Expand All @@ -90,6 +92,7 @@ extension LayerTree {
lhs.transform == rhs.transform &&
lhs.clip == rhs.clip &&
lhs.clipRule == rhs.clipRule &&
lhs.clipUnits == rhs.clipUnits &&
lhs.mask == rhs.mask &&
lhs.filters == rhs.filters
}
Expand Down
5 changes: 5 additions & 0 deletions SwiftDraw/Sources/LayerTree/LayerTree.Shape.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ extension LayerTree {
var shape: Shape
var transform: Transform.Matrix
}

enum ClipUnits: Hashable {
case userSpaceOnUse
case objectBoundingBox
}
}

extension LayerTree.Shape {
Expand Down
111 changes: 111 additions & 0 deletions SwiftDraw/Sources/Renderer/Renderer.SFSymbol+ClipPath.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//
// Renderer.SFSymbol+ClipPath.swift
// SwiftDraw
//
// Created by SwiftDraw contributors
// Copyright 2026 Simon Whitty
//
// Distributed under the permissive zlib license
// Get the latest version from here:
//
// https://github.com/swhitty/SwiftDraw
//
// This software is provided 'as-is', without any express or implied
// warranty. In no event will the authors be held liable for any damages
// arising from the use of this software.
//
// Permission is granted to anyone to use this software for any purpose,
// including commercial applications, and to alter it and redistribute it
// freely, subject to the following restrictions:
//
// 1. The origin of this software must not be misrepresented; you must not
// claim that you wrote the original software. If you use this software
// in a product, an acknowledgment in the product documentation would be
// appreciated but is not required.
//
// 2. Altered source versions must be plainly marked as such, and must not be
// misrepresented as being the original software.
//
// 3. This notice may not be removed or altered from any source distribution.
//

#if canImport(CoreGraphics)
import Foundation
import CoreGraphics
import SwiftDrawDOM

extension SFSymbolRenderer {

/// Bake the intersection of `path` with the union of `clipShapes` into a single LayerTree.Path.
/// Returns `nil` when the result is empty (path lies entirely outside the clip region).
/// Returns the original `path` unchanged when the running platform predates CGPath boolean ops.
/// `clipCTM` is applied to each clip shape so it sits in the same coordinate space as `path`.
static func intersect(path: LayerTree.Path,
with clipShapes: [LayerTree.ClipShape],
clipRule: LayerTree.FillRule?,
clipUnits: LayerTree.ClipUnits,
clipCTM: LayerTree.Transform.Matrix) -> LayerTree.Path? {
guard !clipShapes.isEmpty else { return path }

let pathCG = CGProvider().createPath(from: .path(path))
guard !pathCG.isEmpty else { return nil }

let unitsTransform: LayerTree.Transform.Matrix
switch clipUnits {
case .userSpaceOnUse:
unitsTransform = .identity
case .objectBoundingBox:
let bounds = path.bounds
guard bounds.width > 0, bounds.height > 0 else { return nil }
unitsTransform = LayerTree.Transform.Matrix(
a: bounds.width, b: 0,
c: 0, d: bounds.height,
tx: bounds.minX, ty: bounds.minY
)
}

let clipCG = makeUnionedCGPath(
from: clipShapes,
preCTM: unitsTransform,
postCTM: clipCTM
)
guard !clipCG.isEmpty else { return nil }

let rule: CGPathFillRule = (clipRule == .evenodd) ? .evenOdd : .winding

if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
let intersected = pathCG.intersection(clipCG, using: rule)
if intersected.isEmpty { return nil }
return intersected.makePath()
} else {
// Fall back for older deployment targets: keep the path when its bbox lies
// entirely inside the clip bbox; drop it when entirely outside; otherwise
// pass it through unchanged so existing behavior is preserved.
return fallbackIntersect(pathCG: pathCG, clipCG: clipCG, original: path)
}
}

private static func makeUnionedCGPath(from clipShapes: [LayerTree.ClipShape],
preCTM: LayerTree.Transform.Matrix,
postCTM: LayerTree.Transform.Matrix) -> CGPath {
let provider = CGProvider()
let union = CGMutablePath()
for clipShape in clipShapes {
let combined = preCTM
.concatenated(clipShape.transform)
.concatenated(postCTM)
let transform = provider.createTransform(from: combined)
let shapePath = provider.createPath(from: clipShape.shape)
union.addPath(shapePath, transform: transform)
}
return union
}

private static func fallbackIntersect(pathCG: CGPath, clipCG: CGPath, original: LayerTree.Path) -> LayerTree.Path? {
let pathBounds = pathCG.boundingBoxOfPath
let clipBounds = clipCG.boundingBoxOfPath
if !pathBounds.intersects(clipBounds) { return nil }
return original
}
}
#endif
36 changes: 32 additions & 4 deletions SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,6 @@ extension SFSymbolRenderer {

let isSFSymbolLayer = containsAcceptedName(layer.class)
guard isSFSymbolLayer || layer.opacity > 0 else { return [] }
guard layer.clip.isEmpty else {
print("Warning:", "clip-path unsupported in SF Symbols.", to: &.standardError)
return []
}
guard layer.mask == nil else {
print("Warning:", "mask unsupported in SF Symbols.", to: &.standardError)
return []
Expand Down Expand Up @@ -237,9 +233,41 @@ extension SFSymbolRenderer {
}
}

if !layer.clip.isEmpty {
paths = applyClip(to: paths,
clipShapes: layer.clip,
clipRule: layer.clipRule,
clipUnits: layer.clipUnits,
ctm: ctm)
}

return paths
}

static func applyClip(to paths: [SymbolPath],
clipShapes: [LayerTree.ClipShape],
clipRule: LayerTree.FillRule?,
clipUnits: LayerTree.ClipUnits,
ctm: LayerTree.Transform.Matrix) -> [SymbolPath] {
#if canImport(CoreGraphics)
var result = [SymbolPath]()
result.reserveCapacity(paths.count)
for symbolPath in paths {
if let clipped = intersect(path: symbolPath.path,
with: clipShapes,
clipRule: clipRule,
clipUnits: clipUnits,
clipCTM: ctm) {
result.append(SymbolPath(class: symbolPath.class, path: clipped))
}
}
return result
#else
print("Warning:", "clip-path requires CoreGraphics.", to: &.standardError)
return paths
#endif
}

static func makeFillPath(for shape: LayerTree.Shape,
fill: LayerTree.FillAttributes,
preserve: Bool) -> LayerTree.Path? {
Expand Down
Loading
Loading