diff --git a/Examples/Sources/GalleryView.swift b/Examples/Sources/GalleryView.swift
index f71d20f..b23b0e2 100644
--- a/Examples/Sources/GalleryView.swift
+++ b/Examples/Sources/GalleryView.swift
@@ -34,37 +34,33 @@ import SwiftUI
struct GalleryView: View {
- var images: [SVG] = {
- [
- "thats-no-moon.svg",
- "avocado.svg",
- "angry.svg",
- "ogre.svg",
- "monkey.svg",
- "fuji.svg",
- "dish.svg",
- "mouth-open.svg",
- "sleepy.svg",
- "smile.svg",
- "snake.svg",
- "spider.svg",
- "star-struck.svg",
- "worried.svg",
- "yawning.svg",
- "thats-no-moon.svg",
- "alert.svg",
- "effigy.svg",
- "stylesheet-multiple.svg"
- ].compactMap {
- SVG(named: $0, in: .samples)
- }
- }()
+ var images = [
+ "thats-no-moon.svg",
+ "avocado.svg",
+ "angry.svg",
+ "ogre.svg",
+ "monkey.svg",
+ "fuji.svg",
+ "dish.svg",
+ "mouth-open.svg",
+ "sleepy.svg",
+ "smile.svg",
+ "snake.svg",
+ "spider.svg",
+ "star-struck.svg",
+ "worried.svg",
+ "yawning.svg",
+ "thats-no-moon.svg",
+ "alert.svg",
+ "effigy.svg",
+ "stylesheet-multiple.svg"
+ ]
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
ForEach(images, id: \.self) { image in
- SVGView(svg: image)
+ SVGView(image, bundle: .samples)
.aspectRatio(contentMode: .fit)
.padding([.leading, .trailing], 10)
}
diff --git a/README.md b/README.md
index 0e66f63..510269c 100644
--- a/README.md
+++ b/README.md
@@ -42,13 +42,14 @@ Display an image within `SVGView`:
```swift
var body: some View {
- SVGView(named: "sample.svg")
+ SVGView("sample.svg")
.aspectRatio(contentMode: .fit)
.padding()
}
```
-Pass an `SVG` instance for better performance:
+When you load by name, SVGView uses an internal cache so repeated lookups are efficient.
+For more predictable performance (avoiding any cache lookup or parsing), you can pass an already-created SVG instance:
```swift
var image: SVG
diff --git a/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift b/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift
index d0444f4..f46494c 100644
--- a/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift
+++ b/SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift
@@ -46,6 +46,9 @@ extension LayerTree {
private var hasLoggedGradientWarning = false
private var hasLoggedMaskWarning = false
+ private var paths: [LayerTree.Shape: P.Types.Path] = [:]
+ private var images: [LayerTree.Image: P.Types.Image] = [:]
+
init(provider: P, size: LayerTree.Size, scale: LayerTree.Float = 3.0, options: SVG.Options) {
self.provider = provider
self.size = size
@@ -192,7 +195,7 @@ extension LayerTree {
fill: FillAttributes,
colorConverter: any ColorConverter) -> [RendererCommand
] {
var commands = [RendererCommand]()
- let path = provider.createPath(from: shape)
+ let path = makeCachedPath(from: shape)
switch fill.fill {
case .color(let color):
@@ -303,7 +306,7 @@ extension LayerTree {
}
func renderCommands(for image: Image) -> [RendererCommand] {
- guard let renderImage = provider.createImage(from: image) else { return [] }
+ guard let renderImage = makeCachedImage(from: image) else { return [] }
let size = provider.createSize(from: renderImage)
guard size.width > 0 && size.height > 0 else { return [] }
@@ -312,6 +315,26 @@ extension LayerTree {
return [.draw(image: renderImage, in: rect)]
}
+ private func makeCachedPath(from shape: LayerTree.Shape) -> P.Types.Path {
+ if let existing = paths[shape] {
+ return existing
+ }
+ let new = provider.createPath(from: shape)
+ paths[shape] = new
+ return new
+ }
+
+ private func makeCachedImage(from image: Image) -> P.Types.Image? {
+ if let existing = images[image] {
+ return existing
+ }
+ guard let new = provider.createImage(from: image) else {
+ return nil
+ }
+ images[image] = new
+ return new
+ }
+
func makeImageFrame(for image: Image, bitmapSize: LayerTree.Size) -> LayerTree.Rect {
var frame = LayerTree.Rect(
x: image.origin.x,
@@ -376,14 +399,20 @@ extension LayerTree {
guard !shapes.isEmpty else { return [] }
let paths = shapes.map { clip in
if clip.transform == .identity {
- return provider.createPath(from: clip.shape)
+ return makeCachedPath(from: clip.shape)
} else {
- return provider.createPath(from: .path(clip.shape.path.applying(matrix: clip.transform)))
+ return makeCachedPath(from: .path(clip.shape.path.applying(matrix: clip.transform)))
}
}
- let clipPath = provider.createPath(from: paths)
+
let rule = provider.createFillRule(from: rule ?? .nonzero)
- return [.setClip(path: clipPath, rule: rule)]
+
+ if paths.count == 1 {
+ return [.setClip(path: paths[0], rule: rule)]
+ } else {
+ let clipPath = provider.createPath(from: paths)
+ return [.setClip(path: clipPath, rule: rule)]
+ }
}
diff --git a/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics+Cost.swift b/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics+Cost.swift
new file mode 100644
index 0000000..5630d2e
--- /dev/null
+++ b/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics+Cost.swift
@@ -0,0 +1,119 @@
+//
+// Renderer.CoreGraphics+Cost.swift
+// SwiftDraw
+//
+// Created by Simon Whitty on 31/8/25.
+// Copyright 2025 WhileLoop Pty Ltd. All rights reserved.
+//
+// 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 CoreGraphics
+
+extension [RendererCommand] {
+
+ var estimatedCost: Int {
+ let commandCost = MemoryLayout.stride * count
+ let pathCost = Set(allPaths).reduce(0) { $0 + $1.estimatedCost }
+ let imageCost = Set(allImages).reduce(0) { $0 + $1.estimatedCost }
+ return commandCost + pathCost + imageCost
+ }
+}
+
+extension CGPath {
+
+ var estimatedCost: Int {
+ var total = 0
+ applyWithBlock { element in
+ switch element.pointee.type {
+ case .moveToPoint, .addLineToPoint, .closeSubpath:
+ total += MemoryLayout.stride + MemoryLayout.stride
+ case .addQuadCurveToPoint:
+ total += MemoryLayout.stride + 2 * MemoryLayout.stride
+ case .addCurveToPoint:
+ total += MemoryLayout.stride + 3 * MemoryLayout.stride
+ @unknown default:
+ break
+ }
+ }
+ return MemoryLayout.size + total
+ }
+}
+
+extension CGImage {
+ var estimatedCost: Int { bytesPerRow * height }
+}
+
+extension RendererCommand {
+
+ var allPaths: [CGPath] {
+ switch self {
+ case .setClip(path: let p, rule: _):
+ return [p]
+ case .setFillPattern(let p):
+ return p.contents.allPaths
+ case .stroke(let p):
+ return [p]
+ case .clipStrokeOutline(let p):
+ return [p]
+ case .fill(let p, rule: _):
+ return [p]
+ default:
+ return []
+ }
+ }
+
+ var allImages: [CGImage] {
+ switch self {
+ case .setFillPattern(let p):
+ return p.contents.allImages
+ case .draw(image: let i, in: _):
+ return [i]
+ default:
+ return []
+ }
+ }
+}
+
+extension [RendererCommand] {
+
+ var allPaths: [CGPath] {
+ var paths = [CGPath]()
+ for command in self {
+ paths.append(contentsOf: command.allPaths)
+ }
+ return paths
+ }
+
+ var allImages: [CGImage] {
+ var images = [CGImage]()
+ for command in self {
+ images.append(contentsOf: command.allImages)
+ }
+ return images
+ }
+}
+
+#endif
diff --git a/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift b/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift
index 4390504..05b993d 100644
--- a/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift
+++ b/SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift
@@ -59,8 +59,8 @@ struct CGTypes: RendererTypes, Sendable {
struct CGTransformingPattern: Hashable {
- let bounds: CGRect
- let contents: [RendererCommand]
+ var bounds: CGRect
+ var contents: [RendererCommand]
init(bounds: CGRect, contents: [RendererCommand]) {
self.bounds = bounds
diff --git a/SwiftDraw/Sources/SVG.swift b/SwiftDraw/Sources/SVG.swift
index c3d9e33..97a1085 100644
--- a/SwiftDraw/Sources/SVG.swift
+++ b/SwiftDraw/Sources/SVG.swift
@@ -47,12 +47,17 @@ public struct SVG: Hashable, Sendable {
var commands: [RendererCommand]
public init?(fileURL url: URL, options: SVG.Options = .default) {
- do {
- let svg = try DOM.SVG.parse(fileURL: url)
- self.init(dom: svg, options: options)
- } catch {
- XMLParser.logParsingError(for: error, filename: url.lastPathComponent, parsing: nil)
- return nil
+ if let svg = SVGGCache.shared.svg(fileURL: url) {
+ self = svg
+ } else {
+ do {
+ let svg = try DOM.SVG.parse(fileURL: url)
+ self.init(dom: svg, options: options)
+ SVGGCache.shared.setSVG(self, for: url)
+ } catch {
+ XMLParser.logParsingError(for: error, filename: url.lastPathComponent, parsing: nil)
+ return nil
+ }
}
}
diff --git a/SwiftDraw/Sources/SVGCache.swift b/SwiftDraw/Sources/SVGCache.swift
new file mode 100644
index 0000000..46de4f5
--- /dev/null
+++ b/SwiftDraw/Sources/SVGCache.swift
@@ -0,0 +1,75 @@
+//
+// SVG.swift
+// SwiftDraw
+//
+// Created by Simon Whitty on 24/5/17.
+// Copyright 2020 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
+public import Foundation
+
+#if canImport(CoreGraphics)
+import CoreGraphics
+
+public final class SVGGCache: Sendable {
+
+ public static let shared = SVGGCache()
+
+ nonisolated(unsafe)private let cache: NSCache>
+
+ public init(totalCostLimit: Int = defaultTotalCostLimit) {
+ self.cache = NSCache()
+ self.cache.totalCostLimit = totalCostLimit
+ }
+
+ public func svg(fileURL: URL) -> SVG? {
+ cache.object(forKey: fileURL as NSURL)?.value
+ }
+
+ public func setSVG(_ svg: SVG, for fileURL: URL) {
+ cache.setObject(Box(svg), forKey: fileURL as NSURL, cost: svg.commands.estimatedCost)
+ }
+
+ final class Box: NSObject {
+ let value: T
+ init(_ value: T) { self.value = value }
+ }
+
+ public static var defaultTotalCostLimit: Int {
+ #if canImport(WatchKit)
+ // 2 MB
+ return 2 * 1024 * 1024
+ #elseif canImport(AppKit)
+ // 200 MB
+ return 200 * 1024 * 1024
+ #else
+ // 50 MB
+ return 50 * 1024 * 1024
+ #endif
+ }
+}
+#endif
diff --git a/SwiftDraw/Tests/Renderer/Renderer.CoreGraphics+CostTests.swift b/SwiftDraw/Tests/Renderer/Renderer.CoreGraphics+CostTests.swift
new file mode 100644
index 0000000..df9dd42
--- /dev/null
+++ b/SwiftDraw/Tests/Renderer/Renderer.CoreGraphics+CostTests.swift
@@ -0,0 +1,108 @@
+//
+// Renderer.CoreGraphics+CostTests.swift
+// SwiftDraw
+//
+// Created by Simon Whitty on 31/8/25.
+// Copyright 2025 WhileLoop Pty Ltd. All rights reserved.
+//
+// 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 CoreGraphics
+import Foundation
+@testable import SwiftDraw
+import SwiftDrawDOM
+import Testing
+
+struct RendererCoreGraphicsCostTests {
+
+ @Test
+ func duplicatePathInstancesRemoved() throws {
+ let source = try SVG.fromXML(#"""
+
+
+ """#)
+
+ let uniquePaths = Set(source.commands.allPaths.map(ObjectIdentifier.init))
+ #expect(uniquePaths.count == 1)
+ }
+
+ @Test
+ func duplicateImageInstancesRemoved() throws {
+ let source = try SVG.fromXML(#"""
+
+
+ """#)
+
+ let uniqueImages = Set(source.commands.allImages.map(ObjectIdentifier.init))
+ #expect(uniqueImages.count == 1)
+ }
+
+ @Test
+ func pathEstimatedCost() throws {
+ let source = try SVG.fromXML(#"""
+
+
+ """#)
+
+ #expect(source.commands.allPaths[0].estimatedCost == 168)
+ #expect(source.commands.estimatedCost == 232)
+ }
+
+ @Test
+ func imageEstimatedCost() throws {
+ let source = try SVG.fromXML(#"""
+
+
+ """#)
+
+ #expect(source.commands.allImages[0].estimatedCost == 100)
+ #expect(source.commands.estimatedCost == 108)
+ }
+
+ @Test
+ func shapesEstimatedCost() throws {
+ let image = try #require(SVG(named: "shapes.svg", in: .test))
+ #expect(image.commands.estimatedCost == 19220)
+ }
+}
+
+extension SVG {
+ static func fromXML(_ text: String, filename: String = #file) throws -> SVG {
+ let dom = try DOM.SVG.parse(data: text.data(using: .utf8)!)
+ return SVG(dom: dom, options: .default)
+ }
+}
+#endif