Skip to content

Commit

Permalink
Add a CoreGraphics cross-import overlay with support for attaching `C…
Browse files Browse the repository at this point in the history
…GImage`s. (#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.
  • Loading branch information
grynspan authored Dec 10, 2024
1 parent a4ed760 commit 906092d
Show file tree
Hide file tree
Showing 8 changed files with 568 additions and 0 deletions.
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ let package = Package(
name: "TestingTests",
dependencies: [
"Testing",
"_Testing_CoreGraphics",
"_Testing_Foundation",
],
swiftSettings: .packageSettings
Expand Down Expand Up @@ -91,6 +92,14 @@ let package = Package(
),

// Cross-import overlays (not supported by Swift Package Manager)
.target(
name: "_Testing_CoreGraphics",
dependencies: [
"Testing",
],
path: "Sources/Overlays/_Testing_CoreGraphics",
swiftSettings: .packageSettings
),
.target(
name: "_Testing_Foundation",
dependencies: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
public import CoreGraphics
private import ImageIO

/// A protocol describing images that can be converted to instances of
/// ``Testing/Attachment``.
///
/// Instances of types conforming to this protocol do not themselves conform to
/// ``Testing/Attachable``. Instead, the testing library provides additional
/// initializers on ``Testing/Attachment`` that take instances of such types and
/// handle converting them to image data when needed.
///
/// The following system-provided image types conform to this protocol and can
/// be attached to a test:
///
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
///
/// You do not generally need to add your own conformances to this protocol. If
/// you have an image in another format that needs to be attached to a test,
/// first convert it to an instance of one of the types above.
@_spi(Experimental)
public protocol AttachableAsCGImage {
/// An instance of `CGImage` representing this image.
///
/// - Throws: Any error that prevents the creation of an image.
var attachableCGImage: CGImage { get throws }

/// The orientation of the image.
///
/// The value of this property is the raw value of an instance of
/// `CGImagePropertyOrientation`. The default value of this property is
/// `.up`.
///
/// This property is not part of the public interface of the testing
/// library. It may be removed in a future update.
var _attachmentOrientation: UInt32 { get }

/// The scale factor of the image.
///
/// The value of this property is typically greater than `1.0` when an image
/// originates from a Retina Display screenshot or similar. The default value
/// of this property is `1.0`.
///
/// This property is not part of the public interface of the testing
/// library. It may be removed in a future update.
var _attachmentScaleFactor: CGFloat { get }

/// Make a copy of this instance to pass to an attachment.
///
/// - Returns: A copy of `self`, or `self` if no copy is needed.
///
/// Several system image types do not conform to `Sendable`; use this
/// function to make copies of such images that will not be shared outside
/// of an attachment and so can be generally safely stored.
///
/// The default implementation of this function when `Self` conforms to
/// `Sendable` simply returns `self`.
///
/// This function is not part of the public interface of the testing library.
/// It may be removed in a future update.
func _makeCopyForAttachment() -> Self
}

extension AttachableAsCGImage {
public var _attachmentOrientation: UInt32 {
CGImagePropertyOrientation.up.rawValue
}

public var _attachmentScaleFactor: CGFloat {
1.0
}
}

extension AttachableAsCGImage where Self: Sendable {
public func _makeCopyForAttachment() -> Self {
self
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
@_spi(ForSwiftTestingOnly) @_spi(Experimental) public import Testing

public import UniformTypeIdentifiers

extension Attachment {
/// Initialize an instance of this type that encloses the given image.
///
/// - Parameters:
/// - attachableValue: The value that will be attached to the output of
/// the test run.
/// - preferredName: The preferred name of the attachment when writing it
/// to a test report or to disk. If `nil`, the testing library attempts
/// to derive a reasonable filename for the attached value.
/// - contentType: The image format with which to encode `attachableValue`.
/// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image),
/// the result is undefined. Pass `nil` to let the testing library decide
/// which image format to use.
/// - encodingQuality: The encoding quality to use when encoding the image.
/// If the image format used for encoding (specified by the `contentType`
/// argument) does not support variable-quality encoding, the value of
/// this argument is ignored.
/// - sourceLocation: The source location of the call to this initializer.
/// This value is used when recording issues associated with the
/// attachment.
///
/// This is the designated initializer for this type when attaching an image
/// that conforms to ``AttachableAsCGImage``.
fileprivate init<T>(
attachableValue: T,
named preferredName: String?,
contentType: (any Sendable)?,
encodingQuality: Float,
sourceLocation: SourceLocation
) where AttachableValue == _AttachableImageContainer<T> {
var imageContainer = _AttachableImageContainer(image: attachableValue, encodingQuality: encodingQuality)

// Update the preferred name to include an extension appropriate for the
// given content type. (Note the `else` branch duplicates the logic in
// `preferredContentType(forEncodingQuality:)` but will go away once our
// minimum deployment targets include the UniformTypeIdentifiers framework.)
var preferredName = preferredName ?? Self.defaultPreferredName
if #available(_uttypesAPI, *) {
let contentType: UTType = contentType
.map { $0 as! UTType }
.flatMap { contentType in
if UTType.image.conforms(to: contentType) {
// This type is an abstract base type of .image (or .image itself.)
// We'll infer the concrete type based on other arguments.
return nil
}
return contentType
} ?? .preferred(forEncodingQuality: encodingQuality)
preferredName = (preferredName as NSString).appendingPathExtension(for: contentType)
imageContainer.contentType = contentType
} else {
// The caller can't provide a content type, so we'll pick one for them.
let ext = if encodingQuality < 1.0 {
"jpg"
} else {
"png"
}
if (preferredName as NSString).pathExtension.caseInsensitiveCompare(ext) != .orderedSame {
preferredName = (preferredName as NSString).appendingPathExtension(ext) ?? preferredName
}
}

self.init(imageContainer, named: preferredName, sourceLocation: sourceLocation)
}

/// Initialize an instance of this type that encloses the given image.
///
/// - Parameters:
/// - attachableValue: The value that will be attached to the output of
/// the test run.
/// - preferredName: The preferred name of the attachment when writing it
/// to a test report or to disk. If `nil`, the testing library attempts
/// to derive a reasonable filename for the attached value.
/// - contentType: The image format with which to encode `attachableValue`.
/// If this type does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image),
/// the result is undefined. Pass `nil` to let the testing library decide
/// which image format to use.
/// - encodingQuality: The encoding quality to use when encoding the image.
/// If the image format used for encoding (specified by the `contentType`
/// argument) does not support variable-quality encoding, the value of
/// this argument is ignored.
/// - sourceLocation: The source location of the call to this initializer.
/// This value is used when recording issues associated with the
/// attachment.
///
/// The following system-provided image types conform to the
/// ``AttachableAsCGImage`` protocol and can be attached to a test:
///
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
@_spi(Experimental)
@available(_uttypesAPI, *)
public init<T>(
_ attachableValue: T,
named preferredName: String? = nil,
as contentType: UTType?,
encodingQuality: Float = 1.0,
sourceLocation: SourceLocation = #_sourceLocation
) where AttachableValue == _AttachableImageContainer<T> {
self.init(attachableValue: attachableValue, named: preferredName, contentType: contentType, encodingQuality: encodingQuality, sourceLocation: sourceLocation)
}

/// Initialize an instance of this type that encloses the given image.
///
/// - Parameters:
/// - attachableValue: The value that will be attached to the output of
/// the test run.
/// - preferredName: The preferred name of the attachment when writing it
/// to a test report or to disk. If `nil`, the testing library attempts
/// to derive a reasonable filename for the attached value.
/// - encodingQuality: The encoding quality to use when encoding the image.
/// If the image format used for encoding (specified by the `contentType`
/// argument) does not support variable-quality encoding, the value of
/// this argument is ignored.
/// - sourceLocation: The source location of the call to this initializer.
/// This value is used when recording issues associated with the
/// attachment.
///
/// The following system-provided image types conform to the
/// ``AttachableAsCGImage`` protocol and can be attached to a test:
///
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
@_spi(Experimental)
public init<T>(
_ attachableValue: T,
named preferredName: String? = nil,
encodingQuality: Float = 1.0,
sourceLocation: SourceLocation = #_sourceLocation
) where AttachableValue == _AttachableImageContainer<T> {
self.init(attachableValue: attachableValue, named: preferredName, contentType: nil, encodingQuality: encodingQuality, sourceLocation: sourceLocation)
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
public import CoreGraphics

@_spi(Experimental)
extension CGImage: AttachableAsCGImage {
public var attachableCGImage: CGImage {
self
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

#if SWT_TARGET_OS_APPLE && canImport(CoreGraphics)
/// A type representing an error that can occur when attaching an image.
@_spi(ForSwiftTestingOnly)
public enum ImageAttachmentError: Error, CustomStringConvertible {
/// The specified content type did not conform to `.image`.
case contentTypeDoesNotConformToImage

/// The image could not be converted to an instance of `CGImage`.
case couldNotCreateCGImage

/// The image destination could not be created.
case couldNotCreateImageDestination

/// The image could not be converted.
case couldNotConvertImage

@_spi(ForSwiftTestingOnly)
public var description: String {
switch self {
case .contentTypeDoesNotConformToImage:
"The specified type does not represent an image format."
case .couldNotCreateCGImage:
"Could not create the corresponding Core Graphics image."
case .couldNotCreateImageDestination:
"Could not create the Core Graphics image destination to encode this image."
case .couldNotConvertImage:
"Could not convert the image to the specified format."
}
}
}
#endif
Loading

0 comments on commit 906092d

Please sign in to comment.