Skip to content

Commit 3c12372

Browse files
committed
estimatedCost of SVG commands
1 parent 55dfc53 commit 3c12372

File tree

4 files changed

+264
-8
lines changed

4 files changed

+264
-8
lines changed

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
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
//
2+
// Renderer.CoreGraphics+CostTests.swift
3+
// SwiftDraw
4+
//
5+
// Created by Simon Whitty on 31/8/25.
6+
// Copyright 2025 WhileLoop Pty Ltd. All rights reserved.
7+
//
8+
// https://github.com/swhitty/SwiftDraw
9+
//
10+
// This software is provided 'as-is', without any express or implied
11+
// warranty. In no event will the authors be held liable for any damages
12+
// arising from the use of this software.
13+
//
14+
// Permission is granted to anyone to use this software for any purpose,
15+
// including commercial applications, and to alter it and redistribute it
16+
// freely, subject to the following restrictions:
17+
//
18+
// 1. The origin of this software must not be misrepresented; you must not
19+
// claim that you wrote the original software. If you use this software
20+
// in a product, an acknowledgment in the product documentation would be
21+
// appreciated but is not required.
22+
//
23+
// 2. Altered source versions must be plainly marked as such, and must not be
24+
// misrepresented as being the original software.
25+
//
26+
// 3. This notice may not be removed or altered from any source distribution.
27+
//
28+
29+
#if canImport(CoreGraphics)
30+
import CoreGraphics
31+
import Foundation
32+
@testable import SwiftDraw
33+
import SwiftDrawDOM
34+
import Testing
35+
36+
struct RendererCoreGraphicsCostTests {
37+
38+
@Test
39+
func duplicatePathInstancesRemoved() throws {
40+
let source = try SVG.fromXML(#"""
41+
<?xml version="1.0" encoding="UTF-8"?>
42+
<svg width="64" height="64" version="1.1" xmlns="http://www.w3.org/2000/svg">
43+
<clipPath id="cp1">
44+
<rect width="30" height="30" />
45+
</clipPath>
46+
<rect width="30" height="30" fill="red" stroke="blue" />
47+
<rect width="30" height="30" fill="pink" stroke="yellow" clip-path="url(#cp1)"/>
48+
</svg>
49+
"""#)
50+
51+
let uniquePaths = Set(source.commands.allPaths.map(ObjectIdentifier.init))
52+
#expect(uniquePaths.count == 1)
53+
}
54+
55+
@Test
56+
func duplicateImageInstancesRemoved() throws {
57+
let source = try SVG.fromXML(#"""
58+
<?xml version="1.0" encoding="UTF-8"?>
59+
<svg width="64" height="64" version="1.1" xmlns="http://www.w3.org/2000/svg">
60+
<image id="dot" width="6" height="6" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" />
61+
<image id="dot" width="6" height="6" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" />
62+
</svg>
63+
"""#)
64+
65+
let uniqueImages = Set(source.commands.allImages.map(ObjectIdentifier.init))
66+
#expect(uniqueImages.count == 1)
67+
}
68+
69+
@Test
70+
func pathEstimatedCost() throws {
71+
let source = try SVG.fromXML(#"""
72+
<?xml version="1.0" encoding="UTF-8"?>
73+
<svg width="64" height="64" version="1.1" xmlns="http://www.w3.org/2000/svg">
74+
<rect width="30" height="30" fill="red" stroke="blue" />
75+
</svg>
76+
"""#)
77+
78+
#expect(source.commands.allPaths[0].estimatedCost == 168)
79+
#expect(source.commands.estimatedCost == 232)
80+
}
81+
82+
@Test
83+
func imageEstimatedCost() throws {
84+
let source = try SVG.fromXML(#"""
85+
<?xml version="1.0" encoding="UTF-8"?>
86+
<svg width="64" height="64" version="1.1" xmlns="http://www.w3.org/2000/svg">
87+
<image id="dot" width="6" height="6" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" />
88+
</svg>
89+
"""#)
90+
91+
#expect(source.commands.allImages[0].estimatedCost == 100)
92+
#expect(source.commands.estimatedCost == 108)
93+
}
94+
95+
@Test
96+
func shapesEstimatedCost() throws {
97+
let image = try #require(SVG(named: "shapes.svg", in: .test))
98+
#expect(image.commands.estimatedCost == 19220)
99+
}
100+
}
101+
102+
extension SVG {
103+
static func fromXML(_ text: String, filename: String = #file) throws -> SVG {
104+
let dom = try DOM.SVG.parse(data: text.data(using: .utf8)!)
105+
return SVG(dom: dom, options: .default)
106+
}
107+
}
108+
#endif

0 commit comments

Comments
 (0)