Skip to content

Commit f4379f5

Browse files
authored
Use a custom AttachableImageFormat type instead of directly relying on UTType. (#1203)
This PR creates a platform-agnostic type to represent image formats for image attachments instead of relying directly on `UTType`. The implementation still requires `UTType` on Apple platforms, but on non-Apple platforms we can use the same type to represent those platforms' platform-specific image format enums (e.g. on Windows, it can box `CLSID`.) This reduces the platform-specific API surface area for image attachments. ### 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 f9f25bf commit f4379f5

File tree

6 files changed

+232
-76
lines changed

6 files changed

+232
-76
lines changed

Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ private import ImageIO
3131
/// you have an image in another format that needs to be attached to a test,
3232
/// first convert it to an instance of one of the types above.
3333
@_spi(Experimental)
34+
@available(_uttypesAPI, *)
3435
public protocol AttachableAsCGImage {
3536
/// An instance of `CGImage` representing this image.
3637
///
@@ -73,6 +74,7 @@ public protocol AttachableAsCGImage {
7374
func _makeCopyForAttachment() -> Self
7475
}
7576

77+
@available(_uttypesAPI, *)
7678
extension AttachableAsCGImage {
7779
public var _attachmentOrientation: UInt32 {
7880
CGImagePropertyOrientation.up.rawValue
@@ -83,6 +85,7 @@ extension AttachableAsCGImage {
8385
}
8486
}
8587

88+
@available(_uttypesAPI, *)
8689
extension AttachableAsCGImage where Self: Sendable {
8790
public func _makeCopyForAttachment() -> Self {
8891
self
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024–2025 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(Experimental) public import Testing
13+
14+
public import UniformTypeIdentifiers
15+
16+
@available(_uttypesAPI, *)
17+
extension AttachableImageFormat {
18+
/// Get the content type to use when encoding the image, substituting a
19+
/// concrete type for `UTType.image` in particular.
20+
///
21+
/// - Parameters:
22+
/// - imageFormat: The image format to use, or `nil` if the developer did
23+
/// not specify one.
24+
/// - preferredName: The preferred name of the image for which a type is
25+
/// needed.
26+
///
27+
/// - Returns: An instance of `UTType` referring to a concrete image type.
28+
///
29+
/// This function is not part of the public interface of the testing library.
30+
static func computeContentType(for imageFormat: Self?, withPreferredName preferredName: String) -> UTType {
31+
guard let imageFormat else {
32+
// The developer didn't specify a type. Substitute the generic `.image`
33+
// and solve for that instead.
34+
return computeContentType(for: Self(.image, encodingQuality: 1.0), withPreferredName: preferredName)
35+
}
36+
37+
switch imageFormat.kind {
38+
case .png:
39+
return .png
40+
case .jpeg:
41+
return .jpeg
42+
case let .systemValue(contentType):
43+
let contentType = contentType as! UTType
44+
if contentType != .image {
45+
// The developer explicitly specified a type.
46+
return contentType
47+
}
48+
49+
// The developer didn't specify a concrete type, so try to derive one from
50+
// the preferred name's path extension.
51+
let pathExtension = (preferredName as NSString).pathExtension
52+
if !pathExtension.isEmpty,
53+
let contentType = UTType(filenameExtension: pathExtension, conformingTo: .image),
54+
contentType.isDeclared {
55+
return contentType
56+
}
57+
58+
// We couldn't derive a concrete type from the path extension, so pick
59+
// between PNG and JPEG based on the encoding quality.
60+
return imageFormat.encodingQuality < 1.0 ? .jpeg : .png
61+
}
62+
}
63+
64+
/// The content type corresponding to this image format.
65+
///
66+
/// The value of this property always conforms to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image).
67+
public var contentType: UTType {
68+
switch kind {
69+
case .png:
70+
return .png
71+
case .jpeg:
72+
return .jpeg
73+
case let .systemValue(contentType):
74+
return contentType as! UTType
75+
}
76+
}
77+
78+
/// Initialize an instance of this type with the given content type and
79+
/// encoding quality.
80+
///
81+
/// - Parameters:
82+
/// - contentType: The image format to use when encoding images.
83+
/// - encodingQuality: The encoding quality to use when encoding images. For
84+
/// the lowest supported quality, pass `0.0`. For the highest supported
85+
/// quality, pass `1.0`.
86+
///
87+
/// If the target image format does not support variable-quality encoding,
88+
/// the value of the `encodingQuality` argument is ignored.
89+
///
90+
/// If `imageFormat` is not `nil` and does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image),
91+
/// the result is undefined.
92+
public init(_ contentType: UTType, encodingQuality: Float = 1.0) {
93+
precondition(
94+
contentType.conforms(to: .image),
95+
"An image cannot be attached as an instance of type '\(contentType.identifier)'. Use a type that conforms to 'public.image' instead."
96+
)
97+
self.init(kind: .systemValue(contentType), encodingQuality: encodingQuality)
98+
}
99+
}
100+
#endif

Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
1212
@_spi(Experimental) public import Testing
1313

14-
public import UniformTypeIdentifiers
15-
1614
@_spi(Experimental)
1715
@available(_uttypesAPI, *)
1816
extension Attachment {
@@ -24,10 +22,7 @@ extension Attachment {
2422
/// - preferredName: The preferred name of the attachment when writing it
2523
/// to a test report or to disk. If `nil`, the testing library attempts
2624
/// to derive a reasonable filename for the attached value.
27-
/// - contentType: The image format with which to encode `attachableValue`.
28-
/// - encodingQuality: The encoding quality to use when encoding the image.
29-
/// For the lowest supported quality, pass `0.0`. For the highest
30-
/// supported quality, pass `1.0`.
25+
/// - imageFormat: The image format with which to encode `attachableValue`.
3126
/// - sourceLocation: The source location of the call to this initializer.
3227
/// This value is used when recording issues associated with the
3328
/// attachment.
@@ -39,26 +34,20 @@ extension Attachment {
3934
/// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage)
4035
/// (macOS)
4136
///
42-
/// The testing library uses the image format specified by `contentType`. Pass
37+
/// The testing library uses the image format specified by `imageFormat`. Pass
4338
/// `nil` to let the testing library decide which image format to use. If you
4439
/// pass `nil`, then the image format that the testing library uses depends on
4540
/// the path extension you specify in `preferredName`, if any. If you do not
4641
/// specify a path extension, or if the path extension you specify doesn't
4742
/// correspond to an image format the operating system knows how to write, the
4843
/// testing library selects an appropriate image format for you.
49-
///
50-
/// If the target image format does not support variable-quality encoding,
51-
/// the value of the `encodingQuality` argument is ignored. If `contentType`
52-
/// is not `nil` and does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image),
53-
/// the result is undefined.
5444
public init<T>(
5545
_ attachableValue: T,
5646
named preferredName: String? = nil,
57-
as contentType: UTType? = nil,
58-
encodingQuality: Float = 1.0,
47+
as imageFormat: AttachableImageFormat? = nil,
5948
sourceLocation: SourceLocation = #_sourceLocation
6049
) where AttachableValue == _AttachableImageWrapper<T> {
61-
let imageWrapper = _AttachableImageWrapper(image: attachableValue, encodingQuality: encodingQuality, contentType: contentType)
50+
let imageWrapper = _AttachableImageWrapper(image: attachableValue, imageFormat: imageFormat)
6251
self.init(imageWrapper, named: preferredName, sourceLocation: sourceLocation)
6352
}
6453

@@ -69,10 +58,7 @@ extension Attachment {
6958
/// - preferredName: The preferred name of the attachment when writing it to
7059
/// a test report or to disk. If `nil`, the testing library attempts to
7160
/// derive a reasonable filename for the attached value.
72-
/// - contentType: The image format with which to encode `attachableValue`.
73-
/// - encodingQuality: The encoding quality to use when encoding the image.
74-
/// For the lowest supported quality, pass `0.0`. For the highest
75-
/// supported quality, pass `1.0`.
61+
/// - imageFormat: The image format with which to encode `attachableValue`.
7662
/// - sourceLocation: The source location of the call to this function.
7763
///
7864
/// This function creates a new instance of ``Attachment`` wrapping `image`
@@ -85,26 +71,20 @@ extension Attachment {
8571
/// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage)
8672
/// (macOS)
8773
///
88-
/// The testing library uses the image format specified by `contentType`. Pass
74+
/// The testing library uses the image format specified by `imageFormat`. Pass
8975
/// `nil` to let the testing library decide which image format to use. If you
9076
/// pass `nil`, then the image format that the testing library uses depends on
9177
/// the path extension you specify in `preferredName`, if any. If you do not
9278
/// specify a path extension, or if the path extension you specify doesn't
9379
/// correspond to an image format the operating system knows how to write, the
9480
/// testing library selects an appropriate image format for you.
95-
///
96-
/// If the target image format does not support variable-quality encoding,
97-
/// the value of the `encodingQuality` argument is ignored. If `contentType`
98-
/// is not `nil` and does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image),
99-
/// the result is undefined.
10081
public static func record<T>(
10182
_ image: consuming T,
10283
named preferredName: String? = nil,
103-
as contentType: UTType? = nil,
104-
encodingQuality: Float = 1.0,
84+
as imageFormat: AttachableImageFormat? = nil,
10585
sourceLocation: SourceLocation = #_sourceLocation
10686
) where AttachableValue == _AttachableImageWrapper<T> {
107-
let attachment = Self(image, named: preferredName, as: contentType, encodingQuality: encodingQuality, sourceLocation: sourceLocation)
87+
let attachment = Self(image, named: preferredName, as: imageFormat, sourceLocation: sourceLocation)
10888
Self.record(attachment, sourceLocation: sourceLocation)
10989
}
11090
}

Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift

Lines changed: 10 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
//
1010

1111
#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
12-
public import Testing
12+
@_spi(Experimental) public import Testing
1313
private import CoreGraphics
1414

1515
private import ImageIO
@@ -60,49 +60,12 @@ public struct _AttachableImageWrapper<Image>: Sendable where Image: AttachableAs
6060
/// instances of this type it creates hold "safe" `NSImage` instances.
6161
nonisolated(unsafe) var image: Image
6262

63-
/// The encoding quality to use when encoding the represented image.
64-
var encodingQuality: Float
63+
/// The image format to use when encoding the represented image.
64+
var imageFormat: AttachableImageFormat?
6565

66-
/// Storage for ``contentType``.
67-
private var _contentType: UTType?
68-
69-
/// The content type to use when encoding the image.
70-
///
71-
/// The testing library uses this property to determine which image format to
72-
/// encode the associated image as when it is attached to a test.
73-
///
74-
/// If the value of this property does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image),
75-
/// the result is undefined.
76-
var contentType: UTType {
77-
get {
78-
_contentType ?? .image
79-
}
80-
set {
81-
precondition(
82-
newValue.conforms(to: .image),
83-
"An image cannot be attached as an instance of type '\(newValue.identifier)'. Use a type that conforms to 'public.image' instead."
84-
)
85-
_contentType = newValue
86-
}
87-
}
88-
89-
/// The content type to use when encoding the image, substituting a concrete
90-
/// type for `UTType.image`.
91-
///
92-
/// This property is not part of the public interface of the testing library.
93-
var computedContentType: UTType {
94-
if contentType == .image {
95-
return encodingQuality < 1.0 ? .jpeg : .png
96-
}
97-
return contentType
98-
}
99-
100-
init(image: Image, encodingQuality: Float, contentType: UTType?) {
66+
init(image: Image, imageFormat: AttachableImageFormat?) {
10167
self.image = image._makeCopyForAttachment()
102-
self.encodingQuality = encodingQuality
103-
if let contentType {
104-
self.contentType = contentType
105-
}
68+
self.imageFormat = imageFormat
10669
}
10770
}
10871

@@ -121,16 +84,16 @@ extension _AttachableImageWrapper: AttachableWrapper {
12184
let attachableCGImage = try image.attachableCGImage
12285

12386
// Create the image destination.
124-
let typeIdentifier = computedContentType.identifier as CFString
125-
guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, typeIdentifier, 1, nil) else {
87+
let contentType = AttachableImageFormat.computeContentType(for: imageFormat, withPreferredName: attachment.preferredName)
88+
guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, contentType.identifier as CFString, 1, nil) else {
12689
throw ImageAttachmentError.couldNotCreateImageDestination
12790
}
12891

12992
// Configure the properties of the image conversion operation.
13093
let orientation = image._attachmentOrientation
13194
let scaleFactor = image._attachmentScaleFactor
13295
let properties: [CFString: Any] = [
133-
kCGImageDestinationLossyCompressionQuality: CGFloat(encodingQuality),
96+
kCGImageDestinationLossyCompressionQuality: CGFloat(imageFormat?.encodingQuality ?? 1.0),
13497
kCGImagePropertyOrientation: orientation,
13598
kCGImagePropertyDPIWidth: 72.0 * scaleFactor,
13699
kCGImagePropertyDPIHeight: 72.0 * scaleFactor,
@@ -151,7 +114,8 @@ extension _AttachableImageWrapper: AttachableWrapper {
151114
}
152115

153116
public borrowing func preferredName(for attachment: borrowing Attachment<Self>, basedOn suggestedName: String) -> String {
154-
(suggestedName as NSString).appendingPathExtension(for: computedContentType)
117+
let contentType = AttachableImageFormat.computeContentType(for: imageFormat, withPreferredName: suggestedName)
118+
return (suggestedName as NSString).appendingPathExtension(for: contentType)
155119
}
156120
}
157121
#endif

0 commit comments

Comments
 (0)