From 5bc6205b264a0560020dc9c94ddfaef8003e37a7 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 26 Oct 2026 02:33:15 +0800 Subject: [PATCH] feat: add CGColorSpace and color conversions --- Sources/OpenCoreGraphics/CGColor.swift | 42 ++-- Sources/OpenCoreGraphics/CGColorSpace.swift | 201 ++++++++++++++++++ .../CGColorConversionTests.swift | 136 ++++++++++++ .../CGColorSpaceTests.swift | 93 ++++++++ 4 files changed, 458 insertions(+), 14 deletions(-) create mode 100644 Sources/OpenCoreGraphics/CGColorSpace.swift create mode 100644 Tests/OpenCoreGraphicsShimsTests/CGColorConversionTests.swift create mode 100644 Tests/OpenCoreGraphicsShimsTests/CGColorSpaceTests.swift diff --git a/Sources/OpenCoreGraphics/CGColor.swift b/Sources/OpenCoreGraphics/CGColor.swift index cf7dab4..004195c 100644 --- a/Sources/OpenCoreGraphics/CGColor.swift +++ b/Sources/OpenCoreGraphics/CGColor.swift @@ -2,44 +2,48 @@ // CGColor.swift // OpenCoreGraphics +public import CoreFoundation public import Foundation -open class CGColor: NSObject { - private enum Space: Hashable { - case genericRGB - case sRGB - case genericGray - case genericGrayGamma2_2 - } - - private let space: Space +open class CGColor: Foundation.NSObject { + private let space: CGColorSpace private let storedComponents: [CGFloat] public init(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { - space = .genericRGB + space = CGColorSpace(.genericRGB) storedComponents = [red, green, blue, alpha].map { min(max($0, 0), 1) } super.init() } public init(srgbRed red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { - space = .sRGB + space = CGColorSpace(.sRGB) storedComponents = [red, green, blue, alpha].map { min(max($0, 0), 1) } super.init() } public init(gray: CGFloat, alpha: CGFloat) { - space = .genericGray + space = CGColorSpace(.genericGray) storedComponents = [gray, alpha].map { min(max($0, 0), 1) } super.init() } public init(genericGrayGamma2_2Gray gray: CGFloat, alpha: CGFloat) { - space = .genericGrayGamma2_2 + space = CGColorSpace(.genericGrayGamma2_2) storedComponents = [gray, alpha].map { min(max($0, 0), 1) } super.init() } - private init(space: Space, components: [CGFloat]) { + public init?(colorSpace: CGColorSpace, components: UnsafePointer) { + space = colorSpace + let count = colorSpace.numberOfComponents + storedComponents = (0...count).map { index in + let value = components[index] + return index < count && colorSpace.usesExtendedRange ? value : min(max(value, 0), 1) + } + super.init() + } + + private init(space: CGColorSpace, components: [CGFloat]) { self.space = space storedComponents = components super.init() @@ -59,6 +63,8 @@ open class CGColor: NSObject { public var components: [CGFloat]? { storedComponents } + public var colorSpace: CGColorSpace? { space } + public var numberOfComponents: Int { storedComponents.count } public var alpha: CGFloat { storedComponents[storedComponents.count - 1] } @@ -72,6 +78,14 @@ open class CGColor: NSObject { return CGColor(space: space, components: components) } + public func converted(to space: CGColorSpace, intent: CGColorRenderingIntent, options: CFDictionary?) -> CGColor? { + guard self.space != space else { return self } + // These fixed matrix/curve profiles use the same transform for each intent. + var components = self.space.convert(storedComponents, to: space) + components.append(alpha) + return CGColor(colorSpace: space, components: components) + } + open override func isEqual(_ object: Any?) -> Bool { guard let other = object as? CGColor else { return false } return self === other diff --git a/Sources/OpenCoreGraphics/CGColorSpace.swift b/Sources/OpenCoreGraphics/CGColorSpace.swift new file mode 100644 index 0000000..d0c2e17 --- /dev/null +++ b/Sources/OpenCoreGraphics/CGColorSpace.swift @@ -0,0 +1,201 @@ +// +// CGColorSpace.swift +// OpenCoreGraphics + +public import CoreFoundation +public import Foundation + +public enum CGColorRenderingIntent: Int32, Sendable { + case defaultIntent = 0 + case absoluteColorimetric = 1 + case relativeColorimetric = 2 + case perceptual = 3 + case saturation = 4 +} + +public enum CGColorSpaceModel: Int32, Sendable { + case unknown = -1 + case monochrome = 0 + case rgb = 1 + case cmyk = 2 + case lab = 3 + case deviceN = 4 + case indexed = 5 + case pattern = 6 + case XYZ = 7 +} + +public final class CGColorSpace: Foundation.NSObject, @unchecked Sendable { + enum Kind: String, CaseIterable { + case genericRGB = "kCGColorSpaceGenericRGB" + case genericGray = "kCGColorSpaceGenericGray" + case genericGrayGamma2_2 = "kCGColorSpaceGenericGrayGamma2_2" + case sRGB = "kCGColorSpaceSRGB" + case extendedSRGB = "kCGColorSpaceExtendedSRGB" + case linearSRGB = "kCGColorSpaceLinearSRGB" + case extendedLinearSRGB = "kCGColorSpaceExtendedLinearSRGB" + case displayP3 = "kCGColorSpaceDisplayP3" + + var name: CFString { + CFStringCreateWithCString(nil, rawValue, CFStringBuiltInEncodings.UTF8.rawValue)! + } + } + + private let kind: Kind + + init(_ kind: Kind) { + self.kind = kind + super.init() + } + + public convenience init?(name: CFString) { + guard let kind = Kind.allCases.first(where: { CFEqual($0.name, name) }) else { + return nil + } + self.init(kind) + } + + public static var sRGB: CFString { Kind.sRGB.name } + public static var extendedSRGB: CFString { Kind.extendedSRGB.name } + public static var linearSRGB: CFString { Kind.linearSRGB.name } + public static var extendedLinearSRGB: CFString { Kind.extendedLinearSRGB.name } + public static var displayP3: CFString { Kind.displayP3.name } + public static var genericGrayGamma2_2: CFString { Kind.genericGrayGamma2_2.name } + + public var name: CFString? { kind.name } + + public var model: CGColorSpaceModel { + switch kind { + case .genericGray, .genericGrayGamma2_2: .monochrome + default: .rgb + } + } + + public var numberOfComponents: Int { + model == .monochrome ? 1 : 3 + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? CGColorSpace else { return false } + return kind == other.kind + } + + public override var hash: Int { kind.hashValue } + + var usesExtendedRange: Bool { + kind == .extendedSRGB || kind == .extendedLinearSRGB + } + + private enum TransferFunction { + case linear + case sRGB + case gamma1_8 + + func decode(_ value: CGFloat) -> CGFloat { + switch self { + case .linear: + return value + case .sRGB: + let magnitude = abs(value) + if magnitude <= 0.04045 { return value / 12.92 } + return copysign(pow((magnitude + 0.055) / 1.055, 2.4), value) + case .gamma1_8: + if abs(value) <= 0.03125 { return value / 16 } + return copysign(pow(abs(value), 1.8), value) + } + } + + func encode(_ value: CGFloat) -> CGFloat { + switch self { + case .linear: + return value + case .sRGB: + let magnitude = abs(value) + if magnitude <= 0.0031308 { return value * 12.92 } + return copysign(1.055 * pow(magnitude, 1 / 2.4) - 0.055, value) + case .gamma1_8: + if abs(value) <= 0.001953125 { return value * 16 } + return copysign(pow(abs(value), 1 / 1.8), value) + } + } + } + + private var transferFunction: TransferFunction { + switch kind { + case .linearSRGB, .extendedLinearSRGB: .linear + case .genericRGB, .genericGray: .gamma1_8 + // Generic Gray Gamma 2.2 uses the sRGB transfer curve. + default: .sRGB + } + } + + private enum Primaries { + case sRGB + case displayP3 + case genericRGB + case gray + } + + private var primaries: Primaries { + switch kind { + case .displayP3: .displayP3 + case .genericRGB: .genericRGB + case .genericGray, .genericGrayGamma2_2: .gray + default: .sRGB + } + } + + func convert(_ components: [CGFloat], to destination: CGColorSpace) -> [CGFloat] { + if primaries == destination.primaries { + guard transferFunction != destination.transferFunction else { + return Array(components.prefix(numberOfComponents)) + } + return components.prefix(numberOfComponents).map { + destination.transferFunction.encode(transferFunction.decode($0)) + } + } + let linear = components.prefix(numberOfComponents).map(transferFunction.decode) + let rgb: (CGFloat, CGFloat, CGFloat) + switch primaries { + case .sRGB: + rgb = (linear[0], linear[1], linear[2]) + case .gray: + rgb = (linear[0], linear[0], linear[0]) + case .displayP3: + // D65 Display P3 to linear sRGB, from the standard RGB/XYZ matrices. + rgb = ( + 1.2249401763 * linear[0] - 0.2249401763 * linear[1], + -0.0420569547 * linear[0] + 1.0420569547 * linear[1], + -0.0196375546 * linear[0] - 0.0786360456 * linear[1] + 1.0982736002 * linear[2] + ) + case .genericRGB: + // Generic RGB profile primaries, adapted to linear sRGB. + rgb = ( + 1.0252417326 * linear[0] - 0.0265452433 * linear[1] + 0.0013035525 * linear[2], + 0.0194007643 * linear[0] + 0.9480088353 * linear[1] + 0.0325903520 * linear[2], + -0.0017621303 * linear[0] - 0.0014344937 * linear[1] + 1.0031965971 * linear[2] + ) + } + let result: [CGFloat] + switch destination.primaries { + case .sRGB: + result = [rgb.0, rgb.1, rgb.2] + case .gray: + // Normalized D50 luminance of the sRGB profile. + result = [0.2224952 * rgb.0 + 0.7168951 * rgb.1 + 0.0606097 * rgb.2] + case .displayP3: + result = [ + 0.8224619687 * rgb.0 + 0.1775380313 * rgb.1, + 0.0331941989 * rgb.0 + 0.9668058011 * rgb.1, + 0.0170826307 * rgb.0 + 0.0723974407 * rgb.1 + 0.9105199286 * rgb.2, + ] + case .genericRGB: + result = [ + 0.9748595404 * rgb.0 + 0.0272938328 * rgb.1 - 0.0021534126 * rgb.2, + -0.0200081405 * rgb.0 + 1.0542304567 * rgb.1 - 0.0342222652 * rgb.2, + 0.0016837457 * rgb.0 + 0.0015554102 * rgb.1 + 0.9967608709 * rgb.2, + ] + } + return result.map(destination.transferFunction.encode) + } +} diff --git a/Tests/OpenCoreGraphicsShimsTests/CGColorConversionTests.swift b/Tests/OpenCoreGraphicsShimsTests/CGColorConversionTests.swift new file mode 100644 index 0000000..b8c13c4 --- /dev/null +++ b/Tests/OpenCoreGraphicsShimsTests/CGColorConversionTests.swift @@ -0,0 +1,136 @@ +// +// CGColorConversionTests.swift +// OpenCoreGraphicsShimsTests + +import Foundation +import OpenCoreGraphicsShims +import Testing +#if OPENCOREGRAPHICS_COREGRAPHICS +import class CoreGraphics.CGColor +import class CoreGraphics.CGColorSpace +import enum CoreGraphics.CGColorRenderingIntent +#else +import class OpenCoreGraphics.CGColor +import class OpenCoreGraphics.CGColorSpace +import enum OpenCoreGraphics.CGColorRenderingIntent +#endif + +@Suite +struct CGColorConversionTests { + private func expectComponents(_ color: CGColor?, _ expected: [CGFloat], tolerance: CGFloat = 0.00001) throws { + let components = try #require(color?.components) + #expect(components.count == expected.count) + for (actual, expected) in zip(components, expected) { + #expect(abs(actual - expected) < tolerance) + } + } + + @Test + func srgbTransferFunctions() throws { + let srgb = try #require(CGColorSpace(name: CGColorSpace.extendedSRGB)) + let linear = try #require(CGColorSpace(name: CGColorSpace.extendedLinearSRGB)) + let color = try #require(CGColor(colorSpace: srgb, components: [0.25, 0.5, 0.75, 0.5])) + let converted = try #require(color.converted(to: linear, intent: .defaultIntent, options: nil)) + #expect(converted.colorSpace == linear) + try expectComponents(converted, [0.0508761, 0.2140411, 0.5225216, 0.5]) + try expectComponents(converted.converted(to: srgb, intent: .defaultIntent, options: nil), [0.25, 0.5, 0.75, 0.5]) + } + + @Test(arguments: [CGFloat(-1.25), -0.25, -0.02, 0, 0.02, 0.04045, 0.5, 1, 1.25]) + func extendedRoundTrip(value: CGFloat) throws { + let srgb = try #require(CGColorSpace(name: CGColorSpace.extendedSRGB)) + let linear = try #require(CGColorSpace(name: CGColorSpace.extendedLinearSRGB)) + let color = try #require(CGColor(colorSpace: srgb, components: [value, value, value, 0.5])) + let converted = try #require(color.converted(to: linear, intent: .defaultIntent, options: nil)) + try expectComponents(converted.converted(to: srgb, intent: .defaultIntent, options: nil), [value, value, value, 0.5]) + } + + @Test + func extendedTransferPreservesSignAndRange() throws { + let srgb = try #require(CGColorSpace(name: CGColorSpace.extendedSRGB)) + let linear = try #require(CGColorSpace(name: CGColorSpace.extendedLinearSRGB)) + let color = try #require(CGColor(colorSpace: srgb, components: [-0.25, -0.02, 1.25, 0.5])) + try expectComponents(color.converted(to: linear, intent: .defaultIntent, options: nil), [-0.0508761, -0.00154799, 1.6659399, 0.5]) + } + + @Test + func sameSpacePreservesUnclampedAlpha() throws { + let srgb = try #require(CGColorSpace(name: CGColorSpace.extendedSRGB)) + let color = try #require(CGColor(colorSpace: srgb, components: [-0.25, 0.5, 1.25, 1])) + let copy = try #require(color.copy(alpha: 1.5)) + let equalSpace = try #require(CGColorSpace(name: CGColorSpace.extendedSRGB)) + let converted = try #require(copy.converted(to: equalSpace, intent: .defaultIntent, options: nil)) + #expect(converted == copy) + #expect(converted.components == [-0.25, 0.5, 1.25, 1.5]) + } + + @Test + func conversionClampsToDestinationRange() throws { + let extended = try #require(CGColorSpace(name: CGColorSpace.extendedSRGB)) + let srgb = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let linear = try #require(CGColorSpace(name: CGColorSpace.extendedLinearSRGB)) + let color = try #require(CGColor(colorSpace: extended, components: [-0.25, 0.5, 1.25, 1])) + let copy = try #require(color.copy(alpha: 1.5)) + let bounded = try #require(copy.converted(to: srgb, intent: .defaultIntent, options: nil)) + #expect(bounded.components == [0, 0.5, 1, 1]) + try expectComponents(copy.converted(to: linear, intent: .defaultIntent, options: nil), [-0.0508761, 0.2140411, 1.6659399, 1]) + } + + @Test + func displayP3Conversion() throws { + let p3 = try #require(CGColorSpace(name: CGColorSpace.displayP3)) + let extended = try #require(CGColorSpace(name: CGColorSpace.extendedSRGB)) + let red = try #require(CGColor(colorSpace: p3, components: [1, 0, 0, 0.5])) + // Native ICC profile rounding differs slightly from the standard matrix. + try expectComponents(red.converted(to: extended, intent: .defaultIntent, options: nil), [1.0931, -0.2268, -0.1501, 0.5], tolerance: 0.0005) + let color = try #require(CGColor(colorSpace: p3, components: [0.25, 0.5, 0.75, 0.5])) + let converted = try #require(color.converted(to: extended, intent: .defaultIntent, options: nil)) + try expectComponents(converted, [0.12398, 0.50736, 0.77112, 0.5], tolerance: 0.0005) + try expectComponents(converted.converted(to: p3, intent: .defaultIntent, options: nil), [0.25, 0.5, 0.75, 0.5], tolerance: 0.0005) + } + + @Test(arguments: [CGColorRenderingIntent.defaultIntent, .absoluteColorimetric, .relativeColorimetric, .perceptual, .saturation]) + func boundedDisplayP3Conversion(intent: CGColorRenderingIntent) throws { + let p3 = try #require(CGColorSpace(name: CGColorSpace.displayP3)) + let srgb = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let red = try #require(CGColor(colorSpace: p3, components: [1, 0, 0, 0.5])) + let converted = try #require(red.converted(to: srgb, intent: intent, options: nil)) + #expect(converted.colorSpace == srgb) + #expect(converted.components == [1, 0, 0, 0.5]) + } + + @Test + func genericColorsConvertToRGB() throws { + let linear = try #require(CGColorSpace(name: CGColorSpace.extendedLinearSRGB)) + let gray = CGColor(gray: 0.5, alpha: 0.25) + try expectComponents(gray.converted(to: linear, intent: .defaultIntent, options: nil), [0.2871746, 0.2871746, 0.2871746, 0.25]) + let gammaGray = CGColor(genericGrayGamma2_2Gray: 0.5, alpha: 0.25) + try expectComponents(gammaGray.converted(to: linear, intent: .defaultIntent, options: nil), [0.2140411, 0.2140411, 0.2140411, 0.25]) + let rgb = CGColor(red: 0.25, green: 0.5, blue: 0.75, alpha: 0.5) + let converted = try #require(rgb.converted(to: linear, intent: .defaultIntent, options: nil)) + try expectComponents(converted, [0.0777045, 0.2932618, 0.5971608, 0.5]) + let generic = try #require(rgb.colorSpace) + try expectComponents(converted.converted(to: generic, intent: .defaultIntent, options: nil), [0.25, 0.5, 0.75, 0.5]) + } + + @Test(arguments: [CGFloat(0.003), 0.02, 0.03125]) + func genericTransferNearBlack(value: CGFloat) throws { + let linear = try #require(CGColorSpace(name: CGColorSpace.extendedLinearSRGB)) + for color in [CGColor(gray: value, alpha: 0.5), CGColor(red: value, green: value, blue: value, alpha: 0.5)] { + let converted = try #require(color.converted(to: linear, intent: .defaultIntent, options: nil)) + try expectComponents(converted, [value / 16, value / 16, value / 16, 0.5]) + let space = try #require(color.colorSpace) + try expectComponents(converted.converted(to: space, intent: .defaultIntent, options: nil), color.components!) + } + } + + @Test + func rgbConvertsToGray() throws { + let linear = try #require(CGColorSpace(name: CGColorSpace.extendedLinearSRGB)) + let gray = try #require(CGColor(gray: 0, alpha: 1).colorSpace) + let gammaGray = try #require(CGColorSpace(name: CGColorSpace.genericGrayGamma2_2)) + let color = try #require(CGColor(colorSpace: linear, components: [0.25, 0.5, 0.75, 0.5])) + try expectComponents(color.converted(to: gray, intent: .defaultIntent, options: nil), [0.649226, 0.5]) + try expectComponents(color.converted(to: gammaGray, intent: .defaultIntent, options: nil), [0.708044, 0.5]) + } +} diff --git a/Tests/OpenCoreGraphicsShimsTests/CGColorSpaceTests.swift b/Tests/OpenCoreGraphicsShimsTests/CGColorSpaceTests.swift new file mode 100644 index 0000000..fa94358 --- /dev/null +++ b/Tests/OpenCoreGraphicsShimsTests/CGColorSpaceTests.swift @@ -0,0 +1,93 @@ +// +// CGColorSpaceTests.swift +// OpenCoreGraphicsShimsTests + +import Foundation +import OpenCoreGraphicsShims +import Testing +#if OPENCOREGRAPHICS_COREGRAPHICS +import class CoreGraphics.CGColor +import class CoreGraphics.CGColorSpace +#else +import class OpenCoreGraphics.CGColor +import class OpenCoreGraphics.CGColorSpace +#endif + +@Suite +struct CGColorSpaceTests { + @Test + func namedSpaces() throws { + for name in [CGColorSpace.sRGB, CGColorSpace.extendedSRGB, + CGColorSpace.linearSRGB, CGColorSpace.extendedLinearSRGB, + CGColorSpace.displayP3] { + let space = try #require(CGColorSpace(name: name)) + #expect(CFEqual(space.name, name)) + #expect(space.model == .rgb) + #expect(space.numberOfComponents == 3) + } + let gray = try #require(CGColorSpace(name: CGColorSpace.genericGrayGamma2_2)) + #expect(gray.model == .monochrome) + #expect(gray.numberOfComponents == 1) + } + + @Test + func unknownName() { + let name = CFStringCreateWithCString(nil, "UnknownColorSpace", CFStringBuiltInEncodings.UTF8.rawValue)! + #expect(CGColorSpace(name: name) == nil) + } + + @Test + func equalityAndHash() throws { + let first = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let second = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + #expect(first == second) + #expect(first.hashValue == second.hashValue) + #expect(Set([first, second]).count == 1) + #expect(first != CGColorSpace(name: CGColorSpace.extendedSRGB)) + #expect(first != CGColorSpace(name: CGColorSpace.linearSRGB)) + } + + @Test + func convenienceColorSpaces() throws { + let srgb = CGColor(srgbRed: 0.25, green: 0.5, blue: 0.75, alpha: 1) + #expect(srgb.colorSpace == CGColorSpace(name: CGColorSpace.sRGB)) + #expect(CGColor.black.colorSpace == CGColorSpace(name: CGColorSpace.genericGrayGamma2_2)) + #expect(CGColor(red: 0, green: 0, blue: 0, alpha: 1).colorSpace?.model == .rgb) + #expect(CGColor(gray: 0, alpha: 1).colorSpace?.model == .monochrome) + } + + @Test + func componentStorageAndCopy() throws { + let space = try #require(CGColorSpace(name: CGColorSpace.extendedSRGB)) + var values: [CGFloat] = [-0.25, 0.5, 1.25, 1.5] + let color = try #require(CGColor(colorSpace: space, components: &values)) + values[0] = 1 + #expect(color.components == [-0.25, 0.5, 1.25, 1]) + #expect(color.colorSpace == space) + let copy = try #require(color.copy(alpha: 1.5)) + #expect(copy.colorSpace == space) + #expect(copy.components == [-0.25, 0.5, 1.25, 1.5]) + } + + @Test + func boundedAndExtendedComponents() throws { + for name in [CGColorSpace.sRGB, CGColorSpace.linearSRGB, CGColorSpace.displayP3] { + let space = try #require(CGColorSpace(name: name)) + let color = try #require(CGColor(colorSpace: space, components: [-0.25, 0.5, 1.25, -1])) + #expect(color.components == [0, 0.5, 1, 0]) + } + for name in [CGColorSpace.extendedSRGB, CGColorSpace.extendedLinearSRGB] { + let space = try #require(CGColorSpace(name: name)) + let color = try #require(CGColor(colorSpace: space, components: [.nan, .infinity, -.infinity, .nan])) + let components = try #require(color.components) + #expect(components[0].isNaN) + #expect(components[1] == .infinity) + #expect(components[2] == -.infinity) + #expect(color.alpha.isNaN) + } + let gray = try #require(CGColorSpace(name: CGColorSpace.genericGrayGamma2_2)) + let color = try #require(CGColor(colorSpace: gray, components: [0.25, 0.5])) + #expect(color.components == [0.25, 0.5]) + #expect(color == CGColor(genericGrayGamma2_2Gray: 0.25, alpha: 0.5)) + } +}