From 54348dd6880443a5f2771d405b66f15df87fa17b Mon Sep 17 00:00:00 2001 From: Radu Ursache Date: Wed, 22 Apr 2026 20:57:00 +0300 Subject: [PATCH] Support clip-path in SF Symbol output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the early-return that printed "clip-path unsupported in SF Symbols" and instead bakes path ∩ clipPath into the emitted symbol path using CGPath.intersection on macOS 13/iOS 16+. Mirrors the existing Renderer.SFSymbol+CGPath.swift pattern used for stroke outlines. Adds clipPathUnits support (userSpaceOnUse + objectBoundingBox) by threading a new LayerTree.ClipUnits through DOM.ClipPath and Layer. Fixes #37 --- DOM/Sources/DOM.SVG.swift | 6 + DOM/Sources/Parser.XML.SVG.swift | 5 +- .../Sources/LayerTree/LayerTree.Builder.swift | 11 + .../Sources/LayerTree/LayerTree.Layer.swift | 3 + .../Sources/LayerTree/LayerTree.Shape.swift | 5 + .../Renderer/Renderer.SFSymbol+ClipPath.swift | 111 +++++++ .../Sources/Renderer/Renderer.SFSymbol.swift | 36 ++- .../Renderer.SFSymbolClipPathTests.swift | 299 ++++++++++++++++++ SwiftDraw/Tests/Test.bundle/clip-circle.svg | 6 + SwiftDraw/Tests/Test.bundle/clip-contains.svg | 6 + SwiftDraw/Tests/Test.bundle/clip-ellipse.svg | 6 + SwiftDraw/Tests/Test.bundle/clip-group.svg | 9 + .../Tests/Test.bundle/clip-multi-shape.svg | 9 + SwiftDraw/Tests/Test.bundle/clip-outside.svg | 6 + .../Tests/Test.bundle/clip-path-element.svg | 6 + SwiftDraw/Tests/Test.bundle/clip-polygon.svg | 6 + SwiftDraw/Tests/Test.bundle/clip-rect.svg | 6 + .../Tests/Test.bundle/clip-rule-evenodd.svg | 8 + .../Test.bundle/clip-transform-child.svg | 8 + .../Test.bundle/clip-transformed-element.svg | 8 + .../Tests/Test.bundle/clip-units-bbox.svg | 8 + 21 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 SwiftDraw/Sources/Renderer/Renderer.SFSymbol+ClipPath.swift create mode 100644 SwiftDraw/Tests/Renderer/Renderer.SFSymbolClipPathTests.swift create mode 100644 SwiftDraw/Tests/Test.bundle/clip-circle.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-contains.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-ellipse.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-group.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-multi-shape.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-outside.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-path-element.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-polygon.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-rect.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-rule-evenodd.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-transform-child.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-transformed-element.svg create mode 100644 SwiftDraw/Tests/Test.bundle/clip-units-bbox.svg diff --git a/DOM/Sources/DOM.SVG.swift b/DOM/Sources/DOM.SVG.swift index 917ea2f0..c551ca9a 100644 --- a/DOM/Sources/DOM.SVG.swift +++ b/DOM/Sources/DOM.SVG.swift @@ -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 { diff --git a/DOM/Sources/Parser.XML.SVG.swift b/DOM/Sources/Parser.XML.SVG.swift index 2a57a364..901cbab1 100644 --- a/DOM/Sources/Parser.XML.SVG.swift +++ b/DOM/Sources/Parser.XML.SVG.swift @@ -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] { diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift index f5d43224..ca9ae1c8 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Builder.swift @@ -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) @@ -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 diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift index cd856071..5f3057c8 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Layer.swift @@ -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] = [] @@ -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) } @@ -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 } diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.Shape.swift b/SwiftDraw/Sources/LayerTree/LayerTree.Shape.swift index 8f6fafad..df3d696e 100644 --- a/SwiftDraw/Sources/LayerTree/LayerTree.Shape.swift +++ b/SwiftDraw/Sources/LayerTree/LayerTree.Shape.swift @@ -43,6 +43,11 @@ extension LayerTree { var shape: Shape var transform: Transform.Matrix } + + enum ClipUnits: Hashable { + case userSpaceOnUse + case objectBoundingBox + } } extension LayerTree.Shape { diff --git a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol+ClipPath.swift b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol+ClipPath.swift new file mode 100644 index 00000000..9e97d85a --- /dev/null +++ b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol+ClipPath.swift @@ -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 diff --git a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift index 0b5abf47..f5820913 100644 --- a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift +++ b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift @@ -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 [] @@ -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? { diff --git a/SwiftDraw/Tests/Renderer/Renderer.SFSymbolClipPathTests.swift b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolClipPathTests.swift new file mode 100644 index 00000000..c84f0fd1 --- /dev/null +++ b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolClipPathTests.swift @@ -0,0 +1,299 @@ +// +// Renderer.SFSymbolClipPathTests.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. +// + +import SwiftDrawDOM +import XCTest +@testable import SwiftDraw + +#if canImport(CoreGraphics) +final class RendererSFSymbolClipPathTests: XCTestCase { + + // MARK: - Issue #37 reproduction + + /// The exact SVG from the GitHub issue used as the reproducer for the missing clip-path + /// support warning. Pre-fix this rendered nothing and emitted "clip-path unsupported". + /// Post-fix it must produce the baked intersection (the clipping circle). + func testIssue37_minimalRectClippedByCircle_producesIntersectedPath() throws { + let svg = try DOM.SVG.parse(#""" + + + + + + + + + """#) + + let template = try SFSymbolTemplate.parse(SFSymbolRenderer.render(svg: svg)) + + XCTAssertFalse(template.regular.contents.paths.isEmpty) + XCTAssertFalse(template.ultralight.contents.paths.isEmpty) + XCTAssertFalse(template.black.contents.paths.isEmpty) + } + + // MARK: - Clip shape coverage (asserted on raw symbol paths in user space) + + func testClipRect_intersectionIsClippingRect() throws { + let paths = try makeSymbolPaths(forResource: "clip-rect.svg") + XCTAssertEqual(paths.count, 1) + // 100x100 rect ∩ rect(20,20,60,60) = rect(20,20,60,60) + assertBounds(paths[0].path.bounds, + equals: .init(x: 20, y: 20, width: 60, height: 60)) + } + + func testClipCircle_intersectionIsClippingCircle() throws { + let paths = try makeSymbolPaths(forResource: "clip-circle.svg") + XCTAssertEqual(paths.count, 1) + // 100x100 rect ∩ circle(50,50,r=30) = circle(50,50,r=30) → bounds(20,20,60,60) + assertBounds(paths[0].path.bounds, + equals: .init(x: 20, y: 20, width: 60, height: 60), + accuracy: 0.5) + } + + func testClipEllipse_intersectionIsClippingEllipse() throws { + let paths = try makeSymbolPaths(forResource: "clip-ellipse.svg") + XCTAssertEqual(paths.count, 1) + // 100x100 rect ∩ ellipse(cx=50,cy=50,rx=40,ry=20) = ellipse → bounds(10,30,80,40) + assertBounds(paths[0].path.bounds, + equals: .init(x: 10, y: 30, width: 80, height: 40), + accuracy: 0.5) + } + + func testClipPolygon_intersectionMatchesPolygon() throws { + let paths = try makeSymbolPaths(forResource: "clip-polygon.svg") + XCTAssertEqual(paths.count, 1) + // Triangle(50,10)-(90,90)-(10,90) → bounds(10,10,80,80) + assertBounds(paths[0].path.bounds, + equals: .init(x: 10, y: 10, width: 80, height: 80), + accuracy: 0.5) + } + + func testClipPathElement_intersectionMatchesPath() throws { + let paths = try makeSymbolPaths(forResource: "clip-path-element.svg") + XCTAssertEqual(paths.count, 1) + assertBounds(paths[0].path.bounds, + equals: .init(x: 20, y: 20, width: 60, height: 60), + accuracy: 0.5) + } + + func testClipMultiShape_unionsAllChildren() throws { + let paths = try makeSymbolPaths(forResource: "clip-multi-shape.svg") + XCTAssertEqual(paths.count, 1) + // Two overlapping circles centered at (35,50) and (65,50) with r=20. + // Union extents: x ∈ [15,85], y ∈ [30,70] + assertBounds(paths[0].path.bounds, + equals: .init(x: 15, y: 30, width: 70, height: 40), + accuracy: 0.5) + } + + func testClipOnGroup_appliesToAllChildren() throws { + let paths = try makeSymbolPaths(forResource: "clip-group.svg") + // Two rects in a clipped group; each gets independently intersected with the rect clip. + // Each result fits inside (20,20,60,60). + XCTAssertGreaterThan(paths.count, 0) + for sp in paths { + let b = sp.path.bounds + XCTAssertGreaterThanOrEqual(b.minX, 19.5) + XCTAssertLessThanOrEqual(b.maxX, 80.5) + XCTAssertGreaterThanOrEqual(b.minY, 19.5) + XCTAssertLessThanOrEqual(b.maxY, 80.5) + } + } + + func testClipRuleEvenOdd_producesAnnulusBounds() throws { + let paths = try makeSymbolPaths(forResource: "clip-rule-evenodd.svg") + // Outer 80x80 with inner 40x40 hole using evenodd → bounds remain 10,10,80,80 + XCTAssertEqual(paths.count, 1) + assertBounds(paths[0].path.bounds, + equals: .init(x: 10, y: 10, width: 80, height: 80), + accuracy: 0.5) + } + + func testClipUnitsObjectBoundingBox_appliesShapeRelativeCoords() throws { + let paths = try makeSymbolPaths(forResource: "clip-units-bbox.svg") + XCTAssertEqual(paths.count, 1) + // rect(20,20,60,60) clipped by bbox-relative rect (0.25,0.25,0.5,0.5) + // → user-space rect (35,35,30,30) + assertBounds(paths[0].path.bounds, + equals: .init(x: 35, y: 35, width: 30, height: 30), + accuracy: 0.5) + } + + func testClipFullyContainsShape_keepsOriginalBounds() throws { + let paths = try makeSymbolPaths(forResource: "clip-contains.svg") + XCTAssertEqual(paths.count, 1) + assertBounds(paths[0].path.bounds, + equals: .init(x: 20, y: 20, width: 60, height: 60), + accuracy: 0.5) + } + + func testClipFullyOutsideShape_dropsPath() throws { + let paths = try makeSymbolPaths(forResource: "clip-outside.svg") + XCTAssertEqual(paths.count, 0) + } + + func testClipChildTransform_isApplied() throws { + let paths = try makeSymbolPaths(forResource: "clip-transform-child.svg") + XCTAssertEqual(paths.count, 1) + // Clip child rect(0,0,40,40) with transform translate(30,30) → effective (30,30,40,40) + assertBounds(paths[0].path.bounds, + equals: .init(x: 30, y: 30, width: 40, height: 40), + accuracy: 0.5) + } + + func testClipPropagatesThroughElementTransform() throws { + let paths = try makeSymbolPaths(forResource: "clip-transformed-element.svg") + XCTAssertEqual(paths.count, 1) + // 100x100 rect drawn with translate(50,50), clipped by circle(0,0,r=30) in local space. + // Post-transform clip is a circle at (50,50) with r=30 → bounds (20,20,60,60) + assertBounds(paths[0].path.bounds, + equals: .init(x: 20, y: 20, width: 60, height: 60), + accuracy: 0.5) + } + + // MARK: - Full pipeline smoke (renders to SF Symbol template) + + func testFullPipeline_clipCircle_emitsThreeWeightVariants() throws { + let url = try Bundle.test.url(forResource: "clip-circle.svg") + let template = try SFSymbolTemplate.parse(SFSymbolRenderer.render(fileURL: url)) + XCTAssertEqual(template.regular.contents.paths.count, 1) + XCTAssertEqual(template.ultralight.contents.paths.count, 1) + XCTAssertEqual(template.black.contents.paths.count, 1) + } + + func testFullPipeline_clipFullyOutside_throwsNoValidContent() throws { + let url = try Bundle.test.url(forResource: "clip-outside.svg") + XCTAssertThrowsError(try SFSymbolRenderer.render(fileURL: url)) + } + + // MARK: - Layer-level coverage + + /// Independent smoke test: the layer's clipUnits propagates through Builder. + func testBuilder_setsClipUnits_objectBoundingBox() throws { + let svg = try DOM.SVG.parse(#""" + + + + + + + + + """#) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let clipped = firstClippedLayer(in: layer) + XCTAssertNotNil(clipped) + XCTAssertEqual(clipped?.clipUnits, .objectBoundingBox) + } + + func testBuilder_setsClipUnits_userSpaceOnUseByDefault() throws { + let svg = try DOM.SVG.parse(#""" + + + + + + + """#) + let layer = LayerTree.Builder(svg: svg).makeLayer() + let clipped = firstClippedLayer(in: layer) + XCTAssertNotNil(clipped) + XCTAssertEqual(clipped?.clipUnits, .userSpaceOnUse) + } + + // MARK: - Helpers + + private func makeSymbolPaths(forResource named: String) throws -> [SFSymbolRenderer.SymbolPath] { + let url = try Bundle.test.url(forResource: named) + let svg = try DOM.SVG.parse(fileURL: url) + return SFSymbolRenderer.getPaths(for: svg) ?? [] + } + + private func assertBounds(_ actual: LayerTree.Rect, + equals expected: LayerTree.Rect, + accuracy: LayerTree.Float = 0.01, + file: StaticString = #file, + line: UInt = #line) { + XCTAssertEqual(actual.minX, expected.minX, accuracy: accuracy, "minX", file: file, line: line) + XCTAssertEqual(actual.minY, expected.minY, accuracy: accuracy, "minY", file: file, line: line) + XCTAssertEqual(actual.width, expected.width, accuracy: accuracy, "width", file: file, line: line) + XCTAssertEqual(actual.height, expected.height, accuracy: accuracy, "height", file: file, line: line) + } + + private func firstClippedLayer(in layer: LayerTree.Layer) -> LayerTree.Layer? { + if !layer.clip.isEmpty { return layer } + for c in layer.contents { + if case .layer(let inner) = c, let hit = firstClippedLayer(in: inner) { + return hit + } + } + return nil + } +} +#endif + +private extension DOM.SVG { + static func parse(_ text: String, filename: String = #file) throws -> DOM.SVG { + let element = try XML.SAXParser.parse(data: text.data(using: .utf8)!) + let parser = XMLParser(options: [], filename: filename) + return try parser.parseSVG(element) + } +} + +private extension SFSymbolRenderer { + + static func render(fileURL: URL) throws -> String { + let renderer = SFSymbolRenderer( + size: .small, + options: [], + insets: .init(), + insetsUltralight: .init(), + insetsBlack: .init(), + precision: 3, + isLegacyInsets: false + ) + return try renderer.render(regular: fileURL, ultralight: nil, black: nil) + } + + static func render(svg: DOM.SVG) throws -> String { + let renderer = SFSymbolRenderer( + size: .small, + options: [], + insets: .init(), + insetsUltralight: .init(), + insetsBlack: .init(), + precision: 3, + isLegacyInsets: false + ) + return try renderer.render(default: svg, ultralight: nil, black: nil) + } +} diff --git a/SwiftDraw/Tests/Test.bundle/clip-circle.svg b/SwiftDraw/Tests/Test.bundle/clip-circle.svg new file mode 100644 index 00000000..6d871e9c --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-circle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-contains.svg b/SwiftDraw/Tests/Test.bundle/clip-contains.svg new file mode 100644 index 00000000..ffa15e13 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-contains.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-ellipse.svg b/SwiftDraw/Tests/Test.bundle/clip-ellipse.svg new file mode 100644 index 00000000..a46aaa83 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-ellipse.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-group.svg b/SwiftDraw/Tests/Test.bundle/clip-group.svg new file mode 100644 index 00000000..92cc3642 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-group.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-multi-shape.svg b/SwiftDraw/Tests/Test.bundle/clip-multi-shape.svg new file mode 100644 index 00000000..5708aff1 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-multi-shape.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-outside.svg b/SwiftDraw/Tests/Test.bundle/clip-outside.svg new file mode 100644 index 00000000..e704ad5e --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-outside.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-path-element.svg b/SwiftDraw/Tests/Test.bundle/clip-path-element.svg new file mode 100644 index 00000000..a3edca71 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-path-element.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-polygon.svg b/SwiftDraw/Tests/Test.bundle/clip-polygon.svg new file mode 100644 index 00000000..b28d9ff9 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-polygon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-rect.svg b/SwiftDraw/Tests/Test.bundle/clip-rect.svg new file mode 100644 index 00000000..f456257b --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-rect.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-rule-evenodd.svg b/SwiftDraw/Tests/Test.bundle/clip-rule-evenodd.svg new file mode 100644 index 00000000..2c48fdfe --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-rule-evenodd.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-transform-child.svg b/SwiftDraw/Tests/Test.bundle/clip-transform-child.svg new file mode 100644 index 00000000..c12e4083 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-transform-child.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-transformed-element.svg b/SwiftDraw/Tests/Test.bundle/clip-transformed-element.svg new file mode 100644 index 00000000..7c6caaa1 --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-transformed-element.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/SwiftDraw/Tests/Test.bundle/clip-units-bbox.svg b/SwiftDraw/Tests/Test.bundle/clip-units-bbox.svg new file mode 100644 index 00000000..0b91379b --- /dev/null +++ b/SwiftDraw/Tests/Test.bundle/clip-units-bbox.svg @@ -0,0 +1,8 @@ + + + + + + + +