diff --git a/Package.swift b/Package.swift index e95345b..921cbe9 100644 --- a/Package.swift +++ b/Package.swift @@ -43,6 +43,7 @@ let package = Package( .process("iphone5.jpg"), .process("Pixls/DNG/hdrmerge-bayer-fp16-w-pred-deflate_.dng"), .process("Pixls/X3F/DP2M1726_.X3F"), + .process("outline-invert_2x.png") ] ), ] diff --git a/Sources/Carpaccio/CGImage+Extensions.swift b/Sources/Carpaccio/CGImage+Extensions.swift index 4285231..c75de94 100644 --- a/Sources/Carpaccio/CGImage+Extensions.swift +++ b/Sources/Carpaccio/CGImage+Extensions.swift @@ -9,6 +9,7 @@ import Foundation import CoreGraphics import ImageIO +import Accelerate #if os(iOS) import MobileCoreServices @@ -106,7 +107,7 @@ public extension CGImage { throw CGImageExtensionError.failedToLoadCGImage } - if let colorSpace = colorSpace { + if let colorSpace = colorSpace, colorSpace != cgImage.colorSpace { return try cgImage.convertedToColorSpace(colorSpace) } return cgImage @@ -157,10 +158,39 @@ public extension CGImage { return pngData } - func convertedToColorSpace(_ colorSpace: CGColorSpace) throws -> CGImage { - guard let convertedImage = self.copy(colorSpace: colorSpace) else { - throw CGImageExtensionError.failedToConvertColorSpace + /// Return a copy of the image converted to the target color space. + /// + /// Callee is responsible for checking that the colour space of the source image is not already the target color space. + func convertedToColorSpace(_ colorSpace: CGColorSpace, renderingIntent: CGColorRenderingIntent = .defaultIntent) + throws -> CGImage { + precondition(self.colorSpace != colorSpace) + guard + let sourceImageFormat = vImage_CGImageFormat(cgImage: self), + let destinationImageFormat = vImage_CGImageFormat( + // TODO: Generalise this to work with arbitrary color space bit depths + bitsPerComponent: 8, + // + 1 for alpha (TODO: generalise over alpha / non-alpha channeled) + bitsPerPixel: 8 * (colorSpace.numberOfComponents + 1), + colorSpace: colorSpace, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.first.rawValue), + renderingIntent: renderingIntent) else { + throw CGImageExtensionError.failedToConvertColorSpace + } + + let sourceBuffer = try vImage_Buffer(cgImage: self) + var destinationBuffer = try vImage_Buffer(width: Int(sourceBuffer.width), + height: Int(sourceBuffer.height), + bitsPerPixel: destinationImageFormat.bitsPerPixel) + + let converter = try vImageConverter.make(sourceFormat: sourceImageFormat, + destinationFormat: destinationImageFormat) + try converter.convert(source: sourceBuffer, destination: &destinationBuffer) + + defer { + sourceBuffer.free() + destinationBuffer.free() } - return convertedImage + + return try destinationBuffer.createCGImage(format: destinationImageFormat) } } diff --git a/Sources/Carpaccio/ImageLoader.swift b/Sources/Carpaccio/ImageLoader.swift index d20a9da..3caf1fc 100644 --- a/Sources/Carpaccio/ImageLoader.swift +++ b/Sources/Carpaccio/ImageLoader.swift @@ -241,8 +241,11 @@ public class ImageLoader: ImageLoaderProtocol, URLBackedImageLoaderProtocol { try stopIfCancelled(cancelChecker, "Before converting color space of thumbnail image") - let image = try thumbnail.convertedToColorSpace(colorSpace) - return image + if thumbnail.colorSpace != colorSpace { + let image = try thumbnail.convertedToColorSpace(colorSpace) + return image + } + return thumbnail }() // Crop letterboxing out, if needed diff --git a/Tests/CarpaccioTests/CarpaccioTests.swift b/Tests/CarpaccioTests/CarpaccioTests.swift index 33be9db..3697939 100644 --- a/Tests/CarpaccioTests/CarpaccioTests.swift +++ b/Tests/CarpaccioTests/CarpaccioTests.swift @@ -296,15 +296,38 @@ class CarpaccioTests: XCTestCase { XCTAssertEqual(map[image3], "walrus") // Simulate moving images to a Keepers subdirectory - let newURL1 = originalURL1.deletingLastPathComponent().appendingPathComponent("Keepers").appendingPathComponent(originalURL1.lastPathComponent) + let newURL1 = originalURL1 + .deletingLastPathComponent() + .appendingPathComponent("Keepers") + .appendingPathComponent(originalURL1.lastPathComponent) image1.updateURL(newURL1) - let newURL2 = originalURL1.deletingLastPathComponent().appendingPathComponent("Keepers").appendingPathComponent(originalURL2.lastPathComponent) + + let newURL2 = originalURL1 + .deletingLastPathComponent() + .appendingPathComponent("Keepers") + .appendingPathComponent(originalURL2.lastPathComponent) image2.updateURL(newURL2) - let newURL3 = originalURL1.deletingLastPathComponent().appendingPathComponent("Keepers").appendingPathComponent(originalURL3.lastPathComponent) + + let newURL3 = originalURL1.deletingLastPathComponent() + .appendingPathComponent("Keepers") + .appendingPathComponent(originalURL3.lastPathComponent) image3.updateURL(newURL3) XCTAssertEqual(map[image1], "cat") XCTAssertEqual(map[image2], "dog") XCTAssertEqual(map[image3], "walrus") } + + func testColorSpaceConversion() throws { + guard let url = Bundle.module.url(forResource: "outline-invert_2x", withExtension: "png") else { + XCTAssert(false) + return + } + + let dataProvider = CGDataProvider(filename: url.path)! + let image = CGImage(pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true, intent: .defaultIntent)! + XCTAssertEqual(image.colorSpace!.name, CGColorSpace.genericGrayGamma2_2) + let convertedImage = try image.convertedToColorSpace(CGColorSpace(name: CGColorSpace.sRGB)!, renderingIntent: .perceptual) + XCTAssertEqual(convertedImage.colorSpace!.name, CGColorSpace.sRGB) + } } diff --git a/Tests/CarpaccioTests/outline-invert_2x.png b/Tests/CarpaccioTests/outline-invert_2x.png new file mode 100644 index 0000000..32dd1b3 Binary files /dev/null and b/Tests/CarpaccioTests/outline-invert_2x.png differ