Skip to content

Instantly share code, notes, and snippets.

@khanlou
Created August 17, 2025 15:35
Show Gist options
  • Save khanlou/808c8f4b16d14d5d97119e544eb716d7 to your computer and use it in GitHub Desktop.
Save khanlou/808c8f4b16d14d5d97119e544eb716d7 to your computer and use it in GitHub Desktop.
// 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