Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(mobile): preserve wide-gamut image colors
Co-authored-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
  • Loading branch information
npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr
npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr committed Jul 20, 2026
commit 507ef9031152057a5b673dc938ea553a73f1019b
22 changes: 19 additions & 3 deletions mobile/ios/Runner/MediaSanitizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,39 @@ enum MediaSanitizer {
static func sanitizeImage(_ image: UIImage, mimeType: String) throws -> Data? {
switch mimeType {
case "image/png":
guard let encoded = image.pngData() else { return nil }
guard let image = renderInSRGB(image), let encoded = image.pngData() else { return nil }
return try scrubPng(encoded)
case "image/jpeg":
return try encodeJpeg(image)
case "image/webp":
guard let encoded = image.pngData() else { return nil }
guard let image = renderInSRGB(image), let encoded = image.pngData() else { return nil }
return try scrubPng(encoded)
default:
return nil
}
}

static func encodeJpeg(_ image: UIImage) throws -> Data? {
guard let encoded = image.jpegData(compressionQuality: 1.0) else { return nil }
guard
let image = renderInSRGB(image),
let encoded = image.jpegData(compressionQuality: 1.0)
else {
return nil
}
return try scrubJpeg(encoded)
}

private static func renderInSRGB(_ image: UIImage) -> UIImage? {
guard image.size.width > 0, image.size.height > 0 else { return nil }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
format.opaque = false
format.preferredRange = .standard
return UIGraphicsImageRenderer(size: image.size, format: format).image { _ in
image.draw(in: CGRect(origin: .zero, size: image.size))
}
}

static func scrubPng(_ data: Data) throws -> Data {
Comment thread
brow marked this conversation as resolved.
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
throw MediaSanitizationError.invalidPng
Expand Down
73 changes: 73 additions & 0 deletions mobile/ios/RunnerTests/RunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,79 @@ class RunnerTests: XCTestCase {
XCTAssertNotNil(UIImage(data: encoded))
}

func testSanitizeDisplayP3ImagePreservesRenderedColorInSRGB() throws {
let image = try displayP3Image(red: 0.9, green: 0.2, blue: 0.1)
let expectedColor = try sRGBPixel(from: image)
let mimeTypesAndAccuracy: [(mimeType: String, accuracy: UInt8)] = [
("image/png", 0), ("image/jpeg", 1),
]

for (mimeType, accuracy) in mimeTypesAndAccuracy {
let sanitized = try XCTUnwrap(
MediaSanitizer.sanitizeImage(image, mimeType: mimeType),
"Failed to sanitize Display-P3 image as \(mimeType)"
)

try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: mimeType)
let decoded = try XCTUnwrap(UIImage(data: sanitized))
XCTAssertEqual(
decoded.cgImage?.colorSpace?.name,
CGColorSpace(name: CGColorSpace.sRGB)?.name
)
let actualColor = try sRGBPixel(from: decoded)
XCTAssertEqual(actualColor.count, expectedColor.count)
for (actual, expected) in zip(actualColor, expectedColor) {
XCTAssertLessThanOrEqual(
actual > expected ? actual - expected : expected - actual,
accuracy
)
}
}
}

private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage {
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
let context = try XCTUnwrap(
CGContext(
data: nil,
width: 1,
height: 1,
bitsPerComponent: 8,
bytesPerRow: 4,
space: colorSpace,
bitmapInfo: bitmapInfo.rawValue
)
)
context.setFillColor(
try XCTUnwrap(CGColor(colorSpace: colorSpace, components: [red, green, blue, 1]))
)
context.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
return UIImage(cgImage: try XCTUnwrap(context.makeImage()))
}

private func sRGBPixel(from image: UIImage) throws -> [UInt8] {
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.sRGB))
var bytes = [UInt8](repeating: 0, count: 4)
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
let context = try bytes.withUnsafeMutableBytes { bytes in
try XCTUnwrap(
CGContext(
data: bytes.baseAddress,
width: 1,
height: 1,
bitsPerComponent: 8,
bytesPerRow: 4,
space: colorSpace,
bitmapInfo: bitmapInfo.rawValue
)
)
}
context.interpolationQuality = .none
context.draw(try XCTUnwrap(image.cgImage), in: CGRect(x: 0, y: 0, width: 1, height: 1))
return bytes
}

private func fixtureData(named name: String, extension fileExtension: String) throws -> Data {
let url = try XCTUnwrap(
Bundle(for: RunnerTests.self).url(forResource: name, withExtension: fileExtension))
Expand Down
Loading