Skip to content

Commit af422b9

Browse files
authored
Merge pull request #101 from swhitty/svg-cache
SVGCache
2 parents 55dfc53 + 20a5e7f commit af422b9

File tree

8 files changed

+375
-42
lines changed

8 files changed

+375
-42
lines changed

Examples/Sources/GalleryView.swift

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,37 +34,33 @@ import SwiftUI
3434

3535
struct GalleryView: View {
3636

37-
var images: [SVG] = {
38-
[
39-
"thats-no-moon.svg",
40-
"avocado.svg",
41-
"angry.svg",
42-
"ogre.svg",
43-
"monkey.svg",
44-
"fuji.svg",
45-
"dish.svg",
46-
"mouth-open.svg",
47-
"sleepy.svg",
48-
"smile.svg",
49-
"snake.svg",
50-
"spider.svg",
51-
"star-struck.svg",
52-
"worried.svg",
53-
"yawning.svg",
54-
"thats-no-moon.svg",
55-
"alert.svg",
56-
"effigy.svg",
57-
"stylesheet-multiple.svg"
58-
].compactMap {
59-
SVG(named: $0, in: .samples)
60-
}
61-
}()
37+
var images = [
38+
"thats-no-moon.svg",
39+
"avocado.svg",
40+
"angry.svg",
41+
"ogre.svg",
42+
"monkey.svg",
43+
"fuji.svg",
44+
"dish.svg",
45+
"mouth-open.svg",
46+
"sleepy.svg",
47+
"smile.svg",
48+
"snake.svg",
49+
"spider.svg",
50+
"star-struck.svg",
51+
"worried.svg",
52+
"yawning.svg",
53+
"thats-no-moon.svg",
54+
"alert.svg",
55+
"effigy.svg",
56+
"stylesheet-multiple.svg"
57+
]
6258

6359
var body: some View {
6460
ScrollView {
6561
LazyVStack(spacing: 20) {
6662
ForEach(images, id: \.self) { image in
67-
SVGView(svg: image)
63+
SVGView(image, bundle: .samples)
6864
.aspectRatio(contentMode: .fit)
6965
.padding([.leading, .trailing], 10)
7066
}

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,14 @@ Display an image within `SVGView`:
4242

4343
```swift
4444
var body: some View {
45-
SVGView(named: "sample.svg")
45+
SVGView("sample.svg")
4646
.aspectRatio(contentMode: .fit)
4747
.padding()
4848
}
4949
```
5050

51-
Pass an `SVG` instance for better performance:
51+
When you load by name, SVGView uses an internal cache so repeated lookups are efficient.
52+
For more predictable performance (avoiding any cache lookup or parsing), you can pass an already-created SVG instance:
5253

5354
```swift
5455
var image: SVG

SwiftDraw/Sources/LayerTree/LayerTree.CommandGenerator.swift

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ extension LayerTree {
4646
private var hasLoggedGradientWarning = false
4747
private var hasLoggedMaskWarning = false
4848

49+
private var paths: [LayerTree.Shape: P.Types.Path] = [:]
50+
private var images: [LayerTree.Image: P.Types.Image] = [:]
51+
4952
init(provider: P, size: LayerTree.Size, scale: LayerTree.Float = 3.0, options: SVG.Options) {
5053
self.provider = provider
5154
self.size = size
@@ -192,7 +195,7 @@ extension LayerTree {
192195
fill: FillAttributes,
193196
colorConverter: any ColorConverter) -> [RendererCommand<P.Types>] {
194197
var commands = [RendererCommand<P.Types>]()
195-
let path = provider.createPath(from: shape)
198+
let path = makeCachedPath(from: shape)
196199

197200
switch fill.fill {
198201
case .color(let color):
@@ -303,7 +306,7 @@ extension LayerTree {
303306
}
304307

305308
func renderCommands(for image: Image) -> [RendererCommand<P.Types>] {
306-
guard let renderImage = provider.createImage(from: image) else { return [] }
309+
guard let renderImage = makeCachedImage(from: image) else { return [] }
307310
let size = provider.createSize(from: renderImage)
308311
guard size.width > 0 && size.height > 0 else { return [] }
309312

@@ -312,6 +315,26 @@ extension LayerTree {
312315
return [.draw(image: renderImage, in: rect)]
313316
}
314317

318+
private func makeCachedPath(from shape: LayerTree.Shape) -> P.Types.Path {
319+
if let existing = paths[shape] {
320+
return existing
321+
}
322+
let new = provider.createPath(from: shape)
323+
paths[shape] = new
324+
return new
325+
}
326+
327+
private func makeCachedImage(from image: Image) -> P.Types.Image? {
328+
if let existing = images[image] {
329+
return existing
330+
}
331+
guard let new = provider.createImage(from: image) else {
332+
return nil
333+
}
334+
images[image] = new
335+
return new
336+
}
337+
315338
func makeImageFrame(for image: Image, bitmapSize: LayerTree.Size) -> LayerTree.Rect {
316339
var frame = LayerTree.Rect(
317340
x: image.origin.x,
@@ -376,14 +399,20 @@ extension LayerTree {
376399
guard !shapes.isEmpty else { return [] }
377400
let paths = shapes.map { clip in
378401
if clip.transform == .identity {
379-
return provider.createPath(from: clip.shape)
402+
return makeCachedPath(from: clip.shape)
380403
} else {
381-
return provider.createPath(from: .path(clip.shape.path.applying(matrix: clip.transform)))
404+
return makeCachedPath(from: .path(clip.shape.path.applying(matrix: clip.transform)))
382405
}
383406
}
384-
let clipPath = provider.createPath(from: paths)
407+
385408
let rule = provider.createFillRule(from: rule ?? .nonzero)
386-
return [.setClip(path: clipPath, rule: rule)]
409+
410+
if paths.count == 1 {
411+
return [.setClip(path: paths[0], rule: rule)]
412+
} else {
413+
let clipPath = provider.createPath(from: paths)
414+
return [.setClip(path: clipPath, rule: rule)]
415+
}
387416
}
388417

389418

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//
2+
// Renderer.CoreGraphics+Cost.swift
3+
// SwiftDraw
4+
//
5+
// Created by Simon Whitty on 31/8/25.
6+
// Copyright 2025 WhileLoop Pty Ltd. All rights reserved.
7+
//
8+
// Distributed under the permissive zlib license
9+
// Get the latest version from here:
10+
//
11+
// https://github.com/swhitty/SwiftDraw
12+
//
13+
// This software is provided 'as-is', without any express or implied
14+
// warranty. In no event will the authors be held liable for any damages
15+
// arising from the use of this software.
16+
//
17+
// Permission is granted to anyone to use this software for any purpose,
18+
// including commercial applications, and to alter it and redistribute it
19+
// freely, subject to the following restrictions:
20+
//
21+
// 1. The origin of this software must not be misrepresented; you must not
22+
// claim that you wrote the original software. If you use this software
23+
// in a product, an acknowledgment in the product documentation would be
24+
// appreciated but is not required.
25+
//
26+
// 2. Altered source versions must be plainly marked as such, and must not be
27+
// misrepresented as being the original software.
28+
//
29+
// 3. This notice may not be removed or altered from any source distribution.
30+
//
31+
32+
#if canImport(CoreGraphics)
33+
import CoreGraphics
34+
35+
extension [RendererCommand<CGTypes>] {
36+
37+
var estimatedCost: Int {
38+
let commandCost = MemoryLayout<Self>.stride * count
39+
let pathCost = Set(allPaths).reduce(0) { $0 + $1.estimatedCost }
40+
let imageCost = Set(allImages).reduce(0) { $0 + $1.estimatedCost }
41+
return commandCost + pathCost + imageCost
42+
}
43+
}
44+
45+
extension CGPath {
46+
47+
var estimatedCost: Int {
48+
var total = 0
49+
applyWithBlock { element in
50+
switch element.pointee.type {
51+
case .moveToPoint, .addLineToPoint, .closeSubpath:
52+
total += MemoryLayout<CGPathElement>.stride + MemoryLayout<CGPoint>.stride
53+
case .addQuadCurveToPoint:
54+
total += MemoryLayout<CGPathElement>.stride + 2 * MemoryLayout<CGPoint>.stride
55+
case .addCurveToPoint:
56+
total += MemoryLayout<CGPathElement>.stride + 3 * MemoryLayout<CGPoint>.stride
57+
@unknown default:
58+
break
59+
}
60+
}
61+
return MemoryLayout<CGPath>.size + total
62+
}
63+
}
64+
65+
extension CGImage {
66+
var estimatedCost: Int { bytesPerRow * height }
67+
}
68+
69+
extension RendererCommand<CGTypes> {
70+
71+
var allPaths: [CGPath] {
72+
switch self {
73+
case .setClip(path: let p, rule: _):
74+
return [p]
75+
case .setFillPattern(let p):
76+
return p.contents.allPaths
77+
case .stroke(let p):
78+
return [p]
79+
case .clipStrokeOutline(let p):
80+
return [p]
81+
case .fill(let p, rule: _):
82+
return [p]
83+
default:
84+
return []
85+
}
86+
}
87+
88+
var allImages: [CGImage] {
89+
switch self {
90+
case .setFillPattern(let p):
91+
return p.contents.allImages
92+
case .draw(image: let i, in: _):
93+
return [i]
94+
default:
95+
return []
96+
}
97+
}
98+
}
99+
100+
extension [RendererCommand<CGTypes>] {
101+
102+
var allPaths: [CGPath] {
103+
var paths = [CGPath]()
104+
for command in self {
105+
paths.append(contentsOf: command.allPaths)
106+
}
107+
return paths
108+
}
109+
110+
var allImages: [CGImage] {
111+
var images = [CGImage]()
112+
for command in self {
113+
images.append(contentsOf: command.allImages)
114+
}
115+
return images
116+
}
117+
}
118+
119+
#endif

SwiftDraw/Sources/Renderer/Renderer.CoreGraphics.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ struct CGTypes: RendererTypes, Sendable {
5959

6060
struct CGTransformingPattern: Hashable {
6161

62-
let bounds: CGRect
63-
let contents: [RendererCommand<CGTypes>]
62+
var bounds: CGRect
63+
var contents: [RendererCommand<CGTypes>]
6464

6565
init(bounds: CGRect, contents: [RendererCommand<CGTypes>]) {
6666
self.bounds = bounds

SwiftDraw/Sources/SVG.swift

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,17 @@ public struct SVG: Hashable, Sendable {
4747
var commands: [RendererCommand<CGTypes>]
4848

4949
public init?(fileURL url: URL, options: SVG.Options = .default) {
50-
do {
51-
let svg = try DOM.SVG.parse(fileURL: url)
52-
self.init(dom: svg, options: options)
53-
} catch {
54-
XMLParser.logParsingError(for: error, filename: url.lastPathComponent, parsing: nil)
55-
return nil
50+
if let svg = SVGGCache.shared.svg(fileURL: url) {
51+
self = svg
52+
} else {
53+
do {
54+
let svg = try DOM.SVG.parse(fileURL: url)
55+
self.init(dom: svg, options: options)
56+
SVGGCache.shared.setSVG(self, for: url)
57+
} catch {
58+
XMLParser.logParsingError(for: error, filename: url.lastPathComponent, parsing: nil)
59+
return nil
60+
}
5661
}
5762
}
5863

SwiftDraw/Sources/SVGCache.swift

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//
2+
// SVG.swift
3+
// SwiftDraw
4+
//
5+
// Created by Simon Whitty on 24/5/17.
6+
// Copyright 2020 Simon Whitty
7+
//
8+
// Distributed under the permissive zlib license
9+
// Get the latest version from here:
10+
//
11+
// https://github.com/swhitty/SwiftDraw
12+
//
13+
// This software is provided 'as-is', without any express or implied
14+
// warranty. In no event will the authors be held liable for any damages
15+
// arising from the use of this software.
16+
//
17+
// Permission is granted to anyone to use this software for any purpose,
18+
// including commercial applications, and to alter it and redistribute it
19+
// freely, subject to the following restrictions:
20+
//
21+
// 1. The origin of this software must not be misrepresented; you must not
22+
// claim that you wrote the original software. If you use this software
23+
// in a product, an acknowledgment in the product documentation would be
24+
// appreciated but is not required.
25+
//
26+
// 2. Altered source versions must be plainly marked as such, and must not be
27+
// misrepresented as being the original software.
28+
//
29+
// 3. This notice may not be removed or altered from any source distribution.
30+
//
31+
32+
import SwiftDrawDOM
33+
public import Foundation
34+
35+
#if canImport(CoreGraphics)
36+
import CoreGraphics
37+
38+
public final class SVGGCache: Sendable {
39+
40+
public static let shared = SVGGCache()
41+
42+
nonisolated(unsafe)private let cache: NSCache<NSURL, Box<SVG>>
43+
44+
public init(totalCostLimit: Int = defaultTotalCostLimit) {
45+
self.cache = NSCache()
46+
self.cache.totalCostLimit = totalCostLimit
47+
}
48+
49+
public func svg(fileURL: URL) -> SVG? {
50+
cache.object(forKey: fileURL as NSURL)?.value
51+
}
52+
53+
public func setSVG(_ svg: SVG, for fileURL: URL) {
54+
cache.setObject(Box(svg), forKey: fileURL as NSURL, cost: svg.commands.estimatedCost)
55+
}
56+
57+
final class Box<T: Hashable>: NSObject {
58+
let value: T
59+
init(_ value: T) { self.value = value }
60+
}
61+
62+
public static var defaultTotalCostLimit: Int {
63+
#if canImport(WatchKit)
64+
// 2 MB
65+
return 2 * 1024 * 1024
66+
#elseif canImport(AppKit)
67+
// 200 MB
68+
return 200 * 1024 * 1024
69+
#else
70+
// 50 MB
71+
return 50 * 1024 * 1024
72+
#endif
73+
}
74+
}
75+
#endif

0 commit comments

Comments
 (0)