Created
August 17, 2025 15:35
-
-
Save khanlou/808c8f4b16d14d5d97119e544eb716d7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Naive port of https://github.com/Myndex/apca-w3/blob/master/src/apca-w3.js | |
import Foundation | |
enum SA98G { | |
static let mainTRC = 2.4 // 2.4 exponent for emulating actual monitor perception | |
// For reverseAPCA | |
static func mainTRCencode() -> Double { return 1 / mainTRC } | |
// sRGB coefficients | |
static let sRco = 0.2126729 | |
static let sGco = 0.7151522 | |
static let sBco = 0.0721750 | |
// G-4g constants for use with 2.4 exponent | |
static let normBG = 0.56 | |
static let normTXT = 0.57 | |
static let revTXT = 0.62 | |
static let revBG = 0.65 | |
// G-4g Clamps and Scalers | |
static let blkThrs = 0.022 | |
static let blkClmp = 1.414 | |
static let scaleBoW = 1.14 | |
static let scaleWoB = 1.14 | |
static let loBoWoffset = 0.027 | |
static let loWoBoffset = 0.027 | |
static let deltaYmin = 0.0005 | |
static let loClip = 0.1 | |
///// MAGIC NUMBERS for UNCLAMP, for use with 0.022 & 1.414 ///// | |
// Magic Numbers for reverseAPCA | |
static let mFactor = 1.94685544331710 | |
static func mFactInv() -> Double { return 1 / mFactor } | |
static let mOffsetIn = 0.03873938165714010 | |
static let mExpAdj = 0.2833433964208690 | |
static func mExp() -> Double { return mExpAdj / blkClmp } | |
static let mOffsetOut = 0.3128657958707580 | |
} | |
struct ColorComponents { | |
let r: Double | |
let g: Double | |
let b: Double | |
////////// ƒ sRGBtoY() ////////////////////////////////////////////////// | |
var luminanceInSRGB: Double { // send sRGB 8bpc (0xFFFFFF) or string | |
func simpleExp(_ chan: Double) -> Double { return pow(chan/255.0, SA98G.mainTRC); }; | |
return SA98G.sRco * simpleExp(r) + SA98G.sGco * simpleExp(g) + SA98G.sBco * simpleExp(b); | |
} // End sRGBtoY() | |
////////// ƒ displayP3toY() ///////////////////////////////////////////// | |
var luminanceInDisplayP3: Double { | |
let mainTRC = 2.4; // 2.4 exponent emulates actual monitor perception | |
let sRco = 0.2289829594805780, | |
sGco = 0.6917492625852380, | |
sBco = 0.0792677779341829; // displayP3 coefficients | |
func simpleExp(_ chan: Double) -> Double { return pow(chan, mainTRC); }; | |
return sRco * simpleExp(r) + sGco * simpleExp(g) + sBco * simpleExp(b); | |
} | |
////////// ƒ adobeRGBtoY() ///////////////////////////////////////////// | |
var luminanceInAdobeRGB: Double { | |
let mainTRC = 2.35; // 2.35 exponent emulates actual monitor perception | |
let sRco = 0.2973550227113810, | |
sGco = 0.6273727497145280, | |
sBco = 0.0752722275740913; // adobeRGB coefficients | |
func simpleExp(_ chan: Double) -> Double { return pow(chan/255.0, mainTRC); }; | |
return sRco * simpleExp(r) + sGco * simpleExp(g) + sBco * simpleExp(b); | |
} | |
} | |
func APCAcontrast(txtY: Double, bgY: Double, places: Int = -1) -> Double { | |
// send linear Y (luminance) for text and background. | |
// txtY and bgY must be between 0.0-1.0 | |
// IMPORTANT: Do not swap, polarity is important. | |
let icp = 0.0...1.1 // input range clamp / input error check | |
if (txtY.isNaN || bgY.isNaN || min(txtY,bgY) < icp.lowerBound || max(txtY,bgY) > icp.upperBound) { | |
return 0.0; // return zero on error | |
}; | |
////////// SAPC LOCAL VARS ///////////////////////////////////////// | |
var SAPC = 0.0; // For raw SAPC values | |
var outputContrast = 0.0; // For weighted final values | |
// TUTORIAL | |
// Use Y for text and BG, and soft clamp black, | |
// return 0 for very close luminances, determine | |
// polarity, and calculate SAPC raw contrast | |
// Then scale for easy to remember levels. | |
// Note that reverse contrast (white text on black) | |
// intentionally returns a negative number | |
// Proper polarity is important! | |
////////// BLACK SOFT CLAMP //////////////////////////////////////// | |
// Soft clamps Y for either color if it is near black. | |
let txtY = (txtY > SA98G.blkThrs) | |
? txtY | |
: txtY + pow(SA98G.blkThrs - txtY, SA98G.blkClmp); | |
let bgY = (bgY > SA98G.blkThrs) | |
? bgY | |
: bgY + pow(SA98G.blkThrs - bgY, SA98G.blkClmp); | |
///// Return 0 Early for extremely low ∆Y | |
if (abs(bgY - txtY) < SA98G.deltaYmin ) { return 0.0; } | |
////////// APCA/SAPC CONTRAST - LOW CLIP (W3 LICENSE) /////////////// | |
if ( bgY > txtY ) { // For normal polarity, black text on white (BoW) | |
// Calculate the SAPC contrast value and scale | |
SAPC = ( pow(bgY, SA98G.normBG) - pow(txtY, SA98G.normTXT) ) * SA98G.scaleBoW; | |
// Low Contrast smooth rollout to prevent polarity reversal | |
// and also a low-clip for very low contrasts | |
outputContrast = (SAPC < SA98G.loClip) ? 0.0 : SAPC - SA98G.loBoWoffset; | |
} else { // For reverse polarity, light text on dark (WoB) | |
SAPC = ( pow(bgY, SA98G.revBG) - | |
pow(txtY, SA98G.revTXT) ) * SA98G.scaleWoB; | |
outputContrast = (SAPC > -SA98G.loClip) ? 0.0 : SAPC + SA98G.loWoBoffset; | |
} | |
// return Lc (lightness contrast) as a signed numeric value | |
// Round to the nearest whole number as string is optional. | |
// Rounded can be a signed INT as output will be within ± 127 | |
// places = -1 returns signed float, 1 or more set that many places | |
// 0 returns rounded string, uses BoW or WoB instead of minus sign | |
if (places < 0 ){ // Default (-1) number out, all others are strings | |
return outputContrast * 100.0; | |
} else if (places == 0 ){ | |
return round(abs(outputContrast)*100.0)//+'<sub>'+polCat+'</sub>'; | |
} else { return 0.0 } | |
} // End APCAcontrast() | |
////////// ƒ calcAPCA() ///////////////////////////////////////////// | |
func calcAPCA (textColor: ColorComponents, bgColor: ColorComponents, places: Int = -1, round: Bool = true) -> Double { | |
return APCAcontrast( txtY: textColor.luminanceInSRGB, bgY: bgColor.luminanceInSRGB, places: places) | |
} // End calcAPCA() | |
////////////////////////////////////////////////////////////////////////////// | |
////////// ƒ fontLookupAPCA() 0.1.7 (G) \//////////////////////////////// | |
///////// \////////////////////////////// | |
func fontLookupAPCA(contrast: Double, places: Int = 2) -> [Double] { | |
//////////////////////////////////////////////////////////////////////////// | |
///// CONTRAST * FONT WEIGHT & SIZE ///////////////////////////////////// | |
// Font size interpolations. Here the chart was re-ordered to put | |
// the main contrast levels each on one line, instead of font size per line. | |
// First column is LC value, then each following column is font size by weight | |
// G G G G G G Public Beta 0.1.7 (G) • MAY 28 2022 | |
// Lc values under 70 should have Lc 15 ADDED if used for body text | |
// All font sizes are in px and reference font is Barlow | |
// 999: prohibited - too low contrast | |
// 777: NON TEXT at this minimum weight stroke | |
// 666 - this is for spot text, not fluent-Things like copyright or placeholder. | |
// 5xx - minimum font at this weight for content, 5xx % 500 for font-size | |
// 4xx - minimum font at this weight for any purpose], 4xx % 400 for font-size | |
// MAIN FONT SIZE LOOKUP | |
//// ASCENDING SORTED Public Beta 0.1.7 (G) • MAY 28 2022 //// | |
//// Lc 45 * 0.2 = 9 which is the index for the row for Lc 45 | |
// MAIN FONT LOOKUP May 28 2022 EXPANDED | |
// Sorted by Lc Value | |
// First row is standard weights 100-900 | |
// First column is font size in px | |
// All other values are the Lc contrast | |
// 999 = too low. 777 = non-text and spot text only | |
let fontMatrixAscend = [ | |
[-1,100,200,300,400,500,600,700,800,900], | |
[0,999,999,999,999,999,999,999,999,999], | |
[10,999,999,999,999,999,999,999,999,999], | |
[15,777,777,777,777,777,777,777,777,777], | |
[20,777,777,777,777,777,777,777,777,777], | |
[25,777,777,777,120,120,108,96,96,96], | |
[30,777,777,120,108,108,96,72,72,72], | |
[35,777,120,108,96,72,60,48,48,48], | |
[40,120,108,96,60,48,42,32,32,32], | |
[45,108,96,72,42,32,28,24,24,24], | |
[50,96,72,60,32,28,24,21,21,21], | |
[55,80,60,48,28,24,21,18,18,18], | |
[60,72,48,42,24,21,18,16,16,18], | |
[65,68,46,32,21.75,19,17,15,16,18], | |
[70,64,44,28,19.5,18,16,14.5,16,18], | |
[75,60,42,24,18,16,15,14,16,18], | |
[80,56,38.25,23,17.25,15.81,14.81,14,16,18], | |
[85,52,34.5,22,16.5,15.625,14.625,14,16,18], | |
[90,48,32,21,16,15.5,14.5,14,16,18], | |
[95,45,28,19.5,15.5,15,14,13.5,16,18], | |
[100,42,26.5,18.5,15,14.5,13.5,13,16,18], | |
[105,39,25,18,14.5,14,13,12,16,18], | |
[110,36,24,18,14,13,12,11,16,18], | |
[115,34.5,22.5,17.25,12.5,11.875,11.25,10.625,14.5,16.5], | |
[120,33,21,16.5,11,10.75,10.5,10.25,13,15], | |
[125,32,20,16,10,10,10,10,12,14], | |
]; | |
// ASCENDING SORTED Public Beta 0.1.7 (G) • MAY 28 2022 //// | |
// DELTA - MAIN FONT LOOKUP May 28 2022 EXPANDED | |
// EXPANDED Sorted by Lc Value •• DELTA | |
// The pre-calculated deltas of the above array | |
let fontDeltaAscend = [ | |
[-1,100,200,300,400,500,600,700,800,900], | |
[0,0,0,0,0,0,0,0,0,0], | |
[10,0,0,0,0,0,0,0,0,0], | |
[15,0,0,0,0,0,0,0,0,0], | |
[20,0,0,0,0,0,0,0,0,0], | |
[25,0,0,0,12,12,12,24,24,24], | |
[30,0,0,12,12,36,36,24,24,24], | |
[35,0,12,12,36,24,18,16,16,16], | |
[40,12,12,24,18,16,14,8,8,8], | |
[45,12,24,12,10,4,4,3,3,3], | |
[50,16,12,12,4,4,3,3,3,3], | |
[55,8,12,6,4,3,3,2,2,0], | |
[60,4,2,10,2.25,2,1,1,0,0], | |
[65,4,2,4,2.25,1,1,0.5,0,0], | |
[70,4,2,4,1.5,2,1,0.5,0,0], | |
[75,4,3.75,1,0.75,0.188,0.188,0,0,0], | |
[80,4,3.75,1,0.75,0.188,0.188,0,0,0], | |
[85,4,2.5,1,0.5,0.125,0.125,0,0,0], | |
[90,3,4,1.5,0.5,0.5,0.5,0.5,0,0], | |
[95,3,1.5,1,0.5,0.5,0.5,0.5,0,0], | |
[100,3,1.5,0.5,0.5,0.5,0.5,1,0,0], | |
[105,3,1,0,0.5,1,1,1,0,0], | |
[110,1.5,1.5,0.75,1.5,1.125,0.75,0.375,1.5,1.5], | |
[115,1.5,1.5,0.75,1.5,1.125,0.75,0.375,1.5,1.5], | |
[120,1,1,0.5,1,0.75,0.5,0.25,1,1], | |
[125,0,0,0,0,0,0,0,0,0], | |
]; | |
// APCA CONTRAST FONT LOOKUP TABLES | |
// Copyright © 2022 by Myndex Research and Andrew Somers. All Rights Reserved | |
// Public Beta 0.1.7 (G) • MAY 28 2022 | |
// For the following arrays, the Y axis is contrastArrayLen | |
// The two x axis are weightArrayLen and scoreArrayLen | |
// MAY 28 2022 | |
let weightArray = [0,100,200,300,400,500,600,700,800,900]; | |
let weightArrayLen = weightArray.count; // X axis | |
var returnArray = [contrast.rounded(),0,0,0,0,0,0,0,0,0,]; | |
let returnArrayLen = returnArray.count; // X axis | |
let contrastArrayAscend = [-1,0,10,15,20,25,30,35,40,45,50,55,60,65,70,75,80,85,90,95,100,105,110,115,120,125,]; | |
let contrastArrayLenAsc = contrastArrayAscend.count; // Y azis | |
//// Lc 45 * 0.2 = 9, and 9 is the index for the row for Lc 45 | |
var tempFont: Double = 777; | |
let contrast = abs(contrast); // Polarity unneeded for LUT | |
let factor = 0.2; // 1/5 as LUT is in increments of 5 | |
let index = (contrast == 0) ? 1 : Int((contrast * factor)) | 0 ; // LUT row... n|0 is bw floor | |
var w = 0; | |
// scoreAdj interpolates the needed font side per the Lc | |
var scoreAdj = (contrast - fontMatrixAscend[index][w]) * factor; | |
w+=1; // determines column in font matrix LUT | |
///////// Font and Score Interpolation \///////////////////////////////// | |
// populate returnArray with interpolated values | |
while w < weightArrayLen { | |
defer { w+=1 } | |
tempFont = fontMatrixAscend[index][w]; | |
if (tempFont > 400) { // declares a specific minimum for the weight. | |
returnArray[w] = tempFont; | |
} else if (contrast < 14.5 ) { | |
returnArray[w] = 999; // 999 = do not use for anything | |
} else if (contrast < 29.5 ) { | |
returnArray[w] = 777; // 777 = non-text only | |
} else { | |
// INTERPOLATION OF FONT SIZE | |
// sets level for 0.5px size increments of smaller fonts | |
// Note bitwise (n|0) instead of floor | |
returnArray[w] = (tempFont > 24) | |
? round(tempFont - (fontDeltaAscend[index][w] * scoreAdj)) | |
: tempFont - (2.0 * fontDeltaAscend[index][w] * scoreAdj) * 0.5; | |
// (n|0) is bitwise floor | |
} | |
} | |
///////// End Interpolation //////////////////////////////////////////// | |
return returnArray | |
} // end fontLookupAPCA | |
/////////\ ///////////////////////////\ | |
//////////\ END fontLookupAPCA() 0.1.7 (G) /////////////////////////////\ | |
/////////////////////////////////////////////////////////////////////////////\ | |
////////////////////////////////////////////////////////////////////////////// | |
//////////// UTILITIES \/////////////////////////////////////////////////// | |
// | |
// | |
//////////// ƒ alphaBlend() ///////////////////////////////////////////// | |
// | |
//// send rgba array for text/icon, rgb for background. | |
//// Only foreground allows alpha of 0.0 to 1.0 | |
//// This blends using gamma encoded space (standard) | |
//// rounded 0-255 or set round=false for number 0.0-255.0 | |
//func alphaBlend (rgbaFG: [Double] = [0,0,0,1.0], rgbBG: [Double] = [0,0,0], round: Bool = true ) { | |
// | |
// rgbaFG[3] = max(min(rgbaFG[3], 1.0), 0.0); // clamp alpha 0-1 | |
// let compBlend = 1.0 - rgbaFG[3]; | |
// let rgbOut = [0,0,0,1,true]; // or just use rgbBG to retain other elements? | |
// | |
// for (let i=0;i<3;i++) { | |
// rgbOut[i] = rgbBG[i] * compBlend + rgbaFG[i] * rgbaFG[3]; | |
// if (round) rgbOut[i] = min(round(rgbOut[i]),255); | |
// }; | |
// return rgbOut; | |
//} // End alphaBlend() | |
//\ //////////////////////////////////////// | |
///\ //////////////////////////////////////// | |
////\ //////////////////////////////////////// | |
/////\ END APCA 0.1.9 G-4g BLOCK //////////////////////////////////////// | |
//////////////////////////////////////////////////////////////////////////// | |
/////////////////////////////////////////////////////////////////////////// |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment