Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4aa7b12
Color: separate alpha from hex
MKoroteev-HORIS May 1, 2026
816ce6c
Fix alpha/opacity propagation in geom rendering pipeline
MKoroteev-HORIS May 1, 2026
9349a41
Propagate fill-opacity through SVG text and CSS style
MKoroteev-HORIS May 1, 2026
28d6317
Add alpha_opacity.ipynb dev notebook
MKoroteev-HORIS May 1, 2026
555e942
Fix test names
MKoroteev-HORIS May 1, 2026
637060e
Clean up resolveColor() interface
MKoroteev-HORIS May 1, 2026
42f62cb
Refactor alpha/opacity handling
MKoroteev-HORIS May 1, 2026
dae3aa4
Clean up manual alpha conversions
MKoroteev-HORIS May 1, 2026
726a4b4
Update tests
MKoroteev-HORIS May 1, 2026
801f145
Fix hint style
MKoroteev-HORIS May 1, 2026
8e3c372
Make use of precalculated opacity values via OPACITY_TABLE
MKoroteev-HORIS May 2, 2026
e11b8b0
Code cleanup
MKoroteev-HORIS May 3, 2026
9cc3215
Merge branch 'master' into alpha-opacity
MKoroteev-HORIS May 3, 2026
95bfd81
Clean up code
MKoroteev-HORIS May 4, 2026
f107e8b
Add test for alpha in Batik since it does not support #rrggbbaa
MKoroteev-HORIS May 4, 2026
cc780a4
Fix import
MKoroteev-HORIS May 4, 2026
105d8f4
Clean up code
MKoroteev-HORIS May 4, 2026
c364b1c
Clean up code; reduce duplication
MKoroteev-HORIS May 4, 2026
12391c5
Clean up code
MKoroteev-HORIS May 4, 2026
6ba1108
Refactoring TextUtil
MKoroteev-HORIS May 4, 2026
9069ce1
Improve parseRGB: better validation and error messages
MKoroteev-HORIS May 5, 2026
c3d0f95
Clean up code
MKoroteev-HORIS May 5, 2026
370cbd2
Add cookbook color_alpha.ipynb
MKoroteev-HORIS May 5, 2026
46d53a2
Update dev notebook alpha_opacity.ipynb
MKoroteev-HORIS May 5, 2026
c8f5528
Update future_changes.md
MKoroteev-HORIS May 5, 2026
fbbacfc
Mention #RRGGBBAA support
MKoroteev-HORIS May 5, 2026
9becd37
Correct future_changes.md wording
MKoroteev-HORIS May 6, 2026
76f0b67
Rename toColorPart -> toHexColorPart
MKoroteev-HORIS May 6, 2026
1566bc0
Refactor parameter naming: alpha for 0..255 and opacity for 0.0..1.0 …
MKoroteev-HORIS May 6, 2026
764ecbc
Merge remote-tracking branch 'origin/master' into alpha-opacity
MKoroteev-HORIS May 6, 2026
2103a4b
Clean up code
MKoroteev-HORIS May 6, 2026
a1472e5
Rename changeAlpha/Opacity to withAlpha/Opacity
MKoroteev-HORIS May 6, 2026
fa6e5a1
Clamping opacity to the range 0..1
MKoroteev-HORIS May 6, 2026
311d64b
Support named colors with opacity like 'steelblue / 0.35'
MKoroteev-HORIS May 6, 2026
7c4e7f8
Use alpha for 0..255 and opacity for 0.0..1.0 in color conversions
MKoroteev-HORIS May 7, 2026
98e6966
Add examples of using different color formats with alpha
MKoroteev-HORIS May 7, 2026
5a58784
Optimize hex parsing
MKoroteev-HORIS May 7, 2026
7fda6dc
Merge branch 'master' into alpha-opacity
MKoroteev-HORIS May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ import org.jetbrains.letsPlot.commons.values.Color
import kotlin.math.*


