diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift index 08c94823c..b470534df 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift @@ -31,6 +31,7 @@ private import ImageIO /// 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) +@available(_uttypesAPI, *) public protocol AttachableAsCGImage { /// An instance of `CGImage` representing this image. /// @@ -73,6 +74,7 @@ public protocol AttachableAsCGImage { func _makeCopyForAttachment() -> Self } +@available(_uttypesAPI, *) extension AttachableAsCGImage { public var _attachmentOrientation: UInt32 { CGImagePropertyOrientation.up.rawValue @@ -83,6 +85,7 @@ extension AttachableAsCGImage { } } +@available(_uttypesAPI, *) extension AttachableAsCGImage where Self: Sendable { public func _makeCopyForAttachment() -> Self { self diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableImageFormat+UTType.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableImageFormat+UTType.swift new file mode 100644 index 000000000..41f7e5998 --- /dev/null +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableImageFormat+UTType.swift @@ -0,0 +1,100 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–2025 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(Experimental) public import Testing + +public import UniformTypeIdentifiers + +@available(_uttypesAPI, *) +extension AttachableImageFormat { + /// Get the content type to use when encoding the image, substituting a + /// concrete type for `UTType.image` in particular. + /// + /// - Parameters: + /// - imageFormat: The image format to use, or `nil` if the developer did + /// not specify one. + /// - preferredName: The preferred name of the image for which a type is + /// needed. + /// + /// - Returns: An instance of `UTType` referring to a concrete image type. + /// + /// This function is not part of the public interface of the testing library. + static func computeContentType(for imageFormat: Self?, withPreferredName preferredName: String) -> UTType { + guard let imageFormat else { + // The developer didn't specify a type. Substitute the generic `.image` + // and solve for that instead. + return computeContentType(for: Self(.image, encodingQuality: 1.0), withPreferredName: preferredName) + } + + switch imageFormat.kind { + case .png: + return .png + case .jpeg: + return .jpeg + case let .systemValue(contentType): + let contentType = contentType as! UTType + if contentType != .image { + // The developer explicitly specified a type. + return contentType + } + + // The developer didn't specify a concrete type, so try to derive one from + // the preferred name's path extension. + let pathExtension = (preferredName as NSString).pathExtension + if !pathExtension.isEmpty, + let contentType = UTType(filenameExtension: pathExtension, conformingTo: .image), + contentType.isDeclared { + return contentType + } + + // We couldn't derive a concrete type from the path extension, so pick + // between PNG and JPEG based on the encoding quality. + return imageFormat.encodingQuality < 1.0 ? .jpeg : .png + } + } + + /// The content type corresponding to this image format. + /// + /// The value of this property always conforms to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image). + public var contentType: UTType { + switch kind { + case .png: + return .png + case .jpeg: + return .jpeg + case let .systemValue(contentType): + return contentType as! UTType + } + } + + /// Initialize an instance of this type with the given content type and + /// encoding quality. + /// + /// - Parameters: + /// - contentType: The image format to use when encoding images. + /// - encodingQuality: The encoding quality to use when encoding images. For + /// the lowest supported quality, pass `0.0`. For the highest supported + /// quality, pass `1.0`. + /// + /// If the target image format does not support variable-quality encoding, + /// the value of the `encodingQuality` argument is ignored. + /// + /// If `imageFormat` is not `nil` and does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), + /// the result is undefined. + public init(_ contentType: UTType, encodingQuality: Float = 1.0) { + precondition( + contentType.conforms(to: .image), + "An image cannot be attached as an instance of type '\(contentType.identifier)'. Use a type that conforms to 'public.image' instead." + ) + self.init(kind: .systemValue(contentType), encodingQuality: encodingQuality) + } +} +#endif diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift index 6c9a76dd5..7e6836ef5 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift @@ -11,8 +11,6 @@ #if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) @_spi(Experimental) public import Testing -public import UniformTypeIdentifiers - @_spi(Experimental) @available(_uttypesAPI, *) extension Attachment { @@ -24,10 +22,7 @@ extension Attachment { /// - 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`. - /// - encodingQuality: The encoding quality to use when encoding the image. - /// For the lowest supported quality, pass `0.0`. For the highest - /// supported quality, pass `1.0`. + /// - imageFormat: The image format with which to encode `attachableValue`. /// - sourceLocation: The source location of the call to this initializer. /// This value is used when recording issues associated with the /// attachment. @@ -39,26 +34,20 @@ extension Attachment { /// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) /// (macOS) /// - /// The testing library uses the image format specified by `contentType`. Pass + /// The testing library uses the image format specified by `imageFormat`. Pass /// `nil` to let the testing library decide which image format to use. If you /// pass `nil`, then the image format that the testing library uses depends on /// the path extension you specify in `preferredName`, if any. If you do not /// specify a path extension, or if the path extension you specify doesn't /// correspond to an image format the operating system knows how to write, the /// testing library selects an appropriate image format for you. - /// - /// If the target image format does not support variable-quality encoding, - /// the value of the `encodingQuality` argument is ignored. If `contentType` - /// is not `nil` and does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), - /// the result is undefined. public init( _ attachableValue: T, named preferredName: String? = nil, - as contentType: UTType? = nil, - encodingQuality: Float = 1.0, + as imageFormat: AttachableImageFormat? = nil, sourceLocation: SourceLocation = #_sourceLocation ) where AttachableValue == _AttachableImageWrapper { - let imageWrapper = _AttachableImageWrapper(image: attachableValue, encodingQuality: encodingQuality, contentType: contentType) + let imageWrapper = _AttachableImageWrapper(image: attachableValue, imageFormat: imageFormat) self.init(imageWrapper, named: preferredName, sourceLocation: sourceLocation) } @@ -69,10 +58,7 @@ extension Attachment { /// - 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`. - /// - encodingQuality: The encoding quality to use when encoding the image. - /// For the lowest supported quality, pass `0.0`. For the highest - /// supported quality, pass `1.0`. + /// - imageFormat: The image format with which to encode `attachableValue`. /// - sourceLocation: The source location of the call to this function. /// /// This function creates a new instance of ``Attachment`` wrapping `image` @@ -85,26 +71,20 @@ extension Attachment { /// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage) /// (macOS) /// - /// The testing library uses the image format specified by `contentType`. Pass + /// The testing library uses the image format specified by `imageFormat`. Pass /// `nil` to let the testing library decide which image format to use. If you /// pass `nil`, then the image format that the testing library uses depends on /// the path extension you specify in `preferredName`, if any. If you do not /// specify a path extension, or if the path extension you specify doesn't /// correspond to an image format the operating system knows how to write, the /// testing library selects an appropriate image format for you. - /// - /// If the target image format does not support variable-quality encoding, - /// the value of the `encodingQuality` argument is ignored. If `contentType` - /// is not `nil` and does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), - /// the result is undefined. public static func record( _ image: consuming T, named preferredName: String? = nil, - as contentType: UTType? = nil, - encodingQuality: Float = 1.0, + as imageFormat: AttachableImageFormat? = nil, sourceLocation: SourceLocation = #_sourceLocation ) where AttachableValue == _AttachableImageWrapper { - let attachment = Self(image, named: preferredName, as: contentType, encodingQuality: encodingQuality, sourceLocation: sourceLocation) + let attachment = Self(image, named: preferredName, as: imageFormat, sourceLocation: sourceLocation) Self.record(attachment, sourceLocation: sourceLocation) } } diff --git a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift index f61b17e7d..f109e3409 100644 --- a/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift +++ b/Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift @@ -9,7 +9,7 @@ // #if SWT_TARGET_OS_APPLE && canImport(CoreGraphics) -public import Testing +@_spi(Experimental) public import Testing private import CoreGraphics private import ImageIO @@ -60,49 +60,12 @@ public struct _AttachableImageWrapper: Sendable where Image: AttachableAs /// instances of this type it creates hold "safe" `NSImage` instances. nonisolated(unsafe) var image: Image - /// The encoding quality to use when encoding the represented image. - var encodingQuality: Float + /// The image format to use when encoding the represented image. + var imageFormat: AttachableImageFormat? - /// Storage for ``contentType``. - private var _contentType: UTType? - - /// The content type to use when encoding the image. - /// - /// The testing library uses this property to determine which image format to - /// encode the associated image as when it is attached to a test. - /// - /// If the value of this property does not conform to [`UTType.image`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/image), - /// the result is undefined. - var contentType: UTType { - get { - _contentType ?? .image - } - set { - precondition( - newValue.conforms(to: .image), - "An image cannot be attached as an instance of type '\(newValue.identifier)'. Use a type that conforms to 'public.image' instead." - ) - _contentType = newValue - } - } - - /// The content type to use when encoding the image, substituting a concrete - /// type for `UTType.image`. - /// - /// This property is not part of the public interface of the testing library. - var computedContentType: UTType { - if contentType == .image { - return encodingQuality < 1.0 ? .jpeg : .png - } - return contentType - } - - init(image: Image, encodingQuality: Float, contentType: UTType?) { + init(image: Image, imageFormat: AttachableImageFormat?) { self.image = image._makeCopyForAttachment() - self.encodingQuality = encodingQuality - if let contentType { - self.contentType = contentType - } + self.imageFormat = imageFormat } } @@ -121,8 +84,8 @@ extension _AttachableImageWrapper: AttachableWrapper { let attachableCGImage = try image.attachableCGImage // Create the image destination. - let typeIdentifier = computedContentType.identifier as CFString - guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, typeIdentifier, 1, nil) else { + let contentType = AttachableImageFormat.computeContentType(for: imageFormat, withPreferredName: attachment.preferredName) + guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, contentType.identifier as CFString, 1, nil) else { throw ImageAttachmentError.couldNotCreateImageDestination } @@ -130,7 +93,7 @@ extension _AttachableImageWrapper: AttachableWrapper { let orientation = image._attachmentOrientation let scaleFactor = image._attachmentScaleFactor let properties: [CFString: Any] = [ - kCGImageDestinationLossyCompressionQuality: CGFloat(encodingQuality), + kCGImageDestinationLossyCompressionQuality: CGFloat(imageFormat?.encodingQuality ?? 1.0), kCGImagePropertyOrientation: orientation, kCGImagePropertyDPIWidth: 72.0 * scaleFactor, kCGImagePropertyDPIHeight: 72.0 * scaleFactor, @@ -151,7 +114,8 @@ extension _AttachableImageWrapper: AttachableWrapper { } public borrowing func preferredName(for attachment: borrowing Attachment, basedOn suggestedName: String) -> String { - (suggestedName as NSString).appendingPathExtension(for: computedContentType) + let contentType = AttachableImageFormat.computeContentType(for: imageFormat, withPreferredName: suggestedName) + return (suggestedName as NSString).appendingPathExtension(for: contentType) } } #endif diff --git a/Sources/Testing/Attachments/AttachableImageFormat.swift b/Sources/Testing/Attachments/AttachableImageFormat.swift new file mode 100644 index 000000000..afb74c80e --- /dev/null +++ b/Sources/Testing/Attachments/AttachableImageFormat.swift @@ -0,0 +1,93 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024–2025 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 +// + +/// A type describing image formats supported by the system that can be used +/// when attaching an image to a test. +/// +/// When you attach an image to a test, you can pass an instance of this type to +/// ``Attachment/record(_:named:as:sourceLocation:)`` so that the testing +/// library knows the image format you'd like to use. If you don't pass an +/// instance of this type, the testing library infers which format to use based +/// on the attachment's preferred name. +/// +/// The PNG and JPEG image formats are always supported. The set of additional +/// supported image formats is platform-specific: +/// +/// - On Apple platforms, you can use [`CGImageDestinationCopyTypeIdentifiers()`](https://developer.apple.com/documentation/imageio/cgimagedestinationcopytypeidentifiers()) +/// from the [Image I/O framework](https://developer.apple.com/documentation/imageio) +/// to determine which formats are supported. +@_spi(Experimental) +@available(_uttypesAPI, *) +public struct AttachableImageFormat: Sendable { + /// An enumeration describing the various kinds of image format that can be + /// used with an attachment. + package enum Kind: Sendable { + /// The (widely-supported) PNG image format. + case png + + /// The (widely-supported) JPEG image format. + case jpeg + + /// A platform-specific image format. + /// + /// - Parameters: + /// - value: A platform-specific value representing the image format to + /// use. The platform-specific cross-import overlay or package is + /// responsible for exposing appropriate interfaces for this case. + /// + /// On Apple platforms, `value` should be an instance of `UTType`. + case systemValue(_ value: any Sendable) + } + + /// The kind of image format represented by this instance. + package var kind: Kind + + /// The encoding quality to use for this image format. + /// + /// The meaning of the value is format-specific with `0.0` being the lowest + /// supported encoding quality and `1.0` being the highest supported encoding + /// quality. The value of this property is ignored for image formats that do + /// not support variable encoding quality. + public internal(set) var encodingQuality: Float = 1.0 + + package init(kind: Kind, encodingQuality: Float) { + self.kind = kind + self.encodingQuality = min(max(0.0, encodingQuality), 1.0) + } +} + +// MARK: - + +@available(_uttypesAPI, *) +extension AttachableImageFormat { + /// The PNG image format. + public static var png: Self { + Self(kind: .png, encodingQuality: 1.0) + } + + /// The JPEG image format with maximum encoding quality. + public static var jpeg: Self { + Self(kind: .jpeg, encodingQuality: 1.0) + } + + /// The JPEG image format. + /// + /// - Parameters: + /// - encodingQuality: The encoding quality to use when serializing an + /// image. A value of `0.0` indicates the lowest supported encoding + /// quality and a value of `1.0` indicates the highest supported encoding + /// quality. + /// + /// - Returns: An instance of this type representing the JPEG image format + /// with the specified encoding quality. + public static func jpeg(withEncodingQuality encodingQuality: Float) -> Self { + Self(kind: .jpeg, encodingQuality: encodingQuality) + } +} diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index 4b16d98ea..bb1573bb9 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -562,7 +562,8 @@ extension AttachmentTests { @Test(arguments: [Float(0.0).nextUp, 0.25, 0.5, 0.75, 1.0], [.png as UTType?, .jpeg, .gif, .image, nil]) func attachCGImage(quality: Float, type: UTType?) throws { let image = try Self.cgImage.get() - let attachment = Attachment(image, named: "diamond", as: type, encodingQuality: quality) + let format = type.map { AttachableImageFormat($0, encodingQuality: quality) } + let attachment = Attachment(image, named: "diamond", as: format) #expect(attachment.attachableValue === image) try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in #expect(buffer.count > 32) @@ -572,11 +573,26 @@ extension AttachmentTests { } } + @available(_uttypesAPI, *) + @Test(arguments: [AttachableImageFormat.png, .jpeg, .jpeg(withEncodingQuality: 0.5), .init(.tiff)]) + func attachCGImage(format: AttachableImageFormat) throws { + let image = try Self.cgImage.get() + let attachment = Attachment(image, named: "diamond", as: format) + #expect(attachment.attachableValue === image) + try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in + #expect(buffer.count > 32) + } + if let ext = format.contentType.preferredFilenameExtension { + #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) + } + } + #if !SWT_NO_EXIT_TESTS @available(_uttypesAPI, *) @Test func cannotAttachCGImageWithNonImageType() async { await #expect(processExitsWith: .failure) { - let attachment = Attachment(try Self.cgImage.get(), named: "diamond", as: .mp3) + let format = AttachableImageFormat(.mp3) + let attachment = Attachment(try Self.cgImage.get(), named: "diamond", as: format) try attachment.attachableValue.withUnsafeBytes(for: attachment) { _ in } } }