Skip to content

Commit 906092d

Browse files
authored
Add a CoreGraphics cross-import overlay with support for attaching CGImages. (#827)
This PR adds a new cross-import overlay target with Apple's Core Graphics framework that allows attaching a `CGImage` as an attachment in an arbitrary image format (PNG, JPEG, etc.) Because `CGImage` is imported into Swift as a non-final class, it cannot conform directly to `Attachable`, so an `AttachableContainer` type acts as a proxy. This type is not meant to be used directly, so its name is underscored. Initializers on `Attachment` are provided so that this abstraction is almost entirely transparent to test authors. A new protocol, `AttachableAsCGImage`, is introduced that abstracts away the relationship between the attached image and Core Graphics; in the future, I intend to make additional image types like `NSImage` and `UIImage` conform to this protocol too. Example usage: ```swift let sparklyDiamonds: CGImage = ... let attachment = Attachment(image, named: "sparkly-diamonds", as: .tiff, encodingQuality: 0.75) ... attachment.attach() ``` The code in this PR is, by definition, specific to Apple's platforms. In the future, I'd be interested in adding Windows/Linux equivalents (`HBITMAP`? Whatever Gnome/KDE/Qt use?) but that's beyond the scope of this PR. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent a4ed760 commit 906092d

8 files changed

+568
-0
lines changed

Package.swift

+9
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ let package = Package(
5151
name: "TestingTests",
5252
dependencies: [
5353
"Testing",
54+
"_Testing_CoreGraphics",
5455
"_Testing_Foundation",
5556
],
5657
swiftSettings: .packageSettings
@@ -91,6 +92,14 @@ let package = Package(
9192
),
9293

9394
// Cross-import overlays (not supported by Swift Package Manager)
95+
.target(
96+
name: "_Testing_CoreGraphics",
97+
dependencies: [
98+
"Testing",
99+
],
100+
path: "Sources/Overlays/_Testing_CoreGraphics",
101+
swiftSettings: .packageSettings
102+
),
94103
.target(
95104
name: "_Testing_Foundation",
96105
dependencies: [
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
12+
public import CoreGraphics
13+
private import ImageIO
14+
15+
/// A protocol describing images that can be converted to instances of
16+
/// ``Testing/Attachment``.
17+
///
18+
/// Instances of types conforming to this protocol do not themselves conform to
19+
/// ``Testing/Attachable``. Instead, the testing library provides additional
20+
/// initializers on ``Testing/Attachment`` that take instances of such types and
21+
/// handle converting them to image data when needed.
22+
///
23+
/// The following system-provided image types conform to this protocol and can
24+
/// be attached to a test:
25+
///
26+
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
27+
///
28+
/// You do not generally need to add your own conformances to this protocol. If
29+
/// you have an image in another format that needs to be attached to a test,
30+
/// first convert it to an instance of one of the types above.
31+
@_spi(Experimental)
32+
public protocol AttachableAsCGImage {
33+
/// An instance of `CGImage` representing this image.
34+
///
35+
/// - Throws: Any error that prevents the creation of an image.
36+
var attachableCGImage: CGImage { get throws }
37+
38+
/// The orientation of the image.
39+
///
40+
/// The value of this property is the raw value of an instance of
41+
/// `CGImagePropertyOrientation`. The default value of this property is
42+
/// `.up`.
43+
///
44+
/// This property is not part of the public interface of the testing
45+
/// library. It may be removed in a future update.
46+
var _attachmentOrientation: UInt32 { get }
47+
48+
/// The scale factor of the image.
49+
///
50+
/// The value of this property is typically greater than `1.0` when an image
51+
/// originates from a Retina Display screenshot or similar. The default value
52+
/// of this property is `1.0`.
53+
///
54+
/// This property is not part of the public interface of the testing
55+
/// library. It may be removed in a future update.
56+
var _attachmentScaleFactor: CGFloat { get }
57+
58+
/// Make a copy of this instance to pass to an attachment.
59+
///
60+
/// - Returns: A copy of `self`, or `self` if no copy is needed.
61+
///
62+
/// Several system image types do not conform to `Sendable`; use this
63+
/// function to make copies of such images that will not be shared outside
64+
/// of an attachment and so can be generally safely stored.
65+
///
66+
/// The default implementation of this function when `Self` conforms to
67+
/// `Sendable` simply returns `self`.
68+
///
69+
/// This function is not part of the public interface of the testing library.
70+
/// It may be removed in a future update.
71+
func _makeCopyForAttachment() -> Self
72+
}
73+
74+
extension AttachableAsCGImage {
75+
public var _attachmentOrientation: UInt32 {
76+
CGImagePropertyOrientation.up.rawValue
77+
}
78+
79+
public var _attachmentScaleFactor: CGFloat {
80+
1.0
81+
}
82+
}
83+
84+
extension AttachableAsCGImage where Self: Sendable {
85+
public func _makeCopyForAttachment() -> Self {
86+
self
87+
}
88+
}
89+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
12+
@_spi(ForSwiftTestingOnly) @_spi(Experimental) public import Testing
13+
14+
public import UniformTypeIdentifiers
15+
16+
extension Attachment {
17+
/// Initialize an instance of this type that encloses the given image.
18+
///
19+
/// - Parameters:
20+
/// - attachableValue: The value that will be attached to the output of
21+
/// the test run.
22+
/// - preferredName: The preferred name of the attachment when writing it
23+
/// to a test report or to disk. If `nil`, the testing library attempts
24+
/// to derive a reasonable filename for the attached value.
25+
/// - contentType: The image format with which to encode `attachableValue`.
26+
/// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image),
27+
/// the result is undefined. Pass `nil` to let the testing library decide
28+
/// which image format to use.
29+
/// - encodingQuality: The encoding quality to use when encoding the image.
30+
/// If the image format used for encoding (specified by the `contentType`
31+
/// argument) does not support variable-quality encoding, the value of
32+
/// this argument is ignored.
33+
/// - sourceLocation: The source location of the call to this initializer.
34+
/// This value is used when recording issues associated with the
35+
/// attachment.
36+
///
37+
/// This is the designated initializer for this type when attaching an image
38+
/// that conforms to ``AttachableAsCGImage``.
39+
fileprivate init<T>(
40+
attachableValue: T,
41+
named preferredName: String?,
42+
contentType: (any Sendable)?,
43+
encodingQuality: Float,
44+
sourceLocation: SourceLocation
45+
) where AttachableValue == _AttachableImageContainer<T> {
46+
var imageContainer = _AttachableImageContainer(image: attachableValue, encodingQuality: encodingQuality)
47+
48+
// Update the preferred name to include an extension appropriate for the
49+
// given content type. (Note the `else` branch duplicates the logic in
50+
// `preferredContentType(forEncodingQuality:)` but will go away once our
51+
// minimum deployment targets include the UniformTypeIdentifiers framework.)
52+
var preferredName = preferredName ?? Self.defaultPreferredName
53+
if #available(_uttypesAPI, *) {
54+
let contentType: UTType = contentType
55+
.map { $0 as! UTType }
56+
.flatMap { contentType in
57+
if UTType.image.conforms(to: contentType) {
58+
// This type is an abstract base type of .image (or .image itself.)
59+
// We'll infer the concrete type based on other arguments.
60+
return nil
61+
}
62+
return contentType
63+
} ?? .preferred(forEncodingQuality: encodingQuality)
64+
preferredName = (preferredName as NSString).appendingPathExtension(for: contentType)
65+
imageContainer.contentType = contentType
66+
} else {
67+
// The caller can't provide a content type, so we'll pick one for them.
68+
let ext = if encodingQuality < 1.0 {
69+
"jpg"
70+
} else {
71+
"png"
72+
}
73+
if (preferredName as NSString).pathExtension.caseInsensitiveCompare(ext) != .orderedSame {
74+
preferredName = (preferredName as NSString).appendingPathExtension(ext) ?? preferredName
75+
}
76+
}
77+
78+
self.init(imageContainer, named: preferredName, sourceLocation: sourceLocation)
79+
}
80+
81+
/// Initialize an instance of this type that encloses the given image.
82+
///
83+
/// - Parameters:
84+
/// - attachableValue: The value that will be attached to the output of
85+
/// the test run.
86+
/// - preferredName: The preferred name of the attachment when writing it
87+
/// to a test report or to disk. If `nil`, the testing library attempts
88+
/// to derive a reasonable filename for the attached value.
89+
/// - contentType: The image format with which to encode `attachableValue`.
90+
/// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image),
91+
/// the result is undefined. Pass `nil` to let the testing library decide
92+
/// which image format to use.
93+
/// - encodingQuality: The encoding quality to use when encoding the image.
94+
/// If the image format used for encoding (specified by the `contentType`
95+
/// argument) does not support variable-quality encoding, the value of
96+
/// this argument is ignored.
97+
/// - sourceLocation: The source location of the call to this initializer.
98+
/// This value is used when recording issues associated with the
99+
/// attachment.
100+
///
101+
/// The following system-provided image types conform to the
102+
/// ``AttachableAsCGImage`` protocol and can be attached to a test:
103+
///
104+
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
105+
@_spi(Experimental)
106+
@available(_uttypesAPI, *)
107+
public init<T>(
108+
_ attachableValue: T,
109+
named preferredName: String? = nil,
110+
as contentType: UTType?,
111+
encodingQuality: Float = 1.0,
112+
sourceLocation: SourceLocation = #_sourceLocation
113+
) where AttachableValue == _AttachableImageContainer<T> {
114+
self.init(attachableValue: attachableValue, named: preferredName, contentType: contentType, encodingQuality: encodingQuality, sourceLocation: sourceLocation)
115+
}
116+
117+
/// Initialize an instance of this type that encloses the given image.
118+
///
119+
/// - Parameters:
120+
/// - attachableValue: The value that will be attached to the output of
121+
/// the test run.
122+
/// - preferredName: The preferred name of the attachment when writing it
123+
/// to a test report or to disk. If `nil`, the testing library attempts
124+
/// to derive a reasonable filename for the attached value.
125+
/// - encodingQuality: The encoding quality to use when encoding the image.
126+
/// If the image format used for encoding (specified by the `contentType`
127+
/// argument) does not support variable-quality encoding, the value of
128+
/// this argument is ignored.
129+
/// - sourceLocation: The source location of the call to this initializer.
130+
/// This value is used when recording issues associated with the
131+
/// attachment.
132+
///
133+
/// The following system-provided image types conform to the
134+
/// ``AttachableAsCGImage`` protocol and can be attached to a test:
135+
///
136+
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
137+
@_spi(Experimental)
138+
public init<T>(
139+
_ attachableValue: T,
140+
named preferredName: String? = nil,
141+
encodingQuality: Float = 1.0,
142+
sourceLocation: SourceLocation = #_sourceLocation
143+
) where AttachableValue == _AttachableImageContainer<T> {
144+
self.init(attachableValue: attachableValue, named: preferredName, contentType: nil, encodingQuality: encodingQuality, sourceLocation: sourceLocation)
145+
}
146+
}
147+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
12+
public import CoreGraphics
13+
14+
@_spi(Experimental)
15+
extension CGImage: AttachableAsCGImage {
16+
public var attachableCGImage: CGImage {
17+
self
18+
}
19+
}
20+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
12+
/// A type representing an error that can occur when attaching an image.
13+
@_spi(ForSwiftTestingOnly)
14+
public enum ImageAttachmentError: Error, CustomStringConvertible {
15+
/// The specified content type did not conform to `.image`.
16+
case contentTypeDoesNotConformToImage
17+
18+
/// The image could not be converted to an instance of `CGImage`.
19+
case couldNotCreateCGImage
20+
21+
/// The image destination could not be created.
22+
case couldNotCreateImageDestination
23+
24+
/// The image could not be converted.
25+
case couldNotConvertImage
26+
27+
@_spi(ForSwiftTestingOnly)
28+
public var description: String {
29+
switch self {
30+
case .contentTypeDoesNotConformToImage:
31+
"The specified type does not represent an image format."
32+
case .couldNotCreateCGImage:
33+
"Could not create the corresponding Core Graphics image."
34+
case .couldNotCreateImageDestination:
35+
"Could not create the Core Graphics image destination to encode this image."
36+
case .couldNotConvertImage:
37+
"Could not convert the image to the specified format."
38+
}
39+
}
40+
}
41+
#endif

0 commit comments

Comments
 (0)