fun rgbFromHcl(hcl: HCL, alpha: Double = 1.0): Color {
fun rgbFromHcl(hcl: HCL, opacity: Double = 1.0): Color {
val luv = luvFromHcl(hcl)
val xyz = xyzFromLuv(luv)
val rgb = rgbFromXyz(xyz)
return rgb.changeAlpha((255 * alpha).roundToInt())
return rgb.withOpacity(opacity)
}


fun rgbFromLab(lab: LAB, alpha: Double = 1.0): Color {
fun rgbFromLab(lab: LAB, opacity: Double = 1.0): Color {
val xyz = xyzFromLab(lab)
val rgb = rgbFromXyz(xyz)
return rgb.changeAlpha((255 * alpha).roundToInt())
return rgb.withOpacity(opacity)
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ fun hslFromRgb(rgb: Color): HSL {
}


fun rgbFromHsl(hsl: HSL, alpha: Double = 1.0): Color {
fun rgbFromHsl(hsl: HSL, opacity: Double = 1.0): Color {
val c = (1.0 - abs(2 * hsl.l - 1.0)) * hsl.s
val h2 = hsl.h / 60
val x = c * (1 - abs(h2 % 2 - 1))
Expand All @@ -77,5 +77,5 @@ fun rgbFromHsl(hsl: HSL, alpha: Double = 1.0): Color {
((g1 + m) * 255).roundToInt(),
((b1 + m) * 255).roundToInt(),
(255 * 1.0).roundToInt()
).changeAlpha((255 * alpha).roundToInt())
).withOpacity(opacity)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,26 @@ class Color @JvmOverloads constructor(
) { "Color components out of range: $this" }
}

fun changeAlpha(newAlpha: Int): Color {
private val hexColorNoAlpha: String by lazy(LazyThreadSafetyMode.NONE) {
"#" + toHexColorPart(red) + toHexColorPart(green) + toHexColorPart(blue)
}

private val hexColor: String by lazy(LazyThreadSafetyMode.NONE) {
if (alpha == 255) hexColorNoAlpha else hexColorNoAlpha + toHexColorPart(alpha)
}

fun withAlpha(newAlpha: Int): Color {
return Color(red, green, blue, newAlpha)
}

fun changeAlpha(newAlpha: Double): Color {
val alphaInt = (newAlpha * 255).roundToInt()
return changeAlpha(alphaInt)
fun withOpacity(opacity: Double): Color {
val alpha = (opacity.coerceIn(0.0, 1.0) * 255).roundToInt()
return withAlpha(alpha)
}

fun multiplyAlpha(mulAlpha: Double): Color {
val newAlpha = alpha / 255.0 * mulAlpha
return changeAlpha(newAlpha)
fun multiplyOpacity(opacity: Double): Color {
Comment thread
MKoroteev-HORIS marked this conversation as resolved.
val newOpacity = alpha / 255.0 * opacity
return withOpacity(newOpacity)
}

override fun equals(other: Any?): Boolean {
Expand All @@ -56,13 +64,12 @@ class Color @JvmOverloads constructor(
}
}

fun toHexColorNoAlpha(): String {
return hexColorNoAlpha
}

fun toHexColor(): String {
val rgb = "#" + toColorPart(red) + toColorPart(green) + toColorPart(blue)
if (alpha == 255) {
return rgb
} else {
return rgb + toColorPart(alpha)
}
return hexColor
}

override fun hashCode(): Int {
Expand Down Expand Up @@ -243,43 +250,33 @@ class Color @JvmOverloads constructor(
}

fun parseRGB(text: String): Color {
val firstParen = findNext(text, "(", 0)
val prefix = text.substring(0, firstParen)

val firstComma = findNext(text, ",", firstParen + 1)
val secondComma = findNext(text, ",", firstComma + 1)

var thirdComma = -1

when {
prefix == RGBA -> thirdComma = findNext(text, ",", secondComma + 1)
prefix == COLOR -> thirdComma = text.indexOf(",", secondComma + 1)
prefix != RGB -> throw IllegalArgumentException(text)
val firstParen = text.indexOf("(")
val lastParen = text.lastIndexOf(")")
if (firstParen == -1 || lastParen == -1 || lastParen < firstParen) {
throw IllegalArgumentException("Invalid color value: $text")
}

val lastParen = findNext(text, ")", thirdComma + 1)
val red = text.substring(firstParen + 1, firstComma).trim { it <= ' ' }.toInt()
val green = text.substring(firstComma + 1, secondComma).trim { it <= ' ' }.toInt()
val prefix = text.substring(0, firstParen)
val components = text.substring(firstParen + 1, lastParen).split(",").map(String::trim)

val blue: Int
val alpha: Int
if (thirdComma == -1) {
blue = text.substring(secondComma + 1, lastParen).trim { it <= ' ' }.toInt()
alpha = 255
} else {
blue = text.substring(secondComma + 1, thirdComma).trim { it <= ' ' }.toInt()
alpha = (text.substring(thirdComma + 1, lastParen).trim { it <= ' ' }.toFloat() * 255).roundToInt()
when (prefix) {
RGB -> require(components.size == 3) { "RGB color format requires exactly 3 components: $text" }
RGBA -> require(components.size == 4) { "RGBA color format requires exactly 4 components: $text" }
COLOR -> require(components.size in 3..4) { "'color()' format requires 3 or 4 components: $text" }
else -> throw IllegalArgumentException("Unsupported RGB color format: $text")
}

return Color(red, green, blue, alpha)
}
return try {
val red = components[0].toInt()
val green = components[1].toInt()
val blue = components[2].toInt()
val opacity = components.getOrNull(3)?.toFloat() ?: 1f
val alpha = (opacity * 255).roundToInt()

private fun findNext(s: String, what: String, from: Int): Int {
val result = s.indexOf(what, from)
if (result == -1) {
throw IllegalArgumentException("text=$s what=$what from=$from")
Color(red, green, blue, alpha)
} catch (e: NumberFormatException) {
throw IllegalArgumentException("Invalid color value: $text", e)
}
return result
}

fun parseHex(hexColor: String): Color {
Expand Down Expand Up @@ -315,17 +312,6 @@ class Color @JvmOverloads constructor(
return Color(r, g, b, a)
}

private fun toColorPart(value: Int): String {
if (value < 0 || value > 255) {
throw IllegalArgumentException("RGB color part must be in range [0..255] but was $value")
}

val result = value.toString(16)
return if (result.length == 1) {
"0$result"
} else {
result
}
}
private fun toHexColorPart(value: Int): String = value.toString(16).padStart(2, '0')
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,35 @@ object Colors {
* - rgba(r, g, b, a)
* - color(r, g, b, a)
* - #rrggbb
* - #rrggbbaa
* - #rgb
* - #rgba
* - white, green, etc.
* - steelblue / 0.35 with opacity in [0, 1]
*/
fun parseColor(c: String): Color {
return when {
c.indexOf('(') > 0 -> Color.parseRGB(c)
c.startsWith("#") -> Color.parseHex(c)
isColorName(c) -> forName(c)
c.contains("/") -> parseColorWithOpacity(c)
else -> throw IllegalArgumentException("Error parsing color value: $c")
}
}

private fun parseColorWithOpacity(c: String): Color {
val components = c.split("/")
if (components.size != 2) {
throw IllegalArgumentException("Error parsing color value: $c")
}

val color = parseColor(components[0].trim())
val opacity = components[1].trim().toDoubleOrNull()
?: throw IllegalArgumentException("Error parsing color value: $c")

return color.withOpacity(opacity)
}

private fun normalizeColorName(name: String): String =
name.replace("-", "")
.replace("_", "")
Expand All @@ -218,10 +235,10 @@ object Colors {
* @param h hue, [0, 360] degree
* @param s saturation, [0, 1]
* @param v value, [0, 1]
* @param alpha [0, 1], 0 - transparent and 1 - opaque.
* @param opacity [0, 1], 0 - transparent and 1 - opaque.
*/
@JvmOverloads
fun rgbFromHsv(h: Double, s: Double, v: Double = 1.0, alpha: Double = 1.0): Color {
fun rgbFromHsv(h: Double, s: Double, v: Double = 1.0, opacity: Double = 1.0): Color {
val hd = h / 60
val c = v * s
val x = c * (1 - abs(hd % 2 - 1))
Expand Down Expand Up @@ -267,7 +284,7 @@ object Colors {
(255 * (r + m)).roundToInt(),
(255 * (g + m)).roundToInt(),
(255 * (b + m)).roundToInt(),
(255 * alpha).roundToInt(),
(255 * opacity).roundToInt(),
)
}

Expand Down Expand Up @@ -306,17 +323,13 @@ object Colors {
)
}

fun mimicTransparency(color: Color, alpha: Double, background: Color): Color {
val red = (color.red * alpha + background.red * (1 - alpha)).toInt()
val green = (color.green * alpha + background.green * (1 - alpha)).toInt()
val blue = (color.blue * alpha + background.blue * (1 - alpha)).toInt()
fun mimicTransparency(color: Color, opacity: Double, background: Color): Color {
val red = (color.red * opacity + background.red * (1 - opacity)).toInt()
val green = (color.green * opacity + background.green * (1 - opacity)).toInt()
val blue = (color.blue * opacity + background.blue * (1 - opacity)).toInt()
return Color(red, green, blue)
}

fun withOpacity(c: Color, opacity: Double): Color {
Comment thread
MKoroteev-HORIS marked this conversation as resolved.
return c.changeAlpha(max(0, min(255, round(255 * opacity).toInt())))
}

fun contrast(color: Color, other: Color): Double {
return (luminance(color) + .05) / (luminance(other) + .05)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,26 @@ class ColorTest {
assertEquals("#11223344", Color(0x11, 0x22, 0x33, 0x44).toHexColor())
}

@Test
fun toHexColorNoAlpha() {
assertEquals("#112233", Color(0x11, 0x22, 0x33, 0x44).toHexColorNoAlpha())
}

@Test
fun withOpacityRoundsToNearestByte() {
assertEquals(128, Color.RED.withOpacity(0.5).alpha)
}

@Test
fun withOpacityClampsBelowZero() {
assertEquals(0, Color.RED.withOpacity(-0.1).alpha)
}

@Test
fun withOpacityClampsAboveOne() {
assertEquals(255, Color.RED.withOpacity(1.5).alpha)
}

@Test
fun parseRGB() {
assertEquals(Color.RED, Color.parseRGB("rgb(255,0,0)"))
Expand All @@ -46,6 +66,24 @@ class ColorTest {
assertEquals(Color.RED, Color.parseRGB("rgba(255,0,0,1.0)"))
}

@Test
fun rgbaRequiresAlpha() {
val e = assertFailsWith<IllegalArgumentException> {
Color.parseRGB("rgba(220, 240, 255)")
}

assertEquals("RGBA color format requires exactly 4 components: rgba(220, 240, 255)", e.message)
}

@Test
fun rgbRejectsExtraAlpha() {
val e = assertFailsWith<IllegalArgumentException> {
Color.parseRGB("rgb(220, 240, 255, 0.5)")
}

assertEquals("RGB color format requires exactly 3 components: rgb(220, 240, 255, 0.5)", e.message)
}

@Test
fun parseColRGB() {
assertEquals(Color.BLUE, Color.parseRGB("color(0,0,255)"))
Expand All @@ -56,6 +94,15 @@ class ColorTest {
assertEquals(Color.BLUE, Color.parseRGB("color(0,0,255,1.0)"))
}

@Test
fun colorRejectsWrongComponentCount() {
val e = assertFailsWith<IllegalArgumentException> {
Color.parseRGB("color(0,0)")
}

assertEquals("'color()' format requires 3 or 4 components: color(0,0)", e.message)
}

@Test
fun parseRgbWithSpaces() {
assertEquals(Color.RED, Color.parseRGB("rgb(255, 0, 0)"))
Expand All @@ -75,4 +122,4 @@ class ColorTest {
Color.parseRGB("rbg(255, 0, )")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class ColorsTest {
assertEquals(Color.RED, Colors.parseColor(Color.RED.toHexColor()))
}

@Test
fun parseHexWithAlpha() {
assertEquals(Color(0, 255, 0, 128), Colors.parseColor("#00ff0080"))
}

@Test
fun parseRGB() {
assertEquals(Color.RED, Colors.parseColor("rgb(255,0,0)"))
Expand All @@ -62,6 +67,30 @@ class ColorsTest {
assertEquals(Color.MAGENTA, Colors.parseColor("magenta"))
}

@Test
fun parseColorNameWithOpacity() {
assertEquals(Color.STEEL_BLUE.withOpacity(0.35), Colors.parseColor("steelblue / 0.35"))
}

@Test
fun parseColorNameWithOpacityNoSpaces() {
assertEquals(Color.STEEL_BLUE.withOpacity(0.35), Colors.parseColor("steelblue/0.35"))
}

@Test
fun percentOpacitySuffixIsNotSupported() {
assertFailsWith<IllegalArgumentException> {
Colors.parseColor("steelblue / 35%")
}
}

@Test
fun opacitySuffixRequiresSingleSlash() {
assertFailsWith<IllegalArgumentException> {
Colors.parseColor("steelblue / 0.35 / 0.5")
}
}

@Test
fun rgbFromHsv() {
assertEquals(Color.BLACK, Colors.rgbFromHsv(0.0, 0.0, 0.0))
Expand Down Expand Up @@ -112,6 +141,13 @@ class ColorsTest {
assertColors(Color(0, 0, 128), HSL(240.0, 1.0, 0.25)) // navy
}

@Test
fun `color space conversions apply opacity`() {
assertEquals(128, rgbFromHsl(HSL(0.0, 1.0, 0.5), opacity = 0.5).alpha)
assertEquals(128, rgbFromHcl(HCL(15.0, 100.0, 65.0), opacity = 0.5).alpha)
assertEquals(128, rgbFromLab(LAB(l = 43.579, a = 45.164, b = 36.823), opacity = 0.5).alpha)
}

@Test
fun hcl() {
fun assertHclToRgb(hcl: HCL, hexRgb: String) {
Expand Down
Loading