Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for more modern image format like JPEG XL #899

Closed
wants to merge 13 commits into from
30 changes: 29 additions & 1 deletion Package.resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
{
"originHash" : "de9ffd5f38c1f245e5a3028d1c9ff43af00171e62802d1b0dfcb28c3486c7c54",
"pins" : [
{
"identity" : "jxl-coder-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/awxkee/jxl-coder-swift.git",
"state" : {
"revision" : "179264567c7dc0dd489859d5572773222358a7f5",
"version" : "1.7.3"
}
},
{
"identity" : "libwebp-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/awxkee/libwebp-ios.git",
"state" : {
"revision" : "09ab26afc64c55a49332c396c4b465c278fcb05f",
"version" : "1.1.1"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
Expand All @@ -8,7 +27,16 @@
"revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c",
"version" : "600.0.0-prerelease-2024-06-12"
}
},
{
"identity" : "webp.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/awxkee/webp.swift.git",
"state" : {
"revision" : "5ee6b41965c161b3adbe29f6c01a2b66b4944867",
"version" : "1.1.1"
}
}
],
"version" : 2
"version" : 3
}
40 changes: 35 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,31 +1,61 @@
// swift-tools-version:5.7
// swift-tools-version:5.10

import PackageDescription

let package = Package(
name: "swift-snapshot-testing",
platforms: [
.iOS(.v13),
.macOS(.v10_15),
.macOS(.v12),
.tvOS(.v13),
.watchOS(.v6),
.watchOS(.v8),
],
products: [
.library(
name: "SnapshotTesting",
targets: ["SnapshotTesting"]
),
.library(
name: "JPEGXLImageSerializer",
targets: ["JPEGXLImageSerializer"]
),
.library(
name: "WEBPImageSerializer",
targets: ["WEBPImageSerializer"]
),
.library(
name: "InlineSnapshotTesting",
targets: ["InlineSnapshotTesting"]
),
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0-prerelease")
.package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0-prerelease"),
.package(url: "https://github.com/awxkee/jxl-coder-swift.git", from: "1.7.3"),
.package(url: "https://github.com/awxkee/webp.swift.git", from: "1.1.1"),
],
targets: [
.target(
name: "SnapshotTesting"
name: "SnapshotTesting",
dependencies: [
"ImageSerializer"
]
),
.target(
name: "ImageSerializer"
),
.target(
name: "JPEGXLImageSerializer",
dependencies: [
"ImageSerializer",
.product(name: "JxlCoder", package: "jxl-coder-swift")
]
),
.target(
name: "WEBPImageSerializer",
dependencies: [
"ImageSerializer",
.product(name: "webp", package: "webp.swift")
]
),
.target(
name: "InlineSnapshotTesting",
Expand Down
54 changes: 54 additions & 0 deletions Sources/ImageSerializer/ImageSerializer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

#if !os(macOS)
import UIKit.UIImage
/// A type alias for `UIImage` on iOS and `NSImage` on macOS.
package typealias SnapImage = UIImage
#else
import AppKit.NSImage
/// A type alias for `UIImage` on iOS and `NSImage` on macOS.
package typealias SnapImage = NSImage
#endif

/// A structure responsible for encoding and decoding images.
///
/// The `ImageSerializer` structure provides two closures:
/// - `encodeImage`: Encodes a `SnapImage` into `Data`.
/// - `decodeImage`: Decodes `Data` back into a `SnapImage`.
///
/// These closures allow you to define custom image serialization logic for different image formats.
package struct ImageSerializer {
/// A closure that encodes a `SnapImage` into `Data`.
package var encodeImage: (_ image: SnapImage) -> Data?

/// A closure that decodes `Data` into a `SnapImage`.
package var decodeImage: (_ data: Data) -> SnapImage?

/// Initializes an `ImageSerializer` with custom encoding and decoding logic.
///
/// - Parameters:
/// - encodeImage: A closure that defines how to encode a `SnapImage` into `Data`.
/// - decodeImage: A closure that defines how to decode `Data` into a `SnapImage`.
package init(encodeImage: @escaping (_: SnapImage) -> Data?, decodeImage: @escaping (_: Data) -> SnapImage?) {
self.encodeImage = encodeImage
self.decodeImage = decodeImage
}
}

/// An enumeration of supported image formats.
///
/// `ImageFormat` defines the formats that can be used for image serialization:
/// - `.jxl`: JPEG XL format.
/// - `.png`: PNG format.
/// - `.heic`: HEIC format.
/// - `.webp`: WEBP format.
///
/// The `defaultValue` is set to `.png`.
public enum ImageFormat: String {
case jxl
case png
case heic
case webp

public static var defaultValue = ImageFormat.png
}
25 changes: 25 additions & 0 deletions Sources/JPEGXLImageSerializer/JPEGXLImageSerializer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation
import JxlCoder
import ImageSerializer

extension ImageSerializer {
/// A static property that provides an `ImageSerializer` for the JPEG XL format.
///
/// This property uses the `JXLCoder` to encode and decode images in the JPEG XL format.
///
/// - Returns: An `ImageSerializer` instance configured for encoding and decoding JPEG XL images.
///
/// - Encoding:
/// - The `encodeImage` closure uses `JXLCoder.encode(image:)` to convert a `SnapImage` into `Data`.
/// - Decoding:
/// - The `decodeImage` closure uses `JXLCoder.decode(data:)` to convert `Data` back into a `SnapImage`.
///
/// - Note: The encoding and decoding operations are performed using the `JXLCoder` library, which supports the JPEG XL format.
package static var jxl: Self {
return ImageSerializer { image in
try? JXLCoder.encode(image: image)
} decodeImage: { data in
try? JXLCoder.decode(data: data)
}
}
}
3 changes: 3 additions & 0 deletions Sources/SnapshotTesting/AssertSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ public var __record: SnapshotTestingConfiguration.Record = {
return .missing
}()

/// We can set the image format globally to better test
public var imageFormat = ImageFormat.defaultValue

/// Asserts that a given value matches a reference on disk.
///
/// - Parameters:
Expand Down
12 changes: 6 additions & 6 deletions Sources/SnapshotTesting/Snapshotting/CALayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
/// assertSnapshot(of: layer, as: .image(precision: 0.99))
/// ```
public static var image: Snapshotting {
return .image(precision: 1)
return .image(precision: 1, format: imageFormat)
}

/// A snapshot strategy for comparing layers based on pixel equality.
Expand All @@ -25,9 +25,9 @@
/// match. 98-99% mimics
/// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
/// human eye.
public static func image(precision: Float, perceptualPrecision: Float = 1) -> Snapshotting {
public static func image(precision: Float, perceptualPrecision: Float = 1, format: ImageFormat) -> Snapshotting {
return SimplySnapshotting.image(
precision: precision, perceptualPrecision: perceptualPrecision
precision: precision, perceptualPrecision: perceptualPrecision, format: format
).pullback { layer in
let image = NSImage(size: layer.bounds.size)
image.lockFocus()
Expand All @@ -46,7 +46,7 @@
extension Snapshotting where Value == CALayer, Format == UIImage {
/// A snapshot strategy for comparing layers based on pixel equality.
public static var image: Snapshotting {
return .image()
return .image(format: imageFormat)
}

/// A snapshot strategy for comparing layers based on pixel equality.
Expand All @@ -59,12 +59,12 @@
/// human eye.
/// - traits: A trait collection override.
public static func image(
precision: Float = 1, perceptualPrecision: Float = 1, traits: UITraitCollection = .init()
precision: Float = 1, perceptualPrecision: Float = 1, traits: UITraitCollection = .init(), format: ImageFormat
)
-> Snapshotting
{
return SimplySnapshotting.image(
precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale
precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale, format: format
).pullback { layer in
renderer(bounds: layer.bounds, for: traits).image { ctx in
layer.setNeedsLayout()
Expand Down
15 changes: 9 additions & 6 deletions Sources/SnapshotTesting/Snapshotting/CGPath.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
#if os(macOS)

import AppKit
import Cocoa
import CoreGraphics

extension Snapshotting where Value == CGPath, Format == NSImage {
/// A snapshot strategy for comparing bezier paths based on pixel equality.
public static var image: Snapshotting {
return .image()
return .image(format: imageFormat)
}

/// A snapshot strategy for comparing bezier paths based on pixel equality.
Expand All @@ -29,10 +30,11 @@
public static func image(
precision: Float = 1,
perceptualPrecision: Float = 1,
drawingMode: CGPathDrawingMode = .eoFill
drawingMode: CGPathDrawingMode = .eoFill,
format: ImageFormat
) -> Snapshotting {
return SimplySnapshotting.image(
precision: precision, perceptualPrecision: perceptualPrecision
precision: precision, perceptualPrecision: perceptualPrecision, format: format
).pullback { path in
let bounds = path.boundingBoxOfPath
var transform = CGAffineTransform(translationX: -bounds.origin.x, y: -bounds.origin.y)
Expand All @@ -52,10 +54,11 @@
#elseif os(iOS) || os(tvOS)
import UIKit


extension Snapshotting where Value == CGPath, Format == UIImage {
/// A snapshot strategy for comparing bezier paths based on pixel equality.
public static var image: Snapshotting {
return .image()
return .image(format: imageFormat)
}

/// A snapshot strategy for comparing bezier paths based on pixel equality.
Expand All @@ -68,10 +71,10 @@
/// human eye.
public static func image(
precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1,
drawingMode: CGPathDrawingMode = .eoFill
drawingMode: CGPathDrawingMode = .eoFill, format: ImageFormat
) -> Snapshotting {
return SimplySnapshotting.image(
precision: precision, perceptualPrecision: perceptualPrecision, scale: scale
precision: precision, perceptualPrecision: perceptualPrecision, scale: scale, format: format
).pullback { path in
let bounds = path.boundingBoxOfPath
let format: UIGraphicsImageRendererFormat
Expand Down
76 changes: 76 additions & 0 deletions Sources/SnapshotTesting/Snapshotting/HEICImageSerializer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
import ImageSerializer

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

@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
/// A struct that provides encoding and decoding functionality for HEIC images.
///
/// `HEICCoder` supports encoding images to HEIC format and decoding HEIC data back into images.
///
/// - Note: The HEIC format is only supported on iOS 14.0+ and macOS 10.15+.
struct HEICCoder {
/// Encodes a `SnapImage` into HEIC format.
///
/// This method converts a `SnapImage` to `Data` using the HEIC format.
///
/// - Parameter image: The image to be encoded. This can be a `UIImage` on iOS or an `NSImage` on macOS.
///
/// - Returns: The encoded image data in HEIC format, or `nil` if encoding fails.
///
/// - Note: The encoding quality is set to 0.8 (lossy compression). On macOS, the image is created using `CGImageDestinationCreateWithData`.
static func encodeImage(_ image: SnapImage) -> Data? {
#if !os(macOS)
guard let cgImage = image.cgImage else { return nil }
#else
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
#endif

let data = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(data, UTType.heic.identifier as CFString, 1, nil) else { return nil }
CGImageDestinationAddImage(destination, cgImage, [kCGImageDestinationLossyCompressionQuality: 0.8] as CFDictionary)
guard CGImageDestinationFinalize(destination) else { return nil }
return data as Data
}

/// Decodes HEIC image data into a `SnapImage`.
///
/// This method converts HEIC image data back into a `SnapImage`.
///
/// - Parameter data: The HEIC data to be decoded.
///
/// - Returns: The decoded image as `SnapImage`, or `nil` if decoding fails.
///
/// - Note: On iOS, this returns a `UIImage`, while on macOS, it returns an `NSImage`.
static func decodeImage(_ data: Data) -> SnapImage? {
#if !os(macOS)
return UIImage(data: data)
#else
return NSImage(data: data)
#endif
}
}

@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
extension ImageSerializer {
/// A static property that provides an `ImageSerializer` configured for HEIC format.
///
/// This property creates an `ImageSerializer` instance that uses `HEICCoder` to handle encoding and decoding of HEIC images.
///
/// - Returns: An `ImageSerializer` instance configured for HEIC format.
///
/// - Note: This property is available only on iOS 14.0 and later.
package static var heic: ImageSerializer {
ImageSerializer(
encodeImage: HEICCoder.encodeImage,
decodeImage: HEICCoder.decodeImage
)
}
}
Loading
Loading