Skip to content

Commit e90a099

Browse files
jamieQpixelmatrix
andauthored
fix(png): Preserve color and alpha in extended-range snapshots (#284)
* Fix alpha encoding in extended-range PNG snapshots * 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 * test(png): Simplify UIKit alpha canary assertions * test(png): Simplify image encoding regression checks Compare decoded pixels with the source image instead of parsing 16-bit PNG samples. * test(png): Check orientation independently of UIKit metadata --------- Co-authored-by: Josh Pyles <josh@linear.app>
1 parent 2fc44fe commit e90a099

2 files changed

Lines changed: 195 additions & 1 deletion

File tree

‎Sources/SnapshotPreviewsCore/UIImage+EMG.swift‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
//
77

88
#if canImport(UIKit)
9+
import ImageIO
910
import UIKit
11+
import UniformTypeIdentifiers
1012

1113
public extension UIImage {
1214
var emg: UIImageSnapshotsNamespace {
@@ -21,7 +23,47 @@ public extension UIImage {
2123
}
2224

2325
public func pngData() -> Data? {
24-
image.pngData()
26+
guard let cgImage = image.cgImage else {
27+
return image.pngData()
28+
}
29+
30+
// UIKit's 16-bit PNG path can leave colors premultiplied by alpha.
31+
// ImageIO's default options avoid that path and the resulting dark edges.
32+
let data = NSMutableData()
33+
guard let destination = CGImageDestinationCreateWithData(
34+
data, UTType.png.identifier as CFString, 1, nil
35+
) else {
36+
return nil
37+
}
38+
39+
// Preserve UIKit's resolution metadata: scale 1 corresponds to 72 DPI.
40+
let dpi = image.scale * 72
41+
let properties: [CFString: Any] = [
42+
kCGImagePropertyOrientation: pngOrientation.rawValue,
43+
kCGImagePropertyDPIWidth: dpi,
44+
kCGImagePropertyDPIHeight: dpi,
45+
]
46+
CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary)
47+
48+
guard CGImageDestinationFinalize(destination) else {
49+
return nil
50+
}
51+
return data as Data
52+
}
53+
54+
// UIImage and ImageIO use different orientation raw values.
55+
private var pngOrientation: CGImagePropertyOrientation {
56+
switch image.imageOrientation {
57+
case .up: return .up
58+
case .down: return .down
59+
case .left: return .left
60+
case .right: return .right
61+
case .upMirrored: return .upMirrored
62+
case .downMirrored: return .downMirrored
63+
case .leftMirrored: return .leftMirrored
64+
case .rightMirrored: return .rightMirrored
65+
@unknown default: return .up
66+
}
2567
}
2668
}
2769
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#if canImport(UIKit)
2+
import ImageIO
3+
import UIKit
4+
import XCTest
5+
@testable import SnapshotPreviewsCore
6+
7+
final class UIImagePNGEncodingTests: XCTestCase {
8+
func testUIKitPNGEncodingStillRequiresAlphaWorkaround() throws {
9+
let image = makeImage(range: .extended)
10+
// Deliberately use UIKit directly, rather than the workaround.
11+
let actual = try pixels(decode(image.pngData()))
12+
let expected = try pixels(XCTUnwrap(image.cgImage))
13+
XCTAssertEqual(actual.count, expected.count)
14+
XCTAssertEqual(actual[3], expected[3])
15+
16+
let options = XCTExpectedFailure.Options()
17+
options.isStrict = true
18+
// Keep fixture/alpha checks outside the expected failure. Only the
19+
// known color error is expected; an unexpected pass flags a possible fix.
20+
XCTExpectFailure(
21+
"UIKit's extended-range PNG alpha bug: if this unexpectedly passes on a new runtime, reassess the ImageIO workaround.",
22+
options: options
23+
) {
24+
for (actual, expected) in zip(actual.prefix(3), expected.prefix(3)) {
25+
XCTAssertEqual(Double(actual), Double(expected), accuracy: 1)
26+
}
27+
}
28+
}
29+
30+
func testExtendedRangePNGPreservesColorAndAlpha() throws {
31+
// Two rows suffice to exercise UIKit's affected PNG path. Compare decoded
32+
// pixels in a common color space without assuming the PNG's bit depth.
33+
let samples: [(red: Double, green: Double, blue: Double, alpha: Double)] = [
34+
(0.96, 0.96, 0.96, 0.5),
35+
(0.8, 0.3, 0.1, 0.25),
36+
(0.2, 0.7, 0.9, 0.75),
37+
(0.2, 0.7, 0.9, 1),
38+
]
39+
for sample in samples {
40+
let color = UIColor(red: sample.red, green: sample.green, blue: sample.blue, alpha: sample.alpha)
41+
let image = makeImage(range: .extended, color: color)
42+
let decoded = try decode(image.emg.pngData())
43+
44+
try assertEqualPixels(decoded, XCTUnwrap(image.cgImage))
45+
}
46+
}
47+
48+
func testStandardRangePNGPreservesColorAndAlpha() throws {
49+
let image = makeImage(range: .standard)
50+
let decoded = try decode(image.emg.pngData())
51+
52+
try assertEqualPixels(decoded, XCTUnwrap(image.cgImage))
53+
}
54+
55+
func testOrientedImageMatchesUIKitPNGEncoding() throws {
56+
let original = try XCTUnwrap(makeImage(range: .standard).cgImage)
57+
let orientations: [(UIImage.Orientation, CGImagePropertyOrientation)] = [
58+
(.up, .up), (.down, .down), (.left, .left), (.right, .right),
59+
(.upMirrored, .upMirrored), (.downMirrored, .downMirrored),
60+
(.leftMirrored, .leftMirrored), (.rightMirrored, .rightMirrored),
61+
]
62+
for (orientation, pngOrientation) in orientations {
63+
let image = UIImage(cgImage: original, scale: 2, orientation: orientation)
64+
let expectedData = try XCTUnwrap(image.pngData())
65+
let data = try XCTUnwrap(image.emg.pngData())
66+
let expected = try decode(expectedData)
67+
let decoded = try decode(data)
68+
69+
try assertEqualPixels(decoded, expected)
70+
// Raw CGImage decoding ignores orientation metadata, so pixel equality
71+
// alone would pass even if every image incorrectly reloaded as .up.
72+
XCTAssertEqual(try XCTUnwrap(UIImage(data: data)).imageOrientation, orientation)
73+
let properties = try pngProperties(data)
74+
let encodedOrientation = try XCTUnwrap(properties[kCGImagePropertyOrientation] as? Int)
75+
XCTAssertEqual(encodedOrientation, Int(pngOrientation.rawValue))
76+
77+
// Older UIKit versions may omit orientation metadata from their PNGs.
78+
let expectedProperties = try pngProperties(expectedData)
79+
if let uikitOrientation = expectedProperties[kCGImagePropertyOrientation] as? Int {
80+
XCTAssertEqual(encodedOrientation, uikitOrientation)
81+
}
82+
}
83+
}
84+
85+
func testEmptyImageReturnsNil() {
86+
XCTAssertNil(UIImage().emg.pngData())
87+
}
88+
89+
func testPNGPreservesImageResolution() throws {
90+
let original = try XCTUnwrap(makeImage(range: .standard).cgImage)
91+
let image = UIImage(cgImage: original, scale: 3, orientation: .up)
92+
let data = try XCTUnwrap(image.emg.pngData())
93+
let properties = try pngProperties(data)
94+
95+
XCTAssertEqual(try XCTUnwrap(properties[kCGImagePropertyDPIWidth] as? Double), 216, accuracy: 0.1)
96+
XCTAssertEqual(try XCTUnwrap(properties[kCGImagePropertyDPIHeight] as? Double), 216, accuracy: 0.1)
97+
}
98+
99+
private func makeImage(
100+
range: UIGraphicsImageRendererFormat.Range,
101+
color: UIColor = UIColor(white: 0.96, alpha: 0.5)
102+
) -> UIImage {
103+
let format = UIGraphicsImageRendererFormat()
104+
format.scale = 1
105+
format.opaque = false
106+
format.preferredRange = range
107+
return UIGraphicsImageRenderer(size: CGSize(width: 4, height: 2), format: format).image { context in
108+
color.setFill()
109+
context.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
110+
// Leave the remaining pixels transparent.
111+
}
112+
}
113+
114+
private func decode(_ data: Data?) throws -> CGImage {
115+
let data = try XCTUnwrap(data)
116+
let source = try XCTUnwrap(CGImageSourceCreateWithData(data as CFData, nil))
117+
return try XCTUnwrap(CGImageSourceCreateImageAtIndex(source, 0, nil))
118+
}
119+
120+
private func pngProperties(_ data: Data) throws -> [CFString: Any] {
121+
let source = try XCTUnwrap(CGImageSourceCreateWithData(data as CFData, nil))
122+
return try XCTUnwrap(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
123+
}
124+
125+
private func assertEqualPixels(_ actual: CGImage, _ expected: CGImage, file: StaticString = #filePath, line: UInt = #line) throws {
126+
XCTAssertEqual(actual.width, expected.width, file: file, line: line)
127+
XCTAssertEqual(actual.height, expected.height, file: file, line: line)
128+
let actualPixels = try pixels(actual)
129+
let expectedPixels = try pixels(expected)
130+
XCTAssertEqual(actualPixels.count, expectedPixels.count, file: file, line: line)
131+
for (actual, expected) in zip(actualPixels, expectedPixels) {
132+
XCTAssertEqual(Double(actual), Double(expected), accuracy: 1, file: file, line: line)
133+
}
134+
}
135+
136+
private func pixels(_ image: CGImage) throws -> [UInt8] {
137+
let context = try XCTUnwrap(CGContext(
138+
data: nil,
139+
width: image.width,
140+
height: image.height,
141+
bitsPerComponent: 8,
142+
bytesPerRow: image.width * 4,
143+
space: CGColorSpace(name: CGColorSpace.sRGB)!,
144+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
145+
))
146+
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
147+
let normalized = try XCTUnwrap(context.makeImage())
148+
let data = try XCTUnwrap(normalized.dataProvider?.data)
149+
return Array(data as Data)
150+
}
151+
}
152+
#endif

0 commit comments

Comments
 (0)