Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/SVGKit/SVGKit.git", from: "3.0.0"),
.package(url: "https://github.com/Snapp-Mobile/SnappTheming", from: "0.0.8"),
.package(url: "https://github.com/Snapp-Mobile/SnappTheming", branch: "main"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// SVGKImage+themeImage.swift
// SnappThemingSVGSupport
//
// Created by Oleksii Kolomiiets on 24.02.2025.
//

import SVGKit
import SnappTheming

extension SVGKImage {
var themeImage: SnappThemingImage? {
#if canImport(UIKit)
self.uiImage
#elseif canImport(AppKit)
self.nsImage
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,83 +8,51 @@
import Foundation
import OSLog
import SVGKit

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
import SnappTheming

/// A utility class for converts SVG data into a `UIImage`.
class SnappThemingSVGSupportImageConverter {
#if canImport(UIKit)
/// The rendered `UIImage` representation of the SVG data.
private(set) var uiImage: UIImage
#elseif canImport(AppKit)
/// The rendered `NSImage` representation of the SVG data.
private(set) var nsImage: NSImage
#endif
final class SnappThemingSVGSupportImageConverter {
/// The rendered `UIImage` representation of the SVG data.
private(set) var image: SnappThemingImage

/// Initializes the `SnappThemingSVGSupportImageConverter` with the provided SVG data.
///
/// - Parameter data: The SVG data to be rendered into a `UIImage`.
/// - Note: If the SVG data is invalid or cannot be rendered, a default system image is used as a fallback.
init(data: Data) {
var svgImage: SVGKImage? = SVGKImage(data: data)
/// - Parameters:
/// - data: The SVG data to be rendered into a `SnappThemingImage`.
/// - svgImageType: The type used for SVG rendering, defaulting to `SVGKImage.self`.
/// - fallbackImageName: The system image name used as a fallback if the SVG data is invalid or cannot be rendered.
///
/// - Note: If the SVG data is invalid or rendering fails, a system image with the name specified in `fallbackImageName`
/// (default: `"exclamationmark.triangle"`) will be used instead.
init(
data: Data,
svgImageType: SVGKImage.Type = SVGKImage.self,
fallbackImageName: String = "exclamationmark.triangle"
) {
var svgImage: SVGKImage? = svgImageType.init(data: data)

// Retry logic for simulator or potential parsing issues
// error: `*** Assertion failure in +[SVGLength pixelsPerInchForCurrentDevice], SVGLength.m:238`
if svgImage == nil {
os_log(.info, "Initial SVG parsing failed. Retrying...")
svgImage = SVGKImage(data: data)
svgImage = svgImageType.init(data: data)
}
if let validSVGImage = svgImage {
// Scale the SVG to a maximum size, maintaining its aspect ratio
let targetSize = CGSize(
width: max(validSVGImage.size.width, 512),
height: max(validSVGImage.size.height, 512)
)
validSVGImage.scaleToFit(inside: targetSize)

#if canImport(UIKit)
// Attempt to render the scaled SVG into a UIImage
if let renderedImage = validSVGImage.uiImage {
self.uiImage = renderedImage
} else {
self.uiImage = Self.defaultFallbackImage
os_log(.error, "Failed to render SVG to UIImage after scaling. Using fallback image.")
}
#elseif canImport(AppKit)
// Attempt to render the scaled SVG into a NSImage
if let renderedImage = validSVGImage.nsImage {
self.nsImage = renderedImage
} else {
self.nsImage = Self.defaultFallbackImage
os_log(.error, "Failed to render SVG to UIImage after scaling. Using fallback image.")
}
#endif
if let validSVGImage = svgImage {
if let renderedImage = validSVGImage.themeImage {
self.image = renderedImage
} else {
self.image = Self.defaultFallbackImage(fallbackImageName)
os_log(.error, "Failed to render SVG to UIImage. Using fallback image.")
}
} else {
#if canImport(UIKit)
self.uiImage = Self.defaultFallbackImage
#elseif canImport(AppKit)
self.nsImage = Self.defaultFallbackImage
#endif
self.image = Self.defaultFallbackImage(fallbackImageName)
os_log(.error, "Failed to parse SVG data after retry. Using fallback image.")
}
}

#if canImport(UIKit)
/// The default fallback image to use when SVG rendering fails.
private static var defaultFallbackImage: UIImage {
UIImage(systemName: "exclamationmark.triangle") ?? UIImage()
}
#elseif canImport(AppKit)
/// The default fallback image to use when SVG rendering fails.
private static var defaultFallbackImage: NSImage {
NSImage(
systemSymbolName: "exclamationmark.triangle",
accessibilityDescription: "Missing image"
) ?? NSImage()
}
#endif
/// The default fallback image to use when SVG rendering fails.
private static func defaultFallbackImage(_ name: String) -> SnappThemingImage {
.system(name) ?? SnappThemingImage()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,9 @@

import Foundation
import OSLog
import SVGKit
import SnappTheming
import UniformTypeIdentifiers

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

/// Extension for `SnappThemingExternalImageProcessorProtocol` to provide a default implementation for SVG processing.
extension SnappThemingExternalImageProcessorProtocol where Self == SnappThemingSVGSupportSVGProcessor {
/// Provides a static instance of `SnappThemingSVGSupportSVGProcessor`.
Expand All @@ -27,45 +20,25 @@ extension SnappThemingExternalImageProcessorProtocol where Self == SnappThemingS

/// A processor for handling SVG image data, conforming to `SnappThemingExternalImageProcessorProtocol`.
public struct SnappThemingSVGSupportSVGProcessor: SnappThemingExternalImageProcessorProtocol {
#if canImport(UIKit)
/// Processes the provided image data and type and converts it into a `UIImage` if the type is `.svg`.
///
/// - Parameter data: Image `Data`.
/// - Parameter type: Image `UTType`.
/// - Returns: A `UIImage` if the processing and conversion are successful; otherwise, `nil`.
///
/// - Note: This method specifically handles `.svg` type. If the type does not match, the method returns `nil`.
/// - Warning: Ensure that the `data` is valid SVG data to avoid potential rendering issues.
public func process(_ data: Data, of type: UTType) -> UIImage? {
guard type == .svg else {
os_log(.error, "Invalid type provided: %{public}@. Only .svg type is supported.", "\(type)")
return nil
}

let uiImage = SnappThemingSVGSupportImageConverter(data: data).uiImage

return uiImage
/// Processes the provided image data and type and converts it into a `SnappThemingImage` if the type is `.svg`.
///
/// - Parameter data: Image `Data`.
/// - Parameter type: Image `UTType`.
/// - Returns: A `SnappThemingImage` if the processing and conversion are successful; otherwise, `nil`.
///
/// - On **iOS, iPadOS, tvOS, watchOS**, and **visionOS**, `SnappThemingImage` is an alias for `UIImage`.
/// - On **macOS**, `SnappThemingImage` is an alias for `NSImage`.
///
/// - Note: This method specifically handles `.svg` type. If the type does not match, the method returns `nil`.
/// - Warning: Ensure that the `data` is valid SVG data to avoid potential rendering issues.
public func process(_ data: Data, of type: UTType) -> SnappThemingImage? {
guard type == .svg else {
os_log(.error, "Invalid type provided: %{public}@. Only .svg type is supported.", "\(type)")
return nil
}
#elseif canImport(AppKit)
/// Processes the provided image data and type and converts it into a `NSImage` if the type is `.svg`.
///
/// - Parameter data: Image `Data`.
/// - Parameter type: Image `UTType`.
/// - Returns: A `NSImage` if the processing and conversion are successful; otherwise, `nil`.
///
/// - Note: This method specifically handles `.svg` type. If the type does not match, the method returns `nil`.
/// - Warning: Ensure that the `data` is valid SVG data to avoid potential rendering issues.
public func process(_ data: Data, of type: UTType) -> NSImage? {
guard type == .svg else {
os_log(.error, "Invalid type provided: %{public}@. Only .svg type is supported.", "\(type)")
return nil
}

let nsImage = SnappThemingSVGSupportImageConverter(data: data).nsImage

return nsImage
}
#endif
return SnappThemingSVGSupportImageConverter(data: data).image
}

/// Initializes the `SnappThemingSVGSupportSVGProcessor`.
///
Expand Down
16 changes: 16 additions & 0 deletions Tests/SnappThemingSVGSupportTests/Fakes/SVGIconString.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// File.swift
// SnappThemingSVGSupport
//
// Created by Oleksii Kolomiiets on 24.02.2025.
//

import Foundation

let svgIconString: String =
"""
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" style="zoom: 1;">
<path d="M3 1H4.5V5H3V1Z" fill="white"/>
<path d="M3 19H4.5V23H3V19Z" fill="white"/>
</svg>
"""
60 changes: 60 additions & 0 deletions Tests/SnappThemingSVGSupportTests/ImageConverterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// Test.swift
// SnappThemingSVGSupport
//
// Created by Oleksii Kolomiiets on 24.02.2025.
//

import Foundation
import SnappTheming
import Testing

@testable import SnappThemingSVGSupport

@Suite
struct ImageConverterTests {
@Test
func testConverterSuccessfulConversionExpectedImage() throws {
let expectedImageData = try #require(svgIconString.data(using: .utf8))
let converter = SnappThemingSVGSupportImageConverter(data: expectedImageData)

#expect(converter.image.size == CGSize(width: 24, height: 24))
}

@Test
func testConverterSVGImageFailedFallbackImage() throws {
let expectedImageData = try #require(svgIconString.data(using: .utf8))
let converter = SnappThemingSVGSupportImageConverter(data: expectedImageData, svgImageType: MockSVGKImage.self)
let fallbackImage: SnappThemingImage = try #require(.system("exclamationmark.triangle"))

#expect(converter.image.size != CGSize(width: 24, height: 24))
#if canImport(UIKit)
#expect(converter.image == fallbackImage)
#elseif canImport(AppKit)
#expect(converter.image.tiffRepresentation == fallbackImage.tiffRepresentation)
#endif
}

@Test
func testConverterEmptyDataFallbackImage() async throws {
let converter = SnappThemingSVGSupportImageConverter(data: Data())
let fallbackImage: SnappThemingImage = try #require(.system("exclamationmark.triangle"))
#if canImport(UIKit)
#expect(converter.image == fallbackImage)
#elseif canImport(AppKit)
#expect(converter.image.tiffRepresentation == fallbackImage.tiffRepresentation)
#endif
}

@Test
func testConverterEmptyDataAndBrokenFallbackImage() async throws {
let converter = SnappThemingSVGSupportImageConverter(data: Data(), fallbackImageName: "")
let fallbackImage: SnappThemingImage = try #require(.system("exclamationmark.triangle"))
#if canImport(UIKit)
#expect(converter.image == SnappThemingImage())
#elseif canImport(AppKit)
#expect(converter.image.tiffRepresentation != fallbackImage.tiffRepresentation)
#expect(converter.image.tiffRepresentation == nil)
#endif
}
}
20 changes: 20 additions & 0 deletions Tests/SnappThemingSVGSupportTests/Mocks/MockSVGKImage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// MockSVGKImage.swift
// SnappThemingSVGSupport
//
// Created by Oleksii Kolomiiets on 24.02.2025.
//

import SVGKit

final class MockSVGKImage: SVGKImage {
#if canImport(UIKit)
override var uiImage: UIImage! {
return nil
}
#elseif canImport(AppKit)
override var nsImage: NSImage! {
return nil
}
#endif
}
52 changes: 52 additions & 0 deletions Tests/SnappThemingSVGSupportTests/SVGProcessorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// SnappThemingSVGSupportSVGProcessorTests.swift
// SnappThemingSVGSupport
//
// Created by Oleksii Kolomiiets on 24.02.2025.
//

import Foundation
import SnappTheming
import Testing

@testable import SnappThemingSVGSupport

@Suite
struct SVGProcessorTests {
let sut: SnappThemingSVGSupportSVGProcessor = .svg

@Test
func testSVGProcessorFail() {
#expect(sut.process(Data(), of: .png) == nil)
#expect(sut.process(Data(), of: .jpeg) == nil)
#expect(sut.process(Data(), of: .pdf) == nil)
#expect(sut.process(Data(), of: .gif) == nil)
}

@Test
func testSVGProcessorFallback() throws {
let emptyStringData = try #require("".data(using: .utf8))
let fallbackImage: SnappThemingImage = try #require(.system("exclamationmark.triangle"))

let emptyStringDataImage = try #require(sut.process(emptyStringData, of: .svg))
let emptyDataImage = try #require(sut.process(Data(), of: .svg))

#if canImport(UIKit)
#expect(emptyStringDataImage == fallbackImage)
#expect(emptyDataImage == fallbackImage)
#elseif canImport(AppKit)
#expect(emptyStringDataImage.tiffRepresentation == fallbackImage.tiffRepresentation)
#expect(emptyDataImage.tiffRepresentation == fallbackImage.tiffRepresentation)
#endif

}

@Test
func testSVGProcessorExpectedImage() throws {
let svgData = try #require(svgIconString.data(using: .utf8))
let processedIcon = try #require(sut.process(svgData, of: .svg))

#expect(processedIcon.size.width == 24)
#expect(processedIcon.size.height == 24)
}
}