blob: 0b61a39336cede79158085d1f150dc076061364a [file] [edit]
// Copyright (C) 2026 Apple Inc. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions
// are met:
// 1. Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// 2. Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
// BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
// THE POSSIBILITY OF SUCH DAMAGE.
public import CoreGraphics
public import Foundation
private import TestWebKitAPILibrary.Helpers.cocoa.TestCocoaImageUtilities
#if WTF_PLATFORM_IOS_FAMILY
public import UIKit
#else
public import AppKit
private import TestWebKitAPILibrary.Helpers.mac.AppKitSPI
#endif
/// A system appearance to draw with.
public enum Appearance: Sendable {
/// The standard light appearance.
case light
/// The standard dark appearance.
case dark
}
/// Runs a closure with the given appearance current for drawing.
///
/// - Parameters:
/// - appearance: The appearance to make current.
/// - body: The work to perform, typically sampling an appearance-sensitive image.
/// - Returns: Whatever `body` returns.
/// - Throws: Whatever `body` throws.
@MainActor
public func withAppearance<T, E: Error>(_ appearance: Appearance, _ body: () throws(E) -> T) throws(E) -> T {
var result: Result<T, E>?
TestCocoaImageUtilities.perform(darkAppearance: appearance == .dark) {
result = Result { () throws(E) in try body() }
}
// swift-format-ignore: NeverForceUnwrap
return try result!.get()
}
/// PNG data for an image of the given size, filled with a single color.
///
/// - Parameters:
/// - size: The size of the image, in points.
/// - color: The color to fill the image with.
/// - Returns: The encoded PNG data.
@MainActor
public func makePNGData(size: CGSize, color: CocoaColor) -> Data {
TestCocoaImageUtilities.pngData(size: size, color: color)
}
/// The color of a single pixel of an image.
///
/// - Parameters:
/// - image: The image to sample.
/// - point: The pixel to sample, in points from the top-left corner of the image.
/// - Returns: The color of that pixel, or `nil` if the image could not be sampled or the point
/// lies outside of it.
@MainActor
public func pixelColor(of image: CocoaImage, at point: CGPoint = .zero) -> CocoaColor? {
TestCocoaImageUtilities.pixelColor(of: image, at: point)
}
/// Whether two colors match on every sRGB component, within a tolerance.
///
/// - Parameters:
/// - color: The first color.
/// - otherColor: The second color.
/// - tolerance: The largest per-component difference that still counts as a match.
/// - Returns: Whether the colors match.
@MainActor
public func compareColors(_ color: CocoaColor?, _ otherColor: CocoaColor?, tolerance: CGFloat = 0.01) -> Bool {
TestCocoaImageUtilities.compareColors(color, otherColor, tolerance: tolerance)
}
extension CocoaImage {
/// Whether this image was created from a system symbol.
public var isSymbol: Bool {
#if WTF_PLATFORM_IOS_FAMILY
isSymbolImage
#else
_isSymbolImage
#endif
}
}
@MainActor
private func makeBitmap(size: CGSize, draw: () -> Void) -> CGImage? {
guard
let width = Int(exactly: size.width.rounded(.up)), width > 0,
let height = Int(exactly: size.height.rounded(.up)), height > 0,
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)
else {
return nil
}
#if HAVE_CGCONTEXT_INIT_WITH_BITMAP_INFO_AND_NULLABLE_COLORSPACE
let context = unsafe CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGBitmapInfo(alpha: .premultipliedLast, byteOrder: .order32Big)
)
#else
let context = unsafe CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).union(.byteOrder32Big).rawValue
)
#endif
guard let context else {
return nil
}
context.translateBy(x: 0, y: CGFloat(height))
context.scaleBy(x: 1, y: -1)
#if WTF_PLATFORM_IOS_FAMILY
UIGraphicsPushContext(context)
defer { UIGraphicsPopContext() }
#else
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
#endif
draw()
return context.makeImage()
}
private func sampleColor(of bitmap: CGImage, atX x: Int, y: Int) -> CocoaColor? {
guard
(0..<bitmap.width).contains(x), (0..<bitmap.height).contains(y),
let pixels = bitmap.dataProvider?.data
else {
return nil
}
let data = pixels as Data
let offset = data.startIndex + y * bitmap.bytesPerRow + x * 4
guard data.indices.contains(offset + 3) else {
return nil
}
let alpha = CGFloat(data[offset + 3]) / 255
guard alpha > 0 else {
return .clear
}
// The bitmap is premultiplied, so divide the alpha back out of each color component.
func component(_ value: UInt8) -> CGFloat {
min(CGFloat(value) / 255 / alpha, 1)
}
return CocoaColor(
red: component(data[offset]),
green: component(data[offset + 1]),
blue: component(data[offset + 2]),
alpha: alpha
)
}
private func sRGBComponents(of color: CocoaColor) -> [CGFloat]? {
#if WTF_PLATFORM_IOS_FAMILY
let cgColor = color.cgColor
#else
guard let cgColor = color.usingColorSpace(.sRGB)?.cgColor else {
return nil
}
#endif
guard
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB),
let converted = cgColor.converted(to: colorSpace, intent: .defaultIntent, options: nil),
let components = converted.components, components.count == 4
else {
return nil
}
return components
}
@objc
@implementation
extension TestCocoaImageUtilities {
@objc(performWithDarkAppearance:block:)
class func perform(darkAppearance: Bool, block: () -> Void) {
#if WTF_PLATFORM_IOS_FAMILY
let style: UIUserInterfaceStyle = darkAppearance ? .dark : .light
UITraitCollection(userInterfaceStyle: style).performAsCurrent(block)
#else
let name: NSAppearance.Name = darkAppearance ? .darkAqua : .aqua
guard let appearance = NSAppearance(named: name) else {
preconditionFailure("Could not create the \(name.rawValue) appearance.")
}
appearance.performAsCurrentDrawingAppearance(block)
#endif
}
class func pngData(size: CGSize, color: CocoaColor) -> Data {
#if WTF_PLATFORM_IOS_FAMILY
let format = UIGraphicsImageRendererFormat.preferred()
format.scale = 1
format.opaque = false
format.preferredRange = .standard
return UIGraphicsImageRenderer(size: size, format: format)
.pngData { context in
color.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
#else
let image = NSImage(size: size)
image.lockFocus()
color.setFill()
NSRect(origin: .zero, size: size).fill()
image.unlockFocus()
guard let cgImage = unsafe image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
preconditionFailure("Could not get a bitmap for a \(size) image that was just drawn.")
}
let representation = NSBitmapImageRep(cgImage: cgImage)
representation.size = size
guard let data = representation.representation(using: .png, properties: [:]) else {
preconditionFailure("Could not encode a \(size) image that was just drawn.")
}
return data
#endif
}
@objc(pixelColorOfImage:atPoint:)
class func pixelColor(of image: CocoaImage, at point: CGPoint) -> CocoaColor? {
#if WTF_PLATFORM_IOS_FAMILY
let image = image.imageAsset?.image(with: .current) ?? image
#endif
guard
let x = Int(exactly: point.x.rounded(.down)),
let y = Int(exactly: point.y.rounded(.down)),
let bitmap = makeBitmap(
size: image.size,
draw: {
#if WTF_PLATFORM_IOS_FAMILY
image.draw(at: .zero)
#else
image.draw(in: CGRect(origin: .zero, size: image.size))
#endif
}
)
else {
return nil
}
return sampleColor(of: bitmap, atX: x, y: y)
}
@objc(compareColor:toColor:tolerance:)
class func compareColors(_ color: CocoaColor?, _ otherColor: CocoaColor?, tolerance: CGFloat) -> Bool {
if color === otherColor {
return true
}
guard let color, let otherColor else {
return false
}
if color.isEqual(otherColor) {
return true
}
guard
let components = sRGBComponents(of: color),
let otherComponents = sRGBComponents(of: otherColor)
else {
return false
}
return zip(components, otherComponents).allSatisfy { abs($0.0 - $0.1) < tolerance }
}
}