Test iOS PNG transparency exports on a cloud Mac

Test iOS PNG transparency exports on a cloud Mac

Test iOS PNG transparency exports on a cloud Mac

Black fringes around exported stickers usually do not mean the PNG is corrupt. More often, premultiplied colors are being treated as straight colors, or an opaque background was filled in before drawing. Run validation on a cloud Mac: first use three pixels to pin down the encoding contract, then inspect the actual exported files. Do not start by comparing full screenshots, or scaling, display backgrounds, and color conversions will all get mixed into your conclusions.

Define the pixel contract first

This test covers SDR, 8-bit, sRGB PNGs, not wide-gamut images or HDR. Prepare a row of three pixels: fully transparent, half-transparent red, and fully opaque blue. The output must remain 3×1, and transparency must survive encoding and decoding.

Distinguish between the two representations. PNG uses unassociated, or straight, alpha; a CoreGraphics bitmap context can use premultiplied alpha. In an explicitly defined premultiplied RGBA buffer, half-transparent red has a red channel of approximately 128 and an alpha of approximately 128—not a red channel of 255.

Do not treat a CGImage's dataProvider bytes as a universal RGBA layout. The color space, channel order, and row stride can all vary. Draw into a bitmap context you have explicitly defined, then read its bytes.

The RGB values of the fully transparent pixel are not acceptance criteria. Its color is invisible, and encoding or conversion may not preserve the original values. Check only that its alpha is zero and that compositing it leaves the background unchanged.

Build a minimal Swift round-trip fixture

On a node with a full Xcode installation, save the following code as alpha-check.swift. It uses only system frameworks, generates a PNG, and decodes it again, with an explicit byte order, color space, and premultiplied format. It writes the output to the current directory, so switch to a dedicated test directory before running it.

import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers

let width = 3
let height = 1
let space = CGColorSpace(name: CGColorSpace.sRGB)!
let info = CGBitmapInfo.byteOrder32Big.rawValue
    | CGImageAlphaInfo.premultipliedLast.rawValue

func context() -> CGContext {
    CGContext(
        data: nil,
        width: width,
        height: height,
        bitsPerComponent: 8,
        bytesPerRow: width * 4,
        space: space,
        bitmapInfo: info
    )!
}

let source = context()
source.clear(CGRect(x: 0, y: 0, width: width, height: height))
source.setFillColor(red: 1, green: 0, blue: 0, alpha: 0.5)
source.fill(CGRect(x: 1, y: 0, width: 1, height: 1))
source.setFillColor(red: 0, green: 0, blue: 1, alpha: 1)
source.fill(CGRect(x: 2, y: 0, width: 1, height: 1))

let url = URL(fileURLWithPath: "alpha-fixture.png")
let destination = CGImageDestinationCreateWithURL(
    url as CFURL, UTType.png.identifier as CFString, 1, nil
)!
CGImageDestinationAddImage(destination, source.makeImage()!, nil)
precondition(CGImageDestinationFinalize(destination), "PNG write failed")

let reader = CGImageSourceCreateWithURL(url as CFURL, nil)!
let image = CGImageSourceCreateImageAtIndex(reader, 0, nil)!
precondition(image.width == width && image.height == height)

func pixels(background: CGFloat?) -> [UInt8] {
    let target = context()
    let bounds = CGRect(x: 0, y: 0, width: width, height: height)
    target.clear(bounds)
    if let value = background {
        target.setFillColor(
            red: value, green: value, blue: value, alpha: 1
        )
        target.fill(bounds)
    }
    target.interpolationQuality = .none
    target.draw(image, in: bounds)
    return Array(UnsafeBufferPointer(
        start: target.data!.assumingMemoryBound(to: UInt8.self),
        count: width * 4
    ))
}

func near(_ actual: UInt8, _ expected: Int) -> Bool {
    abs(Int(actual) - expected) <= 2
}

