Add Color conversion options

This commit is contained in:
2021-07-08 13:17:15 +02:00
parent 57edc59853
commit 9e43f5b746
4 changed files with 390 additions and 266 deletions

View File

@@ -5,6 +5,25 @@ import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
private val hexString = "0123456789abcdef"
private fun Int.toColorHex(minimumDigits: Int = 2): String {
val result = StringBuilder()
var value = this
while(value > 0) {
result.append(hexString[value%16])
value /= 16
}
while(result.length < minimumDigits) {
result.append("0")
}
return result.reverse().toString()
}
/**
* See [CSS Color Module Level 3](https://www.w3.org/TR/2018/REC-css-color-3-20180619/)
*
@@ -30,6 +49,340 @@ class Color(value: String) : CssProperty(value) {
this.rgb = rgb
}
fun hasAlpha(): Boolean = isRgba() || isHexa() || isHsla()
fun getAlpha(): Double = when {
isHexa() || isRgba() -> {
toRGBA().alpha
}
isHsla() -> {
fromHSLANotation().alpha
}
else -> {
1.0
}
}
fun toHex(): String = toRGBA().asHex()
fun isHsla(): Boolean {
val v = rgb ?: value
return v.startsWith("hsla")
}
fun isHsl(): Boolean {
val v = rgb ?: value
return v.startsWith("hsl(")
}
fun isRgba(): Boolean {
val v = rgb ?: value
return v.startsWith("rgba(")
}
fun isRgb(): Boolean {
val v = rgb ?: value
return v.startsWith("rgb(")
}
fun isHex(): Boolean {
val v = rgb ?: value
return v.startsWith("#") && v.length < 8
}
fun isHexa(): Boolean {
val v = rgb ?: value
return v.startsWith("#") && v.length > 7
}
/**
* withAlpha preserves existing alpha value: rgba(0, 0, 0, 0.5).withAlpha(0.1) = rgba(0, 0, 0, 0.05)
*/
fun withAlpha(alpha: Double) =
when {
value.startsWith("hsl", true) -> with(fromHSLANotation()) { hsla(hue, saturation, lightness, normalizeAlpha(alpha) * this.alpha) }
else -> with(toRGBA()) { rgba(red, green, blue, normalizeAlpha(alpha) * this.alpha) }
}
/**
* changeAlpha rewrites existing alpha value: rgba(0, 0, 0, 0.5).withAlpha(0.1) = rgba(0, 0, 0, 0.1)
*/
fun changeAlpha(alpha: Double) =
when {
value.startsWith("hsl", true) -> with(fromHSLANotation()) { hsla(hue, saturation, lightness, normalizeAlpha(alpha)) }
else -> with(toRGBA()) { rgba(red, green, blue, normalizeAlpha(alpha)) }
}
// https://stackoverflow.com/questions/2049230/convert-rgba-color-to-rgb
fun blend(backgroundColor: Color): Color {
val source = this.toRGBA()
val background = backgroundColor.toRGBA()
val targetR = ((1 - source.alpha) * background.red) + (source.alpha * source.red)
val targetG = ((1 - source.alpha) * background.green) + (source.alpha * source.green)
val targetB = ((1 - source.alpha) * background.blue) + (source.alpha * source.blue)
return rgb(targetR.roundToInt(), targetG.roundToInt(), targetB.roundToInt())
}
/**
* Lighten the color by the specified percent (between 0-100), returning a new instance of Color.
*
* @param percent the percent to lighten the Color
* @return a new lightened version of this color
*/
fun lighten(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val lightness = hsla.lightness + (hsla.lightness * (normalizePercent(percent) / 100.0)).roundToInt()
val newHSLa = hsla.copy(lightness = normalizePercent(lightness))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
/**
* Darken the color by the specified percent (between 0-100), returning a new instance of Color.
*
* @param percent the percent to darken the Color
* @return a new darkened version of this color
*/
fun darken(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val darkness = hsla.lightness - (hsla.lightness * (normalizePercent(percent) / 100.0)).roundToInt()
val newHSLa = hsla.copy(lightness = normalizePercent(darkness))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
/**
* Increase contrast, if lightness > 50 then darken else lighten
*
* @param percent the percent to lighten/darken the Color
* @return a new ligtened/darkened version of this color
*/
fun contrast(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val darkness = if (hsla.lightness > 50) {
hsla.lightness - (hsla.lightness * (normalizePercent(percent) / 100.0)).roundToInt()
} else {
hsla.lightness + (hsla.lightness * (normalizePercent(percent) / 100.0)).roundToInt()
}
val newHSLa = hsla.copy(lightness = normalizePercent(darkness))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
/**
* Saturate the color by the specified percent (between 0-100), returning a new instance of Color.
*
* @param percent the percent to saturate the Color
* @return a new saturated version of this color
*/
fun saturate(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val saturation = hsla.saturation + (hsla.saturation * (normalizePercent(percent) / 100.0)).roundToInt()
val newHSLa = hsla.copy(saturation = normalizePercent(saturation))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
/**
* Desaturate the color by the specified percent (between 0-100), returning a new instance of Color.
*
* @param percent the percent to desaturate the Color
* @return a new desaturated version of this color
*/
fun desaturate(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val desaturation = hsla.saturation - (hsla.saturation * (normalizePercent(percent) / 100.0)).roundToInt()
val newHSLa = hsla.copy(saturation = normalizePercent(desaturation))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
internal data class RGBA(
val red: Int,
val green: Int,
val blue: Int,
val alpha: Double = 1.0
) {
// Algorithm adapted from http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/
fun asHSLA(): HSLA {
// scale R, G, B values into 0..1 fractions
val r = red / 255.0
val g = green / 255.0
val b = blue / 255.0
val cMax = maxOf(r, g, b)
val cMin = minOf(r, g, b)
val chroma = cMax - cMin
val lg = normalizeFractionalPercent((cMax + cMin) / 2)
val s = if (chroma != 0.0) normalizeFractionalPercent(chroma / (1.0 - abs((2.0 * lg) - 1.0))) else 0.0
val h = when (cMax) {
cMin -> 0.0
r -> 60 * (((g - b) / chroma) % 6.0)
g -> 60 * (((b - r) / chroma) + 2)
b -> 60 * (((r - g) / chroma) + 4)
else -> error("Unexpected value for max") // theoretically unreachable bc maxOf(r, g, b) above
}
return HSLA(normalizeHue(h), (s * 100).roundToInt(), (lg * 100).roundToInt(), alpha)
}
fun asHex(): String {
val result = StringBuilder()
result.append(red.toColorHex(2))
result.append(green.toColorHex(2))
result.append(blue.toColorHex(2))
return result.toString()
}
}
internal data class HSLA(
val hue: Int,
val saturation: Int,
val lightness: Int,
val alpha: Double = 1.0
) {
// Algorithm from W3C link referenced in class comment (section 4.2.4. HSL color values)
fun asRGBA(): RGBA {
fun hueToRGB(m1: Double, m2: Double, h: Double): Double {
val hu = if (h < 0) h + 1 else if (h > 1) h - 1 else h
return when {
(hu < 1.0 / 6) -> m1 + (m2 - m1) * 6 * hu
(hu < 1.0 / 2) -> m2
(hu < 2.0 / 3) -> m1 + ((m2 - m1) * 6 * (2.0 / 3 - hu))
else -> m1
}
}
if (saturation == 0) return RGBA(lightness, lightness, lightness)
// scale H, S, V values into 0..1 fractions
val h = (hue % 360.0) / 360.0
val s = saturation / 100.0
val lg = lightness / 100.0
val m2 = if (lg < 0.5) lg * (1 + s) else (lg + s - lg * s)
val m1 = 2 * lg - m2
val r = normalizeFractionalPercent(hueToRGB(m1, m2, h + (1.0 / 3)))
val g = normalizeFractionalPercent(hueToRGB(m1, m2, h))
val b = normalizeFractionalPercent(hueToRGB(m1, m2, h - (1.0 / 3)))
return RGBA((r * 255).roundToInt(), (g * 255).roundToInt(), (b * 255).roundToInt(), alpha)
}
}
internal fun fromHSLANotation(): HSLA {
val match = HSLA_REGEX.find(value)
fun getHSLParameter(index: Int) =
match?.groups?.get(index)?.value
?: throw IllegalArgumentException("Expected hsl or hsla notation, got $value")
val hueShape = getHSLParameter(1)
val hue = normalizeHue(
when {
hueShape.endsWith("grad", true) -> hueShape.substringBefore("grad").toDouble() * (9.0 / 10)
hueShape.endsWith("rad", true) -> (hueShape.substringBefore("rad").toDouble() * 180) / PI
hueShape.endsWith("turn", true) -> hueShape.substringBefore("turn").toDouble() * 360.0
hueShape.endsWith("deg", true) -> hueShape.substringBefore("deg").toDouble()
else -> hueShape.toDouble()
}
)
val saturation = normalizePercent(getHSLParameter(2).toInt())
val lightness = normalizePercent(getHSLParameter(3).toInt())
val alpha = normalizeAlpha(match?.groups?.get(4)?.value?.toDouble() ?: 1.0)
return HSLA(hue, saturation, lightness, alpha)
}
internal fun fromRGBANotation(): RGBA {
val match = RGBA_REGEX.find(value)
fun getRGBParameter(index: Int): Int {
val group = match?.groups?.get(index)?.value
?: throw IllegalArgumentException("Expected rgb or rgba notation, got $value")
return when {
(group.endsWith('%')) -> (normalizeFractionalPercent(group.substringBefore('%').toDouble() / 100.0) * 255.0).toInt()
else -> normalizeRGB(group.toInt())
}
}
val red = getRGBParameter(1)
val green = getRGBParameter(2)
val blue = getRGBParameter(3)
val alpha = normalizeAlpha(match?.groups?.get(4)?.value?.toDouble() ?: 1.0)
return RGBA(red, green, blue, alpha)
}
internal fun toRGBA(): RGBA {
val v = rgb ?: value
return when {
v.startsWith("rgb") -> fromRGBANotation()
// Matches #rgb
v.startsWith("#") && v.length == 4 -> RGBA(
"${v[1]}${v[1]}".toInt(16),
"${v[2]}${v[2]}".toInt(16),
"${v[3]}${v[3]}".toInt(16)
)
// Matches both #rrggbb
v.startsWith("#") && v.length == 7 -> RGBA(
(v.substring(1..2)).toInt(16),
(v.substring(3..4)).toInt(16),
(v.substring(5..6)).toInt(16)
)
// Matches both #rrggbbaa
v.startsWith("#") && v.length == 9 -> RGBA(
(v.substring(1..2)).toInt(16),
(v.substring(3..4)).toInt(16),
(v.substring(5..6)).toInt(16),
(v.substring(7..8)).toInt(16) / 255.0
)
else -> throw IllegalArgumentException("Only hexadecimal, rgb, and rgba notations are accepted, got $v")
}
}
companion object {
val initial = Color("initial")
val inherit = Color("inherit")
@@ -219,270 +572,6 @@ class Color(value: String) : CssProperty(value) {
)
}
}
/**
* withAlpha preserves existing alpha value: rgba(0, 0, 0, 0.5).withAlpha(0.1) = rgba(0, 0, 0, 0.05)
*/
fun withAlpha(alpha: Double) =
when {
value.startsWith("hsl", true) -> with(fromHSLANotation()) { hsla(hue, saturation, lightness, normalizeAlpha(alpha) * this.alpha) }
else -> with(toRGBA()) { rgba(red, green, blue, normalizeAlpha(alpha) * this.alpha) }
}
/**
* changeAlpha rewrites existing alpha value: rgba(0, 0, 0, 0.5).withAlpha(0.1) = rgba(0, 0, 0, 0.1)
*/
fun changeAlpha(alpha: Double) =
when {
value.startsWith("hsl", true) -> with(fromHSLANotation()) { hsla(hue, saturation, lightness, normalizeAlpha(alpha)) }
else -> with(toRGBA()) { rgba(red, green, blue, normalizeAlpha(alpha)) }
}
// https://stackoverflow.com/questions/2049230/convert-rgba-color-to-rgb
fun blend(backgroundColor: Color): Color {
val source = this.toRGBA()
val background = backgroundColor.toRGBA()
val targetR = ((1 - source.alpha) * background.red) + (source.alpha * source.red)
val targetG = ((1 - source.alpha) * background.green) + (source.alpha * source.green)
val targetB = ((1 - source.alpha) * background.blue) + (source.alpha * source.blue)
return rgb(targetR.roundToInt(), targetG.roundToInt(), targetB.roundToInt())
}
/**
* Lighten the color by the specified percent (between 0-100), returning a new instance of Color.
*
* @param percent the percent to lighten the Color
* @return a new lightened version of this color
*/
fun lighten(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val lightness = hsla.lightness + (hsla.lightness * (normalizePercent(percent) / 100.0)).roundToInt()
val newHSLa = hsla.copy(lightness = normalizePercent(lightness))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
/**
* Darken the color by the specified percent (between 0-100), returning a new instance of Color.
*
* @param percent the percent to darken the Color
* @return a new darkened version of this color
*/
fun darken(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val darkness = hsla.lightness - (hsla.lightness * (normalizePercent(percent) / 100.0)).roundToInt()
val newHSLa = hsla.copy(lightness = normalizePercent(darkness))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
/**
* Increase contrast, if lightness > 50 then darken else lighten
*
* @param percent the percent to lighten/darken the Color
* @return a new ligtened/darkened version of this color
*/
fun contrast(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val darkness = if (hsla.lightness > 50) {
hsla.lightness - (hsla.lightness * (normalizePercent(percent) / 100.0)).roundToInt()
} else {
hsla.lightness + (hsla.lightness * (normalizePercent(percent) / 100.0)).roundToInt()
}
val newHSLa = hsla.copy(lightness = normalizePercent(darkness))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
/**
* Saturate the color by the specified percent (between 0-100), returning a new instance of Color.
*
* @param percent the percent to saturate the Color
* @return a new saturated version of this color
*/
fun saturate(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val saturation = hsla.saturation + (hsla.saturation * (normalizePercent(percent) / 100.0)).roundToInt()
val newHSLa = hsla.copy(saturation = normalizePercent(saturation))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
/**
* Desaturate the color by the specified percent (between 0-100), returning a new instance of Color.
*
* @param percent the percent to desaturate the Color
* @return a new desaturated version of this color
*/
fun desaturate(percent: Int): Color {
val isHSLA = value.startsWith("hsl", ignoreCase = true)
val hsla = if (isHSLA) fromHSLANotation() else toRGBA().asHSLA()
val desaturation = hsla.saturation - (hsla.saturation * (normalizePercent(percent) / 100.0)).roundToInt()
val newHSLa = hsla.copy(saturation = normalizePercent(desaturation))
return if (isHSLA) {
hsla(newHSLa.hue, newHSLa.saturation, newHSLa.lightness, newHSLa.alpha)
} else {
with(newHSLa.asRGBA()) { rgba(red, green, blue, alpha) }
}
}
internal data class RGBA(
val red: Int,
val green: Int,
val blue: Int,
val alpha: Double = 1.0
) {
// Algorithm adapted from http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/
fun asHSLA(): HSLA {
// scale R, G, B values into 0..1 fractions
val r = red / 255.0
val g = green / 255.0
val b = blue / 255.0
val cMax = maxOf(r, g, b)
val cMin = minOf(r, g, b)
val chroma = cMax - cMin
val lg = normalizeFractionalPercent((cMax + cMin) / 2)
val s = if (chroma != 0.0) normalizeFractionalPercent(chroma / (1.0 - abs((2.0 * lg) - 1.0))) else 0.0
val h = when (cMax) {
cMin -> 0.0
r -> 60 * (((g - b) / chroma) % 6.0)
g -> 60 * (((b - r) / chroma) + 2)
b -> 60 * (((r - g) / chroma) + 4)
else -> error("Unexpected value for max") // theoretically unreachable bc maxOf(r, g, b) above
}
return HSLA(normalizeHue(h), (s * 100).roundToInt(), (lg * 100).roundToInt(), alpha)
}
}
internal data class HSLA(
val hue: Int,
val saturation: Int,
val lightness: Int,
val alpha: Double = 1.0
) {
// Algorithm from W3C link referenced in class comment (section 4.2.4. HSL color values)
fun asRGBA(): RGBA {
fun hueToRGB(m1: Double, m2: Double, h: Double): Double {
val hu = if (h < 0) h + 1 else if (h > 1) h - 1 else h
return when {
(hu < 1.0 / 6) -> m1 + (m2 - m1) * 6 * hu
(hu < 1.0 / 2) -> m2
(hu < 2.0 / 3) -> m1 + ((m2 - m1) * 6 * (2.0 / 3 - hu))
else -> m1
}
}
if (saturation == 0) return RGBA(lightness, lightness, lightness)
// scale H, S, V values into 0..1 fractions
val h = (hue % 360.0) / 360.0
val s = saturation / 100.0
val lg = lightness / 100.0
val m2 = if (lg < 0.5) lg * (1 + s) else (lg + s - lg * s)
val m1 = 2 * lg - m2
val r = normalizeFractionalPercent(hueToRGB(m1, m2, h + (1.0 / 3)))
val g = normalizeFractionalPercent(hueToRGB(m1, m2, h))
val b = normalizeFractionalPercent(hueToRGB(m1, m2, h - (1.0 / 3)))
return RGBA((r * 255).roundToInt(), (g * 255).roundToInt(), (b * 255).roundToInt(), alpha)
}
}
internal fun fromHSLANotation(): HSLA {
val match = HSLA_REGEX.find(value)
fun getHSLParameter(index: Int) =
match?.groups?.get(index)?.value
?: throw IllegalArgumentException("Expected hsl or hsla notation, got $value")
val hueShape = getHSLParameter(1)
val hue = normalizeHue(
when {
hueShape.endsWith("grad", true) -> hueShape.substringBefore("grad").toDouble() * (9.0 / 10)
hueShape.endsWith("rad", true) -> (hueShape.substringBefore("rad").toDouble() * 180) / PI
hueShape.endsWith("turn", true) -> hueShape.substringBefore("turn").toDouble() * 360.0
hueShape.endsWith("deg", true) -> hueShape.substringBefore("deg").toDouble()
else -> hueShape.toDouble()
}
)
val saturation = normalizePercent(getHSLParameter(2).toInt())
val lightness = normalizePercent(getHSLParameter(3).toInt())
val alpha = normalizeAlpha(match?.groups?.get(4)?.value?.toDouble() ?: 1.0)
return HSLA(hue, saturation, lightness, alpha)
}
internal fun fromRGBANotation(): RGBA {
val match = RGBA_REGEX.find(value)
fun getRGBParameter(index: Int): Int {
val group = match?.groups?.get(index)?.value
?: throw IllegalArgumentException("Expected rgb or rgba notation, got $value")
return when {
(group.endsWith('%')) -> (normalizeFractionalPercent(group.substringBefore('%').toDouble() / 100.0) * 255.0).toInt()
else -> normalizeRGB(group.toInt())
}
}
val red = getRGBParameter(1)
val green = getRGBParameter(2)
val blue = getRGBParameter(3)
val alpha = normalizeAlpha(match?.groups?.get(4)?.value?.toDouble() ?: 1.0)
return RGBA(red, green, blue, alpha)
}
internal fun toRGBA(): RGBA {
val v = rgb ?: value
return when {
v.startsWith("rgb") -> fromRGBANotation()
// Matches #rgb
v.startsWith("#") && v.length == 4 -> RGBA(
"${v[1]}${v[1]}".toInt(16),
"${v[2]}${v[2]}".toInt(16),
"${v[3]}${v[3]}".toInt(16)
)
// Matches both #rrggbb and #rrggbbaa
v.startsWith("#") && (v.length == 7 || v.length == 9) -> RGBA(
(v.substring(1..2)).toInt(16),
(v.substring(3..4)).toInt(16),
(v.substring(5..6)).toInt(16)
)
else -> throw IllegalArgumentException("Only hexadecimal, rgb, and rgba notations are accepted, got $v")
}
}
}
private fun String.withZeros() = this + "0".repeat(max(0, 3 - this.length))