From 99e362c3a9c18d741af856a565225ffe812febe2 Mon Sep 17 00:00:00 2001 From: Josh Pyles Date: Thu, 17 Sep 2026 09:29:55 -0700 Subject: [PATCH 1/5] Fix alpha encoding in extended-range PNG snapshots --- .../SnapshotPreviewsCore/UIImage+EMG.swift | 22 ++- .../UIImagePNGEncodingTests.swift | 134 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift diff --git a/Sources/SnapshotPreviewsCore/UIImage+EMG.swift b/Sources/SnapshotPreviewsCore/UIImage+EMG.swift index ae2b65c3..f3d1ae67 100644 --- a/Sources/SnapshotPreviewsCore/UIImage+EMG.swift +++ b/Sources/SnapshotPreviewsCore/UIImage+EMG.swift @@ -6,7 +6,9 @@ // #if canImport(UIKit) +import ImageIO import UIKit +import UniformTypeIdentifiers public extension UIImage { var emg: UIImageSnapshotsNamespace { @@ -21,7 +23,25 @@ public extension UIImage { } public func pngData() -> Data? { - image.pngData() + guard let cgImage = image.cgImage else { + return image.pngData() + } + + // UIImage.pngData() can leave 16-bit colors premultiplied by alpha, + // which creates dark edges when the PNG is displayed. + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { + return nil + } + let properties: [CFString: Any] = [ + kCGImagePropertyDPIWidth: image.scale * 72, + kCGImagePropertyDPIHeight: image.scale * 72, + ] + CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) else { + return nil + } + return data as Data } } } diff --git a/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift new file mode 100644 index 00000000..640e5084 --- /dev/null +++ b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift @@ -0,0 +1,134 @@ +#if canImport(UIKit) +import ImageIO +import UIKit +import XCTest +import zlib +@testable import SnapshotPreviewsCore + +final class UIImagePNGEncodingTests: XCTestCase { + func testExtendedRangePNGPreservesColorAndAlpha() throws { + let format = UIGraphicsImageRendererFormat() + format.scale = 3 + format.preferredRange = .extended + // Use a screen-sized image: UIKit's PNG encoding path depends on image size. + let image = UIGraphicsImageRenderer(size: CGSize(width: 402, height: 874), format: format).image { context in + UIColor(white: 0.96, alpha: 0.5).setFill() + context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } + let data = try XCTUnwrap(image.emg.pngData()) + let pixel = try pngPixel(data) + + // PNG stores straight color, even though the source CGImage is premultiplied. + for channel in pixel.prefix(3) { + XCTAssertEqual(Double(channel) / 65535, 0.96, accuracy: 0.001) + } + XCTAssertEqual(Double(pixel[3]) / 65535, 0.5, accuracy: 0.001) + } + + func testStandardRangePNGPreservesColorAndAlpha() throws { + let image = makeImage(range: .standard) + let decoded = try decode(image.emg.pngData()) + + try assertEqualPixels(decoded, XCTUnwrap(image.cgImage)) + } + + func testOrientedImageMatchesUIKitPNGEncoding() throws { + let original = try XCTUnwrap(makeImage(range: .standard).cgImage) + for orientation in [UIImage.Orientation.up, .down, .left, .right, .upMirrored, .downMirrored, .leftMirrored, .rightMirrored] { + let image = UIImage(cgImage: original, scale: 2, orientation: orientation) + let expected = try decode(image.pngData()) + let decoded = try decode(image.emg.pngData()) + + try assertEqualPixels(decoded, expected) + } + } + + func testEmptyImageReturnsNil() { + XCTAssertNil(UIImage().emg.pngData()) + } + + func testPNGPreservesImageResolution() throws { + let original = try XCTUnwrap(makeImage(range: .standard).cgImage) + let image = UIImage(cgImage: original, scale: 3, orientation: .up) + let data = try XCTUnwrap(image.emg.pngData()) + let source = try XCTUnwrap(CGImageSourceCreateWithData(data as CFData, nil)) + let properties = try XCTUnwrap(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + + XCTAssertEqual(try XCTUnwrap(properties[kCGImagePropertyDPIWidth] as? Double), 216, accuracy: 0.1) + XCTAssertEqual(try XCTUnwrap(properties[kCGImagePropertyDPIHeight] as? Double), 216, accuracy: 0.1) + } + + private func makeImage(range: UIGraphicsImageRendererFormat.Range) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.preferredRange = range + return UIGraphicsImageRenderer(size: CGSize(width: 4, height: 1), format: format).image { context in + UIColor(white: 0.96, alpha: 0.5).setFill() + context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + UIColor.red.withAlphaComponent(0.25).setFill() + context.fill(CGRect(x: 1, y: 0, width: 1, height: 1)) + UIColor.blue.setFill() + context.fill(CGRect(x: 2, y: 0, width: 1, height: 1)) + // Leave the last pixel transparent. + } + } + + private func decode(_ data: Data?) throws -> CGImage { + let data = try XCTUnwrap(data) + let source = try XCTUnwrap(CGImageSourceCreateWithData(data as CFData, nil)) + return try XCTUnwrap(CGImageSourceCreateImageAtIndex(source, 0, nil)) + } + + private func pngPixel(_ data: Data) throws -> [UInt16] { + // Inspect stored samples directly, independent of decoder alpha handling. + let bytes = [UInt8](data) + let width = bytes[16..<20].reduce(0) { ($0 << 8) | Int($1) } + let height = bytes[20..<24].reduce(0) { ($0 << 8) | Int($1) } + XCTAssertEqual(bytes[24], 16) + XCTAssertEqual(bytes[25], 6) + XCTAssertEqual(bytes[28], 0) + var compressed = [UInt8]() + var offset = 8 + while offset + 12 <= bytes.count { + let length = bytes[offset.. [UInt8] { + let context = try XCTUnwrap(CGContext( + data: nil, + width: image.width, + height: image.height, + bitsPerComponent: 8, + bytesPerRow: image.width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let data = try XCTUnwrap(context.data) + return Array(UnsafeBufferPointer(start: data.assumingMemoryBound(to: UInt8.self), count: image.width * image.height * 4)) + } +} +#endif From b79e735cf2d506010882827ebc485ff6eccb63e2 Mon Sep 17 00:00:00 2001 From: Jamie <2119834+jamieQ@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:09:58 -0500 Subject: [PATCH 2/5] fix(png): Preserve orientation and strengthen encoding coverage Preserve UIImage orientation metadata when exporting through ImageIO. Use small color-aware regression fixtures and a strict expected-failure check to flag a future UIKit alpha fix. Refs #281 --- .../SnapshotPreviewsCore/UIImage+EMG.swift | 32 +++++- .../UIImagePNGEncodingTests.swift | 107 +++++++++++++----- 2 files changed, 108 insertions(+), 31 deletions(-) diff --git a/Sources/SnapshotPreviewsCore/UIImage+EMG.swift b/Sources/SnapshotPreviewsCore/UIImage+EMG.swift index f3d1ae67..3fb3aaea 100644 --- a/Sources/SnapshotPreviewsCore/UIImage+EMG.swift +++ b/Sources/SnapshotPreviewsCore/UIImage+EMG.swift @@ -27,22 +27,44 @@ public extension UIImage { return image.pngData() } - // UIImage.pngData() can leave 16-bit colors premultiplied by alpha, - // which creates dark edges when the PNG is displayed. + // UIKit's 16-bit PNG path can leave colors premultiplied by alpha. + // ImageIO's default options avoid that path and the resulting dark edges. let data = NSMutableData() - guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { + guard let destination = CGImageDestinationCreateWithData( + data, UTType.png.identifier as CFString, 1, nil + ) else { return nil } + + // Preserve UIKit's resolution metadata: scale 1 corresponds to 72 DPI. + let dpi = image.scale * 72 let properties: [CFString: Any] = [ - kCGImagePropertyDPIWidth: image.scale * 72, - kCGImagePropertyDPIHeight: image.scale * 72, + kCGImagePropertyOrientation: pngOrientation.rawValue, + kCGImagePropertyDPIWidth: dpi, + kCGImagePropertyDPIHeight: dpi, ] CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) else { return nil } return data as Data } + + // UIImage and ImageIO use different orientation raw values. + private var pngOrientation: CGImagePropertyOrientation { + switch image.imageOrientation { + case .up: return .up + case .down: return .down + case .left: return .left + case .right: return .right + case .upMirrored: return .upMirrored + case .downMirrored: return .downMirrored + case .leftMirrored: return .leftMirrored + case .rightMirrored: return .rightMirrored + @unknown default: return .up + } + } } } #endif diff --git a/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift index 640e5084..6250bc7c 100644 --- a/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift +++ b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift @@ -6,23 +6,65 @@ import zlib @testable import SnapshotPreviewsCore final class UIImagePNGEncodingTests: XCTestCase { - func testExtendedRangePNGPreservesColorAndAlpha() throws { - let format = UIGraphicsImageRendererFormat() - format.scale = 3 - format.preferredRange = .extended - // Use a screen-sized image: UIKit's PNG encoding path depends on image size. - let image = UIGraphicsImageRenderer(size: CGSize(width: 402, height: 874), format: format).image { context in - UIColor(white: 0.96, alpha: 0.5).setFill() - context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + func testUIKitPNGEncodingStillRequiresAlphaWorkaround() throws { + let image = makeImage(range: .extended) + XCTAssertEqual(try XCTUnwrap(image.cgImage).bitsPerComponent, 16) + // Deliberately use UIKit directly, rather than the workaround. + let pixel = try pngPixel(XCTUnwrap(image.pngData())) + XCTAssertEqual(Double(pixel[3]) / 65535, 0.5, accuracy: 0.001) + let colorError = try XCTUnwrap(pixel.prefix(3).map { + abs(Double($0) / 65535 - 0.96) + }.max()) + + let options = XCTExpectedFailure.Options() + options.isStrict = true + // Keep fixture/format/alpha checks outside the expected failure. Only the + // known color error is expected; an unexpected pass flags a possible fix. + XCTExpectFailure( + "UIKit's extended-range PNG alpha bug: if this unexpectedly passes on a new runtime, reassess the ImageIO workaround.", + options: options + ) { + XCTAssertLessThanOrEqual(colorError, 0.001) } - let data = try XCTUnwrap(image.emg.pngData()) - let pixel = try pngPixel(data) + } - // PNG stores straight color, even though the source CGImage is premultiplied. - for channel in pixel.prefix(3) { - XCTAssertEqual(Double(channel) / 65535, 0.96, accuracy: 0.001) + func testExtendedRangePNGPreservesColorAndAlpha() throws { + // Two rows suffice to exercise UIKit's affected PNG path. Keep this + // independent of device dimensions, screen scale, and decoder behavior. + let samples: [(red: Double, green: Double, blue: Double, alpha: Double)] = [ + (0.96, 0.96, 0.96, 0.5), + (0.8, 0.3, 0.1, 0.25), + (0.2, 0.7, 0.9, 0.75), + (0.2, 0.7, 0.9, 1), + ] + for sample in samples { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + format.preferredRange = .extended + let color = UIColor(red: sample.red, green: sample.green, blue: sample.blue, alpha: sample.alpha) + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 2), format: format).image { context in + color.setFill() + context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } + let source = try XCTUnwrap(image.cgImage) + XCTAssertEqual(source.bitsPerComponent, 16) + let data = try XCTUnwrap(image.emg.pngData()) + let decoded = try decode(data) + // ImageIO may choose Display P3 for the PNG. Compare against the input + // color converted into the encoded profile, not assumed sRGB samples. + let expectedColor = try XCTUnwrap(color.cgColor.converted( + to: XCTUnwrap(decoded.colorSpace), intent: .defaultIntent, options: nil + )) + let expectedComponents = try XCTUnwrap(expectedColor.components) + XCTAssertEqual(expectedComponents.count, 4) + let pixel = try pngPixel(data) + + // PNG stores straight color, even though the source CGImage is premultiplied. + for (channel, expected) in zip(pixel, expectedComponents) { + XCTAssertEqual(Double(channel) / 65535, Double(expected), accuracy: 0.001) + } } - XCTAssertEqual(Double(pixel[3]) / 65535, 0.5, accuracy: 0.001) } func testStandardRangePNGPreservesColorAndAlpha() throws { @@ -36,10 +78,21 @@ final class UIImagePNGEncodingTests: XCTestCase { let original = try XCTUnwrap(makeImage(range: .standard).cgImage) for orientation in [UIImage.Orientation.up, .down, .left, .right, .upMirrored, .downMirrored, .leftMirrored, .rightMirrored] { let image = UIImage(cgImage: original, scale: 2, orientation: orientation) - let expected = try decode(image.pngData()) - let decoded = try decode(image.emg.pngData()) + let expectedData = try XCTUnwrap(image.pngData()) + let data = try XCTUnwrap(image.emg.pngData()) + let expected = try decode(expectedData) + let decoded = try decode(data) try assertEqualPixels(decoded, expected) + // Raw CGImage decoding ignores orientation metadata, so pixel equality + // alone would pass even if every image incorrectly reloaded as .up. + XCTAssertEqual(try XCTUnwrap(UIImage(data: data)).imageOrientation, orientation) + let properties = try pngProperties(data) + let expectedProperties = try pngProperties(expectedData) + XCTAssertEqual( + try XCTUnwrap(properties[kCGImagePropertyOrientation] as? Int), + try XCTUnwrap(expectedProperties[kCGImagePropertyOrientation] as? Int) + ) } } @@ -51,8 +104,7 @@ final class UIImagePNGEncodingTests: XCTestCase { let original = try XCTUnwrap(makeImage(range: .standard).cgImage) let image = UIImage(cgImage: original, scale: 3, orientation: .up) let data = try XCTUnwrap(image.emg.pngData()) - let source = try XCTUnwrap(CGImageSourceCreateWithData(data as CFData, nil)) - let properties = try XCTUnwrap(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + let properties = try pngProperties(data) XCTAssertEqual(try XCTUnwrap(properties[kCGImagePropertyDPIWidth] as? Double), 216, accuracy: 0.1) XCTAssertEqual(try XCTUnwrap(properties[kCGImagePropertyDPIHeight] as? Double), 216, accuracy: 0.1) @@ -61,15 +113,12 @@ final class UIImagePNGEncodingTests: XCTestCase { private func makeImage(range: UIGraphicsImageRendererFormat.Range) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = 1 + format.opaque = false format.preferredRange = range - return UIGraphicsImageRenderer(size: CGSize(width: 4, height: 1), format: format).image { context in + return UIGraphicsImageRenderer(size: CGSize(width: 4, height: 2), format: format).image { context in UIColor(white: 0.96, alpha: 0.5).setFill() context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) - UIColor.red.withAlphaComponent(0.25).setFill() - context.fill(CGRect(x: 1, y: 0, width: 1, height: 1)) - UIColor.blue.setFill() - context.fill(CGRect(x: 2, y: 0, width: 1, height: 1)) - // Leave the last pixel transparent. + // Leave the remaining pixels transparent. } } @@ -79,6 +128,11 @@ final class UIImagePNGEncodingTests: XCTestCase { return try XCTUnwrap(CGImageSourceCreateImageAtIndex(source, 0, nil)) } + private func pngProperties(_ data: Data) throws -> [CFString: Any] { + let source = try XCTUnwrap(CGImageSourceCreateWithData(data as CFData, nil)) + return try XCTUnwrap(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + } + private func pngPixel(_ data: Data) throws -> [UInt16] { // Inspect stored samples directly, independent of decoder alpha handling. let bytes = [UInt8](data) @@ -127,8 +181,9 @@ final class UIImagePNGEncodingTests: XCTestCase { bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue )) context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) - let data = try XCTUnwrap(context.data) - return Array(UnsafeBufferPointer(start: data.assumingMemoryBound(to: UInt8.self), count: image.width * image.height * 4)) + let normalized = try XCTUnwrap(context.makeImage()) + let data = try XCTUnwrap(normalized.dataProvider?.data) + return Array(data as Data) } } #endif From 5c5ba3f649889a371e7dff8407ff345995c39f01 Mon Sep 17 00:00:00 2001 From: Jamie <2119834+jamieQ@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:09:12 -0500 Subject: [PATCH 3/5] test(png): Simplify UIKit alpha canary assertions --- Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift index 6250bc7c..391b8c01 100644 --- a/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift +++ b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift @@ -12,9 +12,6 @@ final class UIImagePNGEncodingTests: XCTestCase { // Deliberately use UIKit directly, rather than the workaround. let pixel = try pngPixel(XCTUnwrap(image.pngData())) XCTAssertEqual(Double(pixel[3]) / 65535, 0.5, accuracy: 0.001) - let colorError = try XCTUnwrap(pixel.prefix(3).map { - abs(Double($0) / 65535 - 0.96) - }.max()) let options = XCTExpectedFailure.Options() options.isStrict = true @@ -24,7 +21,9 @@ final class UIImagePNGEncodingTests: XCTestCase { "UIKit's extended-range PNG alpha bug: if this unexpectedly passes on a new runtime, reassess the ImageIO workaround.", options: options ) { - XCTAssertLessThanOrEqual(colorError, 0.001) + for channel in pixel.prefix(3) { + XCTAssertEqual(Double(channel) / 65535, 0.96, accuracy: 0.001) + } } } From 372eb156d6db19fc462e840f359323b7427df37e Mon Sep 17 00:00:00 2001 From: Jamie <2119834+jamieQ@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:32:24 -0500 Subject: [PATCH 4/5] test(png): Simplify image encoding regression checks Compare decoded pixels with the source image instead of parsing 16-bit PNG samples. --- .../UIImagePNGEncodingTests.swift | 80 +++++-------------- 1 file changed, 18 insertions(+), 62 deletions(-) diff --git a/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift index 391b8c01..49c865e6 100644 --- a/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift +++ b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift @@ -2,34 +2,34 @@ import ImageIO import UIKit import XCTest -import zlib @testable import SnapshotPreviewsCore final class UIImagePNGEncodingTests: XCTestCase { func testUIKitPNGEncodingStillRequiresAlphaWorkaround() throws { let image = makeImage(range: .extended) - XCTAssertEqual(try XCTUnwrap(image.cgImage).bitsPerComponent, 16) // Deliberately use UIKit directly, rather than the workaround. - let pixel = try pngPixel(XCTUnwrap(image.pngData())) - XCTAssertEqual(Double(pixel[3]) / 65535, 0.5, accuracy: 0.001) + let actual = try pixels(decode(image.pngData())) + let expected = try pixels(XCTUnwrap(image.cgImage)) + XCTAssertEqual(actual.count, expected.count) + XCTAssertEqual(actual[3], expected[3]) let options = XCTExpectedFailure.Options() options.isStrict = true - // Keep fixture/format/alpha checks outside the expected failure. Only the + // Keep fixture/alpha checks outside the expected failure. Only the // known color error is expected; an unexpected pass flags a possible fix. XCTExpectFailure( "UIKit's extended-range PNG alpha bug: if this unexpectedly passes on a new runtime, reassess the ImageIO workaround.", options: options ) { - for channel in pixel.prefix(3) { - XCTAssertEqual(Double(channel) / 65535, 0.96, accuracy: 0.001) + for (actual, expected) in zip(actual.prefix(3), expected.prefix(3)) { + XCTAssertEqual(Double(actual), Double(expected), accuracy: 1) } } } func testExtendedRangePNGPreservesColorAndAlpha() throws { - // Two rows suffice to exercise UIKit's affected PNG path. Keep this - // independent of device dimensions, screen scale, and decoder behavior. + // Two rows suffice to exercise UIKit's affected PNG path. Compare decoded + // pixels in a common color space without assuming the PNG's bit depth. let samples: [(red: Double, green: Double, blue: Double, alpha: Double)] = [ (0.96, 0.96, 0.96, 0.5), (0.8, 0.3, 0.1, 0.25), @@ -37,32 +37,11 @@ final class UIImagePNGEncodingTests: XCTestCase { (0.2, 0.7, 0.9, 1), ] for sample in samples { - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - format.opaque = false - format.preferredRange = .extended let color = UIColor(red: sample.red, green: sample.green, blue: sample.blue, alpha: sample.alpha) - let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 2), format: format).image { context in - color.setFill() - context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) - } - let source = try XCTUnwrap(image.cgImage) - XCTAssertEqual(source.bitsPerComponent, 16) - let data = try XCTUnwrap(image.emg.pngData()) - let decoded = try decode(data) - // ImageIO may choose Display P3 for the PNG. Compare against the input - // color converted into the encoded profile, not assumed sRGB samples. - let expectedColor = try XCTUnwrap(color.cgColor.converted( - to: XCTUnwrap(decoded.colorSpace), intent: .defaultIntent, options: nil - )) - let expectedComponents = try XCTUnwrap(expectedColor.components) - XCTAssertEqual(expectedComponents.count, 4) - let pixel = try pngPixel(data) - - // PNG stores straight color, even though the source CGImage is premultiplied. - for (channel, expected) in zip(pixel, expectedComponents) { - XCTAssertEqual(Double(channel) / 65535, Double(expected), accuracy: 0.001) - } + let image = makeImage(range: .extended, color: color) + let decoded = try decode(image.emg.pngData()) + + try assertEqualPixels(decoded, XCTUnwrap(image.cgImage)) } } @@ -109,13 +88,16 @@ final class UIImagePNGEncodingTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(properties[kCGImagePropertyDPIHeight] as? Double), 216, accuracy: 0.1) } - private func makeImage(range: UIGraphicsImageRendererFormat.Range) -> UIImage { + private func makeImage( + range: UIGraphicsImageRendererFormat.Range, + color: UIColor = UIColor(white: 0.96, alpha: 0.5) + ) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = 1 format.opaque = false format.preferredRange = range return UIGraphicsImageRenderer(size: CGSize(width: 4, height: 2), format: format).image { context in - UIColor(white: 0.96, alpha: 0.5).setFill() + color.setFill() context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) // Leave the remaining pixels transparent. } @@ -132,32 +114,6 @@ final class UIImagePNGEncodingTests: XCTestCase { return try XCTUnwrap(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) } - private func pngPixel(_ data: Data) throws -> [UInt16] { - // Inspect stored samples directly, independent of decoder alpha handling. - let bytes = [UInt8](data) - let width = bytes[16..<20].reduce(0) { ($0 << 8) | Int($1) } - let height = bytes[20..<24].reduce(0) { ($0 << 8) | Int($1) } - XCTAssertEqual(bytes[24], 16) - XCTAssertEqual(bytes[25], 6) - XCTAssertEqual(bytes[28], 0) - var compressed = [UInt8]() - var offset = 8 - while offset + 12 <= bytes.count { - let length = bytes[offset.. Date: Thu, 8 Oct 2026 13:51:21 -0500 Subject: [PATCH 5/5] test(png): Check orientation independently of UIKit metadata --- .../UIImagePNGEncodingTests.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift index 49c865e6..e00729a4 100644 --- a/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift +++ b/Tests/SnapshotPreviewsTests/UIImagePNGEncodingTests.swift @@ -54,7 +54,12 @@ final class UIImagePNGEncodingTests: XCTestCase { func testOrientedImageMatchesUIKitPNGEncoding() throws { let original = try XCTUnwrap(makeImage(range: .standard).cgImage) - for orientation in [UIImage.Orientation.up, .down, .left, .right, .upMirrored, .downMirrored, .leftMirrored, .rightMirrored] { + let orientations: [(UIImage.Orientation, CGImagePropertyOrientation)] = [ + (.up, .up), (.down, .down), (.left, .left), (.right, .right), + (.upMirrored, .upMirrored), (.downMirrored, .downMirrored), + (.leftMirrored, .leftMirrored), (.rightMirrored, .rightMirrored), + ] + for (orientation, pngOrientation) in orientations { let image = UIImage(cgImage: original, scale: 2, orientation: orientation) let expectedData = try XCTUnwrap(image.pngData()) let data = try XCTUnwrap(image.emg.pngData()) @@ -66,11 +71,14 @@ final class UIImagePNGEncodingTests: XCTestCase { // alone would pass even if every image incorrectly reloaded as .up. XCTAssertEqual(try XCTUnwrap(UIImage(data: data)).imageOrientation, orientation) let properties = try pngProperties(data) + let encodedOrientation = try XCTUnwrap(properties[kCGImagePropertyOrientation] as? Int) + XCTAssertEqual(encodedOrientation, Int(pngOrientation.rawValue)) + + // Older UIKit versions may omit orientation metadata from their PNGs. let expectedProperties = try pngProperties(expectedData) - XCTAssertEqual( - try XCTUnwrap(properties[kCGImagePropertyOrientation] as? Int), - try XCTUnwrap(expectedProperties[kCGImagePropertyOrientation] as? Int) - ) + if let uikitOrientation = expectedProperties[kCGImagePropertyOrientation] as? Int { + XCTAssertEqual(encodedOrientation, uikitOrientation) + } } }