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