let rgba = pixels(background: nil)
precondition(rgba[3] == 0)
precondition(near(rgba[4], 128) && near(rgba[7], 128))
precondition(rgba[8] == 0 && rgba[10] == 255 && rgba[11] == 255)

let black = pixels(background: 0)
let white = pixels(background: 1)
precondition(near(black[4], 128) && black[5] == 0 && black[6] == 0)
precondition(white[4] == 255 && near(white[5], 127))
precondition(near(white[6], 127))
precondition(Array(white[0...3]) == [255, 255, 255, 255])
precondition(Array(black[0...3]) == [0, 0, 0, 255])

Preserve the exit code when running the script. Do not judge success by the background color in an image viewer:

mkdir -p alpha-check
cd alpha-check
xcrun swift ../alpha-check.swift

A failed assertion causes a nonzero exit status. Do not run this validation script with optimization options that remove assertion checks. The two-level tolerance accommodates 8-bit quantization; it is not permission to widen the error margin arbitrarily.

Use black and white backgrounds to distinguish two types of error

In the bitmap context above, half-transparent red composited over black should produce approximately (128,0,0); over white, it should produce approximately (255,127,127). These values define the compositing result for this context, not a universal color guarantee for every renderer.

Testing only against black will miss some problems. For example, if a black background was already filled in before export, the file can still display red, but transparent pixels will have become opaque black pixels. A white-background check immediately exposes this kind of background contamination.

Multiplying an already premultiplied red value by alpha again darkens the red and can create black halos along semitransparent outlines. Treating straight colors as premultiplied can instead make the edges too bright. When troubleshooting, first confirm the declared buffer format, then inspect where conversions occur. Do not try to fix the problem by increasing sharpening.

Connect the fixture to the real iOS export path

The script above verifies that the tools and assertions are reliable; it does not mean the app's export logic has passed. Next, feed the same three-color fixture into the production encoding function and decode the data it returns, rather than calling the sample encoder again.

Validate UIKit drawing separately

If the app uses UIGraphicsImageRenderer, explicitly set format.opaque = false in the iOS test target. For pixel-dimension tests, also set format.scale = 1 so logical dimensions are not confused with the file's pixel dimensions. If production uses a different scale, add a separate set of dimension assertions for that scale.

Decode the returned PNG data with ImageIO and draw it into the same sRGB context to normalize the representation. Do not treat UIImage.size as the output's pixel dimensions. Tests involving main-thread drawing should run on the main actor so the test itself does not introduce unpredictable behavior.

Add another fixture with an antialiased diagonal edge. Verify that it actually contains edge pixels with alpha values between zero and the maximum, then composite it over black and white backgrounds. Do not tie the test to every edge byte produced by a particular OS version.

Save diagnostic evidence when a test fails

For every failure, save at least the original PNG, decoded dimensions, specified color space, sample coordinates, expected and actual RGBA values, and toolchain version. Synthetic test images are sufficient; there is no need to upload user images.

If the dimensions are correct but every pixel is opaque, first check the drawing context's opaque setting and background fills. If alpha is correct but the edges are dark, check for double premultiplication. If colors are shifted across the image, verify color-space conversions before considering pixel tolerances.

The final validation gate should report three independent conclusions: dimensions match, transparency is preserved, and background compositing meets the contract. Being able to open the file is only a prerequisite. Shared pixel logic can run automatically on a cloud Mac, but actual UIKit exports and the final files produced after sharing still need separate validation to catch re-encoding at intermediate stages.

Frequently asked questions

Does an alpha channel prove that transparency survived export?

No. Every alpha value could still be fully opaque. Decode known transparent and translucent pixels and verify both their alpha values and composited colors.

Can cloud Mac pixel tests replace iOS device testing?

Not entirely. They validate shared encoding logic and file pixels. UIKit rendering, display gamut and the actual sharing path still need iOS tests.

Assign a cloud Mac to your next build

Dedicated physical Mac mini, not a virtual machine. Choose a daily, weekly, monthly, or quarterly rental. Check the console for current availability and configuration details.